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
|