File size: 4,819 Bytes
850e745
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
"""Phase 11 — mode B : « apprentissage permanent » — EXPÉRIMENTAL ET INSTABLE.

⚠️ AVERTISSEMENT HONNÊTE. Ce module met à jour les **poids** du modèle pendant
l'« ingestion » d'un cours (quelques pas de gradient). Ce n'est :

* **pas** un vrai fine-tuning (peu de pas, peu de données) ;
* **pas** stable : l'oubli catastrophique est réel, et une mise à jour peut
  **dégrader** d'autres capacités du modèle déjà mesurées (accord image/audio, etc.) ;
* à utiliser qu'avec un **tampon de rejeu** (on ré-entraîne sur d'anciens exemples
  mémorisés) pour limiter l'oubli.

Ce que cela sait faire, honnêtement : mémoriser un court texte (une leçon) et le
restituer. Ce que cela ne fait pas : apprendre un domaine, raisonner, ou égaler un
entraînement complet. Mesures dans docs/architecture.md §10.

Aucun optimiseur externe : boucle SGD/AdamW PyTorch écrite à la main.
"""

from __future__ import annotations

from collections import deque
from dataclasses import dataclass, field

import torch

from src.tokenizer import CortexTokenizer
from src.tokenizer.special_tokens import BOS_ID, EOS_ID


@dataclass
class ReplayBuffer:
    """Tampon de rejeu : garde les derniers exemples vus pour limiter l'oubli."""

    capacity: int = 32
    items: deque = field(default_factory=lambda: deque(maxlen=32))

    def __post_init__(self) -> None:
        if self.items.maxlen != self.capacity:
            self.items = deque(self.items, maxlen=self.capacity)

    def add(self, token_ids: list[int]) -> None:
        self.items.append(list(token_ids))

    def sample(self, n: int, rng) -> list[list[int]]:
        if not self.items:
            return []
        n = min(n, len(self.items))
        idx = rng.choice(len(self.items), size=n, replace=False)
        return [self.items[int(i)] for i in idx]

    def __len__(self) -> int:
        return len(self.items)


def _encode_chunks(tokenizer: CortexTokenizer, text: str, ctx_len: int,
                   max_chunks: int) -> list[list[int]]:
    """Découpe un texte en fenêtres ``<bos> ... <eos>`` de ``ctx_len`` tokens max."""
    ids = [BOS_ID, *tokenizer.encode(text), EOS_ID]
    chunks = [ids[i:i + ctx_len] for i in range(0, len(ids), ctx_len)] if ids else []
    return [c for c in chunks if len(c) >= 2][:max_chunks]


@dataclass
class PermanentLearner:
    """Ingestion d'un cours par mise à jour des poids (mode B)."""

    model: torch.nn.Module
    tokenizer: CortexTokenizer
    lr: float = 3e-4
    steps_per_lesson: int = 20
    ctx_len: int = 128
    replay_ratio: float = 0.5  # part de rejeu vs nouveau texte
    replay: ReplayBuffer = field(default_factory=ReplayBuffer)
    device: str = "cpu"

    def __post_init__(self) -> None:
        self.model.to(self.device)
        self.optimizer = torch.optim.AdamW(self.model.parameters(), lr=self.lr)

    def _loss_on(self, token_ids: list[int]) -> torch.Tensor:
        x = torch.tensor([token_ids[:-1]], dtype=torch.long, device=self.device)
        y = torch.tensor([token_ids[1:]], dtype=torch.long, device=self.device)
        _logits, loss = self.model(x, y)
        return loss

    def ingest(self, text: str, seed: int = 0) -> dict:
        """Met à jour les poids sur ``text`` (+ rejeu). Retourne les métriques."""
        rng = __import__("numpy").random.default_rng(seed)
        chunks = _encode_chunks(self.tokenizer, text, self.ctx_len, max_chunks=8)
        if not chunks:
            return {"loss_before": 0.0, "loss_after": 0.0, "steps": 0, "replay_size": len(self.replay)}

        before = float(sum(self._loss_on(c).item() for c in chunks) / len(chunks))

        self.model.train()
        for step in range(self.steps_per_lesson):
            new = chunks[step % len(chunks)]
            loss = self._loss_on(new)
            n_replay = max(1, int(round(self.replay_ratio * 4)))
            for old in self.replay.sample(n_replay, rng):
                loss = loss + self._loss_on(old)
            self.optimizer.zero_grad()
            loss.backward()
            torch.nn.utils.clip_grad_norm_(self.model.parameters(), 1.0)
            self.optimizer.step()

        for c in chunks:
            self.replay.add(c)
        self.model.eval()
        after = float(sum(self._loss_on(c).item() for c in chunks) / len(chunks))
        return {"loss_before": round(before, 4), "loss_after": round(after, 4),
                "steps": self.steps_per_lesson, "replay_size": len(self.replay)}

    def recall(self, prompt: str, max_new_tokens: int = 40) -> str:
        """Teste la restitution après ingestion (génération greedy)."""
        from src.generate import generate_text

        self.model.eval()
        return generate_text(self.model, self.tokenizer, prompt,
                             max_new_tokens=max_new_tokens, greedy=True)