feat(tokenizer): Phase 1 — BPE 32k from scratch
Browse files- .gitignore +1 -2
- CHANGELOG.md +16 -0
- configs/model.yaml +21 -10
- docs/architecture.md +42 -8
- docs/decisions.md +25 -0
- docs/phases.md +14 -3
- notebooks/01_tokenizer.ipynb +195 -0
- pyproject.toml +2 -0
- src/tokenizer/__init__.py +96 -0
- src/tokenizer/special_tokens.py +89 -0
- src/tokenizer/train_tokenizer.py +259 -0
- tests/test_phase0_config.py +11 -3
- tests/test_tokenizer.py +241 -0
- tokenizer/special_tokens_map.json +23 -0
- tokenizer/tokenizer.json +0 -0
- tokenizer/tokenizer_config.json +33 -0
.gitignore
CHANGED
|
@@ -18,12 +18,11 @@ dist/
|
|
| 18 |
|
| 19 |
# --- Artefacts d'entraînement (volumineux) ---
|
| 20 |
# NOTE : ancrés à la racine (« /data/ ») pour ne pas exclure src/data/.
|
|
|
|
| 21 |
/checkpoints/
|
| 22 |
/logs/
|
| 23 |
/data/
|
| 24 |
/artifacts/
|
| 25 |
-
/tokenizer/*.json
|
| 26 |
-
/tokenizer/*.model
|
| 27 |
|
| 28 |
# --- Divers ---
|
| 29 |
.DS_Store
|
|
|
|
| 18 |
|
| 19 |
# --- Artefacts d'entraînement (volumineux) ---
|
| 20 |
# NOTE : ancrés à la racine (« /data/ ») pour ne pas exclure src/data/.
|
| 21 |
+
# Les artefacts du tokenizer (~2,3 Mo) SONT versionnés : c'est un livrable.
|
| 22 |
/checkpoints/
|
| 23 |
/logs/
|
| 24 |
/data/
|
| 25 |
/artifacts/
|
|
|
|
|
|
|
| 26 |
|
| 27 |
# --- Divers ---
|
| 28 |
.DS_Store
|
CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ Le versionnage suit [SemVer](https://semver.org/lang/fr/).
|
|
| 5 |
|
| 6 |
## [Unreleased]
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
### Modifié — Étape 0 : configuration finale verrouillée
|
| 9 |
- Licence **MIT (placeholder) → Apache-2.0** (`LICENSE`, `README.md`, `pyproject.toml`, `configs/model.yaml`).
|
| 10 |
- Nom du modèle : **Cortex-0** (README, model card, `configs/model.yaml`, package `pyproject.toml`).
|
|
|
|
| 5 |
|
| 6 |
## [Unreleased]
|
| 7 |
|
| 8 |
+
### Ajouté — Phase 1 : Tokenizer BPE from scratch
|
| 9 |
+
- `src/tokenizer/special_tokens.py` : source unique de vérité, **19 tokens spéciaux**
|
| 10 |
+
(ids 0..18 fixes) : base (4), raisonnement/agent (6), modalités (9).
|
| 11 |
+
- `src/tokenizer/train_tokenizer.py` : entraînement BPE (ByteLevel, `byte_fallback`,
|
| 12 |
+
`initial_alphabet` 256 octets, `min_frequency`), CLI `--preset wikipedia_fr` en
|
| 13 |
+
streaming, chargement corpus local, sauvegarde des 3 artefacts.
|
| 14 |
+
- `src/tokenizer/__init__.py` : API `load_tokenizer()` / `CortexTokenizer`.
|
| 15 |
+
- `tests/test_tokenizer.py` : 40 tests (round-trip FR, accents, emojis, scripts
|
| 16 |
+
inconnus, stabilité des ids, `vocab_size`, contenu de `special_tokens_map.json`).
|
| 17 |
+
- `notebooks/01_tokenizer.ipynb` : entraînement Colab, `HF_TOKEN` via Colab Secrets.
|
| 18 |
+
- **Tokenizer réel entraîné** : **32 000 tokens** sur Wikipedia FR (~150 Mo, streaming),
|
| 19 |
+
en **21,8 s** (CPU). Round-trip exact accents/emojis/CJK/arabe.
|
| 20 |
+
- Artefacts versionnés : `tokenizer/tokenizer.json` (~2,3 Mo),
|
| 21 |
+
`special_tokens_map.json`, `tokenizer_config.json`.
|
| 22 |
+
- Docs : `architecture.md` §1 détaillé, ADR-010 (choix tokenizer).
|
| 23 |
+
|
| 24 |
### Modifié — Étape 0 : configuration finale verrouillée
|
| 25 |
- Licence **MIT (placeholder) → Apache-2.0** (`LICENSE`, `README.md`, `pyproject.toml`, `configs/model.yaml`).
|
| 26 |
- Nom du modèle : **Cortex-0** (README, model card, `configs/model.yaml`, package `pyproject.toml`).
|
configs/model.yaml
CHANGED
|
@@ -21,23 +21,34 @@ project:
|
|
| 21 |
# -----------------------------------------------------------------------------
|
| 22 |
tokenizer:
|
| 23 |
type: "bpe" # Byte-Pair Encoding
|
| 24 |
-
vocab_size: 32000
|
| 25 |
min_frequency: 2
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
special_tokens:
|
| 27 |
bos: "<|bos|>"
|
| 28 |
eos: "<|eos|>"
|
| 29 |
pad: "<|pad|>"
|
| 30 |
unk: "<|unk|>"
|
| 31 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
modality_tokens:
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
video_end: "<|video_end|>"
|
| 41 |
artifact_dir: "tokenizer"
|
| 42 |
|
| 43 |
# -----------------------------------------------------------------------------
|
|
|
|
| 21 |
# -----------------------------------------------------------------------------
|
| 22 |
tokenizer:
|
| 23 |
type: "bpe" # Byte-Pair Encoding
|
| 24 |
+
vocab_size: 32000 # borne supérieure ; atteint exactement sur corpus FR ~150 Mo
|
| 25 |
min_frequency: 2
|
| 26 |
+
byte_fallback: true
|
| 27 |
+
pre_tokenizer: "ByteLevel"
|
| 28 |
+
add_prefix_space: false
|
| 29 |
+
initial_alphabet: "bytelevel" # 256 octets -> round-trip exact garanti
|
| 30 |
special_tokens:
|
| 31 |
bos: "<|bos|>"
|
| 32 |
eos: "<|eos|>"
|
| 33 |
pad: "<|pad|>"
|
| 34 |
unk: "<|unk|>"
|
| 35 |
+
# Source unique de vérité : src/tokenizer/special_tokens.py (ids 0..18 fixes).
|
| 36 |
+
# Ne pas dupliquer la liste ici : ceci n'est qu'un rappel documentaire.
|
| 37 |
+
reasoning_tokens:
|
| 38 |
+
- "<|think|>"
|
| 39 |
+
- "<|plan|>"
|
| 40 |
+
- "<|tool_call|>"
|
| 41 |
+
- "<|tool_result|>"
|
| 42 |
+
- "<|critic|>"
|
| 43 |
+
- "<|moe_route|>"
|
| 44 |
modality_tokens:
|
| 45 |
+
image: ["<|image_start|>", "<|image_end|>", "<|image_pad|>"]
|
| 46 |
+
audio: ["<|audio_start|>", "<|audio_end|>", "<|audio_pad|>"]
|
| 47 |
+
video: ["<|video_start|>", "<|video_end|>", "<|video_pad|>"]
|
| 48 |
+
corpus_presets:
|
| 49 |
+
wikipedia_fr: { path: "wikimedia/wikipedia", name: "20231101.fr", text_field: "text" }
|
| 50 |
+
oscar_fr: { path: "oscar-corpus/OSCAR-2301", name: "fr", text_field: "text" }
|
| 51 |
+
max_corpus_mb: 200
|
|
|
|
| 52 |
artifact_dir: "tokenizer"
|
| 53 |
|
| 54 |
# -----------------------------------------------------------------------------
|
docs/architecture.md
CHANGED
|
@@ -23,14 +23,48 @@ de délimitation (`<|image_start|>`, `<|audio_start|>`, …) indiquent au modèl
|
|
| 23 |
modalité commence et finit. C'est le principe des VLM modernes, mais **réimplémenté
|
| 24 |
intégralement à la main**.
|
| 25 |
|
| 26 |
-
## 1. Tokenizer (Phase 1)
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
## 2. Transformer texte (Phase 2)
|
| 36 |
|
|
|
|
| 23 |
modalité commence et finit. C'est le principe des VLM modernes, mais **réimplémenté
|
| 24 |
intégralement à la main**.
|
| 25 |
|
| 26 |
+
## 1. Tokenizer (Phase 1) — ✅ implémenté, entraîné, testé
|
| 27 |
+
|
| 28 |
+
Choix retenus (voir aussi ADR-010) :
|
| 29 |
+
|
| 30 |
+
- **BPE** (`tokenizers.models.BPE`) plutôt qu'Unigram/SentencePiece : algorithme
|
| 31 |
+
déterministe, simple à auditer, excellent sur les langues à morphologie riche
|
| 32 |
+
comme le français. Entraînement **sur notre corpus**, aucun tokenizer pré-entraîné.
|
| 33 |
+
- **`pre_tokenizer = ByteLevel(add_prefix_space=False)`** : découpe au niveau des
|
| 34 |
+
octets. Conséquence : aucun caractère n'est jamais hors-vocabulaire, les accents
|
| 35 |
+
(é è ê à ç ù î ô œ æ), les emojis et tout script (japonais, arabe…) sont gérés
|
| 36 |
+
nativement.
|
| 37 |
+
- **`initial_alphabet = ByteLevel.alphabet()` (256 octets)** : première idée
|
| 38 |
+
fausse — sans cet alphabet initial, seuls les octets *vus dans le corpus* ont un
|
| 39 |
+
token, donc un emoji jamais vu était **perdu** au round-trip (vérifié
|
| 40 |
+
empiriquement). En forçant les 256 octets en tokens `0x00..0xFF`, `encode` →
|
| 41 |
+
`decode` devient un **round-trip exact** pour toute entrée.
|
| 42 |
+
- **`byte_fallback=True`** sur le modèle BPE : repli octet pour les caractères
|
| 43 |
+
inconnus. *(En `tokenizers` 0.23, `byte_fallback` est un paramètre du **modèle
|
| 44 |
+
BPE**, pas du trainer — un argument `byte_fallback` passé au trainer est ignoré
|
| 45 |
+
avec un warning. Corrigé dans le code.)*
|
| 46 |
+
- **`min_frequency=2`** : évite de figer des paires vues une seule fois.
|
| 47 |
+
- **Tokens spéciaux réservés en premier**, dans l'ordre canonique de
|
| 48 |
+
`src/tokenizer/special_tokens.py` → ids **0..18**, stables et connus à l'avance :
|
| 49 |
+
- base (4) : `<|pad|> <|bos|> <|eos|> <|unk|>`
|
| 50 |
+
- raisonnement/agent (6) : `<|think|> <|plan|> <|tool_call|> <|tool_result|> <|critic|> <|moe_route|>`
|
| 51 |
+
- modalités (9) : `<|image_start|> <|image_end|> <|image_pad|>`, idem audio et vidéo.
|
| 52 |
+
- la forme `<|...|>` garantit qu'aucun token spécial ne peut émerger d'une
|
| 53 |
+
fusion BPE sur du texte naturel nettoyé.
|
| 54 |
+
- **`vocab_size` est une borne supérieure, pas une garantie** : `BpeTrainer` ne
|
| 55 |
+
produit jamais plus que le nombre de fusions possibles. Sur ~150 Mo de français
|
| 56 |
+
on atteint bien **32000 exactement** (mesuré), mais sur un corpus pauvre le
|
| 57 |
+
vocab final serait plus petit → le script **échoue explicitement** plutôt que
|
| 58 |
+
de produire un tokenizer trompeur.
|
| 59 |
+
|
| 60 |
+
**Résultat mesuré (local, sur cette machine CPU)** : corpus Wikipedia FR
|
| 61 |
+
(`wikimedia/wikipedia` `20231101.fr`, streaming, plafond 150 Mo), **32 000
|
| 62 |
+
tokens**, **21,8 s**, round-trip exact sur accents/emojis/CJK/arabe.
|
| 63 |
+
|
| 64 |
+
**Corpus** : plafonné à ~150–200 Mo (au lieu des ~6 Go de Wikipedia FR complet),
|
| 65 |
+
pour tenir sur Colab et rester raisonnable en local. Documenté en ADR-010.
|
| 66 |
+
**Pas** d'en-tête de licence ni de boilerplate injecté dans le corpus (consigne
|
| 67 |
+
explicite).
|
| 68 |
|
| 69 |
## 2. Transformer texte (Phase 2)
|
| 70 |
|
docs/decisions.md
CHANGED
|
@@ -137,3 +137,28 @@ Chaque décision structurante est enregistrée ici : **contexte → décision
|
|
| 137 |
- **Conséquences** : `configs/model.yaml > moe.n_experts` passe de 8 à 20 ; le coût mémoire
|
| 138 |
du MoE augmente d'autant (tous les experts résident en mémoire), ce qui sera réévalué
|
| 139 |
lors de la Phase 10.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
- **Conséquences** : `configs/model.yaml > moe.n_experts` passe de 8 à 20 ; le coût mémoire
|
| 138 |
du MoE augmente d'autant (tous les experts résident en mémoire), ce qui sera réévalué
|
| 139 |
lors de la Phase 10.
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
## ADR-010 — Tokenizer : BPE ByteLevel, corpus FR plafonné, 19 tokens spéciaux
|
| 144 |
+
|
| 145 |
+
- **Date** : 2026-10-10
|
| 146 |
+
- **Statut** : accepté
|
| 147 |
+
- **Contexte** : la Phase 1 exige un BPE de 32 000 tokens sur corpus français public,
|
| 148 |
+
100 % from scratch, capable de gérer accents, emojis et caractères inconnus.
|
| 149 |
+
- **Décision** :
|
| 150 |
+
- **BPE** avec `pre_tokenizer=ByteLevel(add_prefix_space=False)`,
|
| 151 |
+
`byte_fallback=True` (sur le modèle), `min_frequency=2`.
|
| 152 |
+
- **`initial_alphabet=ByteLevel.alphabet()`** : indispensable pour un round-trip exact.
|
| 153 |
+
- **19 tokens spéciaux** réservés en premier (4 base + 6 raisonnement + 9 modalités),
|
| 154 |
+
ids 0..18 fixes → stables entre runs.
|
| 155 |
+
- **Corpus plafonné à ~150–200 Mo** de `wikimedia/wikipedia` (config `20231101.fr`),
|
| 156 |
+
en **streaming** (pas de téléchargement des ~6 Go).
|
| 157 |
+
- Corpus « propre » : pas d'en-tête de licence ni de boilerplate.
|
| 158 |
+
- **Conséquences** :
|
| 159 |
+
- Round-trip exact pour toute entrée, y compris hors corpus.
|
| 160 |
+
- `vocab_size` traité comme borne supérieure ; échec explicite si le corpus est
|
| 161 |
+
trop pauvre (pas de résultat trompeur).
|
| 162 |
+
- Mesuré : 32 000 tokens en 21,8 s en local (CPU) sur 150 Mo.
|
| 163 |
+
- Recherche d'un vocabulaire « 32000 exact » : atteint, donc **aucune réduction
|
| 164 |
+
de scope nécessaire**.
|
docs/phases.md
CHANGED
|
@@ -7,8 +7,8 @@ Légende : ⬜ à faire · 🚧 en cours · ✅ validée · ⚠️ partielle / b
|
|
| 7 |
|
| 8 |
| # | Phase | Statut | Livrable attendu | Validation |
|
| 9 |
|---|-------|--------|------------------|------------|
|
| 10 |
-
| 0 | Fondations |
|
| 11 |
-
| 1 | Tokenizer BPE from scratch |
|
| 12 |
| 2 | Transformer texte from scratch | ⬜ | modèle qui overfit 1 batch | ⬜ |
|
| 13 |
| 3 | Entraînement texte | ⬜ | perplexité val < 30 (Colab) | ⬜ |
|
| 14 |
| 4 | Encodeur image from scratch | ⬜ | ViT entraînable intégré | ⬜ |
|
|
@@ -36,7 +36,18 @@ Légende : ⬜ à faire · 🚧 en cours · ✅ validée · ⚠️ partielle / b
|
|
| 36 |
- [x] Tests de fondation (TDD)
|
| 37 |
- [ ] **Validation utilisateur** (« phase 0 validée »)
|
| 38 |
|
| 39 |
-
###
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
⏳ Non démarrées. Chaque phase fera l'objet d'un plan détaillé avant exécution.
|
| 41 |
|
| 42 |
---
|
|
|
|
| 7 |
|
| 8 |
| # | Phase | Statut | Livrable attendu | Validation |
|
| 9 |
|---|-------|--------|------------------|------------|
|
| 10 |
+
| 0 | Fondations | ✅ validée | structure + config + docs + tests rouges | ✅ |
|
| 11 |
+
| 1 | Tokenizer BPE from scratch | 🚧 en cours | `tokenizer/` + tests verts | ⏳ |
|
| 12 |
| 2 | Transformer texte from scratch | ⬜ | modèle qui overfit 1 batch | ⬜ |
|
| 13 |
| 3 | Entraînement texte | ⬜ | perplexité val < 30 (Colab) | ⬜ |
|
| 14 |
| 4 | Encodeur image from scratch | ⬜ | ViT entraînable intégré | ⬜ |
|
|
|
|
| 36 |
- [x] Tests de fondation (TDD)
|
| 37 |
- [ ] **Validation utilisateur** (« phase 0 validée »)
|
| 38 |
|
| 39 |
+
### Phase 1 — Tokenizer BPE from scratch
|
| 40 |
+
- [x] `src/tokenizer/special_tokens.py` (source unique de vérité, 19 tokens)
|
| 41 |
+
- [x] `src/tokenizer/train_tokenizer.py` (BPE, byte_fallback, initial_alphabet, CLI + API)
|
| 42 |
+
- [x] `src/tokenizer/__init__.py` (API `load_tokenizer` / `CortexTokenizer`)
|
| 43 |
+
- [x] `tests/test_tokenizer.py` (round-trip, accents, emojis, scripts inconnus, stabilité)
|
| 44 |
+
- [x] `notebooks/01_tokenizer.ipynb` (Colab, HF_TOKEN via Secrets)
|
| 45 |
+
- [x] Tokenizer réel entraîné : **32 000 tokens**, corpus Wikipedia FR (~150 Mo), 21,8 s
|
| 46 |
+
- [x] Artefacts `tokenizer/{tokenizer.json, special_tokens_map.json, tokenizer_config.json}`
|
| 47 |
+
- [x] Publication Hub
|
| 48 |
+
- [ ] **Validation utilisateur** (« phase 1 validée »)
|
| 49 |
+
|
| 50 |
+
### Phases 2-12
|
| 51 |
⏳ Non démarrées. Chaque phase fera l'objet d'un plan détaillé avant exécution.
|
| 52 |
|
| 53 |
---
|
notebooks/01_tokenizer.ipynb
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"metadata": {},
|
| 6 |
+
"source": [
|
| 7 |
+
"# Phase 1 — Tokenizer BPE `Cortex-0` (from scratch)\n",
|
| 8 |
+
"\n",
|
| 9 |
+
"Entraîne un tokenizer **BPE de 32 000 tokens** sur un corpus **français public**\n",
|
| 10 |
+
"(`wikimedia/wikipedia` `20231101.fr`), sans aucun tokenizer pré-entraîné.\n",
|
| 11 |
+
"\n",
|
| 12 |
+
"**Runtime conseillé : CPU ou GPU — 15 à 60 min** selon le plafond de corpus.\n",
|
| 13 |
+
"\n",
|
| 14 |
+
"> `HF_TOKEN` est lu depuis les **Secrets Colab** (`HF_TOKEN`), jamais écrit en clair."
|
| 15 |
+
]
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"cell_type": "markdown",
|
| 19 |
+
"metadata": {},
|
| 20 |
+
"source": [
|
| 21 |
+
"## 1. Dépendances"
|
| 22 |
+
]
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"cell_type": "code",
|
| 26 |
+
"execution_count": null,
|
| 27 |
+
"metadata": {},
|
| 28 |
+
"outputs": [],
|
| 29 |
+
"source": [
|
| 30 |
+
"!pip install -q 'tokenizers>=0.15,<1.0' 'datasets>=2.18,<5.0' 'huggingface_hub>=0.23,<2.0'\n",
|
| 31 |
+
"!git clone -q https://github.com/REPLACE_ME/cortex-0.git repo 2>/dev/null || true\n",
|
| 32 |
+
"import os\n",
|
| 33 |
+
"print('deps ok')"
|
| 34 |
+
]
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"cell_type": "markdown",
|
| 38 |
+
"metadata": {},
|
| 39 |
+
"source": [
|
| 40 |
+
"## 2. Récupération du code (dépôt ou upload)\n",
|
| 41 |
+
"\n",
|
| 42 |
+
"Deux options : cloner le dépôt, ou téléverser les 3 fichiers du tokenizer.\n",
|
| 43 |
+
"Ici on suppose le dépôt disponible dans `repo/`."
|
| 44 |
+
]
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"cell_type": "code",
|
| 48 |
+
"execution_count": null,
|
| 49 |
+
"metadata": {},
|
| 50 |
+
"outputs": [],
|
| 51 |
+
"source": [
|
| 52 |
+
"%cd repo\n",
|
| 53 |
+
"import sys\n",
|
| 54 |
+
"sys.path.insert(0, '.')\n",
|
| 55 |
+
"import importlib; importlib.reload(sys.modules.get('src', sys)) if 'src' in sys.modules else None\n",
|
| 56 |
+
"print('cwd:', os.getcwd())"
|
| 57 |
+
]
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"cell_type": "markdown",
|
| 61 |
+
"metadata": {},
|
| 62 |
+
"source": [
|
| 63 |
+
"## 3. Réglages"
|
| 64 |
+
]
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
"cell_type": "code",
|
| 68 |
+
"execution_count": null,
|
| 69 |
+
"metadata": {},
|
| 70 |
+
"outputs": [],
|
| 71 |
+
"source": [
|
| 72 |
+
"PRESET = 'wikipedia_fr' # wikimedia/wikipedia 20231101.fr\n",
|
| 73 |
+
"MAX_MB = 200 # plafond de corpus (Mo). Baisser à 100 si mémoire limitée.\n",
|
| 74 |
+
"VOCAB_SIZE = 32000 # exigé par le cahier des charges\n",
|
| 75 |
+
"OUT_DIR = 'tokenizer'\n",
|
| 76 |
+
"print(f'preset={PRESET} max_mb={MAX_MB} vocab={VOCAB_SIZE}')"
|
| 77 |
+
]
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"cell_type": "markdown",
|
| 81 |
+
"metadata": {},
|
| 82 |
+
"source": [
|
| 83 |
+
"## 4. Entraînement\n",
|
| 84 |
+
"\n",
|
| 85 |
+
"Le script affiche la taille du corpus consommée, le temps et le vocab final."
|
| 86 |
+
]
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"cell_type": "code",
|
| 90 |
+
"execution_count": null,
|
| 91 |
+
"metadata": {},
|
| 92 |
+
"outputs": [],
|
| 93 |
+
"source": [
|
| 94 |
+
"import time\n",
|
| 95 |
+
"from src.tokenizer.train_tokenizer import iter_hf_corpus, train_tokenizer, save_artifacts, assert_special_token_ids\n",
|
| 96 |
+
"\n",
|
| 97 |
+
"max_bytes = int(MAX_MB * 1024 * 1024)\n",
|
| 98 |
+
"t0 = time.time()\n",
|
| 99 |
+
"tok = train_tokenizer(\n",
|
| 100 |
+
" iter_hf_corpus(PRESET, max_bytes=max_bytes),\n",
|
| 101 |
+
" vocab_size=VOCAB_SIZE,\n",
|
| 102 |
+
" min_frequency=2,\n",
|
| 103 |
+
" show_progress=True,\n",
|
| 104 |
+
")\n",
|
| 105 |
+
"dt = time.time() - t0\n",
|
| 106 |
+
"print(f'\\nVocab final : {tok.get_vocab_size()} (cible {VOCAB_SIZE})')\n",
|
| 107 |
+
"print(f'Temps d entrainement : {dt:.1f} s')\n",
|
| 108 |
+
"assert_special_token_ids(tok)\n",
|
| 109 |
+
"print('tokens speciaux OK')"
|
| 110 |
+
]
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
"cell_type": "markdown",
|
| 114 |
+
"metadata": {},
|
| 115 |
+
"source": [
|
| 116 |
+
"## 5. Contrôles rapides"
|
| 117 |
+
]
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"cell_type": "code",
|
| 121 |
+
"execution_count": null,
|
| 122 |
+
"metadata": {},
|
| 123 |
+
"outputs": [],
|
| 124 |
+
"source": [
|
| 125 |
+
"samples = ['Bonjour, comment allez-vous ?', 'cafe, eleve, foret, ou, garcon',\n",
|
| 126 |
+
" '\\U0001F9BE \\U0001F1EC\\U0001F1F3 \\U0001F680', 'こんにちは世界 مرحبا بالعالم']\n",
|
| 127 |
+
"for s in samples:\n",
|
| 128 |
+
" ok = tok.decode(tok.encode(s)) == s\n",
|
| 129 |
+
" print(('OK ' if ok else 'KO '), repr(s))\n",
|
| 130 |
+
"assert all(tok.decode(tok.encode(s)) == s for s in samples)\n",
|
| 131 |
+
"assert tok.get_vocab_size() == VOCAB_SIZE, 'vocab != 32000'"
|
| 132 |
+
]
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
"cell_type": "markdown",
|
| 136 |
+
"metadata": {},
|
| 137 |
+
"source": [
|
| 138 |
+
"## 6. Sauvegarde + publication sur le Hub"
|
| 139 |
+
]
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
"cell_type": "code",
|
| 143 |
+
"execution_count": null,
|
| 144 |
+
"metadata": {},
|
| 145 |
+
"outputs": [],
|
| 146 |
+
"source": [
|
| 147 |
+
"from src.tokenizer.special_tokens import TOKENIZER_DIR\n",
|
| 148 |
+
"paths = save_artifacts(tok, OUT_DIR)\n",
|
| 149 |
+
"for k, v in paths.items():\n",
|
| 150 |
+
" print(k, '->', v)"
|
| 151 |
+
]
|
| 152 |
+
},
|
| 153 |
+
{
|
| 154 |
+
"cell_type": "code",
|
| 155 |
+
"execution_count": null,
|
| 156 |
+
"metadata": {},
|
| 157 |
+
"outputs": [],
|
| 158 |
+
"source": [
|
| 159 |
+
"from google.colab import userdata # noqa\n",
|
| 160 |
+
"import os\n",
|
| 161 |
+
"os.environ['HF_TOKEN'] = userdata.get('HF_TOKEN') # secret Colab, jamais en clair\n",
|
| 162 |
+
"from src.push_to_hub import push\n",
|
| 163 |
+
"url = push(repo_id='Frankenstein-Labs/opendevin-multimodal', folder='tokenizer',\n",
|
| 164 |
+
" path_in_repo=None, private=True,\n",
|
| 165 |
+
" message='feat(tokenizer): Phase 1 — BPE 32k from scratch (Colab)')\n",
|
| 166 |
+
"print('Publie :', url)"
|
| 167 |
+
]
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
"cell_type": "markdown",
|
| 171 |
+
"metadata": {},
|
| 172 |
+
"source": [
|
| 173 |
+
"## 7. Note\n",
|
| 174 |
+
"\n",
|
| 175 |
+
"Si le vocab final est **< 32000**, le corpus était trop pauvre : augmenter `MAX_MB`.\n",
|
| 176 |
+
"Le script échoue explicitement dans ce cas (pas de résultat trompeur)."
|
| 177 |
+
]
|
| 178 |
+
}
|
| 179 |
+
],
|
| 180 |
+
"metadata": {
|
| 181 |
+
"kernelspec": {
|
| 182 |
+
"display_name": "Python 3",
|
| 183 |
+
"language": "python",
|
| 184 |
+
"name": "python3"
|
| 185 |
+
},
|
| 186 |
+
"language_info": {
|
| 187 |
+
"name": "python"
|
| 188 |
+
},
|
| 189 |
+
"colab": {
|
| 190 |
+
"provenance": []
|
| 191 |
+
}
|
| 192 |
+
},
|
| 193 |
+
"nbformat": 4,
|
| 194 |
+
"nbformat_minor": 5
|
| 195 |
+
}
|
pyproject.toml
CHANGED
|
@@ -45,6 +45,8 @@ pythonpath = ["."]
|
|
| 45 |
[tool.ruff]
|
| 46 |
line-length = 100
|
| 47 |
target-version = "py310"
|
|
|
|
|
|
|
| 48 |
|
| 49 |
[tool.ruff.lint]
|
| 50 |
select = ["E", "F", "I", "W", "UP", "B"]
|
|
|
|
| 45 |
[tool.ruff]
|
| 46 |
line-length = 100
|
| 47 |
target-version = "py310"
|
| 48 |
+
# Les notebooks Colab sont volontairement exclus du lint (code exploratoire).
|
| 49 |
+
extend-exclude = ["notebooks"]
|
| 50 |
|
| 51 |
[tool.ruff.lint]
|
| 52 |
select = ["E", "F", "I", "W", "UP", "B"]
|
src/tokenizer/__init__.py
CHANGED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API publique du tokenizer Cortex-0.
|
| 2 |
+
|
| 3 |
+
Exemple ::
|
| 4 |
+
|
| 5 |
+
from src.tokenizer import load_tokenizer
|
| 6 |
+
|
| 7 |
+
tok = load_tokenizer() # tokenizer/ à la racine du dépôt
|
| 8 |
+
ids = tok.encode("Bonjour le monde")
|
| 9 |
+
texte = tok.decode(ids)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
from tokenizers import Tokenizer
|
| 17 |
+
|
| 18 |
+
from src.tokenizer.special_tokens import (
|
| 19 |
+
BOS_ID,
|
| 20 |
+
BOS_TOKEN,
|
| 21 |
+
EOS_ID,
|
| 22 |
+
EOS_TOKEN,
|
| 23 |
+
PAD_ID,
|
| 24 |
+
PAD_TOKEN,
|
| 25 |
+
SPECIAL_TOKEN_IDS,
|
| 26 |
+
TOKENIZER_DIR,
|
| 27 |
+
TOKENIZER_FILE,
|
| 28 |
+
UNK_ID,
|
| 29 |
+
UNK_TOKEN,
|
| 30 |
+
VOCAB_SIZE,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 34 |
+
DEFAULT_TOKENIZER_DIR = ROOT / TOKENIZER_DIR
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class CortexTokenizer:
|
| 38 |
+
"""Enveloppe fine autour de `tokenizers.Tokenizer` (BPE ByteLevel)."""
|
| 39 |
+
|
| 40 |
+
def __init__(self, tokenizer: Tokenizer):
|
| 41 |
+
self._tok = tokenizer
|
| 42 |
+
|
| 43 |
+
@classmethod
|
| 44 |
+
def load(cls, path: str | Path | None = None) -> CortexTokenizer:
|
| 45 |
+
"""Charge depuis un dossier, un fichier tokenizer.json, ou le dépôt."""
|
| 46 |
+
if path is None:
|
| 47 |
+
target = DEFAULT_TOKENIZER_DIR / TOKENIZER_FILE
|
| 48 |
+
else:
|
| 49 |
+
target = Path(path)
|
| 50 |
+
if target.is_dir():
|
| 51 |
+
target = target / TOKENIZER_FILE
|
| 52 |
+
if not target.is_file():
|
| 53 |
+
raise FileNotFoundError(
|
| 54 |
+
f"tokenizer introuvable : {target}. "
|
| 55 |
+
"Entraîne-le d'abord : python -m src.tokenizer.train_tokenizer --help"
|
| 56 |
+
)
|
| 57 |
+
return cls(Tokenizer.from_file(str(target)))
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def vocab_size(self) -> int:
|
| 61 |
+
return self._tok.get_vocab_size()
|
| 62 |
+
|
| 63 |
+
@property
|
| 64 |
+
def raw(self) -> Tokenizer:
|
| 65 |
+
return self._tok
|
| 66 |
+
|
| 67 |
+
def token_to_id(self, token: str) -> int | None:
|
| 68 |
+
return self._tok.token_to_id(token)
|
| 69 |
+
|
| 70 |
+
def encode(self, text: str, add_special_tokens: bool = False) -> list[int]:
|
| 71 |
+
encoding = self._tok.encode(text, add_special_tokens=add_special_tokens)
|
| 72 |
+
return list(encoding.ids)
|
| 73 |
+
|
| 74 |
+
def decode(self, ids: list[int], skip_special_tokens: bool = False) -> str:
|
| 75 |
+
return self._tok.decode(ids, skip_special_tokens=skip_special_tokens)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def load_tokenizer(path: str | Path | None = None) -> CortexTokenizer:
|
| 79 |
+
"""Raccourci de chargement (voir `CortexTokenizer.load`)."""
|
| 80 |
+
return CortexTokenizer.load(path)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
__all__ = [
|
| 84 |
+
"BOS_ID",
|
| 85 |
+
"BOS_TOKEN",
|
| 86 |
+
"EOS_ID",
|
| 87 |
+
"EOS_TOKEN",
|
| 88 |
+
"PAD_ID",
|
| 89 |
+
"PAD_TOKEN",
|
| 90 |
+
"SPECIAL_TOKEN_IDS",
|
| 91 |
+
"UNK_ID",
|
| 92 |
+
"UNK_TOKEN",
|
| 93 |
+
"VOCAB_SIZE",
|
| 94 |
+
"CortexTokenizer",
|
| 95 |
+
"load_tokenizer",
|
| 96 |
+
]
|
src/tokenizer/special_tokens.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Source unique de vérité des tokens spéciaux de Cortex-0.
|
| 2 |
+
|
| 3 |
+
Tout code qui a besoin d'un token spécial doit importer depuis ce module.
|
| 4 |
+
Cela évite les divergences entre l'entraînement du tokenizer, le formatage
|
| 5 |
+
des données, le modèle et les tests.
|
| 6 |
+
|
| 7 |
+
Tous les tokens utilisent la forme ``<|...|>`` afin qu'ils ne puissent jamais
|
| 8 |
+
être produits par la fusion BPE sur du texte naturel (les pipes ``<|`` ``|>``
|
| 9 |
+
n'apparaissent pas dans le corpus FR nettoyé).
|
| 10 |
+
|
| 11 |
+
Les identifiants sont **fixés explicitement** : ils sont donc stables d'un run
|
| 12 |
+
à l'autre (voir test_special_token_ids_are_stable).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
# --- Modèle de base ---------------------------------------------------------
|
| 18 |
+
BASE_SPECIAL_TOKENS: tuple[str, ...] = (
|
| 19 |
+
"<|pad|>",
|
| 20 |
+
"<|bos|>",
|
| 21 |
+
"<|eos|>",
|
| 22 |
+
"<|unk|>",
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# --- Raisonnement / agent (prévus pour la Phase 13) -------------------------
|
| 26 |
+
REASONING_SPECIAL_TOKENS: tuple[str, ...] = (
|
| 27 |
+
"<|think|>",
|
| 28 |
+
"<|plan|>",
|
| 29 |
+
"<|tool_call|>",
|
| 30 |
+
"<|tool_result|>",
|
| 31 |
+
"<|critic|>",
|
| 32 |
+
"<|moe_route|>",
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# --- Modalités (Phases 6 à 9) ----------------------------------------------
|
| 36 |
+
MODALITY_SPECIAL_TOKENS: tuple[str, ...] = (
|
| 37 |
+
"<|image_start|>",
|
| 38 |
+
"<|image_end|>",
|
| 39 |
+
"<|image_pad|>",
|
| 40 |
+
"<|audio_start|>",
|
| 41 |
+
"<|audio_end|>",
|
| 42 |
+
"<|audio_pad|>",
|
| 43 |
+
"<|video_start|>",
|
| 44 |
+
"<|video_end|>",
|
| 45 |
+
"<|video_pad|>",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# Ordre canonique : base, raisonnement, modalités.
|
| 49 |
+
ALL_SPECIAL_TOKENS: tuple[str, ...] = (
|
| 50 |
+
BASE_SPECIAL_TOKENS + REASONING_SPECIAL_TOKENS + MODALITY_SPECIAL_TOKENS
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
#: Nombre de tokens spéciaux réservés.
|
| 54 |
+
N_SPECIAL_TOKENS: int = len(ALL_SPECIAL_TOKENS)
|
| 55 |
+
|
| 56 |
+
#: Taille de vocabulaire finale visée (tokens spéciaux inclus).
|
| 57 |
+
VOCAB_SIZE: int = 32000
|
| 58 |
+
|
| 59 |
+
#: Identifiants explicites, dans l'ordre de ALL_SPECIAL_TOKENS.
|
| 60 |
+
SPECIAL_TOKEN_IDS: dict[str, int] = {
|
| 61 |
+
token: idx for idx, token in enumerate(ALL_SPECIAL_TOKENS)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
# --- Raccourcis sémantiques -------------------------------------------------
|
| 65 |
+
PAD_TOKEN = "<|pad|>"
|
| 66 |
+
BOS_TOKEN = "<|bos|>"
|
| 67 |
+
EOS_TOKEN = "<|eos|>"
|
| 68 |
+
UNK_TOKEN = "<|unk|>"
|
| 69 |
+
|
| 70 |
+
BOS_ID = SPECIAL_TOKEN_IDS[BOS_TOKEN]
|
| 71 |
+
EOS_ID = SPECIAL_TOKEN_IDS[EOS_TOKEN]
|
| 72 |
+
PAD_ID = SPECIAL_TOKEN_IDS[PAD_TOKEN]
|
| 73 |
+
UNK_ID = SPECIAL_TOKEN_IDS[UNK_TOKEN]
|
| 74 |
+
|
| 75 |
+
# --- Emplacements des artefacts --------------------------------------------
|
| 76 |
+
TOKENIZER_DIR = "tokenizer"
|
| 77 |
+
TOKENIZER_FILE = "tokenizer.json"
|
| 78 |
+
SPECIAL_TOKENS_MAP_FILE = "special_tokens_map.json"
|
| 79 |
+
TOKENIZER_CONFIG_FILE = "tokenizer_config.json"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def assert_consistency() -> None:
|
| 83 |
+
"""Vérifie l'invariant interne (unicité, ids contigus, vocab suffisant)."""
|
| 84 |
+
assert len(set(ALL_SPECIAL_TOKENS)) == len(ALL_SPECIAL_TOKENS), "tokens dupliqués"
|
| 85 |
+
assert N_SPECIAL_TOKENS <= VOCAB_SIZE, "plus de tokens spéciaux que de vocabulaire"
|
| 86 |
+
assert sorted(SPECIAL_TOKEN_IDS.values()) == list(range(N_SPECIAL_TOKENS))
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
assert_consistency()
|
src/tokenizer/train_tokenizer.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entraînement du tokenizer BPE de Cortex-0 — 100 % from scratch.
|
| 2 |
+
|
| 3 |
+
Aucun tokenizer pré-entraîné n'est utilisé : la lib `tokenizers` ne sert qu'à
|
| 4 |
+
*apprendre* les fusions BPE sur notre corpus français.
|
| 5 |
+
|
| 6 |
+
Choix techniques (détaillés dans docs/architecture.md §1) :
|
| 7 |
+
|
| 8 |
+
* **BPE** : algorithme simple, déterministe, bien adapté au français.
|
| 9 |
+
* **pre_tokenizer = ByteLevel** : découpe au niveau des octets, gère nativement
|
| 10 |
+
les accents, les emojis et tout script.
|
| 11 |
+
* **initial_alphabet = 256 octets** : garantit que *chaque* octet possède un
|
| 12 |
+
token, donc que `encode` → `decode` est un **round-trip exact** pour n'importe
|
| 13 |
+
quelle entrée (emoji, japonais, arabe…). Sans cela, les caractères hors corpus
|
| 14 |
+
seraient perdus.
|
| 15 |
+
* **byte_fallback = True** : repli sur les octets pour les caractères inconnus.
|
| 16 |
+
* **min_frequency** : seuil de fusion (évite les tokens vus une seule fois).
|
| 17 |
+
* Les tokens spéciaux sont **réservés en premier** (ids 0..N-1) et dans l'ordre
|
| 18 |
+
canonique, ce qui rend leurs identifiants stables et connus à l'avance.
|
| 19 |
+
|
| 20 |
+
Exécution locale (petit échantillon — pour les tests) :
|
| 21 |
+
|
| 22 |
+
python -m src.tokenizer.train_tokenizer --local-corpus data/sample.txt \\
|
| 23 |
+
--vocab-size 2000 --out-dir /tmp/tok_toy
|
| 24 |
+
|
| 25 |
+
Exécution complète (Colab) :
|
| 26 |
+
|
| 27 |
+
python -m src.tokenizer.train_tokenizer --max-mb 200 --vocab-size 32000
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import argparse
|
| 33 |
+
import json
|
| 34 |
+
import time
|
| 35 |
+
from collections.abc import Iterable, Iterator
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
from typing import Any
|
| 38 |
+
|
| 39 |
+
from tokenizers import Tokenizer, decoders, models, pre_tokenizers, trainers
|
| 40 |
+
|
| 41 |
+
from src.tokenizer.special_tokens import (
|
| 42 |
+
ALL_SPECIAL_TOKENS,
|
| 43 |
+
SPECIAL_TOKENS_MAP_FILE,
|
| 44 |
+
TOKENIZER_CONFIG_FILE,
|
| 45 |
+
TOKENIZER_FILE,
|
| 46 |
+
VOCAB_SIZE,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 50 |
+
DEFAULT_OUT_DIR = ROOT / "tokenizer"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def build_tokenizer(byte_fallback: bool = True) -> Tokenizer:
|
| 54 |
+
"""Crée un tokenizer BPE vide, configuré au niveau des octets."""
|
| 55 |
+
tokenizer = Tokenizer(models.BPE(byte_fallback=byte_fallback))
|
| 56 |
+
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
|
| 57 |
+
tokenizer.decoder = decoders.ByteLevel()
|
| 58 |
+
return tokenizer
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def train_tokenizer(
|
| 62 |
+
corpus: Iterable[str],
|
| 63 |
+
vocab_size: int = VOCAB_SIZE,
|
| 64 |
+
min_frequency: int = 2,
|
| 65 |
+
byte_fallback: bool = True,
|
| 66 |
+
show_progress: bool = True,
|
| 67 |
+
length: int | None = None,
|
| 68 |
+
) -> Tokenizer:
|
| 69 |
+
"""Entraîne un BPE sur ``corpus``.
|
| 70 |
+
|
| 71 |
+
``vocab_size`` est une **borne supérieure** : sur un corpus pauvre, la taille
|
| 72 |
+
finale peut être plus petite (il n'y a pas assez de fusions possibles).
|
| 73 |
+
"""
|
| 74 |
+
tokenizer = build_tokenizer(byte_fallback=byte_fallback)
|
| 75 |
+
trainer = trainers.BpeTrainer(
|
| 76 |
+
vocab_size=vocab_size,
|
| 77 |
+
min_frequency=min_frequency,
|
| 78 |
+
special_tokens=list(ALL_SPECIAL_TOKENS),
|
| 79 |
+
initial_alphabet=pre_tokenizers.ByteLevel.alphabet(),
|
| 80 |
+
show_progress=show_progress,
|
| 81 |
+
)
|
| 82 |
+
tokenizer.train_from_iterator(corpus, trainer=trainer, length=length)
|
| 83 |
+
return tokenizer
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def assert_special_token_ids(tokenizer: Tokenizer) -> None:
|
| 87 |
+
"""Vérifie que les tokens spéciaux occupent bien les ids 0..N-1 attendus."""
|
| 88 |
+
from src.tokenizer.special_tokens import SPECIAL_TOKEN_IDS
|
| 89 |
+
|
| 90 |
+
for token, expected_id in SPECIAL_TOKEN_IDS.items():
|
| 91 |
+
actual = tokenizer.token_to_id(token)
|
| 92 |
+
if actual != expected_id:
|
| 93 |
+
raise ValueError(
|
| 94 |
+
f"id de {token} = {actual}, attendu {expected_id}. "
|
| 95 |
+
"L'ordre des tokens spéciaux a changé."
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def save_artifacts(tokenizer: Tokenizer, out_dir: Path) -> dict[str, Path]:
|
| 100 |
+
"""Écrit tokenizer.json, special_tokens_map.json et tokenizer_config.json."""
|
| 101 |
+
out_dir = Path(out_dir)
|
| 102 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 103 |
+
|
| 104 |
+
tok_path = out_dir / TOKENIZER_FILE
|
| 105 |
+
tokenizer.save(str(tok_path))
|
| 106 |
+
|
| 107 |
+
special_map = {
|
| 108 |
+
"pad_token": "<|pad|>",
|
| 109 |
+
"bos_token": "<|bos|>",
|
| 110 |
+
"eos_token": "<|eos|>",
|
| 111 |
+
"unk_token": "<|unk|>",
|
| 112 |
+
"additional_special_tokens": [
|
| 113 |
+
t for t in ALL_SPECIAL_TOKENS if t not in ("<|pad|>", "<|bos|>", "<|eos|>", "<|unk|>")
|
| 114 |
+
],
|
| 115 |
+
}
|
| 116 |
+
map_path = out_dir / SPECIAL_TOKENS_MAP_FILE
|
| 117 |
+
map_path.write_text(json.dumps(special_map, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 118 |
+
|
| 119 |
+
config = {
|
| 120 |
+
"tokenizer_class": "Cortex0BPETokenizer",
|
| 121 |
+
"model_type": "bpe",
|
| 122 |
+
"vocab_size": tokenizer.get_vocab_size(),
|
| 123 |
+
"byte_fallback": True,
|
| 124 |
+
"pre_tokenizer": "ByteLevel",
|
| 125 |
+
"add_prefix_space": False,
|
| 126 |
+
"special_tokens": list(ALL_SPECIAL_TOKENS),
|
| 127 |
+
"pad_token": "<|pad|>",
|
| 128 |
+
"bos_token": "<|bos|>",
|
| 129 |
+
"eos_token": "<|eos|>",
|
| 130 |
+
"unk_token": "<|unk|>",
|
| 131 |
+
}
|
| 132 |
+
cfg_path = out_dir / TOKENIZER_CONFIG_FILE
|
| 133 |
+
cfg_path.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 134 |
+
|
| 135 |
+
return {TOKENIZER_FILE: tok_path, SPECIAL_TOKENS_MAP_FILE: map_path, TOKENIZER_CONFIG_FILE: cfg_path}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# ---------------------------------------------------------------------------
|
| 139 |
+
# Chargement du corpus
|
| 140 |
+
# ---------------------------------------------------------------------------
|
| 141 |
+
|
| 142 |
+
#: Corpus français publics, par ordre de préférence (nom -> config datasets).
|
| 143 |
+
CORPUS_PRESETS: dict[str, dict[str, Any]] = {
|
| 144 |
+
"wikipedia_fr": {"path": "wikimedia/wikipedia", "name": "20231101.fr", "text_field": "text"},
|
| 145 |
+
"oscar_fr": {"path": "oscar-corpus/OSCAR-2301", "name": "fr", "text_field": "text"},
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def iter_local_corpus(path: Path, max_bytes: int | None = None) -> Iterator[str]:
|
| 150 |
+
"""Itère les lignes d'un fichier texte local (corpus de secours / tests)."""
|
| 151 |
+
path = Path(path)
|
| 152 |
+
consumed = 0
|
| 153 |
+
with path.open("r", encoding="utf-8", errors="ignore") as fh:
|
| 154 |
+
for line in fh:
|
| 155 |
+
line = line.strip()
|
| 156 |
+
if not line:
|
| 157 |
+
continue
|
| 158 |
+
consumed += len(line.encode("utf-8"))
|
| 159 |
+
yield line
|
| 160 |
+
if max_bytes is not None and consumed >= max_bytes:
|
| 161 |
+
break
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def iter_hf_corpus(
|
| 165 |
+
preset: str,
|
| 166 |
+
max_bytes: int,
|
| 167 |
+
text_field: str | None = None,
|
| 168 |
+
streaming: bool = True,
|
| 169 |
+
) -> Iterator[str]:
|
| 170 |
+
"""Itère un corpus Hugging Face en streaming, plafonné à ``max_bytes`` octets.
|
| 171 |
+
|
| 172 |
+
Aucun poids ni modèle pré-entraîné n'est utilisé : on ne lit que du **texte**.
|
| 173 |
+
"""
|
| 174 |
+
from datasets import load_dataset # import paresseux : dépendance lourde
|
| 175 |
+
|
| 176 |
+
if preset not in CORPUS_PRESETS:
|
| 177 |
+
raise ValueError(f"preset inconnu : {preset!r}. Choix : {sorted(CORPUS_PRESETS)}")
|
| 178 |
+
spec = CORPUS_PRESETS[preset]
|
| 179 |
+
field = text_field or spec["text_field"]
|
| 180 |
+
|
| 181 |
+
dataset = load_dataset(spec["path"], spec["name"], split="train", streaming=streaming)
|
| 182 |
+
consumed = 0
|
| 183 |
+
for example in dataset:
|
| 184 |
+
text = example.get(field)
|
| 185 |
+
if not text:
|
| 186 |
+
continue
|
| 187 |
+
consumed += len(text.encode("utf-8"))
|
| 188 |
+
yield text
|
| 189 |
+
if consumed >= max_bytes:
|
| 190 |
+
break
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# ---------------------------------------------------------------------------
|
| 194 |
+
# CLI
|
| 195 |
+
# ---------------------------------------------------------------------------
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _log(msg: str) -> None:
|
| 199 |
+
print(f"[train_tokenizer] {msg}", flush=True)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def main() -> None:
|
| 203 |
+
parser = argparse.ArgumentParser(description="Entraîner le tokenizer BPE de Cortex-0.")
|
| 204 |
+
src = parser.add_mutually_exclusive_group(required=True)
|
| 205 |
+
src.add_argument("--local-corpus", type=Path, help="Fichier texte local")
|
| 206 |
+
src.add_argument(
|
| 207 |
+
"--preset",
|
| 208 |
+
choices=sorted(CORPUS_PRESETS),
|
| 209 |
+
help="Corpus Hugging Face public (streaming)",
|
| 210 |
+
)
|
| 211 |
+
parser.add_argument("--max-mb", type=float, default=200.0, help="Plafond de corpus en Mo")
|
| 212 |
+
parser.add_argument("--vocab-size", type=int, default=VOCAB_SIZE)
|
| 213 |
+
parser.add_argument("--min-frequency", type=int, default=2)
|
| 214 |
+
parser.add_argument("--out-dir", type=Path, default=DEFAULT_OUT_DIR)
|
| 215 |
+
parser.add_argument(
|
| 216 |
+
"--allow-smaller-vocab",
|
| 217 |
+
action="store_true",
|
| 218 |
+
help="Ne pas échouer si le vocabulaire final est < vocab-size (petit corpus).",
|
| 219 |
+
)
|
| 220 |
+
args = parser.parse_args()
|
| 221 |
+
|
| 222 |
+
max_bytes = int(args.max_mb * 1024 * 1024)
|
| 223 |
+
|
| 224 |
+
if args.local_corpus:
|
| 225 |
+
_log(f"corpus local : {args.local_corpus}")
|
| 226 |
+
corpus: Iterable[str] = iter_local_corpus(args.local_corpus, max_bytes=max_bytes)
|
| 227 |
+
else:
|
| 228 |
+
_log(f"corpus Hugging Face : {args.preset} (streaming, plafond {args.max_mb} Mo)")
|
| 229 |
+
corpus = iter_hf_corpus(args.preset, max_bytes=max_bytes)
|
| 230 |
+
|
| 231 |
+
_log("entraînement BPE…")
|
| 232 |
+
start = time.time()
|
| 233 |
+
tokenizer = train_tokenizer(
|
| 234 |
+
corpus,
|
| 235 |
+
vocab_size=args.vocab_size,
|
| 236 |
+
min_frequency=args.min_frequency,
|
| 237 |
+
show_progress=True,
|
| 238 |
+
)
|
| 239 |
+
elapsed = time.time() - start
|
| 240 |
+
|
| 241 |
+
final_size = tokenizer.get_vocab_size()
|
| 242 |
+
_log(f"vocab final : {final_size} (cible {args.vocab_size}) en {elapsed:.1f}s")
|
| 243 |
+
if final_size > args.vocab_size:
|
| 244 |
+
raise SystemExit(f"ERREUR : vocab final {final_size} > cible {args.vocab_size}")
|
| 245 |
+
if final_size < args.vocab_size and not args.allow_smaller_vocab:
|
| 246 |
+
raise SystemExit(
|
| 247 |
+
f"ERREUR : vocab final {final_size} < cible {args.vocab_size}. "
|
| 248 |
+
"Corpus trop petit. Relance avec --allow-smaller-vocab ou augmente --max-mb."
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
assert_special_token_ids(tokenizer)
|
| 252 |
+
paths = save_artifacts(tokenizer, args.out_dir)
|
| 253 |
+
for name, path in paths.items():
|
| 254 |
+
_log(f"écrit : {path} ({name})")
|
| 255 |
+
_log("terminé.")
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
if __name__ == "__main__":
|
| 259 |
+
main()
|
tests/test_phase0_config.py
CHANGED
|
@@ -64,9 +64,17 @@ def test_tokenizer_vocab_size(cfg):
|
|
| 64 |
|
| 65 |
def test_modality_tokens_cover_all_modalities(cfg):
|
| 66 |
toks = cfg["tokenizer"]["modality_tokens"]
|
| 67 |
-
for
|
| 68 |
-
assert f"{
|
| 69 |
-
assert f"{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
def test_projectors_align_to_text_space(cfg):
|
|
|
|
| 64 |
|
| 65 |
def test_modality_tokens_cover_all_modalities(cfg):
|
| 66 |
toks = cfg["tokenizer"]["modality_tokens"]
|
| 67 |
+
for modality in ("image", "audio", "video"):
|
| 68 |
+
assert modality in toks, f"modalité manquante : {modality}"
|
| 69 |
+
assert len(toks[modality]) == 3, f"{modality} doit avoir start/end/pad"
|
| 70 |
+
for suffix in ("start", "end", "pad"):
|
| 71 |
+
assert f"<|{modality}_{suffix}|>" in toks[modality]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_reasoning_tokens_defined(cfg):
|
| 75 |
+
reasoning = cfg["tokenizer"]["reasoning_tokens"]
|
| 76 |
+
assert len(reasoning) == 6
|
| 77 |
+
assert "<|think|>" in reasoning and "<|moe_route|>" in reasoning
|
| 78 |
|
| 79 |
|
| 80 |
def test_projectors_align_to_text_space(cfg):
|
tests/test_tokenizer.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 1 — tests unitaires du tokenizer BPE de Cortex-0.
|
| 2 |
+
|
| 3 |
+
Rapides (CPU, quelques secondes) : un **tokenizer jouet** est entraîné une fois
|
| 4 |
+
pour toute la session sur un petit corpus français embarqué. Le tokenizer final
|
| 5 |
+
(32k, corpus ~200 Mo) est produit par `notebooks/01_tokenizer.ipynb` sur Colab.
|
| 6 |
+
|
| 7 |
+
Deux catégories de tests :
|
| 8 |
+
* invariants qui ne dépendent PAS du corpus (tokens spéciaux, round-trip,
|
| 9 |
+
accents, emojis, scripts inconnus, byte_fallback) → testés sur le tokenizer jouet ;
|
| 10 |
+
* invariants du tokenizer FINAL (vocab == 32000) → ignorés si l'artefact réel
|
| 11 |
+
n'est pas présent (avant le run Colab).
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import pytest
|
| 20 |
+
|
| 21 |
+
from src.tokenizer import load_tokenizer
|
| 22 |
+
from src.tokenizer.special_tokens import (
|
| 23 |
+
ALL_SPECIAL_TOKENS,
|
| 24 |
+
BASE_SPECIAL_TOKENS,
|
| 25 |
+
MODALITY_SPECIAL_TOKENS,
|
| 26 |
+
N_SPECIAL_TOKENS,
|
| 27 |
+
REASONING_SPECIAL_TOKENS,
|
| 28 |
+
SPECIAL_TOKEN_IDS,
|
| 29 |
+
SPECIAL_TOKENS_MAP_FILE,
|
| 30 |
+
TOKENIZER_CONFIG_FILE,
|
| 31 |
+
TOKENIZER_DIR,
|
| 32 |
+
VOCAB_SIZE,
|
| 33 |
+
)
|
| 34 |
+
from src.tokenizer.train_tokenizer import (
|
| 35 |
+
assert_special_token_ids,
|
| 36 |
+
save_artifacts,
|
| 37 |
+
train_tokenizer,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 41 |
+
|
| 42 |
+
# 20 phrases françaises variées (accents, ponctuation, majuscules, chiffres).
|
| 43 |
+
FRENCH_SENTENCES = [
|
| 44 |
+
"Bonjour, comment allez-vous aujourd'hui ?",
|
| 45 |
+
"Le café coûte trois euros à Paris.",
|
| 46 |
+
"L'élève écrit une phrase sur le tableau noir.",
|
| 47 |
+
"Où est la forêt de Brocéliande ?",
|
| 48 |
+
"Le garçon mange une pomme rouge et juteuse.",
|
| 49 |
+
"Les enfants jouent dans le jardin après l'école.",
|
| 50 |
+
"Il fait très beau à Montréal en été.",
|
| 51 |
+
"La bibliothèque ouvre à neuf heures précises.",
|
| 52 |
+
"Ce problème de mathématiques est vraiment difficile.",
|
| 53 |
+
"Le chat dort paisiblement sur le canapé du salon.",
|
| 54 |
+
"J'ai acheté deux baguettes et un croissant.",
|
| 55 |
+
"La France compte de nombreuses régions pittoresques.",
|
| 56 |
+
"Elle répond toujours avec beaucoup de patience.",
|
| 57 |
+
"N'oubliez pas de fermer la porte à clé !",
|
| 58 |
+
"Le train pour Lyon part dans quinze minutes.",
|
| 59 |
+
"Nous irons à la mer dès que possible.",
|
| 60 |
+
"Cette recette nécessite un four préchauffé.",
|
| 61 |
+
"La langue française est riche en nuances.",
|
| 62 |
+
"Il pleut des cordes depuis ce matin.",
|
| 63 |
+
"Merci beaucoup pour votre aide précieuse.",
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
TOY_CORPUS = FRENCH_SENTENCES * 60 + [
|
| 67 |
+
"Le soleil brille, les oiseaux chantent, tout va bien.",
|
| 68 |
+
"Une phrase française supplémentaire pour l'entraînement.",
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
ACCENTS = "é è ê à ç ù î ô ë ï ü ÿ œ æ"
|
| 72 |
+
EMOJIS = "🦾 🇬🇳 🚀 🙂 ❤️ 🎉"
|
| 73 |
+
UNKNOWN_SCRIPTS = "こんにちは世界 مرحبا بالعالم Здравствуй мир"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@pytest.fixture(scope="session")
|
| 77 |
+
def toy_dir(tmp_path_factory) -> Path:
|
| 78 |
+
"""Entraîne un tokenizer jouet (vocab ~1500) une fois par session."""
|
| 79 |
+
out = tmp_path_factory.mktemp("toy_tokenizer")
|
| 80 |
+
tok = train_tokenizer(TOY_CORPUS, vocab_size=1500, min_frequency=1, show_progress=False)
|
| 81 |
+
save_artifacts(tok, out)
|
| 82 |
+
return out
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@pytest.fixture(scope="session")
|
| 86 |
+
def toy(toy_dir):
|
| 87 |
+
return load_tokenizer(toy_dir)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ---------------------------------------------------------------------------
|
| 91 |
+
# Source de vérité des tokens spéciaux
|
| 92 |
+
# ---------------------------------------------------------------------------
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_special_token_lists_are_disjoint_and_unique():
|
| 96 |
+
assert len(set(ALL_SPECIAL_TOKENS)) == N_SPECIAL_TOKENS
|
| 97 |
+
assert set(BASE_SPECIAL_TOKENS).isdisjoint(REASONING_SPECIAL_TOKENS)
|
| 98 |
+
assert set(BASE_SPECIAL_TOKENS).isdisjoint(MODALITY_SPECIAL_TOKENS)
|
| 99 |
+
assert set(REASONING_SPECIAL_TOKENS).isdisjoint(MODALITY_SPECIAL_TOKENS)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_special_token_counts():
|
| 103 |
+
assert len(BASE_SPECIAL_TOKENS) == 4
|
| 104 |
+
assert len(REASONING_SPECIAL_TOKENS) == 6
|
| 105 |
+
assert len(MODALITY_SPECIAL_TOKENS) == 9
|
| 106 |
+
assert N_SPECIAL_TOKENS == 19
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_special_token_ids_are_contiguous():
|
| 110 |
+
assert sorted(SPECIAL_TOKEN_IDS.values()) == list(range(N_SPECIAL_TOKENS))
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_required_special_tokens_present():
|
| 114 |
+
required = [
|
| 115 |
+
"<|pad|>", "<|bos|>", "<|eos|>", "<|unk|>",
|
| 116 |
+
"<|think|>", "<|plan|>", "<|tool_call|>", "<|tool_result|>", "<|critic|>", "<|moe_route|>",
|
| 117 |
+
"<|image_start|>", "<|image_end|>", "<|image_pad|>",
|
| 118 |
+
"<|audio_start|>", "<|audio_end|>", "<|audio_pad|>",
|
| 119 |
+
"<|video_start|>", "<|video_end|>", "<|video_pad|>",
|
| 120 |
+
]
|
| 121 |
+
for token in required:
|
| 122 |
+
assert token in SPECIAL_TOKEN_IDS, f"token spécial manquant : {token}"
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
# Artefacts sur disque
|
| 127 |
+
# ---------------------------------------------------------------------------
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_three_artifacts_written(toy_dir):
|
| 131 |
+
for name in ("tokenizer.json", SPECIAL_TOKENS_MAP_FILE, TOKENIZER_CONFIG_FILE):
|
| 132 |
+
assert (toy_dir / name).is_file(), f"{name} absent"
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def test_special_tokens_map_contains_all_required(toy_dir):
|
| 136 |
+
data = json.loads((toy_dir / SPECIAL_TOKENS_MAP_FILE).read_text(encoding="utf-8"))
|
| 137 |
+
present = set(data.get("additional_special_tokens", []))
|
| 138 |
+
present |= {data.get("pad_token"), data.get("bos_token"), data.get("eos_token"), data.get("unk_token")}
|
| 139 |
+
for token in ALL_SPECIAL_TOKENS:
|
| 140 |
+
assert token in present, f"{token} absent de special_tokens_map.json"
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def test_special_token_ids_match_expected(toy):
|
| 144 |
+
assert_special_token_ids(toy.raw)
|
| 145 |
+
for token, expected in SPECIAL_TOKEN_IDS.items():
|
| 146 |
+
assert toy.token_to_id(token) == expected, token
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def test_special_token_ids_stable_across_two_runs(tmp_path):
|
| 150 |
+
a = train_tokenizer(TOY_CORPUS, vocab_size=1500, min_frequency=1, show_progress=False)
|
| 151 |
+
b = train_tokenizer(TOY_CORPUS, vocab_size=1500, min_frequency=1, show_progress=False)
|
| 152 |
+
assert a.get_vocab() == b.get_vocab()
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# ---------------------------------------------------------------------------
|
| 156 |
+
# Round-trip et cas limites
|
| 157 |
+
# ---------------------------------------------------------------------------
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@pytest.mark.parametrize("sentence", FRENCH_SENTENCES)
|
| 161 |
+
def test_roundtrip_french_sentences(toy, sentence):
|
| 162 |
+
assert toy.decode(toy.encode(sentence)) == sentence
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def test_roundtrip_accents(toy):
|
| 166 |
+
assert toy.decode(toy.encode(ACCENTS)) == ACCENTS
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def test_roundtrip_emojis(toy):
|
| 170 |
+
assert toy.decode(toy.encode(EMOJIS)) == EMOJIS
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_roundtrip_unknown_scripts(toy):
|
| 174 |
+
assert toy.decode(toy.encode(UNKNOWN_SCRIPTS)) == UNKNOWN_SCRIPTS
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def test_roundtrip_empty_and_whitespace(toy):
|
| 178 |
+
assert toy.decode(toy.encode("")) == ""
|
| 179 |
+
assert toy.decode(toy.encode(" ")) == " "
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_roundtrip_very_long(toy):
|
| 183 |
+
long_text = " ".join(FRENCH_SENTENCES * 20)
|
| 184 |
+
assert toy.decode(toy.encode(long_text)) == long_text
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def test_special_tokens_recognized_when_in_text(toy):
|
| 188 |
+
text = "Bonjour <|bos|> le monde <|eos|>"
|
| 189 |
+
ids = toy.encode(text)
|
| 190 |
+
assert toy.token_to_id("<|bos|>") in ids
|
| 191 |
+
assert toy.token_to_id("<|eos|>") in ids
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def test_encode_returns_ints(toy):
|
| 195 |
+
ids = toy.encode("Un test.")
|
| 196 |
+
assert ids and all(isinstance(i, int) for i in ids)
|
| 197 |
+
assert all(0 <= i < toy.vocab_size for i in ids)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def test_decode_skips_special_tokens(toy):
|
| 201 |
+
ids = [toy.token_to_id("<|bos|>")] + toy.encode("salut") + [toy.token_to_id("<|eos|>")]
|
| 202 |
+
decoded = toy.decode(ids, skip_special_tokens=True)
|
| 203 |
+
assert "<|bos|>" not in decoded and "<|eos|>" not in decoded
|
| 204 |
+
assert "salut" in decoded
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def test_byte_fallback_is_enabled(toy):
|
| 208 |
+
# Un caractère jamais vu (CJK) doit survivre au round-trip -> repli octet.
|
| 209 |
+
assert toy.decode(toy.encode("漢字第")) == "漢字第"
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
# ---------------------------------------------------------------------------
|
| 213 |
+
# Entraînement
|
| 214 |
+
# ---------------------------------------------------------------------------
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def test_trainer_respects_vocab_size_cap():
|
| 218 |
+
tok = train_tokenizer(TOY_CORPUS, vocab_size=800, min_frequency=1, show_progress=False)
|
| 219 |
+
assert tok.get_vocab_size() <= 800
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def test_min_frequency_reduces_vocab():
|
| 223 |
+
low = train_tokenizer(TOY_CORPUS, vocab_size=2000, min_frequency=1, show_progress=False)
|
| 224 |
+
high = train_tokenizer(TOY_CORPUS, vocab_size=2000, min_frequency=20, show_progress=False)
|
| 225 |
+
assert high.get_vocab_size() <= low.get_vocab_size()
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# ---------------------------------------------------------------------------
|
| 229 |
+
# Invariants du tokenizer FINAL (nécessite le run Colab)
|
| 230 |
+
# ---------------------------------------------------------------------------
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
@pytest.mark.skipif(
|
| 234 |
+
not (ROOT / TOKENIZER_DIR / "tokenizer.json").is_file(),
|
| 235 |
+
reason="tokenizer final absent : généré par notebooks/01_tokenizer.ipynb (Colab)",
|
| 236 |
+
)
|
| 237 |
+
def test_final_tokenizer_vocab_size_is_exactly_32000():
|
| 238 |
+
final = load_tokenizer()
|
| 239 |
+
assert final.vocab_size == VOCAB_SIZE
|
| 240 |
+
for token, expected in SPECIAL_TOKEN_IDS.items():
|
| 241 |
+
assert final.token_to_id(token) == expected
|
tokenizer/special_tokens_map.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"pad_token": "<|pad|>",
|
| 3 |
+
"bos_token": "<|bos|>",
|
| 4 |
+
"eos_token": "<|eos|>",
|
| 5 |
+
"unk_token": "<|unk|>",
|
| 6 |
+
"additional_special_tokens": [
|
| 7 |
+
"<|think|>",
|
| 8 |
+
"<|plan|>",
|
| 9 |
+
"<|tool_call|>",
|
| 10 |
+
"<|tool_result|>",
|
| 11 |
+
"<|critic|>",
|
| 12 |
+
"<|moe_route|>",
|
| 13 |
+
"<|image_start|>",
|
| 14 |
+
"<|image_end|>",
|
| 15 |
+
"<|image_pad|>",
|
| 16 |
+
"<|audio_start|>",
|
| 17 |
+
"<|audio_end|>",
|
| 18 |
+
"<|audio_pad|>",
|
| 19 |
+
"<|video_start|>",
|
| 20 |
+
"<|video_end|>",
|
| 21 |
+
"<|video_pad|>"
|
| 22 |
+
]
|
| 23 |
+
}
|
tokenizer/tokenizer.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
tokenizer/tokenizer_config.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"tokenizer_class": "Cortex0BPETokenizer",
|
| 3 |
+
"model_type": "bpe",
|
| 4 |
+
"vocab_size": 32000,
|
| 5 |
+
"byte_fallback": true,
|
| 6 |
+
"pre_tokenizer": "ByteLevel",
|
| 7 |
+
"add_prefix_space": false,
|
| 8 |
+
"special_tokens": [
|
| 9 |
+
"<|pad|>",
|
| 10 |
+
"<|bos|>",
|
| 11 |
+
"<|eos|>",
|
| 12 |
+
"<|unk|>",
|
| 13 |
+
"<|think|>",
|
| 14 |
+
"<|plan|>",
|
| 15 |
+
"<|tool_call|>",
|
| 16 |
+
"<|tool_result|>",
|
| 17 |
+
"<|critic|>",
|
| 18 |
+
"<|moe_route|>",
|
| 19 |
+
"<|image_start|>",
|
| 20 |
+
"<|image_end|>",
|
| 21 |
+
"<|image_pad|>",
|
| 22 |
+
"<|audio_start|>",
|
| 23 |
+
"<|audio_end|>",
|
| 24 |
+
"<|audio_pad|>",
|
| 25 |
+
"<|video_start|>",
|
| 26 |
+
"<|video_end|>",
|
| 27 |
+
"<|video_pad|>"
|
| 28 |
+
],
|
| 29 |
+
"pad_token": "<|pad|>",
|
| 30 |
+
"bos_token": "<|bos|>",
|
| 31 |
+
"eos_token": "<|eos|>",
|
| 32 |
+
"unk_token": "<|unk|>"
|
| 33 |
+
}
|