Spaced Repetition Algorithm for a Flashcard App

Flash Card

There were some interesting aspects to building this web app.

  1. The spaced repetition algorithm for displaying flash cards to maximise learning.
  2. Using locale-specific web search to fetch the images
  3. Architecting the app as a static web site that uses local storage to store user state.
  4. Using vanilla JavaScript with no framework.
  5. Using 3D animations for flipping cards.
  6. Custom creating all the CSS styling.

In this post, I'll discuss the spaced-repetition algorithm, and defer the other aspects to future posts.

From Wikipedia

Spaced repetition is an evidence-based learning technique that is usually performed with flashcards. Newly introduced and more difficult flashcards are shown more frequently while older and less difficult flashcards are shown less frequently in order to exploit the psychological spacing effect. The use of spaced repetition has been shown to increase rate of learning.

There is a variety of published research on spaced-repetition, and different flashcard apps use a variety of different algorithms, some quite complicated.

I decided to start with something much simpler. Every time a user sees the card they say how accurately they predicted the reverse side of the card. We store a response object that has this correctness score along timestmp t of the response. For each card we calculate an average score, but weighted by time so that more recent scores count for more. Then we sort the list of cards so that the cards with the lowest scores come first.

The core of the algorithm is in common.js:

const TAO = 1000.0 * 60 * 60 * 24

export const score = (responses) => {
  if (responses.length === 0) {
    return -1
  }
  const now = Date.now()
  const weightedSum = responses
    .map((response) =>
         response.correctness * Math.exp((response.t - now) / TAO))
    .reduce((sum, x) => sum + x)
  return weightedSum / responses.length
}

The score function goes through the array of responses and sums up the correctness values weighted by an exponential decay function with a time constant TAO of 24 hours. This means, for example, that a day-old response is weighted at about 0.37 of a recent response.

This score is called from the sort function in index.js:

  const sort = () => {
    cards.sort((a, b) => score(a.responses) - score(b.responses))
  }

My hypothesis is that this simple algorithm with its single TAO parameter is as effective as the more complex algorithms. Hopefully by tuning TAO I can emulate those algorithms.

An observant reader will have noticed some significant algorithmic inefficiencies in the above naive implementation of the algorithm. In particular there are some superlinear big-O complexities that I'll probably need to mitigate as I add more cards to the app.