├── .gitattributes ├── .gitignore ├── .style.yapf ├── CHANGELOG.md ├── EbisuHowto.ipynb ├── README.md ├── UNLICENSE ├── assets ├── atom-one-dark.css ├── highlight.pack.js └── modest.css ├── doc └── doc.md ├── ebisu ├── __init__.py ├── alternate.py ├── ebisu.py └── tests │ └── test_ebisu.py ├── figures ├── forgetting-curve-diff.png ├── forgetting-curve-diff.svg ├── forgetting-curve.png ├── forgetting-curve.svg ├── halflife.png ├── halflife.svg ├── models.png ├── models.svg ├── pidelta.png ├── pidelta.svg ├── pis-betas.png ├── pis-betas.svg ├── pis.png └── pis.svg ├── header.html ├── index.html ├── md2code.js ├── package-lock.json ├── package.json ├── setup.py └── test.json /.gitattributes: -------------------------------------------------------------------------------- 1 | *.html linguist-vendored 2 | assets/* linguist-vendored 3 | 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | __pycache__/ 4 | *.pyc 5 | build/ 6 | dist/ 7 | ebisu.egg-info/ 8 | .ipynb_checkpoints/ 9 | -------------------------------------------------------------------------------- /.style.yapf: -------------------------------------------------------------------------------- 1 | [style] 2 | based_on_style = yapf 3 | COLUMN_LIMIT = 100 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changes to Ebisu 2 | 3 | ## 2.2.0: better numerical stability at high α and β 4 | 5 | Fixes https://github.com/fasiha/ebisu/issues/68: in the binary quiz case, weird things happen in `updateRecall`. Either you get very wrong answers or exceptions are thrown. We can fix this by calculating moments in the log domain. 6 | 7 | If you're testing old quizzes, this version will differ for models in the affected regions. Compare: 8 | ```py 9 | import ebisu 10 | print(ebisu.updateRecall((531,531, 37.98), 0, 1, 24.0)) 11 | 12 | # old: (36.55688622754491, 36.886227544910184, 38.089740065719965) 13 | 14 | # new: # (531.9583078300888, 531.9583078290626, 37.920753773390835) 15 | ``` 16 | 17 | (We actually already figured this out in the JavaScript version: https://github.com/fasiha/ebisu.js/pull/21 and https://github.com/fasiha/ebisu.js/pull/24.) 18 | 19 | ## 2.1.0: soft-binary quizzes and halflife rescaling 20 | 21 | ### 1) Soft-binary fuzzy quizzes 22 | 23 | `updateRecall` can now take *floating point* quizzes between 0 and 1 (inclusive) as `successes`, to handle the case when your quiz isn’t quite correct or wasn’t fully incorrect. Varying this number between 0 and 1 will smoothly vary the halflife of the updated model. Under the hood, there's a noisy-Bernoulli statistical model: check for the math [here](https://fasiha.github.io/ebisu/#bonus-soft-binary-quizzes). 24 | 25 | ### 2) New function to explicitly rescale model halflives 26 | 27 | A new function has been added to the API, `rescaleHalflife`, for those cases when the halflife of a flashcard is just wrong and you need to multiply it by ten (so you see it less often) or divide it by two (so you see it *more* often). 28 | 29 | ### 3) Behavioral change to `updateRecall` 30 | `updateRecall` will by default rebalance models, so that updated models will have `α=β` (to within machine precision) and `t` will be the halflife. 31 | 32 | (This does have a performance impact, so I may have to flip the default to *not* always rebalance in a future release if this turns out to be problematic.) 33 | 34 | (This means running `updateRecall` in 2.1.0 with the same inputs will yield different numbers than 2.0.0. However, the differences are statistically very minor: they both represent very nearly the same probabilistic belief on recall.) 35 | 36 | --- 37 | 38 | Closes long-standing issues [#23](https://github.com/fasiha/ebisu/issues/23) and [#31](https://github.com/fasiha/ebisu/issues/31)—thank you to all participants who weighed in, offered advice, and waited patiently. 39 | 40 | [See all docstrings](https://github.com/fasiha/ebisu/blob/gh-pages/doc/doc.md). 41 | 42 | ## 2.0.0: Bernoulli to binomial quizzes 43 | The API for `updateRecall` has changed because `boolean` results don't make sense for quiz apps that have a sense of "review sessions" in which the same flashcard can be reviewed more than one time, e.g., if a review session consists of conjugating the same verb twice. Therefore, `updateRecall` accepts two integers: 44 | - `successes`, the number of times the user correctly produced the memory encoded in this flashcard, out of 45 | - `total` number of times it was presented to the user. 46 | 47 | The old behavior can be recovered by setting `total=1` and `successes=1` upon success and 0 upon failure. 48 | 49 | The memory models from previous versions remain fully-compatible with this update. 50 | 51 | While this new feature allows more freedom in desining quiz applications, it does open up the possibility of numerical instability when the function receives a very surprising input. Please wrap calls to `updateRecall` in a `try` block to gracefully handle this possibility, and get in touch in case it happens to you a lot. 52 | 53 | ## 1.0.0 54 | **Breaking changes:** 55 | - `predictRecall` returns log-probabilities, which are numbers between -∞ and 0 (log(0) being -∞ and log(1) being 0) by default, as a computational speedup. The returned values can still be sorted, and the lowest value corresponds to the lowest recall probability. Use `exact=True` to get true probabilities (at the cost of an `exp` function evaluation). 56 | - The name of the half-life function is now `modelToPercentileDecay` and has a new API. 57 | 58 | [Robert Kern's discovery](https://github.com/fasiha/ebisu/issues/5) that time-traveling Beta random variables through Ebbinghaus’ exponential decay function transform into GB1 random variables, which have analytic moments, was a major breakthrough. His contribution to this update, in code and ideas and time, cannot be overstated. 59 | 60 | With the GB1 mathematical infrastructure, I was able to completely rethink the update step. Both passing and failing a quiz yield exact analytical moments of the posterior over any time horizon, not just when the test was taken. These are fit to a Beta at the very last minute. There is also a rebalancing step (which Robert foreshadowed in the GitHub [issue](https://github.com/fasiha/ebisu/issues/5) above as a “telescoping” posterior), wherein if one of the Beta’s parameters is large compared to the other, the update is rerun at the approximate half-life of the original unbalanced posterior fit. 61 | 62 | All of these changes are transparent to the user, who will just see more accurate behavior in extreme over- and under-reviewing. 63 | 64 | ## 0.5.6 65 | This version was tested by a couple of users of the Curtiz app (including the developer of Ebisu). Its `updateRecall` function returned models at the test time. 66 | -------------------------------------------------------------------------------- /EbisuHowto.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Ebisu howto\n", 8 | "A quick introduction to using the library to schedule spaced-repetition quizzes in a principled, probabilistically-grounded, Bayesian manner.\n", 9 | "\n", 10 | "See https://fasiha.github.io/ebisu/ for details!" 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": 1, 16 | "metadata": {}, 17 | "outputs": [], 18 | "source": [ 19 | "import ebisu\n", 20 | "\n", 21 | "defaultModel = (4., 4., 24.) # alpha, beta, and half-life in hours" 22 | ] 23 | }, 24 | { 25 | "cell_type": "markdown", 26 | "metadata": {}, 27 | "source": [ 28 | "Ebisu—this is what we’re here to learn about!\n", 29 | "\n", 30 | "Ebisu is a library that’s expected to be embedded inside quiz apps, to help schedule quizzes intelligently. It uses Bayesian statistics to let the app predict what the recall probability is for any fact that the student has learned, and to update that prediction based onthe results of a quiz.\n", 31 | "\n", 32 | "Ebisu uses three numbers to describe its belief about the time-evolution of each fact’s recall probability. Its API consumes them as a 3-tuple, and they are:\n", 33 | "\n", 34 | "- the first we call “alpha” and must be ≥ 2 (well, technically, ≥1 is the raw minimum but unless you’re a professor of statistics, keep it more than two);\n", 35 | "- the second is “beta” and also must be ≥ 2. These two numbers encode our belief about the distribution of recall probabilities at\n", 36 | "- the third element, which here is a half-life. This has units of time, and for this example, we’ll assume it’s in hours. It can be any positive float, but we choose the nice round number of 24 hours.\n", 37 | "\n", 38 | "For the nerds: alpha and beta parameterize a Beta distribution to describe our prior belief of the recall probability one half-life (one day) after a fact’s most recent quiz.\n", 39 | "\n", 40 | "For the rest of us: these three numbers mean we expect the recall probability for a newly-learned fact to be 50% after one day, but allow uncertainty: the recall probability after a day is “around” 42% to 58% (±1 standard deviation).\n", 41 | "\n", 42 | "---\n", 43 | "\n", 44 | "Now. Let’s create a mock database of facts. Say a student has learned two facts, one on the 19th at 2200 hours and another the next morning at 0900 hours." 45 | ] 46 | }, 47 | { 48 | "cell_type": "code", 49 | "execution_count": 2, 50 | "metadata": {}, 51 | "outputs": [], 52 | "source": [ 53 | "from datetime import datetime, timedelta\n", 54 | "date0 = datetime(2017, 4, 19, 22, 0, 0)\n", 55 | "\n", 56 | "database = [dict(factID=1, model=defaultModel, lastTest=date0),\n", 57 | " dict(factID=2, model=defaultModel, lastTest=date0 + timedelta(hours=11))]" 58 | ] 59 | }, 60 | { 61 | "cell_type": "markdown", 62 | "metadata": {}, 63 | "source": [ 64 | "After learning the second fact, at 0900, what does Ebisu expect each fact’s probability of recall to be, for each of the facts?" 65 | ] 66 | }, 67 | { 68 | "cell_type": "code", 69 | "execution_count": 3, 70 | "metadata": {}, 71 | "outputs": [ 72 | { 73 | "name": "stdout", 74 | "output_type": "stream", 75 | "text": [ 76 | "On 2017-04-20 09:06:00,\n", 77 | "Fact #1 probability of recall: 71.5%\n", 78 | "Fact #2 probability of recall: 99.7%\n" 79 | ] 80 | } 81 | ], 82 | "source": [ 83 | "oneHour = timedelta(hours=1)\n", 84 | "\n", 85 | "now = date0 + timedelta(hours=11.1)\n", 86 | "print(\"On {},\".format(now))\n", 87 | "for row in database:\n", 88 | " recall = ebisu.predictRecall(row['model'],\n", 89 | " (now - row['lastTest']) / oneHour,\n", 90 | " exact=True)\n", 91 | " print(\"Fact #{} probability of recall: {:0.1f}%\".format(row['factID'], recall * 100))\n" 92 | ] 93 | }, 94 | { 95 | "cell_type": "markdown", 96 | "metadata": {}, 97 | "source": [ 98 | "Both facts are expected to still be firmly in memory—especially the second one since it was just learned! So the quiz app doesn’t ask the student to review anything yet—though if she wanted to, the quiz app would pick the fact most in danger of being forgotten.\n", 99 | "\n", 100 | "Note how we used `ebisu.predictRecall`, which accepts\n", 101 | "- the current model, and\n", 102 | "- the time elapsed since this fact’s last quiz,\n", 103 | "\n", 104 | "and returns a `float`.\n", 105 | "\n", 106 | "…\n", 107 | "\n", 108 | "Now a few hours have elapsed. It’s just past midnight on the 21st and the student opens the quiz app." 109 | ] 110 | }, 111 | { 112 | "cell_type": "code", 113 | "execution_count": 4, 114 | "metadata": {}, 115 | "outputs": [ 116 | { 117 | "name": "stdout", 118 | "output_type": "stream", 119 | "text": [ 120 | "On 2017-04-21 00:30:00,\n", 121 | "Fact #1 probability of recall: 46.8%\n", 122 | "Fact #2 probability of recall: 63.0%\n" 123 | ] 124 | } 125 | ], 126 | "source": [ 127 | "now = date0 + timedelta(hours=26.5)\n", 128 | "print(\"On {},\".format(now))\n", 129 | "for row in database:\n", 130 | " recall = ebisu.predictRecall(row['model'],\n", 131 | " (now - row['lastTest']) / oneHour,\n", 132 | " exact=True)\n", 133 | " print(\"Fact #{} probability of recall: {:0.1f}%\".format(row['factID'], recall * 100))" 134 | ] 135 | }, 136 | { 137 | "cell_type": "markdown", 138 | "metadata": {}, 139 | "source": [ 140 | "Suppose the quiz app has been configured to quiz the student if the expected recall probability drops below 50%—which it did for fact 1! The app shows the flashcard once, analyzes the user's response, and sets the result of the quiz to `1` if passed and `0` if failed. It calls Ebisu to update the model, giving it this result as well as the `total` number of times it showed this flashcard (one time—Ebisu can support more advanced cases where an app reviews the same flashcard multiple times in a single review session, but let's keep it simple for now)." 141 | ] 142 | }, 143 | { 144 | "cell_type": "code", 145 | "execution_count": 5, 146 | "metadata": {}, 147 | "outputs": [ 148 | { 149 | "name": "stdout", 150 | "output_type": "stream", 151 | "text": [ 152 | "New model for fact #1: (4.040794974809565, 4.040794974809568, 29.18373827290736)\n" 153 | ] 154 | } 155 | ], 156 | "source": [ 157 | "row = database[0] # review FIRST question\n", 158 | "\n", 159 | "result = 1 # success!\n", 160 | "total = 1 # number of times this flashcard was shown (fixed)\n", 161 | "newModel = ebisu.updateRecall(row['model'],\n", 162 | " result,\n", 163 | " total,\n", 164 | " (now - row['lastTest']) / oneHour)\n", 165 | "print('New model for fact #1:', newModel)\n", 166 | "row['model'] = newModel\n", 167 | "row['lastTest'] = now" 168 | ] 169 | }, 170 | { 171 | "cell_type": "markdown", 172 | "metadata": {}, 173 | "source": [ 174 | "Observe how `ebisu.updateRecall` takes\n", 175 | "- the current model,\n", 176 | "- the quiz result, and\n", 177 | "- the time elapsed since the last quiz,\n", 178 | "\n", 179 | "and returns a new model (the new 3-tuple of “alpha”, “beta” and time). We put the new model and the current timestamp into the database.\n", 180 | "\n", 181 | "Now. Suppose the student asks to review another fact—fact 2. It was learned just earlier that morning, and its recall probability is expected to be around 63%, but suppose the student fails this quiz, as sometimes happens." 182 | ] 183 | }, 184 | { 185 | "cell_type": "code", 186 | "execution_count": 6, 187 | "metadata": {}, 188 | "outputs": [ 189 | { 190 | "name": "stdout", 191 | "output_type": "stream", 192 | "text": [ 193 | "New model for fact #2: (4.958645489170429, 4.9586454891705465, 19.76772867641237)\n" 194 | ] 195 | } 196 | ], 197 | "source": [ 198 | "row = database[1] # review SECOND question\n", 199 | "\n", 200 | "result = 0\n", 201 | "newModel = ebisu.updateRecall(row['model'],\n", 202 | " result,\n", 203 | " total,\n", 204 | " (now - row['lastTest']) / oneHour)\n", 205 | "print('New model for fact #2:', newModel)\n", 206 | "row['model'] = newModel\n", 207 | "row['lastTest'] = now" 208 | ] 209 | }, 210 | { 211 | "cell_type": "markdown", 212 | "metadata": {}, 213 | "source": [ 214 | "The new parameters for this fact differ from the previous one because (1) the student failed this quiz while she passed the other, (2) different amounts of time had elapsed since the respective facts were last seen.\n", 215 | "\n", 216 | "Ebisu provides a method to convert parameters to “expected half-life”. It is *not* an essential feature of the API but can be useful:" 217 | ] 218 | }, 219 | { 220 | "cell_type": "code", 221 | "execution_count": 7, 222 | "metadata": {}, 223 | "outputs": [ 224 | { 225 | "name": "stdout", 226 | "output_type": "stream", 227 | "text": [ 228 | "Fact #1 has half-life of ≈29.2 hours\n", 229 | "Fact #2 has half-life of ≈19.8 hours\n" 230 | ] 231 | } 232 | ], 233 | "source": [ 234 | "for row in database:\n", 235 | " meanHalflife = ebisu.modelToPercentileDecay(row['model'])\n", 236 | " print(\"Fact #{} has half-life of ≈{:0.1f} hours\".format(row['factID'], meanHalflife))\n" 237 | ] 238 | }, 239 | { 240 | "cell_type": "markdown", 241 | "metadata": {}, 242 | "source": [ 243 | "Note how the half-life (the time between quizzes for expected recall probability to drop to 50%) for the first question increased from 24 to 29 hours after the student got it right, while it decreased to 20 hours for the second when she got it wrong. Ebisu has incorporated the fact that the second fact had been learned not that long ago and should have been strong, and uses the surprising quiz result to strongly adjust its belief about its recall probability.\n", 244 | "\n", 245 | "---\n", 246 | "\n", 247 | "Suppose the user is tired of reviewing the first fact so often because it’s something they know very well. You could allow the user to delete this flashcard, add it again with a longer initial halflife. But Ebisu gives you a function that will explicitly rescale the halflife of the card as is: `ebisu.rescaleHalflife`, which takes a positive number to act as the halflife scale. In this case, the new halflife is *two* times the old halflife." 248 | ] 249 | }, 250 | { 251 | "cell_type": "code", 252 | "execution_count": 8, 253 | "metadata": {}, 254 | "outputs": [ 255 | { 256 | "name": "stdout", 257 | "output_type": "stream", 258 | "text": [ 259 | "Fact #1 has half-life of ≈58.4 hours\n", 260 | "Fact #2 has half-life of ≈19.8 hours\n" 261 | ] 262 | } 263 | ], 264 | "source": [ 265 | "database[0]['model'] = ebisu.rescaleHalflife(database[0]['model'], 2.0)\n", 266 | "\n", 267 | "for row in database:\n", 268 | " meanHalflife = ebisu.modelToPercentileDecay(row['model'])\n", 269 | " print(\"Fact #{} has half-life of ≈{:0.1f} hours\".format(row['factID'], meanHalflife))" 270 | ] 271 | }, 272 | { 273 | "cell_type": "markdown", 274 | "metadata": {}, 275 | "source": [ 276 | "If the user was worried that this flashcard was shown too *infrequently*, and wanted to see it three times as often, you might pass in `1/3` as the second argument.\n", 277 | "\n", 278 | "This short notebook shows the major functions in the Ebisu API:\n", 279 | "- `ebisu.predictRecall` to find out the expected recall probability for a fact right now, and\n", 280 | "- `ebisu.updateRecall` to update those expectations when a new quiz result is available.\n", 281 | "- `ebisu.modelToPercentileDecay` to find the time when the recall probability reaches a certain value.\n", 282 | "- `ebisu.rescaleHalflife` to adjust the halflife up and down without a quiz.\n", 283 | "\n", 284 | "For more advanced functionality, including non-binary fuzzy quizzes, do consult the [Ebisu](https://fasiha.github.io/ebisu/) website, which links to the API’s docstrings and explains how this all works in greater detail." 285 | ] 286 | }, 287 | { 288 | "cell_type": "markdown", 289 | "metadata": {}, 290 | "source": [ 291 | "## Adanced topics\n", 292 | "### Speeding up `predictRecall`\n", 293 | "\n", 294 | "Above, we used `predictRecall` with the `exact=True` keyword argument to have it return true probabilities. We can reduce runtime if we use the following:" 295 | ] 296 | }, 297 | { 298 | "cell_type": "code", 299 | "execution_count": 9, 300 | "metadata": {}, 301 | "outputs": [ 302 | { 303 | "name": "stdout", 304 | "output_type": "stream", 305 | "text": [ 306 | "6.17 µs ± 430 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)\n", 307 | "4.45 µs ± 110 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)\n" 308 | ] 309 | } 310 | ], 311 | "source": [ 312 | "# As above: a bit slow to get exact probabilities\n", 313 | "%timeit ebisu.predictRecall(database[0]['model'], 100., exact=True)\n", 314 | "\n", 315 | "# A bit faster alternative: get log-probabilities (this is the defalt)\n", 316 | "%timeit ebisu.predictRecall(database[0]['model'], 100., exact=False)" 317 | ] 318 | } 319 | ], 320 | "metadata": { 321 | "kernelspec": { 322 | "display_name": "Python 3", 323 | "language": "python", 324 | "name": "python3" 325 | }, 326 | "language_info": { 327 | "codemirror_mode": { 328 | "name": "ipython", 329 | "version": 3 330 | }, 331 | "file_extension": ".py", 332 | "mimetype": "text/x-python", 333 | "name": "python", 334 | "nbconvert_exporter": "python", 335 | "pygments_lexer": "ipython3", 336 | "version": "3.9.0" 337 | } 338 | }, 339 | "nbformat": 4, 340 | "nbformat_minor": 2 341 | } 342 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /assets/atom-one-dark.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Atom One Dark by Daniel Gamage 4 | Original One Dark Syntax theme from https://github.com/atom/one-dark-syntax 5 | 6 | base: #282c34 7 | mono-1: #abb2bf 8 | mono-2: #818896 9 | mono-3: #5c6370 10 | hue-1: #56b6c2 11 | hue-2: #61aeee 12 | hue-3: #c678dd 13 | hue-4: #98c379 14 | hue-5: #e06c75 15 | hue-5-2: #be5046 16 | hue-6: #d19a66 17 | hue-6-2: #e6c07b 18 | 19 | */ 20 | 21 | .hljs { 22 | display: block; 23 | overflow-x: auto; 24 | padding: 0.5em; 25 | color: #abb2bf; 26 | background: #282c34; 27 | } 28 | 29 | .hljs-comment, 30 | .hljs-quote { 31 | color: #5c6370; 32 | font-style: italic; 33 | } 34 | 35 | .hljs-doctag, 36 | .hljs-keyword, 37 | .hljs-formula { 38 | color: #c678dd; 39 | } 40 | 41 | .hljs-section, 42 | .hljs-name, 43 | .hljs-selector-tag, 44 | .hljs-deletion, 45 | .hljs-subst { 46 | color: #e06c75; 47 | } 48 | 49 | .hljs-literal { 50 | color: #56b6c2; 51 | } 52 | 53 | .hljs-string, 54 | .hljs-regexp, 55 | .hljs-addition, 56 | .hljs-attribute, 57 | .hljs-meta-string { 58 | color: #98c379; 59 | } 60 | 61 | .hljs-built_in, 62 | .hljs-class .hljs-title { 63 | color: #e6c07b; 64 | } 65 | 66 | .hljs-attr, 67 | .hljs-variable, 68 | .hljs-template-variable, 69 | .hljs-type, 70 | .hljs-selector-class, 71 | .hljs-selector-attr, 72 | .hljs-selector-pseudo, 73 | .hljs-number { 74 | color: #d19a66; 75 | } 76 | 77 | .hljs-symbol, 78 | .hljs-bullet, 79 | .hljs-link, 80 | .hljs-meta, 81 | .hljs-selector-id, 82 | .hljs-title { 83 | color: #61aeee; 84 | } 85 | 86 | .hljs-emphasis { 87 | font-style: italic; 88 | } 89 | 90 | .hljs-strong { 91 | font-weight: bold; 92 | } 93 | 94 | .hljs-link { 95 | text-decoration: underline; 96 | } 97 | -------------------------------------------------------------------------------- /assets/highlight.pack.js: -------------------------------------------------------------------------------- 1 | /*! highlight.js v9.11.0 | BSD3 License | git.io/hljslicense */ 2 | !function(e){var n="object"==typeof window&&window||"object"==typeof self&&self;"undefined"!=typeof exports?e(exports):n&&(n.hljs=e({}),"function"==typeof define&&define.amd&&define([],function(){return n.hljs}))}(function(e){function n(e){return e.replace(/&/g,"&").replace(//g,">")}function t(e){return e.nodeName.toLowerCase()}function r(e,n){var t=e&&e.exec(n);return t&&0===t.index}function a(e){return k.test(e)}function i(e){var n,t,r,i,o=e.className+" ";if(o+=e.parentNode?e.parentNode.className:"",t=B.exec(o))return w(t[1])?t[1]:"no-highlight";for(o=o.split(/\s+/),n=0,r=o.length;r>n;n++)if(i=o[n],a(i)||w(i))return i}function o(e){var n,t={},r=Array.prototype.slice.call(arguments,1);for(n in e)t[n]=e[n];return r.forEach(function(e){for(n in e)t[n]=e[n]}),t}function u(e){var n=[];return function r(e,a){for(var i=e.firstChild;i;i=i.nextSibling)3===i.nodeType?a+=i.nodeValue.length:1===i.nodeType&&(n.push({event:"start",offset:a,node:i}),a=r(i,a),t(i).match(/br|hr|img|input/)||n.push({event:"stop",offset:a,node:i}));return a}(e,0),n}function c(e,r,a){function i(){return e.length&&r.length?e[0].offset!==r[0].offset?e[0].offset"}function u(e){s+=""}function c(e){("start"===e.event?o:u)(e.node)}for(var l=0,s="",f=[];e.length||r.length;){var g=i();if(s+=n(a.substring(l,g[0].offset)),l=g[0].offset,g===e){f.reverse().forEach(u);do c(g.splice(0,1)[0]),g=i();while(g===e&&g.length&&g[0].offset===l);f.reverse().forEach(o)}else"start"===g[0].event?f.push(g[0].node):f.pop(),c(g.splice(0,1)[0])}return s+n(a.substr(l))}function l(e){return e.v&&!e.cached_variants&&(e.cached_variants=e.v.map(function(n){return o(e,{v:null},n)})),e.cached_variants||e.eW&&[o(e)]||[e]}function s(e){function n(e){return e&&e.source||e}function t(t,r){return new RegExp(n(t),"m"+(e.cI?"i":"")+(r?"g":""))}function r(a,i){if(!a.compiled){if(a.compiled=!0,a.k=a.k||a.bK,a.k){var o={},u=function(n,t){e.cI&&(t=t.toLowerCase()),t.split(" ").forEach(function(e){var t=e.split("|");o[t[0]]=[n,t[1]?Number(t[1]):1]})};"string"==typeof a.k?u("keyword",a.k):x(a.k).forEach(function(e){u(e,a.k[e])}),a.k=o}a.lR=t(a.l||/\w+/,!0),i&&(a.bK&&(a.b="\\b("+a.bK.split(" ").join("|")+")\\b"),a.b||(a.b=/\B|\b/),a.bR=t(a.b),a.e||a.eW||(a.e=/\B|\b/),a.e&&(a.eR=t(a.e)),a.tE=n(a.e)||"",a.eW&&i.tE&&(a.tE+=(a.e?"|":"")+i.tE)),a.i&&(a.iR=t(a.i)),null==a.r&&(a.r=1),a.c||(a.c=[]),a.c=Array.prototype.concat.apply([],a.c.map(function(e){return l("self"===e?a:e)})),a.c.forEach(function(e){r(e,a)}),a.starts&&r(a.starts,i);var c=a.c.map(function(e){return e.bK?"\\.?("+e.b+")\\.?":e.b}).concat([a.tE,a.i]).map(n).filter(Boolean);a.t=c.length?t(c.join("|"),!0):{exec:function(){return null}}}}r(e)}function f(e,t,a,i){function o(e,n){var t,a;for(t=0,a=n.c.length;a>t;t++)if(r(n.c[t].bR,e))return n.c[t]}function u(e,n){if(r(e.eR,n)){for(;e.endsParent&&e.parent;)e=e.parent;return e}return e.eW?u(e.parent,n):void 0}function c(e,n){return!a&&r(n.iR,e)}function l(e,n){var t=N.cI?n[0].toLowerCase():n[0];return e.k.hasOwnProperty(t)&&e.k[t]}function p(e,n,t,r){var a=r?"":I.classPrefix,i='',i+n+o}function h(){var e,t,r,a;if(!E.k)return n(k);for(a="",t=0,E.lR.lastIndex=0,r=E.lR.exec(k);r;)a+=n(k.substring(t,r.index)),e=l(E,r),e?(B+=e[1],a+=p(e[0],n(r[0]))):a+=n(r[0]),t=E.lR.lastIndex,r=E.lR.exec(k);return a+n(k.substr(t))}function d(){var e="string"==typeof E.sL;if(e&&!y[E.sL])return n(k);var t=e?f(E.sL,k,!0,x[E.sL]):g(k,E.sL.length?E.sL:void 0);return E.r>0&&(B+=t.r),e&&(x[E.sL]=t.top),p(t.language,t.value,!1,!0)}function b(){L+=null!=E.sL?d():h(),k=""}function v(e){L+=e.cN?p(e.cN,"",!0):"",E=Object.create(e,{parent:{value:E}})}function m(e,n){if(k+=e,null==n)return b(),0;var t=o(n,E);if(t)return t.skip?k+=n:(t.eB&&(k+=n),b(),t.rB||t.eB||(k=n)),v(t,n),t.rB?0:n.length;var r=u(E,n);if(r){var a=E;a.skip?k+=n:(a.rE||a.eE||(k+=n),b(),a.eE&&(k=n));do E.cN&&(L+=C),E.skip||(B+=E.r),E=E.parent;while(E!==r.parent);return r.starts&&v(r.starts,""),a.rE?0:n.length}if(c(n,E))throw new Error('Illegal lexeme "'+n+'" for mode "'+(E.cN||"")+'"');return k+=n,n.length||1}var N=w(e);if(!N)throw new Error('Unknown language: "'+e+'"');s(N);var R,E=i||N,x={},L="";for(R=E;R!==N;R=R.parent)R.cN&&(L=p(R.cN,"",!0)+L);var k="",B=0;try{for(var M,j,O=0;;){if(E.t.lastIndex=O,M=E.t.exec(t),!M)break;j=m(t.substring(O,M.index),M[0]),O=M.index+j}for(m(t.substr(O)),R=E;R.parent;R=R.parent)R.cN&&(L+=C);return{r:B,value:L,language:e,top:E}}catch(T){if(T.message&&-1!==T.message.indexOf("Illegal"))return{r:0,value:n(t)};throw T}}function g(e,t){t=t||I.languages||x(y);var r={r:0,value:n(e)},a=r;return t.filter(w).forEach(function(n){var t=f(n,e,!1);t.language=n,t.r>a.r&&(a=t),t.r>r.r&&(a=r,r=t)}),a.language&&(r.second_best=a),r}function p(e){return I.tabReplace||I.useBR?e.replace(M,function(e,n){return I.useBR&&"\n"===e?"
":I.tabReplace?n.replace(/\t/g,I.tabReplace):""}):e}function h(e,n,t){var r=n?L[n]:t,a=[e.trim()];return e.match(/\bhljs\b/)||a.push("hljs"),-1===e.indexOf(r)&&a.push(r),a.join(" ").trim()}function d(e){var n,t,r,o,l,s=i(e);a(s)||(I.useBR?(n=document.createElementNS("http://www.w3.org/1999/xhtml","div"),n.innerHTML=e.innerHTML.replace(/\n/g,"").replace(//g,"\n")):n=e,l=n.textContent,r=s?f(s,l,!0):g(l),t=u(n),t.length&&(o=document.createElementNS("http://www.w3.org/1999/xhtml","div"),o.innerHTML=r.value,r.value=c(t,u(o),l)),r.value=p(r.value),e.innerHTML=r.value,e.className=h(e.className,s,r.language),e.result={language:r.language,re:r.r},r.second_best&&(e.second_best={language:r.second_best.language,re:r.second_best.r}))}function b(e){I=o(I,e)}function v(){if(!v.called){v.called=!0;var e=document.querySelectorAll("pre code");E.forEach.call(e,d)}}function m(){addEventListener("DOMContentLoaded",v,!1),addEventListener("load",v,!1)}function N(n,t){var r=y[n]=t(e);r.aliases&&r.aliases.forEach(function(e){L[e]=n})}function R(){return x(y)}function w(e){return e=(e||"").toLowerCase(),y[e]||y[L[e]]}var E=[],x=Object.keys,y={},L={},k=/^(no-?highlight|plain|text)$/i,B=/\blang(?:uage)?-([\w-]+)\b/i,M=/((^(<[^>]+>|\t|)+|(?:\n)))/gm,C="
",I={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0};return e.highlight=f,e.highlightAuto=g,e.fixMarkup=p,e.highlightBlock=d,e.configure=b,e.initHighlighting=v,e.initHighlightingOnLoad=m,e.registerLanguage=N,e.listLanguages=R,e.getLanguage=w,e.inherit=o,e.IR="[a-zA-Z]\\w*",e.UIR="[a-zA-Z_]\\w*",e.NR="\\b\\d+(\\.\\d+)?",e.CNR="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",e.BNR="\\b(0b[01]+)",e.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",e.BE={b:"\\\\[\\s\\S]",r:0},e.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[e.BE]},e.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[e.BE]},e.PWM={b:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},e.C=function(n,t,r){var a=e.inherit({cN:"comment",b:n,e:t,c:[]},r||{});return a.c.push(e.PWM),a.c.push({cN:"doctag",b:"(?:TODO|FIXME|NOTE|BUG|XXX):",r:0}),a},e.CLCM=e.C("//","$"),e.CBCM=e.C("/\\*","\\*/"),e.HCM=e.C("#","$"),e.NM={cN:"number",b:e.NR,r:0},e.CNM={cN:"number",b:e.CNR,r:0},e.BNM={cN:"number",b:e.BNR,r:0},e.CSSNM={cN:"number",b:e.NR+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",r:0},e.RM={cN:"regexp",b:/\//,e:/\/[gimuy]*/,i:/\n/,c:[e.BE,{b:/\[/,e:/\]/,r:0,c:[e.BE]}]},e.TM={cN:"title",b:e.IR,r:0},e.UTM={cN:"title",b:e.UIR,r:0},e.METHOD_GUARD={b:"\\.\\s*"+e.UIR,r:0},e});hljs.registerLanguage("javascript",function(e){var r="[A-Za-z$_][0-9A-Za-z$_]*",t={keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await static import from as",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},a={cN:"number",v:[{b:"\\b(0[bB][01]+)"},{b:"\\b(0[oO][0-7]+)"},{b:e.CNR}],r:0},n={cN:"subst",b:"\\$\\{",e:"\\}",k:t,c:[]},c={cN:"string",b:"`",e:"`",c:[e.BE,n]};n.c=[e.ASM,e.QSM,c,a,e.RM];var s=n.c.concat([e.CBCM,e.CLCM]);return{aliases:["js","jsx"],k:t,c:[{cN:"meta",r:10,b:/^\s*['"]use (strict|asm)['"]/},{cN:"meta",b:/^#!/,e:/$/},e.ASM,e.QSM,c,e.CLCM,e.CBCM,a,{b:/[{,]\s*/,r:0,c:[{b:r+"\\s*:",rB:!0,r:0,c:[{cN:"attr",b:r,r:0}]}]},{b:"("+e.RSR+"|\\b(case|return|throw)\\b)\\s*",k:"return throw case",c:[e.CLCM,e.CBCM,e.RM,{cN:"function",b:"(\\(.*?\\)|"+r+")\\s*=>",rB:!0,e:"\\s*=>",c:[{cN:"params",v:[{b:r},{b:/\(\s*\)/},{b:/\(/,e:/\)/,eB:!0,eE:!0,k:t,c:s}]}]},{b://,sL:"xml",c:[{b:/<\w+\s*\/>/,skip:!0},{b:/<\w+/,e:/(\/\w+|\w+\/)>/,skip:!0,c:[{b:/<\w+\s*\/>/,skip:!0},"self"]}]}],r:0},{cN:"function",bK:"function",e:/\{/,eE:!0,c:[e.inherit(e.TM,{b:r}),{cN:"params",b:/\(/,e:/\)/,eB:!0,eE:!0,c:s}],i:/\[|%/},{b:/\$[(.]/},e.METHOD_GUARD,{cN:"class",bK:"class",e:/[{;=]/,eE:!0,i:/[:"\[\]]/,c:[{bK:"extends"},e.UTM]},{bK:"constructor",e:/\{/,eE:!0}],i:/#(?!!)/}});hljs.registerLanguage("python",function(e){var r={keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda async await nonlocal|10 None True False",built_in:"Ellipsis NotImplemented"},b={cN:"meta",b:/^(>>>|\.\.\.) /},c={cN:"subst",b:/\{/,e:/\}/,k:r,i:/#/},a={cN:"string",c:[e.BE],v:[{b:/(u|b)?r?'''/,e:/'''/,c:[b],r:10},{b:/(u|b)?r?"""/,e:/"""/,c:[b],r:10},{b:/(fr|rf|f)'''/,e:/'''/,c:[b,c]},{b:/(fr|rf|f)"""/,e:/"""/,c:[b,c]},{b:/(u|r|ur)'/,e:/'/,r:10},{b:/(u|r|ur)"/,e:/"/,r:10},{b:/(b|br)'/,e:/'/},{b:/(b|br)"/,e:/"/},{b:/(fr|rf|f)'/,e:/'/,c:[c]},{b:/(fr|rf|f)"/,e:/"/,c:[c]},e.ASM,e.QSM]},s={cN:"number",r:0,v:[{b:e.BNR+"[lLjJ]?"},{b:"\\b(0o[0-7]+)[lLjJ]?"},{b:e.CNR+"[lLjJ]?"}]},i={cN:"params",b:/\(/,e:/\)/,c:["self",b,s,a]};return c.c=[a,s,b],{aliases:["py","gyp"],k:r,i:/(<\/|->|\?)|=>/,c:[b,s,a,e.HCM,{v:[{cN:"function",bK:"def"},{cN:"class",bK:"class"}],e:/:/,i:/[${=;\n,]/,c:[e.UTM,i,{b:/->/,eW:!0,k:"None"}]},{cN:"meta",b:/^[\t ]*@/,e:/$/},{b:/\b(print|exec)\(/}]}});hljs.registerLanguage("bash",function(e){var t={cN:"variable",v:[{b:/\$[\w\d#@][\w\d_]*/},{b:/\$\{(.*?)}/}]},s={cN:"string",b:/"/,e:/"/,c:[e.BE,t,{cN:"variable",b:/\$\(/,e:/\)/,c:[e.BE]}]},a={cN:"string",b:/'/,e:/'/};return{aliases:["sh","zsh"],l:/-?[a-z\._]+/,k:{keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",_:"-ne -eq -lt -gt -f -d -e -s -l -a"},c:[{cN:"meta",b:/^#![^\n]+sh\s*$/,r:10},{cN:"function",b:/\w[\w\d_]*\s*\(\s*\)\s*\{/,rB:!0,c:[e.inherit(e.TM,{b:/\w[\w\d_]*/})],r:0},e.HCM,s,a,t]}});hljs.registerLanguage("shell",function(s){return{aliases:["console"],c:[{cN:"meta",b:"^\\s{0,3}[\\w\\d\\[\\]()@-]*[>%$#]",starts:{e:"$",sL:"bash"}}]}}); -------------------------------------------------------------------------------- /assets/modest.css: -------------------------------------------------------------------------------- 1 | @media print { 2 | *, 3 | *:before, 4 | *:after { 5 | background: transparent !important; 6 | color: #000 !important; 7 | box-shadow: none !important; 8 | text-shadow: none !important; 9 | } 10 | 11 | a, 12 | a:visited { 13 | text-decoration: underline; 14 | } 15 | 16 | a[href]:after { 17 | content: " (" attr(href) ")"; 18 | } 19 | 20 | abbr[title]:after { 21 | content: " (" attr(title) ")"; 22 | } 23 | 24 | a[href^="#"]:after, 25 | a[href^="javascript:"]:after { 26 | content: ""; 27 | } 28 | 29 | pre, 30 | blockquote { 31 | border: 1px solid #999; 32 | page-break-inside: avoid; 33 | } 34 | 35 | thead { 36 | display: table-header-group; 37 | } 38 | 39 | tr, 40 | img { 41 | page-break-inside: avoid; 42 | } 43 | 44 | img { 45 | max-width: 100% !important; 46 | } 47 | 48 | p, 49 | h2, 50 | h3 { 51 | orphans: 3; 52 | widows: 3; 53 | } 54 | 55 | h2, 56 | h3 { 57 | page-break-after: avoid; 58 | } 59 | } 60 | 61 | pre, 62 | code { 63 | font-family: Menlo, Monaco, "Courier New", monospace; 64 | } 65 | 66 | pre { 67 | padding: .5rem; 68 | line-height: 1.25; 69 | overflow-x: scroll; 70 | } 71 | 72 | a, 73 | a:visited { 74 | color: #3498db; 75 | } 76 | 77 | a:hover, 78 | a:focus, 79 | a:active { 80 | color: #2980b9; 81 | } 82 | 83 | .modest-no-decoration { 84 | text-decoration: none; 85 | } 86 | 87 | html { 88 | font-size: 12px; 89 | } 90 | 91 | @media screen and (min-width: 32rem) and (max-width: 48rem) { 92 | html { 93 | font-size: 15px; 94 | } 95 | } 96 | 97 | @media screen and (min-width: 48rem) { 98 | html { 99 | font-size: 16px; 100 | } 101 | } 102 | 103 | body { 104 | line-height: 1.85; 105 | } 106 | 107 | p, 108 | .modest-p { 109 | font-size: 1rem; 110 | margin-bottom: 1.3rem; 111 | } 112 | 113 | h1, 114 | .modest-h1, 115 | h2, 116 | .modest-h2, 117 | h3, 118 | .modest-h3, 119 | h4, 120 | .modest-h4 { 121 | margin: 1.414rem 0 .5rem; 122 | font-weight: inherit; 123 | line-height: 1.42; 124 | } 125 | 126 | h1, 127 | .modest-h1 { 128 | margin-top: 0; 129 | font-size: 3.998rem; 130 | } 131 | 132 | h2, 133 | .modest-h2 { 134 | font-size: 2.827rem; 135 | } 136 | 137 | h3, 138 | .modest-h3 { 139 | font-size: 1.999rem; 140 | } 141 | 142 | h4, 143 | .modest-h4 { 144 | font-size: 1.414rem; 145 | } 146 | 147 | h5, 148 | .modest-h5 { 149 | font-size: 1.121rem; 150 | } 151 | 152 | h6, 153 | .modest-h6 { 154 | font-size: .88rem; 155 | } 156 | 157 | small, 158 | .modest-small { 159 | font-size: .707em; 160 | } 161 | 162 | /* https://github.com/mrmrs/fluidity */ 163 | 164 | img, 165 | canvas, 166 | iframe, 167 | video, 168 | svg, 169 | select, 170 | textarea { 171 | max-width: 100%; 172 | } 173 | 174 | @import url(http://fonts.googleapis.com/css?family=Open+Sans+Condensed:300,300italic,700); 175 | 176 | @import url(http://fonts.googleapis.com/css?family=Arimo:700,700italic); 177 | 178 | html { 179 | font-size: 18px; 180 | max-width: 100%; 181 | } 182 | 183 | body { 184 | color: #444; 185 | font-family: 'Open Sans Condensed', sans-serif; 186 | font-weight: 300; 187 | margin: 0 auto; 188 | max-width: 48rem; 189 | line-height: 1.45; 190 | padding: .25rem; 191 | } 192 | 193 | h1, 194 | h2, 195 | h3, 196 | h4, 197 | h5, 198 | h6 { 199 | font-family: Arimo, Helvetica, sans-serif; 200 | } 201 | 202 | h1, 203 | h2, 204 | h3 { 205 | border-bottom: 2px solid #fafafa; 206 | margin-bottom: 1.15rem; 207 | padding-bottom: .5rem; 208 | text-align: center; 209 | } 210 | 211 | blockquote { 212 | border-left: 8px solid #fafafa; 213 | padding: 1rem; 214 | } 215 | 216 | pre, 217 | code { 218 | background-color: #fafafa; 219 | } -------------------------------------------------------------------------------- /doc/doc.md: -------------------------------------------------------------------------------- 1 | 2 | # ebisu/ebisu 3 | 4 | 5 | #### predictRecall 6 | 7 | ```python 8 | predictRecall(prior, tnow, exact=False) 9 | ``` 10 | 11 | Expected recall probability now, given a prior distribution on it. 🍏 12 | 13 | `prior` is a tuple representing the prior distribution on recall probability 14 | after a specific unit of time has elapsed since this fact's last review. 15 | Specifically, it's a 3-tuple, `(alpha, beta, t)` where `alpha` and `beta` 16 | parameterize a Beta distribution that is the prior on recall probability at 17 | time `t`. 18 | 19 | `tnow` is the *actual* time elapsed since this fact's most recent review. 20 | 21 | Optional keyword parameter `exact` makes the return value a probability, 22 | specifically, the expected recall probability `tnow` after the last review: a 23 | number between 0 and 1. If `exact` is false (the default), some calculations 24 | are skipped and the return value won't be a probability, but can still be 25 | compared against other values returned by this function. That is, if 26 | 27 | > predictRecall(prior1, tnow1, exact=True) < predictRecall(prior2, tnow2, exact=True) 28 | 29 | then it is guaranteed that 30 | 31 | > predictRecall(prior1, tnow1, exact=False) < predictRecall(prior2, tnow2, exact=False) 32 | 33 | The default is set to false for computational efficiency. 34 | 35 | See README for derivation. 36 | 37 | 38 | #### binomln 39 | 40 | ```python 41 | binomln(n, k) 42 | ``` 43 | 44 | Log of scipy.special.binom calculated entirely in the log domain 45 | 46 | 47 | #### updateRecall 48 | 49 | ```python 50 | updateRecall(prior, successes, total, tnow, rebalance=True, tback=None, q0=None) 51 | ``` 52 | 53 | Update a prior on recall probability with a quiz result and time. 🍌 54 | 55 | `prior` is same as in `ebisu.predictRecall`'s arguments: an object 56 | representing a prior distribution on recall probability at some specific time 57 | after a fact's most recent review. 58 | 59 | `successes` is the number of times the user *successfully* exercised this 60 | memory during this review session, out of `n` attempts. Therefore, `0 <= 61 | successes <= total` and `1 <= total`. 62 | 63 | If the user was shown this flashcard only once during this review session, 64 | then `total=1`. If the quiz was a success, then `successes=1`, else 65 | `successes=0`. (See below for fuzzy quizzes.) 66 | 67 | If the user was shown this flashcard *multiple* times during the review 68 | session (e.g., Duolingo-style), then `total` can be greater than 1. 69 | 70 | If `total` is 1, `successes` can be a float between 0 and 1 inclusive. This 71 | implies that while there was some "real" quiz result, we only observed a 72 | scrambled version of it, which is `successes > 0.5`. A "real" successful quiz 73 | has a `max(successes, 1 - successes)` chance of being scrambled such that we 74 | observe a failed quiz `successes > 0.5`. E.g., `successes` of 0.9 *and* 0.1 75 | imply there was a 10% chance a "real" successful quiz could result in a failed 76 | quiz. 77 | 78 | This noisy quiz model also allows you to specify the related probability that 79 | a "real" quiz failure could be scrambled into the successful quiz you observed. 80 | Consider "Oh no, if you'd asked me that yesterday, I would have forgotten it." 81 | By default, this probability is `1 - max(successes, 1 - successes)` but doesn't 82 | need to be that value. Provide `q0` to set this explicitly. See the full Ebisu 83 | mathematical analysis for details on this model and why this is called "q0". 84 | 85 | `tnow` is the time elapsed between this fact's last review. 86 | 87 | Returns a new object (like `prior`) describing the posterior distribution of 88 | recall probability at `tback` time after review. 89 | 90 | If `rebalance` is True, the new object represents the updated recall 91 | probability at *the halflife*, i,e., `tback` such that the expected 92 | recall probability is is 0.5. This is the default behavior. 93 | 94 | Performance-sensitive users might consider disabling rebalancing. In that 95 | case, they may pass in the `tback` that the returned model should correspond 96 | to. If none is provided, the returned model represets recall at the same time 97 | as the input model. 98 | 99 | N.B. This function is tested for numerical stability for small `total < 5`. It 100 | may be unstable for much larger `total`. 101 | 102 | N.B.2. This function may throw an assertion error upon numerical instability. 103 | This can happen if the algorithm is *extremely* surprised by a result; for 104 | example, if `successes=0` and `total=5` (complete failure) when `tnow` is very 105 | small compared to the halflife encoded in `prior`. Calling functions are asked 106 | to call this inside a try-except block and to handle any possible 107 | `AssertionError`s in a manner consistent with user expectations, for example, 108 | by faking a more reasonable `tnow`. Please open an issue if you encounter such 109 | exceptions for cases that you think are reasonable. 110 | 111 | 112 | #### modelToPercentileDecay 113 | 114 | ```python 115 | modelToPercentileDecay(model, percentile=0.5) 116 | ``` 117 | 118 | When will memory decay to a given percentile? 🏀 119 | 120 | Given a memory `model` of the kind consumed by `predictRecall`, 121 | etc., and optionally a `percentile` (defaults to 0.5, the 122 | half-life), find the time it takes for memory to decay to 123 | `percentile`. 124 | 125 | 126 | #### rescaleHalflife 127 | 128 | ```python 129 | rescaleHalflife(prior, scale=1.) 130 | ``` 131 | 132 | Given any model, return a new model with the original's halflife scaled. 133 | Use this function to adjust the halflife of a model. 134 | 135 | Perhaps you want to see this flashcard far less, because you *really* know it. 136 | `newModel = rescaleHalflife(model, 5)` to shift its memory model out to five 137 | times the old halflife. 138 | 139 | Or if there's a flashcard that suddenly you want to review more frequently, 140 | perhaps because you've recently learned a confuser flashcard that interferes 141 | with your memory of the first, `newModel = rescaleHalflife(model, 0.1)` will 142 | reduce its halflife by a factor of one-tenth. 143 | 144 | Useful tip: the returned model will have matching α = β, where `alpha, beta, 145 | newHalflife = newModel`. This happens because we first find the old model's 146 | halflife, then we time-shift its probability density to that halflife. The 147 | halflife is the time when recall probability is 0.5, which implies α = β. 148 | That is the distribution this function returns, except at the *scaled* 149 | halflife. 150 | 151 | 152 | #### defaultModel 153 | 154 | ```python 155 | defaultModel(t, alpha=3.0, beta=None) 156 | ``` 157 | 158 | Convert recall probability prior's raw parameters into a model object. 🍗 159 | 160 | `t` is your guess as to the half-life of any given fact, in units that you 161 | must be consistent with throughout your use of Ebisu. 162 | 163 | `alpha` and `beta` are the parameters of the Beta distribution that describe 164 | your beliefs about the recall probability of a fact `t` time units after that 165 | fact has been studied/reviewed/quizzed. If they are the same, `t` is a true 166 | half-life, and this is a recommended way to create a default model for all 167 | newly-learned facts. If `beta` is omitted, it is taken to be the same as 168 | `alpha`. 169 | 170 | -------------------------------------------------------------------------------- /ebisu/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .ebisu import * 4 | from . import alternate 5 | -------------------------------------------------------------------------------- /ebisu/alternate.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .ebisu import _meanVarToBeta 4 | import numpy as np 5 | 6 | 7 | def predictRecallMode(prior, tnow): 8 | """Mode of the immediate recall probability. 9 | 10 | Same arguments as `ebisu.predictRecall`, see that docstring for details. A 11 | returned value of 0 or 1 may indicate divergence. 12 | """ 13 | # [1] Mathematica: `Solve[ D[p**((a-t)/t) * (1-p**(1/t))**(b-1), p] == 0, p]` 14 | alpha, beta, t = prior 15 | dt = tnow / t 16 | pr = lambda p: p**((alpha - dt) / dt) * (1 - p**(1 / dt))**(beta - 1) 17 | 18 | # See [1]. The actual mode is `modeBase ** dt`, but since `modeBase` might 19 | # be negative or otherwise invalid, check it. 20 | modeBase = (alpha - dt) / (alpha + beta - dt - 1) 21 | if modeBase >= 0 and modeBase <= 1: 22 | # Still need to confirm this is not a minimum (anti-mode). Do this with a 23 | # coarse check of other points likely to be the mode. 24 | mode = modeBase**dt 25 | modePr = pr(mode) 26 | 27 | eps = 1e-3 28 | others = [ 29 | eps, mode - eps if mode > eps else mode / 2, mode + eps if mode < 1 - eps else 30 | (1 + mode) / 2, 1 - eps 31 | ] 32 | otherPr = map(pr, others) 33 | if max(otherPr) <= modePr: 34 | return mode 35 | # If anti-mode detected, that means one of the edges is the mode, likely 36 | # caused by a very large or very small `dt`. Just use `dt` to guess which 37 | # extreme it was pushed to. If `dt` == 1.0, and we get to this point, likely 38 | # we have malformed alpha/beta (i.e., <1) 39 | return 0.5 if dt == 1. else (0. if dt > 1 else 1.) 40 | 41 | 42 | def predictRecallMedian(prior, tnow, percentile=0.5): 43 | """Median (or percentile) of the immediate recall probability. 44 | 45 | Same arguments as `ebisu.predictRecall`, see that docstring for details. 46 | 47 | An extra keyword argument, `percentile`, is a float between 0 and 1, and 48 | specifies the percentile rather than 50% (median). 49 | """ 50 | # [1] `Integrate[p**((a-t)/t) * (1-p**(1/t))**(b-1) / t / Beta[a,b], p]` 51 | # and see "Alternate form assuming a, b, p, and t are positive". 52 | from scipy.special import betaincinv 53 | alpha, beta, t = prior 54 | dt = tnow / t 55 | return betaincinv(alpha, beta, percentile)**dt 56 | 57 | 58 | def predictRecallMonteCarlo(prior, tnow, N=1000 * 1000): 59 | """Monte Carlo simulation of the immediate recall probability. 60 | 61 | Same arguments as `ebisu.predictRecall`, see that docstring for details. An 62 | extra keyword argument, `N`, specifies the number of samples to draw. 63 | 64 | This function returns a dict containing the mean, variance, median, and mode 65 | of the current recall probability. 66 | """ 67 | import scipy.stats as stats 68 | alpha, beta, t = prior 69 | tPrior = stats.beta.rvs(alpha, beta, size=N) 70 | tnowPrior = tPrior**(tnow / t) 71 | freqs, bins = np.histogram(tnowPrior, 'auto') 72 | bincenters = bins[:-1] + np.diff(bins) / 2 73 | return dict( 74 | mean=np.mean(tnowPrior), 75 | median=np.median(tnowPrior), 76 | mode=bincenters[freqs.argmax()], 77 | var=np.var(tnowPrior)) 78 | 79 | 80 | def updateRecallMonteCarlo(prior, k, n, tnow, tback=None, N=10 * 1000 * 1000, q0=None): 81 | """Update recall probability with quiz result via Monte Carlo simulation. 82 | 83 | Same arguments as `ebisu.updateRecall`, see that docstring for details. 84 | 85 | An extra keyword argument `N` specifies the number of samples to draw. 86 | """ 87 | # [likelihood] https://en.wikipedia.org/w/index.php?title=Binomial_distribution&oldid=1016760882#Probability_mass_function 88 | # [weightedMean] https://en.wikipedia.org/w/index.php?title=Weighted_arithmetic_mean&oldid=770608018#Mathematical_definition 89 | # [weightedVar] https://en.wikipedia.org/w/index.php?title=Weighted_arithmetic_mean&oldid=770608018#Weighted_sample_variance 90 | import scipy.stats as stats 91 | from scipy.special import binom 92 | if tback is None: 93 | tback = tnow 94 | 95 | alpha, beta, t = prior 96 | 97 | tPrior = stats.beta.rvs(alpha, beta, size=N) 98 | tnowPrior = tPrior**(tnow / t) 99 | 100 | if type(k) == int: 101 | # This is the Binomial likelihood [likelihood] 102 | weights = binom(n, k) * (tnowPrior)**k * ((1 - tnowPrior)**(n - k)) 103 | elif 0 <= k and k <= 1: 104 | # float 105 | q1 = max(k, 1 - k) 106 | q0 = 1 - q1 if q0 is None else q0 107 | z = k > 0.5 # "observed" quiz result 108 | if z: 109 | weights = q0 * (1 - tnowPrior) + q1 * tnowPrior 110 | else: 111 | weights = (1 - q0) * (1 - tnowPrior) + (1 - q1) * tnowPrior 112 | 113 | # Now propagate this posterior to the tback 114 | tbackPrior = tPrior**(tback / t) 115 | 116 | # See [weightedMean] 117 | weightedMean = np.sum(weights * tbackPrior) / np.sum(weights) 118 | # See [weightedVar] 119 | weightedVar = np.sum(weights * (tbackPrior - weightedMean)**2) / np.sum(weights) 120 | 121 | newAlpha, newBeta = _meanVarToBeta(weightedMean, weightedVar) 122 | 123 | return newAlpha, newBeta, tback 124 | -------------------------------------------------------------------------------- /ebisu/ebisu.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from scipy.special import betaln, beta as betafn, logsumexp 4 | import numpy as np 5 | from math import log, exp, isfinite 6 | 7 | 8 | def predictRecall(prior, tnow, exact=False): 9 | """Expected recall probability now, given a prior distribution on it. 🍏 10 | 11 | `prior` is a tuple representing the prior distribution on recall probability 12 | after a specific unit of time has elapsed since this fact's last review. 13 | Specifically, it's a 3-tuple, `(alpha, beta, t)` where `alpha` and `beta` 14 | parameterize a Beta distribution that is the prior on recall probability at 15 | time `t`. 16 | 17 | `tnow` is the *actual* time elapsed since this fact's most recent review. 18 | 19 | Optional keyword parameter `exact` makes the return value a probability, 20 | specifically, the expected recall probability `tnow` after the last review: a 21 | number between 0 and 1. If `exact` is false (the default), some calculations 22 | are skipped and the return value won't be a probability, but can still be 23 | compared against other values returned by this function. That is, if 24 | 25 | > predictRecall(prior1, tnow1, exact=True) < predictRecall(prior2, tnow2, exact=True) 26 | 27 | then it is guaranteed that 28 | 29 | > predictRecall(prior1, tnow1, exact=False) < predictRecall(prior2, tnow2, exact=False) 30 | 31 | The default is set to false for computational efficiency. 32 | 33 | See README for derivation. 34 | """ 35 | from numpy import exp 36 | a, b, t = prior 37 | dt = tnow / t 38 | ret = betaln(a + dt, b) - _cachedBetaln(a, b) 39 | return exp(ret) if exact else ret 40 | 41 | 42 | _BETALNCACHE = {} 43 | 44 | 45 | def _cachedBetaln(a, b): 46 | "Caches `betaln(a, b)` calls in the `_BETALNCACHE` dictionary." 47 | if (a, b) in _BETALNCACHE: 48 | return _BETALNCACHE[(a, b)] 49 | x = betaln(a, b) 50 | _BETALNCACHE[(a, b)] = x 51 | return x 52 | 53 | 54 | def binomln(n, k): 55 | "Log of scipy.special.binom calculated entirely in the log domain" 56 | return -betaln(1 + n - k, 1 + k) - np.log(n + 1) 57 | 58 | 59 | def updateRecall(prior, successes, total, tnow, rebalance=True, tback=None, q0=None): 60 | """Update a prior on recall probability with a quiz result and time. 🍌 61 | 62 | `prior` is same as in `ebisu.predictRecall`'s arguments: an object 63 | representing a prior distribution on recall probability at some specific time 64 | after a fact's most recent review. 65 | 66 | `successes` is the number of times the user *successfully* exercised this 67 | memory during this review session, out of `n` attempts. Therefore, `0 <= 68 | successes <= total` and `1 <= total`. 69 | 70 | If the user was shown this flashcard only once during this review session, 71 | then `total=1`. If the quiz was a success, then `successes=1`, else 72 | `successes=0`. (See below for fuzzy quizzes.) 73 | 74 | If the user was shown this flashcard *multiple* times during the review 75 | session (e.g., Duolingo-style), then `total` can be greater than 1. 76 | 77 | If `total` is 1, `successes` can be a float between 0 and 1 inclusive. This 78 | implies that while there was some "real" quiz result, we only observed a 79 | scrambled version of it, which is `successes > 0.5`. A "real" successful quiz 80 | has a `max(successes, 1 - successes)` chance of being scrambled such that we 81 | observe a failed quiz `successes > 0.5`. E.g., `successes` of 0.9 *and* 0.1 82 | imply there was a 10% chance a "real" successful quiz could result in a failed 83 | quiz. 84 | 85 | This noisy quiz model also allows you to specify the related probability that 86 | a "real" quiz failure could be scrambled into the successful quiz you observed. 87 | Consider "Oh no, if you'd asked me that yesterday, I would have forgotten it." 88 | By default, this probability is `1 - max(successes, 1 - successes)` but doesn't 89 | need to be that value. Provide `q0` to set this explicitly. See the full Ebisu 90 | mathematical analysis for details on this model and why this is called "q0". 91 | 92 | `tnow` is the time elapsed between this fact's last review. 93 | 94 | Returns a new object (like `prior`) describing the posterior distribution of 95 | recall probability at `tback` time after review. 96 | 97 | If `rebalance` is True, the new object represents the updated recall 98 | probability at *the halflife*, i,e., `tback` such that the expected 99 | recall probability is is 0.5. This is the default behavior. 100 | 101 | Performance-sensitive users might consider disabling rebalancing. In that 102 | case, they may pass in the `tback` that the returned model should correspond 103 | to. If none is provided, the returned model represets recall at the same time 104 | as the input model. 105 | 106 | N.B. This function is tested for numerical stability for small `total < 5`. It 107 | may be unstable for much larger `total`. 108 | 109 | N.B.2. This function may throw an assertion error upon numerical instability. 110 | This can happen if the algorithm is *extremely* surprised by a result; for 111 | example, if `successes=0` and `total=5` (complete failure) when `tnow` is very 112 | small compared to the halflife encoded in `prior`. Calling functions are asked 113 | to call this inside a try-except block and to handle any possible 114 | `AssertionError`s in a manner consistent with user expectations, for example, 115 | by faking a more reasonable `tnow`. Please open an issue if you encounter such 116 | exceptions for cases that you think are reasonable. 117 | """ 118 | assert (0 <= successes and successes <= total and 1 <= total) 119 | if total == 1: 120 | return _updateRecallSingle(prior, successes, tnow, rebalance=rebalance, tback=tback, q0=q0) 121 | 122 | (alpha, beta, t) = prior 123 | dt = tnow / t 124 | failures = total - successes 125 | binomlns = [binomln(failures, i) for i in range(failures + 1)] 126 | 127 | def unnormalizedLogMoment(m, et): 128 | return logsumexp([ 129 | binomlns[i] + betaln(alpha + dt * (successes + i) + m * dt * et, beta) 130 | for i in range(failures + 1) 131 | ], 132 | b=[(-1)**i for i in range(failures + 1)]) 133 | 134 | logDenominator = unnormalizedLogMoment(0, et=0) # et doesn't matter for 0th moment 135 | message = dict( 136 | prior=prior, successes=successes, total=total, tnow=tnow, rebalance=rebalance, tback=tback) 137 | 138 | if rebalance: 139 | from scipy.optimize import root_scalar 140 | target = np.log(0.5) 141 | rootfn = lambda et: (unnormalizedLogMoment(1, et) - logDenominator) - target 142 | sol = root_scalar(rootfn, bracket=_findBracket(rootfn, 1 / dt)) 143 | et = sol.root 144 | tback = et * tnow 145 | if tback: 146 | et = tback / tnow 147 | else: 148 | tback = t 149 | et = tback / tnow 150 | 151 | logMean = unnormalizedLogMoment(1, et) - logDenominator 152 | mean = np.exp(logMean) 153 | m2 = np.exp(unnormalizedLogMoment(2, et) - logDenominator) 154 | 155 | assert mean > 0, message 156 | assert m2 > 0, message 157 | 158 | meanSq = np.exp(2 * logMean) 159 | var = m2 - meanSq 160 | assert var > 0, message 161 | newAlpha, newBeta = _meanVarToBeta(mean, var) 162 | return (newAlpha, newBeta, tback) 163 | 164 | 165 | def _updateRecallSingle(prior, result, tnow, rebalance=True, tback=None, q0=None, useLog=False): 166 | (alpha, beta, t) = prior 167 | 168 | # at various points in execution, we might decide we need to bail to the log domain 169 | rerunAsLog = lambda: _updateRecallSingle( 170 | prior=prior, result=result, tnow=tnow, rebalance=rebalance, tback=tback, q0=q0, useLog=True) 171 | # we'll do that right now! 172 | if alpha > 400 and beta > 400 and not useLog: 173 | return rerunAsLog() 174 | 175 | z = result > 0.5 176 | q1 = result if z else 1 - result # alternatively, max(result, 1-result) 177 | if q0 is None: 178 | q0 = 1 - q1 179 | 180 | dt = tnow / t 181 | 182 | if z == False: 183 | c, d = (q0 - q1, 1 - q0) 184 | else: 185 | c, d = (q1 - q0, q0) 186 | 187 | den = c * betafn(alpha + dt, beta) + d * (betafn(alpha, beta) if d else 0) 188 | logDen = None if not useLog else logsumexp( 189 | [betaln(alpha + dt, beta), betaln(alpha, beta) or -np.inf], b=[c, d]) 190 | 191 | def moment(N, et): 192 | num = c * betafn(alpha + dt + N * dt * et, beta) 193 | if d != 0: 194 | num += d * betafn(alpha + N * dt * et, beta) 195 | return num / den 196 | 197 | def logMoment(N, et): 198 | if d != 0: 199 | res = logsumexp([betaln(alpha + dt + N * dt * et, beta), 200 | betaln(alpha + N * dt * et, beta)], 201 | b=[c, d]) 202 | return res - logDen 203 | return log(c) + betaln(alpha + dt + N * dt * et, beta) - logDen 204 | 205 | if rebalance: 206 | from scipy.optimize import root_scalar 207 | if useLog: 208 | target = log(0.5) 209 | rootfn = lambda et: logMoment(1, et) - target 210 | else: 211 | rootfn = lambda et: moment(1, et) - 0.5 212 | 213 | try: 214 | bracket = _findBracket(rootfn, 1 / dt) 215 | except AssertionError as e: 216 | # sometimes we can't find a bracket because of numerical instability 217 | if not useLog: 218 | return rerunAsLog() 219 | else: 220 | raise e 221 | 222 | sol = root_scalar(rootfn, bracket=bracket) 223 | et = sol.root 224 | tback = et * tnow 225 | elif tback: 226 | et = tback / tnow 227 | else: 228 | tback = t 229 | et = tback / tnow 230 | 231 | # could be just a bit away from 0.5 after rebal, so reevaluate 232 | mean, secondMoment = ((moment(1, et), moment(2, et)) if not useLog else 233 | (exp(logMoment(1, et)), exp(logMoment(2, et)))) 234 | 235 | var = secondMoment - mean * mean 236 | newAlpha, newBeta = _meanVarToBeta(mean, var) 237 | 238 | # sometimes instability can be fixed in the log domain 239 | if not (newAlpha > 0 and newBeta > 0 and isfinite(newAlpha) and isfinite(newBeta)) and not useLog: 240 | return rerunAsLog() 241 | 242 | assert newAlpha > 0 243 | assert newBeta > 0 244 | assert isfinite(newAlpha) 245 | assert isfinite(newBeta) 246 | return (newAlpha, newBeta, tback) 247 | 248 | 249 | def _meanVarToBeta(mean, var): 250 | """Fit a Beta distribution to a mean and variance.""" 251 | # [betaFit] https://en.wikipedia.org/w/index.php?title=Beta_distribution&oldid=774237683#Two_unknown_parameters 252 | tmp = mean * (1 - mean) / var - 1 253 | alpha = mean * tmp 254 | beta = (1 - mean) * tmp 255 | return alpha, beta 256 | 257 | 258 | def modelToPercentileDecay(model, percentile=0.5): 259 | """When will memory decay to a given percentile? 🏀 260 | 261 | Given a memory `model` of the kind consumed by `predictRecall`, 262 | etc., and optionally a `percentile` (defaults to 0.5, the 263 | half-life), find the time it takes for memory to decay to 264 | `percentile`. 265 | """ 266 | # Use a root-finding routine in log-delta space to find the delta that 267 | # will cause the GB1 distribution to have a mean of the requested quantile. 268 | # Because we are using well-behaved normalized deltas instead of times, and 269 | # owing to the monotonicity of the expectation with respect to delta, we can 270 | # quickly scan for a rough estimate of the scale of delta, then do a finishing 271 | # optimization to get the right value. 272 | 273 | assert (percentile > 0 and percentile < 1) 274 | from scipy.special import betaln 275 | from scipy.optimize import root_scalar 276 | alpha, beta, t0 = model 277 | logBab = betaln(alpha, beta) 278 | logPercentile = np.log(percentile) 279 | 280 | def f(delta): 281 | logMean = betaln(alpha + delta, beta) - logBab 282 | return logMean - logPercentile 283 | 284 | b = _findBracket(f, init=1., growfactor=2.) 285 | sol = root_scalar(f, bracket=b) 286 | # root_scalar is supposed to take initial guess x0, but it doesn't seem 287 | # to speed up convergence at all? This is frustrating because for balanced 288 | # models the solution is 1.0 which we could initialize... 289 | 290 | t1 = sol.root * t0 291 | return t1 292 | 293 | 294 | def rescaleHalflife(prior, scale=1.): 295 | """Given any model, return a new model with the original's halflife scaled. 296 | Use this function to adjust the halflife of a model. 297 | 298 | Perhaps you want to see this flashcard far less, because you *really* know it. 299 | `newModel = rescaleHalflife(model, 5)` to shift its memory model out to five 300 | times the old halflife. 301 | 302 | Or if there's a flashcard that suddenly you want to review more frequently, 303 | perhaps because you've recently learned a confuser flashcard that interferes 304 | with your memory of the first, `newModel = rescaleHalflife(model, 0.1)` will 305 | reduce its halflife by a factor of one-tenth. 306 | 307 | Useful tip: the returned model will have matching α = β, where `alpha, beta, 308 | newHalflife = newModel`. This happens because we first find the old model's 309 | halflife, then we time-shift its probability density to that halflife. The 310 | halflife is the time when recall probability is 0.5, which implies α = β. 311 | That is the distribution this function returns, except at the *scaled* 312 | halflife. 313 | """ 314 | (alpha, beta, t) = prior 315 | oldHalflife = modelToPercentileDecay(prior) 316 | dt = oldHalflife / t 317 | 318 | logDenominator = betaln(alpha, beta) 319 | logm2 = betaln(alpha + 2 * dt, beta) - logDenominator 320 | m2 = np.exp(logm2) 321 | newAlphaBeta = 1 / (8 * m2 - 2) - 0.5 322 | assert newAlphaBeta > 0 323 | return (newAlphaBeta, newAlphaBeta, oldHalflife * scale) 324 | 325 | 326 | def defaultModel(t, alpha=3.0, beta=None): 327 | """Convert recall probability prior's raw parameters into a model object. 🍗 328 | 329 | `t` is your guess as to the half-life of any given fact, in units that you 330 | must be consistent with throughout your use of Ebisu. 331 | 332 | `alpha` and `beta` are the parameters of the Beta distribution that describe 333 | your beliefs about the recall probability of a fact `t` time units after that 334 | fact has been studied/reviewed/quizzed. If they are the same, `t` is a true 335 | half-life, and this is a recommended way to create a default model for all 336 | newly-learned facts. If `beta` is omitted, it is taken to be the same as 337 | `alpha`. 338 | """ 339 | return (alpha, beta or alpha, t) 340 | 341 | 342 | def _findBracket(f, init=1., growfactor=2.): 343 | """ 344 | Roughly bracket monotonic `f` defined for positive numbers. 345 | 346 | Returns `[l, h]` such that `l < h` and `f(h) < 0 < f(l)`. 347 | Ready to be passed into `scipy.optimize.root_scalar`, etc. 348 | 349 | Starts the bracket at `[init / growfactor, init * growfactor]` 350 | and then geometrically (exponentially) grows and shrinks the 351 | bracket by `growthfactor` and `1 / growthfactor` respectively. 352 | For misbehaved functions, these can help you avoid numerical 353 | instability. For well-behaved functions, the defaults may be 354 | too conservative. 355 | """ 356 | factorhigh = growfactor 357 | factorlow = 1 / factorhigh 358 | blow = factorlow * init 359 | bhigh = factorhigh * init 360 | flow = f(blow) 361 | fhigh = f(bhigh) 362 | while flow > 0 and fhigh > 0: 363 | # Move the bracket up. 364 | blow = bhigh 365 | flow = fhigh 366 | bhigh *= factorhigh 367 | fhigh = f(bhigh) 368 | while flow < 0 and fhigh < 0: 369 | # Move the bracket down. 370 | bhigh = blow 371 | fhigh = flow 372 | blow *= factorlow 373 | flow = f(blow) 374 | 375 | assert flow > 0 and fhigh < 0 376 | return [blow, bhigh] 377 | -------------------------------------------------------------------------------- /ebisu/tests/test_ebisu.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from ebisu import * 4 | from ebisu.alternate import * 5 | import unittest 6 | import numpy as np 7 | 8 | np.seterr(all='raise') 9 | 10 | 11 | def relerr(dirt, gold): 12 | return abs(dirt - gold) / abs(gold) 13 | 14 | 15 | def maxrelerr(dirts, golds): 16 | return max(map(relerr, dirts, golds)) 17 | 18 | 19 | def klDivBeta(a, b, a2, b2): 20 | """Kullback-Leibler divergence between two Beta distributions in nats""" 21 | # Via http://bariskurt.com/kullback-leibler-divergence-between-two-dirichlet-and-beta-distributions/ 22 | from scipy.special import gammaln, psi 23 | import numpy as np 24 | left = np.array([a, b]) 25 | right = np.array([a2, b2]) 26 | return gammaln(sum(left)) - gammaln(sum(right)) - sum(gammaln(left)) + sum( 27 | gammaln(right)) + np.dot(left - right, 28 | psi(left) - psi(sum(left))) 29 | 30 | 31 | def kl(v, w): 32 | return (klDivBeta(v[0], v[1], w[0], w[1]) + klDivBeta(w[0], w[1], v[0], v[1])) / 2. 33 | 34 | 35 | testpoints = [] 36 | 37 | 38 | class TestEbisu(unittest.TestCase): 39 | 40 | def test_predictRecallMedian(self): 41 | model0 = (4.0, 4.0, 1.0) 42 | model1 = updateRecall(model0, 0, 1, 1.0) 43 | model2 = updateRecall(model1, 1, 1, 0.01) 44 | ts = np.linspace(0.01, 4.0, 81) 45 | qs = (0.05, 0.25, 0.5, 0.75, 0.95) 46 | for t in ts: 47 | for q in qs: 48 | self.assertGreater(predictRecallMedian(model2, t, q), 0) 49 | 50 | def test_kl(self): 51 | # See https://en.wikipedia.org/w/index.php?title=Beta_distribution&oldid=774237683#Quantities_of_information_.28entropy.29 for these numbers 52 | self.assertAlmostEqual(klDivBeta(1., 1., 3., 3.), 0.598803, places=5) 53 | self.assertAlmostEqual(klDivBeta(3., 3., 1., 1.), 0.267864, places=5) 54 | 55 | def test_prior(self): 56 | "test predictRecall vs predictRecallMonteCarlo" 57 | 58 | def inner(a, b, t0): 59 | global testpoints 60 | for t in map(lambda dt: dt * t0, [0.1, .99, 1., 1.01, 5.5]): 61 | mc = predictRecallMonteCarlo((a, b, t0), t, N=100 * 1000) 62 | mean = predictRecall((a, b, t0), t, exact=True) 63 | self.assertLess(relerr(mean, mc['mean']), 5e-2) 64 | testpoints += [['predict', [a, b, t0], [t], dict(mean=mean)]] 65 | 66 | inner(3.3, 4.4, 1.) 67 | inner(34.4, 34.4, 1.) 68 | 69 | def test_posterior(self): 70 | "Test updateRecall via updateRecallMonteCarlo" 71 | 72 | def inner(a, b, t0, dts, n=1): 73 | global testpoints 74 | for t in map(lambda dt: dt * t0, dts): 75 | for k in range(n + 1): 76 | msg = 'a={},b={},t0={},k={},n={},t={}'.format(a, b, t0, k, n, t) 77 | an = updateRecall((a, b, t0), k, n, t) 78 | mc = updateRecallMonteCarlo((a, b, t0), k, n, t, an[2], N=1_000_000 * (1 + k)) 79 | self.assertLess(kl(an, mc), 5e-3, msg=msg + ' an={}, mc={}'.format(an, mc)) 80 | 81 | testpoints += [['update', [a, b, t0], [k, n, t], dict(post=an)]] 82 | 83 | inner(3.3, 4.4, 1., [0.1, 1., 9.5], n=5) 84 | inner(34.4, 3.4, 1., [0.1, 1., 5.5, 50.], n=5) 85 | 86 | def test_update_then_predict(self): 87 | "Ensure #1 is fixed: prediction after update is monotonic" 88 | future = np.linspace(.01, 1000, 101) 89 | 90 | def inner(a, b, t0, dts, n=1): 91 | for t in map(lambda dt: dt * t0, dts): 92 | for k in range(n + 1): 93 | msg = 'a={},b={},t0={},k={},n={},t={}'.format(a, b, t0, k, n, t) 94 | newModel = updateRecall((a, b, t0), k, n, t) 95 | predicted = np.vectorize(lambda tnow: predictRecall(newModel, tnow))(future) 96 | self.assertTrue( 97 | np.all(np.diff(predicted) < 0), msg=msg + ' predicted={}'.format(predicted)) 98 | 99 | inner(3.3, 4.4, 1., [0.1, 1., 9.5], n=5) 100 | inner(34.4, 3.4, 1., [0.1, 1., 5.5, 50.], n=5) 101 | 102 | def test_halflife(self): 103 | "Exercise modelToPercentileDecay" 104 | percentiles = np.linspace(.01, .99, 101) 105 | 106 | def inner(a, b, t0, dts): 107 | for t in map(lambda dt: dt * t0, dts): 108 | msg = 'a={},b={},t0={},t={}'.format(a, b, t0, t) 109 | ts = np.vectorize(lambda p: modelToPercentileDecay((a, b, t), p))(percentiles) 110 | self.assertTrue(monotonicDecreasing(ts), msg=msg + ' ts={}'.format(ts)) 111 | 112 | inner(3.3, 4.4, 1., [0.1, 1., 9.5]) 113 | inner(34.4, 3.4, 1., [0.1, 1., 5.5, 50.]) 114 | 115 | # make sure all is well for balanced models where we know the halflife already 116 | for t in np.logspace(-1, 2, 10): 117 | for ab in np.linspace(2, 10, 5): 118 | self.assertAlmostEqual(modelToPercentileDecay((ab, ab, t)), t) 119 | 120 | def test_asymptotic(self): 121 | """Failing quizzes in far future shouldn't modify model when updating. 122 | Passing quizzes right away shouldn't modify model when updating. 123 | """ 124 | 125 | def inner(a, b, n=1): 126 | prior = (a, b, 1.0) 127 | hl = modelToPercentileDecay(prior) 128 | ts = np.linspace(.001, 1000, 21) * hl 129 | passhl = np.vectorize(lambda tnow: modelToPercentileDecay(updateRecall(prior, n, n, tnow)))( 130 | ts) 131 | failhl = np.vectorize(lambda tnow: modelToPercentileDecay(updateRecall(prior, 0, n, tnow)))( 132 | ts) 133 | self.assertTrue(monotonicIncreasing(passhl)) 134 | self.assertTrue(monotonicIncreasing(failhl)) 135 | # Passing should only increase halflife 136 | self.assertTrue(np.all(passhl >= hl * .999)) 137 | # Failing should only decrease halflife 138 | self.assertTrue(np.all(failhl <= hl * 1.001)) 139 | 140 | for a in [2., 20, 200]: 141 | for b in [2., 20, 200]: 142 | inner(a, b, n=1) 143 | 144 | def test_rescale(self): 145 | "Test rescaleHalflife" 146 | pre = (3., 4., 1.) 147 | oldhl = modelToPercentileDecay(pre) 148 | for u in [0.1, 1., 10.]: 149 | post = rescaleHalflife(pre, u) 150 | self.assertAlmostEqual(modelToPercentileDecay(post), oldhl * u) 151 | 152 | # don't change halflife: in this case, predictions should be really close 153 | post = rescaleHalflife(pre, 1.0) 154 | for tnow in [1e-2, .1, 1., 10., 100.]: 155 | self.assertAlmostEqual( 156 | predictRecall(pre, tnow, exact=True), predictRecall(post, tnow, exact=True), delta=1e-3) 157 | 158 | def test_fuzzy(self): 159 | "Binary quizzes are heavily tested above. Now test float/fuzzy quizzes here" 160 | global testpoints 161 | fuzzies = np.linspace(0, 1, 7) # test 0 and 1 too 162 | for tnow in np.logspace(-1, 1, 5): 163 | for a in np.linspace(2, 20, 5): 164 | for b in np.linspace(2, 20, 5): 165 | prior = (a, b, 1.0) 166 | newmodels = [updateRecall(prior, q, 1, tnow) for q in fuzzies] 167 | for m, q in zip(newmodels, fuzzies): 168 | # check rebalance is working 169 | newa, newb, newt = m 170 | self.assertAlmostEqual(newa, newb) 171 | self.assertAlmostEqual(newt, modelToPercentileDecay(m)) 172 | 173 | # check that the analytical posterior Beta fit versus Monte Carlo 174 | if 0 < q and q < 1: 175 | mc = updateRecallMonteCarlo(prior, q, 1, tnow, newt, N=1_000_000) 176 | self.assertLess( 177 | kl(m, mc), 1e-4, msg=f'prior={prior}; tnow={tnow}; q={q}; m={m}; mc={mc}') 178 | testpoints += [['update', list(prior), [q, 1, tnow], dict(post=m)]] 179 | 180 | # also important: make sure halflife varies smoothly between q=0 and q=1 181 | self.assertTrue(monotonicIncreasing([x for _, _, x in newmodels])) 182 | 183 | # make sure `tback` works 184 | prior = (3., 4., 10) 185 | tback = 5. 186 | post = updateRecall(prior, 1, 1, 1., rebalance=False, tback=tback) 187 | self.assertAlmostEqual(post[2], tback) 188 | # and default `tback` if everything is omitted is original `t` 189 | post = updateRecall(prior, 1, 1, 1., rebalance=False) 190 | self.assertAlmostEqual(post[2], prior[2]) 191 | 192 | def test_large_alpha_beta(self): 193 | "Fix #68, convergence for large alpha or beta" 194 | global testpoints 195 | t = 37.98442774938748 196 | elapsed = 24.0 197 | for alphaBeta in [400, 531.94, 531.9401709401171, 600]: 198 | prior = (alphaBeta, alphaBeta, t) 199 | args = [0, 1, elapsed] 200 | post = updateRecall(prior, *args) 201 | assert post[0] > alphaBeta * 0.99 202 | 203 | testpoints += [['update', list(prior), args, dict(post=post)]] 204 | 205 | 206 | def monotonicIncreasing(v): 207 | # allow a tiny bit of negative slope 208 | return np.all(np.diff(v) >= -1e-6) 209 | 210 | 211 | def monotonicDecreasing(v): 212 | # same as above, allow a tiny bit of positive slope 213 | return np.all(np.diff(v) <= 1e-6) 214 | 215 | 216 | if __name__ == '__main__': 217 | unittest.TextTestRunner().run(unittest.TestLoader().loadTestsFromModule(TestEbisu())) 218 | 219 | with open("test.json", "w") as out: 220 | import json 221 | out.write(json.dumps(testpoints, indent=1)) 222 | -------------------------------------------------------------------------------- /figures/forgetting-curve-diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fasiha/ebisu/497f87bc59c125ffb8f6dc959bf98f2be2fa804e/figures/forgetting-curve-diff.png -------------------------------------------------------------------------------- /figures/forgetting-curve-diff.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 10 | 11 | 12 | 13 | 19 | 20 | 21 | 22 | 28 | 29 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 2 48 | 49 | 50 | 51 | 52 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 4 63 | 64 | 65 | 66 | 67 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 6 78 | 79 | 80 | 81 | 82 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 8 93 | 94 | 95 | 96 | 97 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 10 108 | 109 | 110 | 111 | 112 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 12 123 | 124 | 125 | 126 | 127 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 14 138 | 139 | 140 | 141 | Time (days) 142 | 143 | 144 | 145 | 146 | 147 | 150 | 151 | 152 | 153 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | −0.015 163 | 164 | 165 | 166 | 167 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | −0.010 178 | 179 | 180 | 181 | 182 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | −0.005 193 | 194 | 195 | 196 | 197 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 0.000 208 | 209 | 210 | 211 | 212 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 0.005 223 | 224 | 225 | 226 | 227 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 0.010 238 | 239 | 240 | 241 | Difference 242 | 243 | 244 | 245 | 248 | 249 | 250 | 301 | 302 | 303 | 354 | 355 | 356 | 359 | 360 | 361 | 364 | 365 | 366 | 369 | 370 | 371 | 374 | 375 | 376 | Expected recall probability minus approximation 377 | 378 | 379 | 380 | 391 | 392 | 393 | 396 | 397 | 398 | 399 | Model A 400 | 401 | 402 | 405 | 406 | 407 | 408 | Model B 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | -------------------------------------------------------------------------------- /figures/forgetting-curve.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fasiha/ebisu/497f87bc59c125ffb8f6dc959bf98f2be2fa804e/figures/forgetting-curve.png -------------------------------------------------------------------------------- /figures/halflife.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fasiha/ebisu/497f87bc59c125ffb8f6dc959bf98f2be2fa804e/figures/halflife.png -------------------------------------------------------------------------------- /figures/halflife.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 10 | 11 | 12 | 13 | 19 | 20 | 21 | 22 | 28 | 29 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 0 48 | 49 | 50 | 51 | 52 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 5 63 | 64 | 65 | 66 | 67 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 10 78 | 79 | 80 | 81 | 82 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 15 93 | 94 | 95 | 96 | 97 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 20 108 | 109 | 110 | 111 | 112 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 25 123 | 124 | 125 | 126 | 127 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 30 138 | 139 | 140 | 141 | Time of test (days after previous test) 142 | 143 | 144 | 145 | 146 | 147 | 150 | 151 | 152 | 153 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 6 163 | 164 | 165 | 166 | 167 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 8 178 | 179 | 180 | 181 | 182 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 10 193 | 194 | 195 | 196 | 197 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 12 208 | 209 | 210 | 211 | 212 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 14 223 | 224 | 225 | 226 | Half-life (days) 227 | 228 | 229 | 230 | 233 | 234 | 235 | 266 | 267 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 338 | 339 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 416 | 417 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 488 | 489 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 538 | 539 | 540 | 543 | 544 | 545 | 548 | 549 | 550 | 553 | 554 | 555 | New half-life (previously 7 days) 556 | 557 | 558 | 559 | 570 | 571 | 572 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | α=β=3, pass 583 | 584 | 585 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | α=β=3, fail 596 | 597 | 598 | 601 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | α=β=12, pass 609 | 610 | 611 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | α=β=12, fail 622 | 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | -------------------------------------------------------------------------------- /figures/models.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fasiha/ebisu/497f87bc59c125ffb8f6dc959bf98f2be2fa804e/figures/models.png -------------------------------------------------------------------------------- /figures/models.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 10 | 11 | 12 | 13 | 19 | 20 | 21 | 22 | 28 | 29 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 0.00 48 | 49 | 50 | 51 | 52 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 0.25 63 | 64 | 65 | 66 | 67 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 0.50 78 | 79 | 80 | 81 | 82 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 0.75 93 | 94 | 95 | 96 | 97 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 1.00 108 | 109 | 110 | 111 | Recall probability after one week 112 | 113 | 114 | 115 | 116 | 117 | 120 | 121 | 122 | 123 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 0.0 133 | 134 | 135 | 136 | 137 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 0.2 148 | 149 | 150 | 151 | 152 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 0.4 163 | 164 | 165 | 166 | 167 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 0.6 178 | 179 | 180 | 181 | 182 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 0.8 193 | 194 | 195 | 196 | 197 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 1.0 208 | 209 | 210 | 211 | Prob. of recall prob. (scaled) 212 | 213 | 214 | 215 | 299 | 300 | 301 | 379 | 380 | 381 | 384 | 385 | 386 | 389 | 390 | 391 | 394 | 395 | 396 | 399 | 400 | 401 | Confidence in recall probability after one half-life 402 | 403 | 404 | 405 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | α=β=3 425 | 426 | 427 | 430 | 431 | 432 | 433 | α=β=12 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | -------------------------------------------------------------------------------- /figures/pidelta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fasiha/ebisu/497f87bc59c125ffb8f6dc959bf98f2be2fa804e/figures/pidelta.png -------------------------------------------------------------------------------- /figures/pidelta.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 10 | 11 | 12 | 13 | 19 | 20 | 21 | 22 | 28 | 29 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 0.0 48 | 49 | 50 | 51 | 52 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 0.2 63 | 64 | 65 | 66 | 67 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 0.4 78 | 79 | 80 | 81 | 82 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 0.6 93 | 94 | 95 | 96 | 97 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 0.8 108 | 109 | 110 | 111 | 112 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 1.0 123 | 124 | 125 | 126 | p (recall probability) 127 | 128 | 129 | 130 | 131 | 132 | 135 | 136 | 137 | 138 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 0 148 | 149 | 150 | 151 | 152 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 1 163 | 164 | 165 | 166 | 167 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 2 178 | 179 | 180 | 181 | 182 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 3 193 | 194 | 195 | 196 | 197 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 4 208 | 209 | 210 | 211 | 212 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 5 223 | 224 | 225 | 226 | 227 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 6 238 | 239 | 240 | 241 | 242 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 7 253 | 254 | 255 | 256 | 257 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 8 268 | 269 | 270 | 271 | Probability(p) 272 | 273 | 274 | 275 | 281 | 282 | 283 | 289 | 290 | 291 | 297 | 298 | 299 | 305 | 306 | 307 | 313 | 314 | 315 | 321 | 322 | 323 | 329 | 330 | 331 | 337 | 338 | 339 | 345 | 346 | 347 | 353 | 354 | 355 | 361 | 362 | 363 | 369 | 370 | 371 | 377 | 378 | 379 | 385 | 386 | 387 | 393 | 394 | 395 | 401 | 402 | 403 | 409 | 410 | 411 | 417 | 418 | 419 | 425 | 426 | 427 | 433 | 434 | 435 | 441 | 442 | 443 | 449 | 450 | 451 | 457 | 458 | 459 | 465 | 466 | 467 | 473 | 474 | 475 | 481 | 482 | 483 | 489 | 490 | 491 | 497 | 498 | 499 | 505 | 506 | 507 | 513 | 514 | 515 | 521 | 522 | 523 | 529 | 530 | 531 | 537 | 538 | 539 | 545 | 546 | 547 | 553 | 554 | 555 | 561 | 562 | 563 | 569 | 570 | 571 | 577 | 578 | 579 | 585 | 586 | 587 | 593 | 594 | 595 | 601 | 602 | 603 | 609 | 610 | 611 | 617 | 618 | 619 | 625 | 626 | 627 | 633 | 634 | 635 | 641 | 642 | 643 | 649 | 650 | 651 | 657 | 658 | 659 | 665 | 666 | 667 | 673 | 674 | 675 | 681 | 682 | 683 | 689 | 690 | 691 | 697 | 698 | 699 | 705 | 706 | 707 | 713 | 714 | 715 | 721 | 722 | 723 | 729 | 730 | 731 | 737 | 738 | 739 | 745 | 746 | 747 | 753 | 754 | 755 | 758 | 759 | 760 | 763 | 764 | 765 | 768 | 769 | 770 | 773 | 774 | 775 | Histograms of p_t^δ for different δ 776 | 777 | 778 | 779 | 790 | 791 | 792 | 798 | 799 | 800 | δ=0.3 801 | 802 | 803 | 809 | 810 | 811 | δ=1.0 812 | 813 | 814 | 820 | 821 | 822 | δ=3.0 823 | 824 | 825 | 826 | 827 | 828 | 829 | 830 | 831 | 832 | 833 | -------------------------------------------------------------------------------- /figures/pis-betas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fasiha/ebisu/497f87bc59c125ffb8f6dc959bf98f2be2fa804e/figures/pis-betas.png -------------------------------------------------------------------------------- /figures/pis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fasiha/ebisu/497f87bc59c125ffb8f6dc959bf98f2be2fa804e/figures/pis.png -------------------------------------------------------------------------------- /figures/pis.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 10 | 11 | 12 | 13 | 19 | 20 | 21 | 22 | 28 | 29 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 0.0 48 | 49 | 50 | 51 | 52 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 0.2 63 | 64 | 65 | 66 | 67 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 0.4 78 | 79 | 80 | 81 | 82 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 0.6 93 | 94 | 95 | 96 | 97 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 0.8 108 | 109 | 110 | 111 | 112 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 1.0 123 | 124 | 125 | 126 | π (recall probability) 127 | 128 | 129 | 130 | 131 | 132 | 135 | 136 | 137 | 138 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 0 148 | 149 | 150 | 151 | 152 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 2 163 | 164 | 165 | 166 | 167 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 4 178 | 179 | 180 | 181 | 182 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 6 193 | 194 | 195 | 196 | 197 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 8 208 | 209 | 210 | 211 | 212 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 10 223 | 224 | 225 | 226 | 227 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 12 238 | 239 | 240 | 241 | 242 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 14 253 | 254 | 255 | 256 | 257 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 16 268 | 269 | 270 | 271 | Density 272 | 273 | 274 | 275 | 281 | 282 | 283 | 289 | 290 | 291 | 297 | 298 | 299 | 305 | 306 | 307 | 313 | 314 | 315 | 321 | 322 | 323 | 329 | 330 | 331 | 337 | 338 | 339 | 345 | 346 | 347 | 353 | 354 | 355 | 361 | 362 | 363 | 369 | 370 | 371 | 377 | 378 | 379 | 385 | 386 | 387 | 393 | 394 | 395 | 401 | 402 | 403 | 409 | 410 | 411 | 417 | 418 | 419 | 425 | 426 | 427 | 433 | 434 | 435 | 441 | 442 | 443 | 449 | 450 | 451 | 457 | 458 | 459 | 465 | 466 | 467 | 473 | 474 | 475 | 481 | 482 | 483 | 489 | 490 | 491 | 497 | 498 | 499 | 505 | 506 | 507 | 513 | 514 | 515 | 521 | 522 | 523 | 529 | 530 | 531 | 537 | 538 | 539 | 545 | 546 | 547 | 553 | 554 | 555 | 561 | 562 | 563 | 569 | 570 | 571 | 577 | 578 | 579 | 585 | 586 | 587 | 593 | 594 | 595 | 601 | 602 | 603 | 609 | 610 | 611 | 617 | 618 | 619 | 625 | 626 | 627 | 633 | 634 | 635 | 641 | 642 | 643 | 649 | 650 | 651 | 657 | 658 | 659 | 665 | 666 | 667 | 673 | 674 | 675 | 681 | 682 | 683 | 689 | 690 | 691 | 697 | 698 | 699 | 705 | 706 | 707 | 713 | 714 | 715 | 721 | 722 | 723 | 729 | 730 | 731 | 737 | 738 | 739 | 745 | 746 | 747 | 753 | 754 | 755 | 758 | 759 | 760 | 763 | 764 | 765 | 768 | 769 | 770 | 773 | 774 | 775 | Histogram of π for different t 776 | 777 | 778 | 779 | 790 | 791 | 792 | 798 | 799 | 800 | t=1.0 801 | 802 | 803 | 809 | 810 | 811 | t=7.0 812 | 813 | 814 | 820 | 821 | 822 | t=30.0 823 | 824 | 825 | 826 | 827 | 828 | 829 | 830 | 831 | 832 | 833 | -------------------------------------------------------------------------------- /header.html: -------------------------------------------------------------------------------- 1 | 2 | Ebisu 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /md2code.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('fs'); 4 | var spawnSync = require('child_process').spawnSync; 5 | var _ = require('lodash'); 6 | 7 | var lines = 8 | fs.readFileSync('README.md', 'utf8').trim().split('\n').map(s => s + '\n'); 9 | var fencepos = 10 | lines.map((s, i) => [s, i]).filter(([s, i]) => s.indexOf('```') === 0); 11 | 12 | var seen = new Set([]); 13 | var replacement = []; 14 | var fnames = []; 15 | _.chunk(fencepos, 2).forEach(([[_, i], [__, j]]) => { 16 | var language = lines[i].match(/```([^\s]+)/); 17 | language = language ? language[1] : language; 18 | 19 | var fname = null; 20 | if (lines[i + 1].indexOf('# export') === 0) { 21 | fname = lines[i + 1].match(/# export ([^\s]+)/)[1]; 22 | fnames.push(fname); 23 | } 24 | var contentStart = i + 1 + (fname === null ? 0 : 1); 25 | var contents = lines.slice(contentStart, j).join(''); 26 | 27 | if (language === 'py' || language === 'python') { 28 | contents = 29 | spawnSync('yapf', [], {input: contents, encoding: 'utf8'}).stdout; 30 | replacement.push({start: i, end: j, contentStart, contents}); 31 | } 32 | 33 | if (fname) { 34 | if (seen.has(fname)) { 35 | fs.appendFileSync(fname, contents); 36 | } else { 37 | if (language === 'py' || language === 'python') { 38 | fs.writeFileSync( 39 | fname, '# -*- coding: utf-8 -*-\n\n'); // I need emoji! 40 | fs.appendFileSync(fname, contents); 41 | } else { 42 | fs.writeFileSync(fname, contents); 43 | } 44 | seen.add(fname); 45 | } 46 | } 47 | }); 48 | 49 | for (var ri = replacement.length - 1; ri >= 0; ri--) { 50 | var r = replacement[ri]; 51 | for (var k = r.contentStart + 1; k < r.end; k++) { 52 | lines[k] = ''; 53 | } 54 | lines[r.contentStart] = r.contents; 55 | } 56 | fs.writeFileSync('README.md', lines.join('')) 57 | 58 | // run a final Yapf on the final files. Ensures newlines between functions, etc. 59 | for (let fname of fnames) { 60 | let contents = fs.readFileSync(fname, 'utf8'); 61 | contents = spawnSync('yapf', [], {input: contents, encoding: 'utf8'}).stdout; 62 | fs.writeFileSync(fname, contents); 63 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Ebisu", 3 | "version": "1.0.0", 4 | "main": "md2code.js", 5 | "repository": "https://github.com/fasiha/ebisu", 6 | "author": "Ahmed Fasih ", 7 | "license": "Unlicense", 8 | "scripts": { 9 | "doc": "pydoc-markdown -m ebisu/ebisu > doc/doc.md", 10 | "build": "node md2code.js", 11 | "html": "cp header.html index.html && pandoc --no-highlight -t html5 -f markdown_github-hard_line_breaks+yaml_metadata_block+markdown_in_html_blocks+auto_identifiers README.md | sed 's/\\\\&/\\&/g' >> index.html && npx mjpage < index.html > tmp && mv tmp index.html && npm run notebook", 12 | "test": "npm run build && python3 -m \"nose\" -v && npm run notebook", 13 | "notebook": "jupyter nbconvert --to notebook --execute EbisuHowto.ipynb", 14 | "pypi": "rm dist/* && python setup.py sdist bdist_wheel && python3 setup.py sdist bdist_wheel && twine upload dist/* --skip-existing" 15 | }, 16 | "dependencies": {}, 17 | "devDependencies": { 18 | "lodash": "^4.17.4", 19 | "mathjax-node-page": "^1.2.7" 20 | } 21 | } -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup(name='ebisu', 4 | version='2.2.0', 5 | description='Intelligent quiz scheduling', 6 | long_description=('Public-domain library for quiz scheduling with' 7 | ' Bayesian statistics.'), 8 | keywords=('quiz review schedule spaced repetition system srs bayesian ' 9 | 'probability beta random variable'), 10 | url='http://github.com/fasiha/ebisu', 11 | author='Ahmed Fasih', 12 | author_email='wuzzyview@gmail.com', 13 | license='Unlicense', 14 | packages=['ebisu'], 15 | test_suite='nose.collector', 16 | tests_require=['nose'], 17 | install_requires=[ 18 | 'scipy', 'numpy' 19 | ], 20 | zip_safe=True) 21 | --------------------------------------------------------------------------------