File size: 9,390 Bytes
4afadbd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
"""
model.py
Architecture du modèle "Hilal Ai" : Transformer Décodeur (style GPT) entraîné
from scratch au niveau caractère.

Cette architecture DOIT correspondre exactement à celle utilisée pendant
l'entraînement sur Colab pour que model.load_state_dict(...) fonctionne.
Si tu as modifié l'architecture sur Colab (dropout, bias, etc.), répercute
les mêmes changements ici.
"""

import json
import logging
import os

import torch
import torch.nn as nn
from torch.nn import functional as F

logger = logging.getLogger("hilal_ai.model")

# ----------------------------------------------------------------------------
# Hyperparamètres (doivent matcher l'entraînement Colab)
# ----------------------------------------------------------------------------
BLOCK_SIZE = 128
N_EMBD = 192
N_HEAD = 6
N_LAYER = 4
DROPOUT = 0.1  # identique à l'entraînement Colab. Sans effet en model.eval()
               # (le dropout est désactivé automatiquement en inference),
               # mais gardé identique pour la cohérence de l'architecture.


# ----------------------------------------------------------------------------
# Architecture
# ----------------------------------------------------------------------------
class Head(nn.Module):
    """Une tête d'auto-attention causale."""

    def __init__(self, head_size):
        super().__init__()
        self.key = nn.Linear(N_EMBD, head_size, bias=False)
        self.query = nn.Linear(N_EMBD, head_size, bias=False)
        self.value = nn.Linear(N_EMBD, head_size, bias=False)
        self.register_buffer("tril", torch.tril(torch.ones(BLOCK_SIZE, BLOCK_SIZE)))
        self.dropout = nn.Dropout(DROPOUT)

    def forward(self, x):
        B, T, C = x.shape
        k = self.key(x)
        q = self.query(x)
        wei = q @ k.transpose(-2, -1) * (k.shape[-1] ** -0.5)
        wei = wei.masked_fill(self.tril[:T, :T] == 0, float("-inf"))
        wei = F.softmax(wei, dim=-1)
        wei = self.dropout(wei)
        v = self.value(x)
        out = wei @ v
        return out


class MultiHeadAttention(nn.Module):
    """Plusieurs têtes d'attention en parallèle."""

    def __init__(self, num_heads, head_size):
        super().__init__()
        self.heads = nn.ModuleList([Head(head_size) for _ in range(num_heads)])
        self.proj = nn.Linear(N_EMBD, N_EMBD)
        self.dropout = nn.Dropout(DROPOUT)

    def forward(self, x):
        out = torch.cat([h(x) for h in self.heads], dim=-1)
        out = self.dropout(self.proj(out))
        return out


class FeedForward(nn.Module):
    """Simple couche feed-forward (MLP) avec activation GELU."""

    def __init__(self, n_embd):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_embd, 4 * n_embd),
            nn.GELU(),
            nn.Linear(4 * n_embd, n_embd),
            nn.Dropout(DROPOUT),
        )

    def forward(self, x):
        return self.net(x)


class Block(nn.Module):
    """Bloc Transformer : communication (attention) puis calcul (FFN)."""

    def __init__(self, n_embd, n_head):
        super().__init__()
        head_size = n_embd // n_head
        self.sa = MultiHeadAttention(n_head, head_size)
        self.ffwd = FeedForward(n_embd)
        self.ln1 = nn.LayerNorm(n_embd)
        self.ln2 = nn.LayerNorm(n_embd)

    def forward(self, x):
        x = x + self.sa(self.ln1(x))
        x = x + self.ffwd(self.ln2(x))
        return x


class MiniGPT(nn.Module):
    """Modèle de langage Transformer Décodeur, niveau caractère."""

    def __init__(self, vocab_size):
        super().__init__()
        self.vocab_size = vocab_size
        self.token_embedding = nn.Embedding(vocab_size, N_EMBD)
        self.position_embedding = nn.Embedding(BLOCK_SIZE, N_EMBD)
        self.blocks = nn.Sequential(*[Block(N_EMBD, N_HEAD) for _ in range(N_LAYER)])
        self.ln_f = nn.LayerNorm(N_EMBD)
        self.lm_head = nn.Linear(N_EMBD, vocab_size)

    def forward(self, idx, targets=None):
        B, T = idx.shape
        tok_emb = self.token_embedding(idx)
        pos_emb = self.position_embedding(
            torch.arange(T, device=idx.device)
        )
        x = tok_emb + pos_emb
        x = self.blocks(x)
        x = self.ln_f(x)
        logits = self.lm_head(x)

        loss = None
        if targets is not None:
            B, T, C = logits.shape
            logits_flat = logits.view(B * T, C)
            targets_flat = targets.view(B * T)
            loss = F.cross_entropy(logits_flat, targets_flat)

        return logits, loss

    @torch.no_grad()
    def generate(self, idx, max_new_tokens, temperature=0.7, top_k=None):
        """
        Génère max_new_tokens nouveaux caractères à partir du contexte idx.
        idx : tensor (B, T) d'indices déjà encodés.
        Le contexte est tronqué à BLOCK_SIZE à chaque étape (fenêtre glissante).
        """
        self.eval()
        for _ in range(max_new_tokens):
            idx_cond = idx[:, -BLOCK_SIZE:]
            logits, _ = self(idx_cond)
            logits = logits[:, -1, :] / max(temperature, 1e-6)

            if top_k is not None:
                v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
                logits[logits < v[:, [-1]]] = float("-inf")

            probs = F.softmax(logits, dim=-1)
            idx_next = torch.multinomial(probs, num_samples=1)
            idx = torch.cat((idx, idx_next), dim=1)
        return idx


# ----------------------------------------------------------------------------
# Tokenizer niveau caractère
# ----------------------------------------------------------------------------
class CharTokenizer:
    """
    Tokenizer char-level. Le mapping stoi/itos DOIT être identique à celui
    utilisé pendant l'entraînement sur Colab, sinon le modèle produira du
    charabia (les poids des embeddings ne correspondront pas aux bons
    caractères).

    Stratégie recommandée :
    1. Pendant l'entraînement sur Colab, sauvegarde ton vocabulaire :
           import json
           with open("vocab.json", "w", encoding="utf-8") as f:
               json.dump({"chars": sorted(list(set(text)))}, f, ensure_ascii=False)
       puis télécharge vocab.json à côté de model.pt.
    2. Place vocab.json dans le même dossier que ce script.

    Si vocab.json est absent, un jeu de caractères par défaut est utilisé
    en secours (lettres FR/EN, chiffres, ponctuation courante). Dans ce cas,
    la cohérence avec les poids entraînés n'est PAS garantie.
    """

    DEFAULT_CHARS = (
        "\n !\"#$%&'()*+,-./0123456789:;<=>?@"
        "ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`"
        "abcdefghijklmnopqrstuvwxyz{|}~"
        "àâäéèêëïîôöùûüçÀÂÄÉÈÊËÏÎÔÖÙÛÜÇœŒ«»…–—’"
    )

    def __init__(self, vocab_path="vocab.json"):
        chars = None
        if vocab_path and os.path.exists(vocab_path):
            try:
                with open(vocab_path, "r", encoding="utf-8") as f:
                    data = json.load(f)
                chars = data["chars"]
                logger.info("Vocabulaire chargé depuis %s (%d caractères).", vocab_path, len(chars))
            except Exception as e:
                logger.warning("Impossible de lire %s (%s). Utilisation du vocabulaire par défaut.", vocab_path, e)

        if chars is None:
            chars = sorted(set(self.DEFAULT_CHARS))
            logger.warning(
                "AUCUN vocab.json trouvé : utilisation d'un vocabulaire par défaut (%d caractères). "
                "Ceci ne correspondra probablement PAS exactement aux poids entraînés sur Colab.",
                len(chars),
            )

        self.chars = chars
        self.vocab_size = len(chars)
        self.stoi = {ch: i for i, ch in enumerate(chars)}
        self.itos = {i: ch for i, ch in enumerate(chars)}
        # Caractère de remplacement pour tout caractère inconnu en entrée
        self.unk_char = chars[0]

    def encode(self, s: str):
        return [self.stoi.get(c, self.stoi.get(self.unk_char, 0)) for c in s]

    def decode(self, indices) -> str:
        return "".join(self.itos.get(int(i), "") for i in indices)


# ----------------------------------------------------------------------------
# Chargement du modèle complet
# ----------------------------------------------------------------------------
def load_model(model_path: str, vocab_path: str = "vocab.json", device: str = "cpu"):
    """
    Instancie le tokenizer + le modèle, charge les poids depuis model_path,
    et renvoie (model, tokenizer) prêts pour l'inférence.
    """
    tokenizer = CharTokenizer(vocab_path)
    model = MiniGPT(vocab_size=tokenizer.vocab_size)

    state_dict = torch.load(model_path, map_location=device)

    # Selon comment le modèle a été sauvegardé sur Colab (state_dict pur,
    # ou checkpoint dict contenant 'model_state_dict'), on gère les deux cas.
    if isinstance(state_dict, dict) and "model_state_dict" in state_dict:
        state_dict = state_dict["model_state_dict"]

    missing, unexpected = model.load_state_dict(state_dict, strict=False)
    if missing:
        logger.warning("Clés manquantes lors du chargement des poids : %s", missing)
    if unexpected:
        logger.warning("Clés inattendues lors du chargement des poids : %s", unexpected)

    model.to(device)
    model.eval()
    return model, tokenizer