├── .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+=""+t(e)+">"}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:/,e:/(\/\w+|\w+\/)>/,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 |
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 |
632 |
--------------------------------------------------------------------------------
/figures/models.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fasiha/ebisu/497f87bc59c125ffb8f6dc959bf98f2be2fa804e/figures/models.png
--------------------------------------------------------------------------------
/figures/models.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
444 |
--------------------------------------------------------------------------------
/figures/pidelta.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fasiha/ebisu/497f87bc59c125ffb8f6dc959bf98f2be2fa804e/figures/pidelta.png
--------------------------------------------------------------------------------
/figures/pidelta.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
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 |
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 |
--------------------------------------------------------------------------------