2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

スペーシング復習アルゴリズムを Lambda × DynamoDB で実装した話【Anki/SM-2ベース】

2
Posted at

はじめに:「今日やるべきカード」はどう決まるか

前回の記事では、知らない言葉をポイっと投げ込んでAIに補完させる運用を紹介した。

今回は、その「投げ込んだカード」を使って繰り返し復習する仕組みの中身を書く。

Cobo Memo は「スペーシング復習(間隔反復)」を採用している。アプリを開くと、今日復習すべきカードが自動で出てくる。復習する順番もタイミングも、自分で考えなくていい。

この仕組みをどう設計・実装したか——というのがこの記事のテーマだ。


「スペーシング復習」とは何か

エビングハウスの忘却曲線は有名だ。人は学習直後から急速に忘れ、時間が経つほど忘却が緩やかになる。

この特性を逆手に取ったのが間隔反復だ。

  • 覚えたばかりのカード → 短い間隔で何度も復習
  • 定着してきたカード → 間隔を延ばして効率化
  • 忘れたカード → 間隔をリセットして再学習

AnkiのSM-2アルゴリズムが有名で、多くのフラッシュカードアプリはこれをベースにしている。Cobo Memoもこの設計思想を採用した。

image.png


システム全体のアーキテクチャ

「どこでアルゴリズムを動かすか」という設計判断から話す。

Flutter App
    │
    │  学習セッション終了
    │  (カードごとの正誤・時間を送信)
    ▼
DynamoDB(LearningSessionテーブル)
    │
    │  DynamoDB Streams(INSERT イベント)
    ▼
Lambda(insertIntoSQS)  ← トリガー専用
    │
    │  SQSにエンキュー
    ▼
SQS(キュー)
    │
    │  非同期で処理
    ▼
Lambda(create/handler.py)  ← アルゴリズム本体
    │
    ├── MasteryStatusテーブル(カードごとの習熟状態)を更新
    └── LearningSummaryテーブル(学習統計)を更新

アルゴリズムはFlutter側(クライアント)ではなく、Lambda(Python)側で動かしている。

理由は3つだ:

  1. 複数デバイスの整合性:モバイルとタブレットで同じカードを学習した場合、状態の計算を1箇所で行わないと食い違いが起きる
  2. ロジックの変更容易性:アプリのアップデートなしにアルゴリズムを調整できる
  3. 非同期でUXを妨げない:セッション保存後、Lambdaが非同期でMasteryStatusを更新するので、ユーザーは待たされない

SQSを挟んでいるのは、DynamoDB Streamsからの直接起動よりリトライと流量制御がしやすいためだ。


状態遷移の設計

カードの学習状態を7つに分けた。

learning
  │
  │ 累積正解率70%以上で正解
  ▼
relearning
  │
  │ 累積正解率85%以上で正解
  ▼
young1(3日後)→ young2(7日後)→ young3(14日後)
  →  youngmature(30日後)→ mature(Ease Factorで動的)
  
※ どのステートでも不正解 → 累積正解率に応じてペナルティ → 翌日復習
# 状態の種類
# learning    : 学習中(まだ定着していない)
# relearning  : 再学習中(learning卒業後に不正解が続いた)
# young1      : 定着化中(初期)  次回: 3日後
# young2      : 定着化中(中期)  次回: 7日後
# young3      : 定着化中(後期)  次回: 14日後
# youngmature : ほぼ定着         次回: 30日後
# mature      : 完全定着          次回: Ease Factorで動的

Ankiの標準的なステートより細かく分けた理由はユーザーへの「進んでいる感」の提供だ。learningmature の2段階だけだと、「なかなかmaturedにならない」という挫折感が生まれやすい。7段階にすることで、少しずつ進んでいることが視覚的にわかる。

material4.png


アルゴリズムの実装(Lambda Python)

compute_mastery() 関数がアルゴリズムの中心だ。

基本の入出力

def compute_mastery(
    old: Dict[str, Any],   # 既存のMasteryStatus(DynamoDBから取得)
    attempts: int,          # セッションでの試行回数
    correct: int,           # セッションでの正解数
    local_today: date       # ユーザーのローカルタイムゾーンの今日
) -> Dict[str, Any]:
    """
    改善された忘却曲線アルゴリズム(Anki/SM-2ベース)
    """

戻り値にはこれらのフィールドが含まれる:

return {
    'totalAttempt':           new_total,       # 累積試行数
    'totalCorrect':           new_corr,        # 累積正解数
    'progressRate':           cumulative_rate, # 累積正解率
    'lastLearnedTime':        today.isoformat(),
    'nextReviewDate':         next_date.isoformat(),  # 次回復習日
    'state':                  state,           # 7段階の習熟状態
    'daysElapsedSinceMature': days_elapsed,    # matureまでの進捗段階数
    'easeFactor':             ease_factor,     # SM-2のEase Factor
}

Ease Factor(難易度係数)

SM-2の核心の一つが easeFactor だ。デフォルト2.5で、正解が続くと増加し、間違えると減少する。mature以降の復習間隔の計算に使う。

# Ease Factor(難易度係数)の調整
# デフォルト2.5、最大3.0、最小1.3(Anki標準)
if session_rate == 1.0:
    # 完全正解:Ease Factorを増加
    ease_factor = min(old_ease + 0.15, 3.0)
else:
    wrong_count = attempts - correct
    if wrong_count == 1 and attempts <= 3:
        # 少ない問題数で1問だけミス:わずかに減少
        ease_factor = max(old_ease - 0.05, 1.3)
    elif session_rate >= 0.8:
        ease_factor = max(old_ease - 0.05, 1.3)
    elif session_rate >= 0.5:
        ease_factor = max(old_ease - 0.1, 1.3)
    else:
        ease_factor = max(old_ease - 0.2, 1.3)

Ease Factorが高いカード(よく正解する)は間隔が長くなり、低いカード(よく間違える)は間隔が短く保たれる。個人の苦手・得意に合わせてアルゴリズムが自動調整される。

不正解時のペナルティ設計

不正解時の処理が設計の肝だ。「1回間違えたらリセット」ではなく、累積正解率に応じて段階的にペナルティを調整している。

if session_rate < 1.0:
    # 1問でも間違えた場合:翌日復習
    if cumulative_rate < 0.6:
        # 累積60%未満:完全リセット(まだ定着していない)
        state = 'learning'
        days_elapsed = 0
    elif cumulative_rate < 0.7:
        # 累積70%未満:2段階戻す
        state = 'relearning' if days_diff >= 7 else 'learning'
        days_elapsed = max(0, old_days - 2)
    elif cumulative_rate < 0.8:
        # 累積80%未満:1段階戻す
        state = 'relearning' if days_diff >= 7 else 'learning'
        days_elapsed = max(0, old_days - 1)
    else:
        # 累積80%以上:維持(十分定着しているので一時的な忘れと判断)
        state = old.get('state', 'learning')
        days_elapsed = old_days

    # 不正解の場合は基本的に翌日復習
    next_date = today + timedelta(days=1)

累積正解率80%以上のカードは、1回間違えても進捗が維持される。これは「何十回も正解してきたカードを、疲れている日に1回間違えたらゼロに戻る」という理不尽さを避けるための設計だ。

完全正解時の進捗

else:
    # 完全正解の場合:進捗を許可
    if cumulative_rate < 0.7:
        state = 'learning'
        days_elapsed = 0
        next_date = today + timedelta(days=1)
    elif cumulative_rate < 0.85:
        state = 'relearning'
        days_elapsed = 0
        next_date = today + timedelta(days=2)
    else:
        # 累積85%以上:mature段階へ進捗
        days_elapsed = old_days + 1

        if days_elapsed == 1:
            state = 'young1'
            next_date = today + timedelta(days=3)
        elif days_elapsed == 2:
            state = 'young2'
            next_date = today + timedelta(days=7)
        elif days_elapsed == 3:
            state = 'young3'
            next_date = today + timedelta(days=14)
        elif days_elapsed == 4:
            state = 'youngmature'
            next_date = today + timedelta(days=30)
        elif days_elapsed == 5:
            state = 'mature'
            # Ease Factorを使用した動的間隔(最大90日)
            interval = int(30 * ease_factor)
            next_date = today + timedelta(days=min(interval, 90))
        else:
            state = 'mature'
            # 長期記憶:指数的増加(最大180日)
            base_interval = 60
            interval = int(base_interval * (ease_factor ** (days_elapsed - 5)))
            next_date = today + timedelta(days=min(interval, 180))

mature 以降の間隔は easeFactor を使って指数的に増加する。Ease Factorが2.5のカードなら:

  • mature初回:30 × 2.5 = 75日後
  • mature2回目:60 × 2.5¹ = 150日 → 上限180日
  • mature3回目:60 × 2.5² = 375日 → 上限180日

つまり、十分に定着したカードは半年に1回の頻度で出てくるだけになる。

NextReviewDateに達していない「早期復習」の扱い

復習期日より前に自主練習した場合は、統計のみ更新して進捗は動かさないという設計にした。

# NextReviewDateに達していない場合は統計のみ更新
if today < next_review_date:
    return {
        'totalAttempt': new_total,
        'totalCorrect': new_corr,
        'progressRate': cumulative_rate,
        'lastLearnedTime': today.isoformat(),
        'nextReviewDate': next_review_str,  # 変更しない
        'state': old.get('state', 'learning'),  # 変更しない
        'daysElapsedSinceMature': int(old.get('daysElapsedSinceMature', 0)),
        'easeFactor': float(old.get('easeFactor', 2.5)),
    }

「前日に予習として全部やって翌日の復習を飛ばせる」という抜け道を防ぐための設計だ。


DynamoDBへの保存設計

MasteryStatus テーブルが習熟状態を管理する。

PK: userId(S)
SK: cardId(S)

主な属性:
- nextReviewDate(S): "2026-04-01"形式
- state(S)         : "young1"など
- progressRate(N)  : 0.0〜1.0
- daysElapsedSinceMature(N): mature段階の進捗
- easeFactor(N)    : 難易度係数
- totalAttempt(N)  : 累積試行数
- totalCorrect(N)  : 累積正解数

Flutter側で「今日のカード」を取り出す

MasteryStatusはDynamoDBにあるが、オフライン優先設計のため、FlutterアプリはローカルのSQLiteにも同期している。「今日のカード」はSQLiteから取り出す。

Future<List<String>> getTodayCardIds(String userId) async {
  final db = await database;
  final today = DateTime.now().toIso8601String().substring(0, 10);

  final rows = await db.query(
    'MasteryStatus',
    columns: ['cardId'],
    where: 'userId = ? AND nextReviewDate <= ? AND state != ?',
    whereArgs: [userId, today, 'mature'],
    orderBy: 'progressRate ASC',  // 習熟度が低いものを先に
  );

  return rows.map((r) => r['cardId'] as String).toList();
}

matureのカードを除外しているのではなく、nextReviewDateで絞り込んでいるので、matureでも期日が来れば出題される。


「暗記学習モード OFF」の設計

Cobo Memoには通常の復習とは別に、「練習モード」がある。練習モードでは習熟状態を動かしたくない(純粋に練習したいだけ)。

# 暗記学習モードに応じて処理を分岐
if memorization_mode:
    # 暗記学習ON:通常の進捗更新
    stats = compute_mastery(old, cnt['attempts'], cnt['correct'], local_today)
else:
    # 暗記学習OFF:統計のみ更新(state, nextReviewDate等は変更しない)
    stats = compute_mastery_stats_only(old, cnt['attempts'], cnt['correct'], local_today)
def compute_mastery_stats_only(old, attempts, correct, local_today):
    """練習モード:totalAttempt/totalCorrect/progressRateのみ更新"""
    new_total = int(old.get('totalAttempt', 0)) + attempts
    new_corr  = int(old.get('totalCorrect', 0)) + correct
    return {
        'totalAttempt': new_total,
        'totalCorrect': new_corr,
        'progressRate': new_corr / new_total if new_total else 0.0,
        'lastLearnedTime': local_today.isoformat(),
        # 以下は変更しない
        'nextReviewDate': old.get('nextReviewDate', local_today.isoformat()),
        'state': old.get('state', 'learning'),
        'daysElapsedSinceMature': int(old.get('daysElapsedSinceMature', 0)),
        'easeFactor': float(old.get('easeFactor', 2.5)),
    }

タイムゾーンの扱い

地味に重要な設計ポイントがタイムゾーンだ。

「今日」という概念はユーザーのローカルタイムゾーンに依存する。日本時間の0:30に学習した場合、UTC基準だと「前日」になってしまう。

Lambdaはサーバー上で動くのでローカルタイムゾーンを知らない。そのため、Flutterアプリからセッションデータにタイムゾーンオフセットをつけて送信し、Lambda側でローカル日付を再現している。

def parse_timezone(offset_str: str) -> timezone:
    """+09:00 や -07:00 形式を timezone オブジェクトに変換"""
    sign = 1 if offset_str[0] == '+' else -1
    hours, minutes = map(int, offset_str[1:].split(':'))
    return timezone(sign * timedelta(hours=hours, minutes=minutes))

# セッションデータから localOffset を取得
dt_utc = datetime.fromisoformat(session['createdAt'].replace('Z', '+00:00'))
if 'localOffset' in session:
    dt = dt_utc.astimezone(parse_timezone(session['localOffset']))

local_today = dt.date()  # これをアルゴリズムに渡す

実装してわかったこと

アルゴリズムより「体感的な公平さ」が先

最初はSM-2のease factorの計算式を正確に再現しようとした。しかし使ってみると、数式の正確さより 「このルールは体感的に公平か?」 の方が継続率に直結した。

「何十回も正解したのに1回間違えたらリセット」はユーザーが不満に思う。「期日前に自主練習したら次の期日が縮まる」も理不尽だ。アルゴリズムの正確性より、こういった「体感的な納得感」を設計に入れる方が難しく、大事だった。

非同期処理はSQSで正解だった

DynamoDB Streams → SQS → Lambda という構成は、最初は「複雑すぎるか」と思った。でも実際には:

  • Lambdaが重い処理をしていてもアプリはサクサク動く
  • 失敗時の再試行が自動
  • 流量制御が簡単

という恩恵が大きかった。Flutter側は「セッションをDynamoDBに書き込む」だけで終わり、あとはバックエンドが非同期に処理する。ユーザー体験とシステムの堅牢性が両立できた。


まとめ

設計要素 採用した方針
アルゴリズムの場所 Lambda(Python)側で計算、Flutter側はDynamoDB/SQLiteを参照するだけ
基本設計 Anki/SM-2をベースに四択クイズ向けに改修
状態数 7段階(learning/relearning/young1〜3/youngmature/mature)
Ease Factor デフォルト2.5、最大3.0、最小1.3(SM-2標準)
不正解時ペナルティ 累積正解率に応じて段階的(80%以上なら維持)
mature以降の間隔 Ease Factorで動的計算(最大180日)
早期復習 統計のみ更新、進捗は変えない
タイムゾーン クライアントからoffsetを送信、Lambda側でローカル日付を再現
非同期化 DynamoDB Streams → SQS → Lambda

「スペーシング復習」は概念としては単純だが、実装の細部——タイムゾーン、ペナルティの公平感、複数デバイスの整合性——に設計の難しさが詰まっている。同じ課題に取り組む方の参考になれば。


Cobo Memo(iOS / Android )

前の記事:AIフラッシュカード続編:知らない言葉を『ポイっと投げ込む』だけ。空欄はGeminiが埋めてくれる

技術スタック: Flutter / AWS Amplify Gen2 / DynamoDB Streams / SQS / Lambda (Python)

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?