Skip to content

Commit a2ce456

Browse files
committed
feat(v4-2): stage_train tokenizes parquet 'text' column via tokenizer_path
Closes V4-2 / cppmega-mlx-862. Fixes G2 backend half: V3-2 only read raw input_ids — Tokenizer Playground selection had no effect on training because backend never tokenized anything. New _tokenize_parquet_text(parquet, tokenizer, n_tokens) helper: loads HuggingFace Tokenizer.from_file(tokenizer_path), encodes the 'text' column rows until n_tokens collected. Returns ([], None) on any failure for clean degradation. stage_train dispatch (priority order): 1. tokenizer_path + parquet[text] → data_source='parquet_tokenized', tokenizer_used=basename 2. parquet[input_ids|token_ids|tokens] → data_source='parquet' (V3-2) 3. anything else → data_source='synthetic' extras gains tokenizer_used: str | null. 5 new pytest (text column path / no-text-column fallback / missing tokenizer fallback / corrupted parquet / vocab clip). Regression 90/90 across V3-1/V3-2/V3-5/V4-2/schedules/runner. V4-1 UI already forwards tokenizer_path via stage_options when set; V4-3 will add the Tokenizer Playground 'Use for training' button so the user can actually pick one.
1 parent ee094dd commit a2ce456

2 files changed

Lines changed: 228 additions & 12 deletions

File tree

cppmega_v4/runner/stages.py

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -423,24 +423,38 @@ def loss_fn(model: nn.Module, emb: mx.array, tgt: mx.array) -> mx.array:
423423
rng_key = mx.random.key(0)
424424
data_source = "synthetic"
425425
token_count = 0
426+
tokenizer_used: str | None = None
426427
parquet_path = opts.get("parquet_path")
428+
tokenizer_path = opts.get("tokenizer_path")
429+
targets = mx.random.randint(0, vocab_size, shape=(batch, seq))
427430
if parquet_path:
428-
try:
429-
tokens = _read_first_n_tokens(parquet_path, n=batch * seq)
431+
# V4-2: prefer tokenize(text) path when both tokenizer and a
432+
# 'text' column are present; fall back to raw-int input_ids
433+
# column (V3-2) when not; fall through to synthetic otherwise.
434+
if tokenizer_path:
435+
tokens, used = _tokenize_parquet_text(
436+
parquet_path, tokenizer_path, n_tokens=batch * seq)
430437
if len(tokens) >= batch * seq:
431-
tokens = [int(t) % vocab_size for t in tokens[:batch * seq]]
438+
tokens = [int(t) % vocab_size
439+
for t in tokens[:batch * seq]]
432440
targets = mx.array(tokens, dtype=mx.int32).reshape(
433441
batch, seq)
434-
data_source = "parquet"
442+
data_source = "parquet_tokenized"
435443
token_count = len(tokens)
436-
else:
437-
targets = mx.random.randint(
438-
0, vocab_size, shape=(batch, seq))
439-
except Exception:
440-
targets = mx.random.randint(
441-
0, vocab_size, shape=(batch, seq))
442-
else:
443-
targets = mx.random.randint(0, vocab_size, shape=(batch, seq))
444+
tokenizer_used = used
445+
if data_source == "synthetic":
446+
try:
447+
tokens = _read_first_n_tokens(
448+
parquet_path, n=batch * seq)
449+
if len(tokens) >= batch * seq:
450+
tokens = [int(t) % vocab_size
451+
for t in tokens[:batch * seq]]
452+
targets = mx.array(tokens, dtype=mx.int32).reshape(
453+
batch, seq)
454+
data_source = "parquet"
455+
token_count = len(tokens)
456+
except Exception:
457+
pass
444458
all_modules = nn.Sequential(*modules, lm_head)
445459
opt, optimizer_kind = _build_optimizer(spec_optim, lr)
446460
loss_and_grad = nn.value_and_grad(all_modules, loss_fn)
@@ -532,6 +546,7 @@ def loss_fn(model: nn.Module, emb: mx.array, tgt: mx.array) -> mx.array:
532546
"optimizer_kind": optimizer_kind,
533547
"data_source": data_source,
534548
"token_count": token_count,
549+
"tokenizer_used": tokenizer_used,
535550
"model_summary": _summarize_model(
536551
ctx.spec, optimizer_kind, schedule_kind_label),
537552
},
@@ -545,6 +560,41 @@ def loss_fn(model: nn.Module, emb: mx.array, tgt: mx.array) -> mx.array:
545560
# ---------------------------------------------------------------------------
546561

547562

563+
def _tokenize_parquet_text(
564+
parquet_path: str, tokenizer_path: str, n_tokens: int,
565+
) -> tuple[list[int], str | None]:
566+
"""V4-2: encode the parquet ``text`` column through a real tokenizer.
567+
568+
Returns (token_ids, tokenizer_basename) on success, ([], None) on any
569+
failure so stage_train can fall through cleanly to the V3-2 raw-int
570+
path or the synthetic fallback. We deliberately swallow all exceptions
571+
here — the goal is non-fatal degradation, not surfacing tokenizer
572+
bugs through the train pipeline.
573+
"""
574+
try:
575+
import pyarrow.parquet as pq
576+
from tokenizers import Tokenizer
577+
from pathlib import Path
578+
table = pq.read_table(parquet_path)
579+
if "text" not in table.column_names:
580+
return [], None
581+
tok = Tokenizer.from_file(str(tokenizer_path))
582+
out: list[int] = []
583+
col = table.column("text")
584+
for chunk in col.chunks:
585+
for cell in chunk.to_pylist():
586+
if cell is None:
587+
continue
588+
enc = tok.encode(str(cell))
589+
for tid in enc.ids:
590+
out.append(int(tid))
591+
if len(out) >= n_tokens:
592+
return out, Path(tokenizer_path).name
593+
return out, Path(tokenizer_path).name
594+
except Exception:
595+
return [], None
596+
597+
548598
def _read_first_n_tokens(parquet_path: str, n: int) -> list[int]:
549599
"""V3-2: read the first ``n`` token ids from a parquet shard so
550600
stage_train can train against real corpus tokens instead of a
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""V4-2: stage_train tokenizes parquet text column when tokenizer_path supplied.
2+
3+
The previous V3-2 path only read raw input_ids columns. Now if the
4+
parquet has a 'text' column and a tokenizer_path is supplied,
5+
stage_train encodes via the tokenizer and reports
6+
extras.data_source='parquet_tokenized' + extras.tokenizer_used.
7+
Falls back cleanly to V3-2 raw-int path or synthetic.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import pathlib
13+
14+
import pyarrow as pa
15+
import pyarrow.parquet as pq
16+
import pytest
17+
from tokenizers import Tokenizer, models, pre_tokenizers, trainers
18+
19+
from cppmega_v4.jsonrpc.schema import VerifyParams
20+
from cppmega_v4.runner import Pipeline, run_pipeline
21+
22+
23+
def _verify_params() -> VerifyParams:
24+
return VerifyParams.model_validate({
25+
"graph": {
26+
"nodes": [
27+
{"id": "attn", "kind": "attention", "params": {}},
28+
{"id": "mlp", "kind": "mlp",
29+
"params": {"intermediate_size": 64, "activation": "swiglu"}},
30+
],
31+
"edges": [{"src": "attn", "dst": "mlp"}],
32+
},
33+
"dim_env": {"B": 1, "S": 8, "H": 32, "nh": 2, "nkv": 1,
34+
"head_dim": 16},
35+
"loss": {"kind": "cross_entropy", "head_outputs": ["mlp"]},
36+
"optim": {"kind": "adamw",
37+
"groups": [{"matcher": "all", "lr": 1e-3,
38+
"weight_decay": 0.01,
39+
"betas": [0.9, 0.95]}]},
40+
})
41+
42+
43+
@pytest.fixture(scope="module")
44+
def trained_tokenizer(tmp_path_factory) -> str:
45+
"""Tiny BPE tokenizer trained on a 4-line synthetic corpus.
46+
Returns path to tokenizer.json. Vocab ≈ 64 tokens."""
47+
tmp = tmp_path_factory.mktemp("tok")
48+
corpus = tmp / "corpus.txt"
49+
corpus.write_text(
50+
"hello world foo bar baz qux\n"
51+
"the quick brown fox jumps over the lazy dog\n"
52+
"lorem ipsum dolor sit amet consectetur\n"
53+
"abcdefghij klmnopqrst uvwxyz\n"
54+
)
55+
tok = Tokenizer(models.BPE(unk_token="<unk>"))
56+
tok.pre_tokenizer = pre_tokenizers.Whitespace()
57+
trainer = trainers.BpeTrainer(
58+
vocab_size=64, min_frequency=1,
59+
special_tokens=["<unk>"],
60+
)
61+
tok.train([str(corpus)], trainer)
62+
out = tmp / "tokenizer.json"
63+
tok.save(str(out))
64+
return str(out)
65+
66+
67+
def _write_text_parquet(tmp_path: pathlib.Path,
68+
rows: list[str] | None = None) -> str:
69+
rows = rows or [
70+
"hello world hello world hello world hello world hello world",
71+
"the quick brown fox jumps over the lazy dog the quick brown",
72+
"lorem ipsum dolor sit amet consectetur adipiscing elit sed do",
73+
"foo bar baz qux foo bar baz qux foo bar baz qux foo bar baz",
74+
]
75+
table = pa.table({"text": rows})
76+
tmp_path.mkdir(parents=True, exist_ok=True)
77+
path = tmp_path / "text.parquet"
78+
pq.write_table(table, path)
79+
return str(path)
80+
81+
82+
def _run(stage_options: dict) -> dict:
83+
spec = _verify_params()
84+
report = run_pipeline(spec, Pipeline.from_dict({
85+
"stages": ["parse", "verify_build_spec", "build_model", "train"],
86+
"stage_options": stage_options,
87+
}))
88+
train = next(s for s in report.stages if s.name == "train")
89+
assert train.status == "ok", f"stage_train failed: {train.error}"
90+
return train.extras
91+
92+
93+
def test_stage_train_tokenizes_text_column(tmp_path, trained_tokenizer):
94+
"""tokenizer_path + parquet[text] → data_source='parquet_tokenized'
95+
+ tokenizer_used reports the tokenizer basename."""
96+
parquet = _write_text_parquet(tmp_path)
97+
extras = _run(stage_options={"train": {
98+
"num_steps": 2,
99+
"parquet_path": parquet,
100+
"tokenizer_path": trained_tokenizer,
101+
}})
102+
assert extras["data_source"] == "parquet_tokenized"
103+
assert extras["tokenizer_used"] == "tokenizer.json"
104+
assert extras["token_count"] >= 8 # batch*seq = 1*8
105+
106+
107+
def test_stage_train_falls_back_to_raw_when_no_text_column(
108+
tmp_path, trained_tokenizer,
109+
):
110+
"""No 'text' column → falls through to V3-2 input_ids path."""
111+
table = pa.table({"input_ids": [list(range(64))]})
112+
tmp_path.mkdir(parents=True, exist_ok=True)
113+
p = tmp_path / "ids.parquet"
114+
pq.write_table(table, p)
115+
extras = _run(stage_options={"train": {
116+
"num_steps": 2,
117+
"parquet_path": str(p),
118+
"tokenizer_path": trained_tokenizer,
119+
}})
120+
# Tokenizer was supplied but text column absent → V3-2 path wins.
121+
assert extras["data_source"] == "parquet"
122+
assert extras["tokenizer_used"] is None
123+
assert extras["token_count"] == 8
124+
125+
126+
def test_stage_train_falls_back_to_synthetic_when_tokenizer_missing(
127+
tmp_path,
128+
):
129+
"""Bad tokenizer_path AND no input_ids column → synthetic."""
130+
parquet = _write_text_parquet(tmp_path)
131+
extras = _run(stage_options={"train": {
132+
"num_steps": 2,
133+
"parquet_path": parquet,
134+
"tokenizer_path": "/nonexistent/tok.json",
135+
}})
136+
assert extras["data_source"] == "synthetic"
137+
assert extras["tokenizer_used"] is None
138+
139+
140+
def test_stage_train_falls_back_when_corrupted_parquet(
141+
tmp_path, trained_tokenizer,
142+
):
143+
"""Corrupt parquet bytes → synthetic; no crash."""
144+
tmp_path.mkdir(parents=True, exist_ok=True)
145+
bad = tmp_path / "bad.parquet"
146+
bad.write_bytes(b"not a parquet file")
147+
extras = _run(stage_options={"train": {
148+
"num_steps": 2,
149+
"parquet_path": str(bad),
150+
"tokenizer_path": trained_tokenizer,
151+
}})
152+
assert extras["data_source"] == "synthetic"
153+
assert extras["tokenizer_used"] is None
154+
155+
156+
def test_stage_train_clips_token_ids_to_vocab(tmp_path, trained_tokenizer):
157+
"""Vocab clip applies to tokenized ids — large id values wrap mod vocab."""
158+
parquet = _write_text_parquet(tmp_path)
159+
extras = _run(stage_options={"train": {
160+
"num_steps": 2, "vocab_size": 32, # smaller than tokenizer vocab
161+
"parquet_path": parquet,
162+
"tokenizer_path": trained_tokenizer,
163+
}})
164+
assert extras["data_source"] == "parquet_tokenized"
165+
# Loss must stay finite even though tokens were clipped.
166+
assert all(l == l and -1e10 < l < 1e10 for l in extras["losses"])

0 commit comments

Comments
 (0)