Add memory coach, transformer recall model, and training FAB.
Introduce Q/K/V memory dialogue with coach APIs, a lightweight NumPy transformer for per-word forgetting prediction, and a floating training menu linking daily quiz, spell, and coach flows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"""Transformer 记忆预测服务:回忆概率、遗忘曲线、复习间隔建议。"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import QuizRecord, User, Word
|
||||
from services.memory_transformer.encoding import (
|
||||
build_sequence_matrix,
|
||||
parse_iso,
|
||||
word_en,
|
||||
)
|
||||
from services.memory_transformer.network import MiniTransformer, init_random_weights
|
||||
from services.memory_visual_service import retention_percent, stability_hours
|
||||
|
||||
WEIGHTS_PATH = Path(__file__).resolve().parents[2] / "data" / "memory_transformer" / "weights.json"
|
||||
DEFAULT_WEIGHTS_PATH = Path(__file__).resolve().parents[2] / "data" / "memory_transformer" / "default_weights.json"
|
||||
|
||||
|
||||
def _hours_since_review(word: Word, now: datetime) -> float:
|
||||
last_at = parse_iso(word.last_reviewed_at or word.created_at)
|
||||
return max(0.0, (now - last_at).total_seconds() / 3600.0)
|
||||
|
||||
|
||||
def _formula_recall(word: Word, hours_since: float) -> float:
|
||||
return retention_percent(hours_since, stability_hours(word)) / 100.0
|
||||
|
||||
|
||||
def _stability_days_from_target_r(target_r: float, hours_since: float) -> int:
|
||||
"""达到目标回忆率所需的额外稳定化间隔(天),用于推荐复习。"""
|
||||
target_r = max(0.55, min(0.95, target_r))
|
||||
if hours_since <= 0:
|
||||
return 1
|
||||
s_hours = -hours_since / np.log(target_r)
|
||||
days = int(max(1, min(30, round(s_hours / 24.0))))
|
||||
return days
|
||||
|
||||
|
||||
class MemoryTransformerService:
|
||||
def __init__(self) -> None:
|
||||
self._model: Optional[MiniTransformer] = None
|
||||
self._loaded_path: Optional[Path] = None
|
||||
|
||||
def _load_model(self) -> MiniTransformer:
|
||||
path = WEIGHTS_PATH if WEIGHTS_PATH.is_file() else DEFAULT_WEIGHTS_PATH
|
||||
if self._model is not None and self._loaded_path == path:
|
||||
return self._model
|
||||
if path.is_file():
|
||||
self._model = MiniTransformer.load_json(path)
|
||||
else:
|
||||
m = MiniTransformer()
|
||||
m.weights = init_random_weights(
|
||||
d_model=m.d_model,
|
||||
n_heads=m.n_heads,
|
||||
d_ff=m.d_ff,
|
||||
n_layers=m.n_layers,
|
||||
)
|
||||
self._model = m
|
||||
self._loaded_path = path
|
||||
return self._model
|
||||
|
||||
def model_ready(self) -> bool:
|
||||
return WEIGHTS_PATH.is_file() or DEFAULT_WEIGHTS_PATH.is_file()
|
||||
|
||||
def predict_for_word(
|
||||
self,
|
||||
db: Session,
|
||||
user: User,
|
||||
word: Word,
|
||||
horizon_hours: Optional[list[float]] = None,
|
||||
) -> dict:
|
||||
now = datetime.now(timezone.utc)
|
||||
records = (
|
||||
db.query(QuizRecord)
|
||||
.filter(QuizRecord.user_id == user.id, QuizRecord.word_id == word.id)
|
||||
.order_by(QuizRecord.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
seq, valid_len = build_sequence_matrix(word, records, now)
|
||||
model = self._load_model()
|
||||
|
||||
p_now = model.predict_proba(seq, valid_len)
|
||||
hours_since = _hours_since_review(word, now)
|
||||
p_formula = _formula_recall(word, hours_since)
|
||||
|
||||
# 事件少时与艾宾浩斯公式融合,冷启动更稳
|
||||
n_events = max(0, valid_len - 1)
|
||||
blend = min(1.0, n_events / 5.0)
|
||||
recall_now = round((blend * p_now + (1.0 - blend) * p_formula) * 100, 1)
|
||||
|
||||
if horizon_hours is None:
|
||||
horizon_hours = [0, 6, 12, 24, 48, 72, 120, 168, 240, 336]
|
||||
curve = []
|
||||
for dh in horizon_hours:
|
||||
future = now + timedelta(hours=dh)
|
||||
seq_f, valid_f = build_sequence_matrix(word, records, future)
|
||||
p_f = model.predict_proba(seq_f, valid_f)
|
||||
h_total = hours_since + dh
|
||||
p_form_f = _formula_recall(word, h_total)
|
||||
recall = round((blend * p_f + (1.0 - blend) * p_form_f) * 100, 1)
|
||||
curve.append(
|
||||
{
|
||||
"hours_ahead": dh,
|
||||
"recall_percent": recall,
|
||||
"forgetting_percent": round(100.0 - recall, 1),
|
||||
}
|
||||
)
|
||||
|
||||
target_r = 0.75
|
||||
recommended_days = _stability_days_from_target_r(
|
||||
target_r, hours_since * max(0.3, recall_now / 100.0)
|
||||
)
|
||||
half_life_hours = None
|
||||
for pt in curve:
|
||||
if pt["recall_percent"] <= 50.0:
|
||||
half_life_hours = pt["hours_ahead"]
|
||||
break
|
||||
|
||||
return {
|
||||
"word_id": word.id,
|
||||
"en": word_en(word),
|
||||
"recall_now_percent": recall_now,
|
||||
"formula_recall_percent": round(p_formula * 100, 1),
|
||||
"model_recall_percent": round(p_now * 100, 1),
|
||||
"blend_weight": round(blend, 2),
|
||||
"event_count": n_events,
|
||||
"model_trained": self.model_ready(),
|
||||
"recommended_review_days": recommended_days,
|
||||
"half_life_hours": half_life_hours,
|
||||
"curve": curve,
|
||||
"attention_hint": self._attention_hint(model, seq, valid_len),
|
||||
}
|
||||
|
||||
def _attention_hint(
|
||||
self,
|
||||
model: MiniTransformer,
|
||||
seq: list[list[float]],
|
||||
valid_len: int,
|
||||
) -> list[dict]:
|
||||
"""返回 CLS 对序列位置的关注度(便于解释「哪些 token 影响预测」)。"""
|
||||
if valid_len <= 1:
|
||||
return [{"index": 0, "label": "词项", "weight": 1.0}]
|
||||
x = np.array(seq[:valid_len], dtype=np.float64)
|
||||
w = model.weights
|
||||
x = x @ w["in_proj"] + w["in_bias"]
|
||||
Q = x[0:1] @ w["L0.Wq"] + w["L0.Bq"]
|
||||
K = x @ w["L0.Wk"] + w["L0.Bk"]
|
||||
sl = slice(0, model.d_k)
|
||||
scores = (Q[:, sl] @ K[:, sl].T)[0] / np.sqrt(model.d_k)
|
||||
attn = np.exp(scores - scores.max())
|
||||
attn = attn / attn.sum()
|
||||
labels = ["词项(CLS)"] + [f"事件{i}" for i in range(1, valid_len)]
|
||||
return [
|
||||
{"index": i, "label": labels[i], "weight": round(float(attn[i]), 3)}
|
||||
for i in range(valid_len)
|
||||
]
|
||||
|
||||
|
||||
memory_transformer_service = MemoryTransformerService()
|
||||
Reference in New Issue
Block a user