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,340 @@
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import QuizRecord, User, Word
|
||||
from schemas import CoachSessionResponse, CoachTurnResponse, CoachWordBrief, MemoryToken
|
||||
from services.memory_visual_service import (
|
||||
memory_visual_service,
|
||||
parse_iso,
|
||||
retention_percent,
|
||||
stability_hours,
|
||||
word_en,
|
||||
word_zh,
|
||||
)
|
||||
from services.quiz_service import quiz_service
|
||||
from services.word_service import word_service
|
||||
|
||||
|
||||
def _mask_zh(zh: str) -> str:
|
||||
zh = zh.strip()
|
||||
if len(zh) <= 1:
|
||||
return zh
|
||||
return zh[0] + "※" * (len(zh) - 1)
|
||||
|
||||
|
||||
def _blank_example(example: str, en: str) -> str:
|
||||
if not example:
|
||||
return ""
|
||||
pattern = re.compile(re.escape(en), re.IGNORECASE)
|
||||
return pattern.sub("______", example, count=1)
|
||||
|
||||
|
||||
def _retention_now(word: Word) -> float:
|
||||
now = datetime.now(timezone.utc)
|
||||
last_at = parse_iso(word.last_reviewed_at or word.created_at)
|
||||
hours = (now - last_at).total_seconds() / 3600
|
||||
return round(retention_percent(hours, stability_hours(word)), 1)
|
||||
|
||||
|
||||
def _last_quiz_hint(db: Session, word_id: int) -> Optional[str]:
|
||||
r = (
|
||||
db.query(QuizRecord)
|
||||
.filter(QuizRecord.word_id == word_id)
|
||||
.order_by(QuizRecord.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if not r:
|
||||
return None
|
||||
return "上次答对" if r.is_correct else "上次答错"
|
||||
|
||||
|
||||
class MemoryCoachService:
|
||||
def build_tokens(self, db: Session, word: Word, reveal_extra: int = 0) -> list[MemoryToken]:
|
||||
en = word_en(word).strip()
|
||||
zh = word_zh(word).strip()
|
||||
retention = _retention_now(word)
|
||||
hint = _last_quiz_hint(db, word.id)
|
||||
|
||||
tokens: list[MemoryToken] = [
|
||||
MemoryToken(
|
||||
role="K",
|
||||
key="retention",
|
||||
label="记忆保持",
|
||||
value=f"{retention}%",
|
||||
revealed=True,
|
||||
),
|
||||
MemoryToken(
|
||||
role="K",
|
||||
key="mastery",
|
||||
label="掌握率",
|
||||
value=f"{word.mastery_score}%",
|
||||
revealed=True,
|
||||
),
|
||||
MemoryToken(
|
||||
role="K",
|
||||
key="status",
|
||||
label="词库状态",
|
||||
value=word.status,
|
||||
revealed=True,
|
||||
),
|
||||
]
|
||||
if hint:
|
||||
tokens.append(
|
||||
MemoryToken(
|
||||
role="K",
|
||||
key="last_quiz",
|
||||
label="练习记录",
|
||||
value=hint,
|
||||
revealed=True,
|
||||
)
|
||||
)
|
||||
|
||||
q_tokens: list[MemoryToken] = [
|
||||
MemoryToken(
|
||||
role="Q",
|
||||
key="zh_full",
|
||||
label="中文释义",
|
||||
value=zh,
|
||||
revealed=True,
|
||||
),
|
||||
MemoryToken(
|
||||
role="Q",
|
||||
key="length",
|
||||
label="字母数",
|
||||
value=str(len(en)),
|
||||
revealed=True,
|
||||
),
|
||||
]
|
||||
if zh:
|
||||
q_tokens.append(
|
||||
MemoryToken(
|
||||
role="Q",
|
||||
key="zh_hint",
|
||||
label="释义提示",
|
||||
value=_mask_zh(zh),
|
||||
revealed=reveal_extra > 0,
|
||||
)
|
||||
)
|
||||
if word.phonetic:
|
||||
q_tokens.append(
|
||||
MemoryToken(
|
||||
role="Q",
|
||||
key="phonetic",
|
||||
label="音标",
|
||||
value=word.phonetic,
|
||||
revealed=reveal_extra > 0,
|
||||
)
|
||||
)
|
||||
if len(en) >= 2:
|
||||
q_tokens.append(
|
||||
MemoryToken(
|
||||
role="Q",
|
||||
key="prefix",
|
||||
label="英文前缀",
|
||||
value=en[:2] + "…",
|
||||
revealed=reveal_extra > 1,
|
||||
)
|
||||
)
|
||||
if len(en) >= 4:
|
||||
q_tokens.append(
|
||||
MemoryToken(
|
||||
role="Q",
|
||||
key="suffix",
|
||||
label="英文尾缀",
|
||||
value="…" + en[-2:],
|
||||
revealed=reveal_extra > 2,
|
||||
)
|
||||
)
|
||||
if word.example_en:
|
||||
blanked = _blank_example(word.example_en, en)
|
||||
if blanked and blanked != word.example_en:
|
||||
q_tokens.append(
|
||||
MemoryToken(
|
||||
role="Q",
|
||||
key="example",
|
||||
label="例句挖空",
|
||||
value=blanked,
|
||||
revealed=reveal_extra > 3,
|
||||
)
|
||||
)
|
||||
|
||||
tokens.extend(q_tokens)
|
||||
tokens.extend(
|
||||
[
|
||||
MemoryToken(
|
||||
role="V",
|
||||
key="en",
|
||||
label="英文",
|
||||
value=en,
|
||||
revealed=False,
|
||||
),
|
||||
MemoryToken(
|
||||
role="V",
|
||||
key="zh",
|
||||
label="中文",
|
||||
value=zh,
|
||||
revealed=False,
|
||||
),
|
||||
]
|
||||
)
|
||||
if word.example_en:
|
||||
tokens.append(
|
||||
MemoryToken(
|
||||
role="V",
|
||||
key="example_en",
|
||||
label="例句",
|
||||
value=word.example_en,
|
||||
revealed=False,
|
||||
)
|
||||
)
|
||||
if word.example_cn:
|
||||
tokens.append(
|
||||
MemoryToken(
|
||||
role="V",
|
||||
key="example_cn",
|
||||
label="例句译文",
|
||||
value=word.example_cn,
|
||||
revealed=False,
|
||||
)
|
||||
)
|
||||
return tokens
|
||||
|
||||
def _reveal_q_tokens(self, tokens: list[MemoryToken], count: int) -> list[MemoryToken]:
|
||||
hidden_q = [t for t in tokens if t.role == "Q" and not t.revealed]
|
||||
for t in hidden_q[:count]:
|
||||
t.revealed = True
|
||||
return tokens
|
||||
|
||||
def _reveal_all_v(self, tokens: list[MemoryToken]) -> list[MemoryToken]:
|
||||
for t in tokens:
|
||||
if t.role == "V":
|
||||
t.revealed = True
|
||||
if t.role == "Q":
|
||||
t.revealed = True
|
||||
return tokens
|
||||
|
||||
def start_session(self, db: Session, user: User) -> CoachSessionResponse:
|
||||
settings = quiz_service.get_settings(db, user)
|
||||
words = quiz_service.select_daily_words(db, user, settings.daily_target)
|
||||
if not words:
|
||||
return CoachSessionResponse(words=[], total=0)
|
||||
|
||||
summaries = memory_visual_service.get_visualization(db, user)["words"]
|
||||
risk_map = {w["id"]: w.get("retention_now", 0) for w in summaries}
|
||||
|
||||
items = [
|
||||
CoachWordBrief(
|
||||
word_id=w.id,
|
||||
zh=word_zh(w),
|
||||
phonetic=w.phonetic,
|
||||
retention_now=risk_map.get(w.id, _retention_now(w)),
|
||||
mastery_score=w.mastery_score,
|
||||
status=w.status,
|
||||
)
|
||||
for w in words
|
||||
]
|
||||
return CoachSessionResponse(words=items, total=len(items))
|
||||
|
||||
def handle_turn(
|
||||
self,
|
||||
db: Session,
|
||||
user: User,
|
||||
word_id: int,
|
||||
stage: str,
|
||||
user_message: str = "",
|
||||
hints_used: int = 0,
|
||||
duration_seconds: int = 0,
|
||||
) -> CoachTurnResponse:
|
||||
word = word_service.get_word(db, user, word_id)
|
||||
en = word_en(word).strip()
|
||||
zh = word_zh(word).strip()
|
||||
phonetic = word.phonetic
|
||||
tokens = self.build_tokens(db, word, reveal_extra=hints_used)
|
||||
messages: list[str] = []
|
||||
expect_input = False
|
||||
is_correct: Optional[bool] = None
|
||||
quiz_recorded = False
|
||||
word_complete = False
|
||||
next_stage = stage
|
||||
input_hint = f"请输入「{zh}」的英文"
|
||||
|
||||
if stage == "intro":
|
||||
next_stage = "derive"
|
||||
expect_input = True
|
||||
|
||||
elif stage == "derive":
|
||||
answer = user_message.strip()
|
||||
if not answer:
|
||||
expect_input = True
|
||||
next_stage = "derive"
|
||||
else:
|
||||
is_correct = answer.lower() == en.lower()
|
||||
if is_correct:
|
||||
tokens = self._reveal_all_v(tokens)
|
||||
messages.append(f"正确:{en}")
|
||||
quiz_service.submit_answer(
|
||||
db,
|
||||
user,
|
||||
word.id,
|
||||
"memory_coach",
|
||||
answer,
|
||||
en,
|
||||
duration_seconds,
|
||||
)
|
||||
quiz_recorded = True
|
||||
word_complete = True
|
||||
next_stage = "done"
|
||||
else:
|
||||
hints_used += 1
|
||||
tokens = self.build_tokens(db, word, reveal_extra=hints_used)
|
||||
tokens = self._reveal_q_tokens(tokens, 1)
|
||||
hidden_left = sum(1 for t in tokens if t.role == "Q" and not t.revealed)
|
||||
messages.append("不对,再试一次。")
|
||||
if hidden_left == 0:
|
||||
tokens = self._reveal_all_v(tokens)
|
||||
messages.append(f"答案:{en}")
|
||||
quiz_service.submit_answer(
|
||||
db,
|
||||
user,
|
||||
word.id,
|
||||
"memory_coach",
|
||||
answer,
|
||||
en,
|
||||
duration_seconds,
|
||||
)
|
||||
quiz_recorded = True
|
||||
word_complete = True
|
||||
next_stage = "done"
|
||||
else:
|
||||
expect_input = True
|
||||
next_stage = "derive"
|
||||
|
||||
elif stage == "done":
|
||||
word_complete = True
|
||||
next_stage = "done"
|
||||
|
||||
else:
|
||||
next_stage = "intro"
|
||||
expect_input = True
|
||||
|
||||
return CoachTurnResponse(
|
||||
assistant_messages=messages,
|
||||
tokens=tokens,
|
||||
stage=next_stage,
|
||||
expect_input=expect_input,
|
||||
prompt_zh=zh,
|
||||
prompt_phonetic=phonetic,
|
||||
input_hint=input_hint,
|
||||
is_correct=is_correct,
|
||||
quiz_recorded=quiz_recorded,
|
||||
word_complete=word_complete,
|
||||
hints_used=hints_used,
|
||||
target_en=en if word_complete else None,
|
||||
target_zh=zh if word_complete else None,
|
||||
)
|
||||
|
||||
|
||||
memory_coach_service = MemoryCoachService()
|
||||
@@ -0,0 +1,3 @@
|
||||
from services.memory_transformer.service import memory_transformer_service
|
||||
|
||||
__all__ = ["memory_transformer_service"]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""将 QuizRecord 序列编码为 Transformer 输入特征。"""
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from models import QuizRecord, Word
|
||||
|
||||
FEATURE_DIM = 16
|
||||
MAX_SEQ_LEN = 32
|
||||
|
||||
QUESTION_TYPES = ("en_to_zh", "zh_to_en", "spell", "memory_coach")
|
||||
STATUS_ORDER = ("new", "learning", "mastered", "weak")
|
||||
|
||||
|
||||
def parse_iso(s: str) -> datetime:
|
||||
s = s.replace("Z", "+00:00")
|
||||
try:
|
||||
return datetime.fromisoformat(s)
|
||||
except ValueError:
|
||||
return datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def word_en(w: Word) -> str:
|
||||
return w.target_text if w.source_lang == "zh" else w.source_text
|
||||
|
||||
|
||||
def _log_hours(delta_hours: float) -> float:
|
||||
return math.log1p(max(0.0, delta_hours)) / math.log1p(24 * 30)
|
||||
|
||||
|
||||
def _one_hot(index: int, size: int) -> list[float]:
|
||||
v = [0.0] * size
|
||||
if 0 <= index < size:
|
||||
v[index] = 1.0
|
||||
return v
|
||||
|
||||
|
||||
def _pad_features(feats: list[float]) -> list[float]:
|
||||
row = feats[:FEATURE_DIM]
|
||||
while len(row) < FEATURE_DIM:
|
||||
row.append(0.0)
|
||||
return row
|
||||
|
||||
|
||||
def word_static_features(word: Word) -> list[float]:
|
||||
en = word_en(word)
|
||||
status_idx = STATUS_ORDER.index(word.status) if word.status in STATUS_ORDER else 1
|
||||
feats = [
|
||||
word.mastery_score / 100.0,
|
||||
min(word.consecutive_correct_count, 10) / 10.0,
|
||||
min(word.correct_count, 50) / 50.0,
|
||||
min(word.wrong_count, 50) / 50.0,
|
||||
min(len(en), 24) / 24.0,
|
||||
]
|
||||
feats.extend(_one_hot(status_idx, len(STATUS_ORDER)))
|
||||
return _pad_features(feats)
|
||||
|
||||
|
||||
def event_features(
|
||||
record: QuizRecord,
|
||||
prev_at: Optional[datetime],
|
||||
at: datetime,
|
||||
) -> list[float]:
|
||||
q_idx = (
|
||||
QUESTION_TYPES.index(record.question_type)
|
||||
if record.question_type in QUESTION_TYPES
|
||||
else 0
|
||||
)
|
||||
if prev_at is None:
|
||||
delta_h = 0.0
|
||||
else:
|
||||
delta_h = max(0.0, (at - prev_at).total_seconds() / 3600.0)
|
||||
dur = min(record.duration_seconds or 0, 600) / 600.0
|
||||
feats = [
|
||||
1.0 if record.is_correct else 0.0,
|
||||
_log_hours(delta_h),
|
||||
dur,
|
||||
]
|
||||
feats.extend(_one_hot(q_idx, len(QUESTION_TYPES)))
|
||||
return _pad_features(feats)
|
||||
|
||||
|
||||
def build_sequence_matrix(
|
||||
word: Word,
|
||||
records: list[QuizRecord],
|
||||
now: Optional[datetime] = None,
|
||||
) -> tuple[list[list[float]], int]:
|
||||
"""
|
||||
返回 (seq_features, valid_len)。
|
||||
第 0 位为词项 CLS(静态),其后为按时间排序的练习事件(最多 MAX_SEQ_LEN-1)。
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
ordered = sorted(records, key=lambda r: r.created_at)
|
||||
seq: list[list[float]] = [word_static_features(word)]
|
||||
|
||||
prev_at: Optional[datetime] = parse_iso(word.created_at)
|
||||
for r in ordered[-(MAX_SEQ_LEN - 1) :]:
|
||||
at = parse_iso(r.created_at)
|
||||
seq.append(event_features(r, prev_at, at))
|
||||
prev_at = at
|
||||
|
||||
valid_len = len(seq)
|
||||
while len(seq) < MAX_SEQ_LEN:
|
||||
seq.append([0.0] * FEATURE_DIM)
|
||||
return seq, valid_len
|
||||
@@ -0,0 +1,166 @@
|
||||
"""轻量 NumPy Transformer:单条序列 → 回忆成功概率 logit。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from services.memory_transformer.encoding import FEATURE_DIM, MAX_SEQ_LEN
|
||||
|
||||
|
||||
def _gelu(x: np.ndarray) -> np.ndarray:
|
||||
return 0.5 * x * (1.0 + np.tanh(math.sqrt(2.0 / math.pi) * (x + 0.044715 * x**3)))
|
||||
|
||||
|
||||
def _softmax(x: np.ndarray, axis: int = -1) -> np.ndarray:
|
||||
e = np.exp(x - np.max(x, axis=axis, keepdims=True))
|
||||
return e / np.sum(e, axis=axis, keepdims=True)
|
||||
|
||||
|
||||
def _layer_norm(x: np.ndarray, gamma: np.ndarray, beta: np.ndarray) -> np.ndarray:
|
||||
mean = x.mean(axis=-1, keepdims=True)
|
||||
var = x.var(axis=-1, keepdims=True) + 1e-6
|
||||
return gamma * (x - mean) / np.sqrt(var) + beta
|
||||
|
||||
|
||||
class MiniTransformer:
|
||||
"""
|
||||
结构:Linear(F→D) + 2×(MHA + FFN) + CLS 读出。
|
||||
仅推理;训练在 train_memory_transformer.py 中用 PyTorch 导出权重。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
d_model: int = 48,
|
||||
n_heads: int = 2,
|
||||
d_ff: int = 96,
|
||||
n_layers: int = 2,
|
||||
):
|
||||
self.d_model = d_model
|
||||
self.n_heads = n_heads
|
||||
self.d_k = d_model // n_heads
|
||||
self.d_ff = d_ff
|
||||
self.n_layers = n_layers
|
||||
self.weights: dict[str, np.ndarray] = {}
|
||||
|
||||
def load_numpy_dict(self, state: dict[str, Any]) -> None:
|
||||
self.d_model = int(state["d_model"])
|
||||
self.n_heads = int(state["n_heads"])
|
||||
self.d_ff = int(state["d_ff"])
|
||||
self.n_layers = int(state["n_layers"])
|
||||
self.d_k = self.d_model // self.n_heads
|
||||
self.weights = {k: np.array(v, dtype=np.float64) for k, v in state["weights"].items()}
|
||||
|
||||
def save_json(self, path: Path) -> None:
|
||||
payload = {
|
||||
"d_model": self.d_model,
|
||||
"n_heads": self.n_heads,
|
||||
"d_ff": self.d_ff,
|
||||
"n_layers": self.n_layers,
|
||||
"weights": {k: v.tolist() for k, v in self.weights.items()},
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
def load_json(cls, path: Path) -> MiniTransformer:
|
||||
state = json.loads(path.read_text(encoding="utf-8"))
|
||||
m = cls()
|
||||
m.load_numpy_dict(state)
|
||||
return m
|
||||
|
||||
def _mha(self, x: np.ndarray, li: int) -> np.ndarray:
|
||||
w = self.weights
|
||||
Wq, Wk, Wv = w[f"L{li}.Wq"], w[f"L{li}.Wk"], w[f"L{li}.Wv"]
|
||||
Wo = w[f"L{li}.Wo"]
|
||||
Bq, Bk, Bv = w[f"L{li}.Bq"], w[f"L{li}.Bk"], w[f"L{li}.Bv"]
|
||||
|
||||
seq, d = x.shape
|
||||
Q = x @ Wq + Bq
|
||||
K = x @ Wk + Bk
|
||||
V = x @ Wv + Bv
|
||||
|
||||
heads = []
|
||||
for h in range(self.n_heads):
|
||||
sl = slice(h * self.d_k, (h + 1) * self.d_k)
|
||||
q, k, v = Q[:, sl], K[:, sl], V[:, sl]
|
||||
scores = (q @ k.T) / math.sqrt(self.d_k)
|
||||
attn = _softmax(scores, axis=-1)
|
||||
heads.append(attn @ v)
|
||||
concat = np.concatenate(heads, axis=-1)
|
||||
return concat @ Wo + w[f"L{li}.Bo"]
|
||||
|
||||
def _ffn(self, x: np.ndarray, li: int) -> np.ndarray:
|
||||
w = self.weights
|
||||
h = _gelu(x @ w[f"L{li}.W1"] + w[f"L{li}.b1"])
|
||||
return h @ w[f"L{li}.W2"] + w[f"L{li}.b2"]
|
||||
|
||||
def forward_logits(self, seq_features: list[list[float]], valid_len: int) -> float:
|
||||
x = np.array(seq_features[:MAX_SEQ_LEN], dtype=np.float64)
|
||||
mask = np.zeros(MAX_SEQ_LEN, dtype=np.float64)
|
||||
mask[:valid_len] = 1.0
|
||||
|
||||
w = self.weights
|
||||
x = x @ w["in_proj"] + w["in_bias"]
|
||||
x = _layer_norm(x, w["ln_in_g"], w["ln_in_b"])
|
||||
|
||||
for li in range(self.n_layers):
|
||||
attn_out = self._mha(x, li)
|
||||
x = _layer_norm(x + attn_out, w[f"L{li}.ln1_g"], w[f"L{li}.ln1_b"])
|
||||
ff = self._ffn(x, li)
|
||||
x = _layer_norm(x + ff, w[f"L{li}.ln2_g"], w[f"L{li}.ln2_b"])
|
||||
|
||||
cls = x[0]
|
||||
return float(cls @ w["head_w"] + w["head_b"])
|
||||
|
||||
def predict_proba(self, seq_features: list[list[float]], valid_len: int) -> float:
|
||||
logit = self.forward_logits(seq_features, valid_len)
|
||||
return float(1.0 / (1.0 + np.exp(-logit)))
|
||||
|
||||
|
||||
def init_random_weights(
|
||||
d_model: int = 48,
|
||||
n_heads: int = 2,
|
||||
d_ff: int = 96,
|
||||
n_layers: int = 2,
|
||||
seed: int = 42,
|
||||
) -> dict[str, np.ndarray]:
|
||||
rng = np.random.default_rng(seed)
|
||||
d_k = d_model // n_heads
|
||||
w: dict[str, np.ndarray] = {}
|
||||
|
||||
def glorot(shape):
|
||||
fan_in, fan_out = shape[0], shape[1] if len(shape) > 1 else shape[0]
|
||||
limit = math.sqrt(6.0 / (fan_in + fan_out))
|
||||
return rng.uniform(-limit, limit, shape)
|
||||
|
||||
w["in_proj"] = glorot((FEATURE_DIM, d_model))
|
||||
w["in_bias"] = np.zeros(d_model)
|
||||
w["ln_in_g"] = np.ones(d_model)
|
||||
w["ln_in_b"] = np.zeros(d_model)
|
||||
|
||||
for li in range(n_layers):
|
||||
w[f"L{li}.Wq"] = glorot((d_model, d_model))
|
||||
w[f"L{li}.Wk"] = glorot((d_model, d_model))
|
||||
w[f"L{li}.Wv"] = glorot((d_model, d_model))
|
||||
w[f"L{li}.Wo"] = glorot((d_model, d_model))
|
||||
w[f"L{li}.Bq"] = np.zeros(d_model)
|
||||
w[f"L{li}.Bk"] = np.zeros(d_model)
|
||||
w[f"L{li}.Bv"] = np.zeros(d_model)
|
||||
w[f"L{li}.Bo"] = np.zeros(d_model)
|
||||
w[f"L{li}.ln1_g"] = np.ones(d_model)
|
||||
w[f"L{li}.ln1_b"] = np.zeros(d_model)
|
||||
w[f"L{li}.W1"] = glorot((d_model, d_ff))
|
||||
w[f"L{li}.b1"] = np.zeros(d_ff)
|
||||
w[f"L{li}.W2"] = glorot((d_ff, d_model))
|
||||
w[f"L{li}.b2"] = np.zeros(d_model)
|
||||
w[f"L{li}.ln2_g"] = np.ones(d_model)
|
||||
w[f"L{li}.ln2_b"] = np.zeros(d_model)
|
||||
|
||||
w["head_w"] = glorot((d_model,)) * 0.1
|
||||
w["head_b"] = np.array(0.0)
|
||||
return w
|
||||
@@ -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()
|
||||
@@ -172,7 +172,7 @@ class QuizService:
|
||||
raise HTTPException(status_code=404, detail="单词不存在")
|
||||
|
||||
settings = self.get_settings(db, user)
|
||||
if question_type == "spell":
|
||||
if question_type in ("spell", "memory_coach"):
|
||||
is_correct = (
|
||||
user_answer.strip().lower() == correct_answer.strip().lower()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user