oc_chat
Loading...
Searching...
No Matches
token_classifier.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# bench/argv_policy/baselines/token_classifier.py
4
5"""
6The token floor: hashed character n-grams into a multinomial logistic regression.
7
8Purpose
9-------
10This is the number every later result on this bench is read against. The argv
11gate is, at bottom, a set of rules over the characters of a command string, so
12before anyone reports that an embedding index or a fine-tuned encoder "learned
13the policy", the bench needs to know what a bag of character n-grams and plain
14gradient descent already achieve. If the floor sits near the gate, the task is a
15token rule and plan 290's encoder has to be read against the floor, not against
16chance.
17
18Features are character n-grams for n in 1 to 4 over the command padded with
19start and end sentinels, hashed into a fixed space with CRC-32 (Python's own
20``hash`` is salted per process, so a model trained in one run would be
21meaningless in the next), counted, and L2-normalised so a long command does not
22simply outvote a short one. The model is a three-class softmax trained by
23minibatch gradient descent with L2 regularisation. No fitting library is used:
24the gradient is written out.
25
26Run it under the PROJECT venv from the subproject root::
27
28 python bench/argv_policy/baselines/token_classifier.py
29
30Implementation note (a deliberate departure from plan 288 P4, which reads
31"numpy or torch"): this is standard-library only. The project venv has neither
32numpy nor torch, installing one is the orchestrator's hard stop, and the scratch
33harness venv that does have them cannot import ``oc_diagnostics``, so a module
34run there could not be Option C compliant. Pure Python is also the better fit
35for the shape of the problem: the features are sparse -- a thirty-character
36command touches roughly 120 of 65536 columns -- so a dense design matrix would
37be about three gigabytes of mostly zeros, and the sparse inner loop this module
38runs is the same arithmetic a sparse numpy implementation would perform.
39
40Responsibilities
41----------------
42- :func:`feature_index` / :func:`features` -- the hashed n-gram representation.
43- :class:`Model` -- the weights and the softmax forecast.
44- :func:`train` -- minibatch gradient descent over a corpus.
45- :func:`evaluate` -- a case set to result rows in the bench schema.
46- :func:`main` -- train, evaluate both case sets, write both result files.
47
48Diagnostics
49-----------
50Domain: OC_CHAT.MAIN
51Levels:
52 L2 - training lifecycle (start, per-epoch loss, finish, files written)
53 L3 - per-epoch accuracy and per-case-set agreement
54 L4 - module load
55
56Contracts
57---------
58- Deterministic: a corpus, a seed and the hyperparameters fix the weights, so
59 the committed result files are reproducible from the committed corpus.
60- Read-only with respect to the corpus and the case sets.
61- Console output is ASCII only; commands are printed through :func:`ascii`.
62- Standard library only, by the reasoning in the implementation note above.
63
64Dependencies
65------------
66- oc_diagnostics (structured debug logging via _dbg)
67"""
68from __future__ import annotations
69
70import argparse
71import json
72import math
73import random
74import sys
75import time
76import zlib
77from dataclasses import dataclass
78from pathlib import Path
79
80from oc_diagnostics import _dbg
81
82_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
83
84#: The three verdicts, in the column order of the weight matrix.
85LABELS: tuple[str, ...] = ("allow", "confirm", "deny")
86
87#: Character n-gram lengths. One character carries the metacharacter signal,
88#: four carries option and subcommand stems like ``-la`` or ``inst``.
89NGRAM_SIZES: tuple[int, ...] = (1, 2, 3, 4)
90
91#: The fixed hash space. Large enough that collisions are rare at this corpus
92#: size, small enough that the weight matrix is a few hundred thousand floats.
93HASH_SPACE = 1 << 16
94
95#: Sentinels marking the start and end of a command, so a leading dash or a
96#: trailing quote is distinguishable from the same character in the middle.
97START_SENTINEL = "\x02"
98END_SENTINEL = "\x03"
99
100#: Hyperparameters chosen by a sweep on the validation split, not by taste: the
101#: rate and epoch count below are where held-out agreement stops improving
102#: (lr 40 / 50 epochs reaches 98.7 percent validation; lr 80 and 100 epochs do
103#: no better). A floor tuned too low would understate the baseline that plans
104#: 289 and 290 are read against, which is the one way this module could mislead.
105DEFAULT_EPOCHS = 50
106DEFAULT_SEED = 7
107DEFAULT_LEARNING_RATE = 40.0
108DEFAULT_L2 = 1e-6
109DEFAULT_BATCH = 32
110
111
112def feature_index(size: int, gram: str) -> int:
113 """Hash one n-gram of length *size* into the feature space.
114
115 CRC-32 rather than :func:`hash`, which is salted per process: a model whose
116 columns move between runs cannot be trained once and reported on later. The
117 length is folded into the key so a two-character gram and the first two
118 characters of a four-character gram do not systematically share a column.
119 """
120
121 return zlib.crc32(f"{size}\x1f{gram}".encode("utf-8")) % HASH_SPACE
122
123
124def features(command: str) -> dict[int, float]:
125 """The L2-normalised hashed n-gram counts of *command*, plus a bias column."""
126
127 padded = f"{START_SENTINEL}{command}{END_SENTINEL}"
128 counts: dict[int, float] = {}
129 for size in NGRAM_SIZES:
130 for start in range(len(padded) - size + 1):
131 index = feature_index(size, padded[start:start + size])
132 counts[index] = counts.get(index, 0.0) + 1.0
133 norm = math.sqrt(sum(value * value for value in counts.values())) or 1.0
134 scaled = {index: value / norm for index, value in counts.items()}
135 scaled[0] = scaled.get(0, 0.0) + 1.0 # bias column
136 return scaled
137
138
139@dataclass
140class Model:
141 """A three-class softmax over the hashed n-gram space."""
142
143 weights: list[list[float]]
144 biases: list[float]
145
146 def scores(self, vector: dict[int, float]) -> list[float]:
147 """The unnormalised class scores of one feature vector."""
148
149 return [
150 self.biases[label] + sum(value * self.weights[label][index]
151 for index, value in vector.items())
152 for label in range(len(LABELS))
153 ]
154
155 def probabilities(self, vector: dict[int, float]) -> list[float]:
156 """The softmax of :meth:`scores`, computed shift-stably."""
157
158 raw = self.scores(vector)
159 top = max(raw)
160 exponentials = [math.exp(value - top) for value in raw]
161 total = sum(exponentials)
162 return [value / total for value in exponentials]
163
164 def predict(self, command: str) -> tuple[str, dict[str, float]]:
165 """The predicted verdict for *command* and the full forecast."""
166
167 probabilities = self.probabilities(features(command))
168 best = max(range(len(LABELS)), key=probabilities.__getitem__)
169 return LABELS[best], dict(zip(LABELS, probabilities))
170
171
173 rows: list[dict[str, str]],
174 epochs: int = DEFAULT_EPOCHS,
175 seed: int = DEFAULT_SEED,
176 learning_rate: float = DEFAULT_LEARNING_RATE,
177 l2: float = DEFAULT_L2,
178 batch: int = DEFAULT_BATCH,
179) -> Model:
180 """Fit the softmax by minibatch gradient descent over *rows*.
181
182 Features are extracted once up front rather than per epoch, which is the
183 difference between a run of seconds and a run of minutes.
184 """
185
186 func = "train"
187 _dbg("OC_CHAT.MAIN", func, f"{len(rows)} rows, {epochs} epochs, batch {batch}", 2)
188 label_of = {name: index for index, name in enumerate(LABELS)}
189 examples = [(features(row["command"]), label_of[row["truth"]]) for row in rows]
190 model = Model(
191 weights=[[0.0] * HASH_SPACE for _ in LABELS],
192 biases=[0.0] * len(LABELS),
193 )
194 rng = random.Random(seed)
195 order = list(range(len(examples)))
196 for epoch in range(epochs):
197 rng.shuffle(order)
198 rate = learning_rate / (1.0 + epoch)
199 loss = 0.0
200 hits = 0
201 for start in range(0, len(order), batch):
202 chunk = order[start:start + batch]
203 gradients: list[dict[int, float]] = [{} for _ in LABELS]
204 bias_gradients = [0.0] * len(LABELS)
205 for position in chunk:
206 vector, truth = examples[position]
207 probabilities = model.probabilities(vector)
208 loss -= math.log(max(probabilities[truth], 1e-12))
209 hits += int(max(range(len(LABELS)),
210 key=probabilities.__getitem__) == truth)
211 for label in range(len(LABELS)):
212 error = probabilities[label] - (1.0 if label == truth else 0.0)
213 if error == 0.0:
214 continue
215 bias_gradients[label] += error
216 target = gradients[label]
217 for index, value in vector.items():
218 target[index] = target.get(index, 0.0) + error * value
219 step = rate / len(chunk)
220 for label in range(len(LABELS)):
221 row = model.weights[label]
222 for index, gradient in gradients[label].items():
223 row[index] -= step * (gradient + l2 * row[index])
224 model.biases[label] -= step * bias_gradients[label]
225 _dbg("OC_CHAT.MAIN", func,
226 f"epoch {epoch + 1}: loss {loss / len(examples):.4f} "
227 f"train accuracy {hits / len(examples):.4f}", 3)
228 _dbg("OC_CHAT.MAIN", func, "training complete", 2)
229 return model
230
231
232def evaluate(model: Model, cases: list[dict[str, str]]) -> list[dict[str, object]]:
233 """Predict every case, timing each decision in process."""
234
235 func = "evaluate"
236 rows: list[dict[str, object]] = []
237 for case in cases:
238 started = time.perf_counter()
239 prediction, probabilities = model.predict(case["command"])
240 elapsed_ms = (time.perf_counter() - started) * 1000.0
241 rows.append({
242 "command": case["command"],
243 "truth": case["truth"],
244 "slice": case["slice"],
245 "pred": prediction,
246 "probs": probabilities,
247 "ms": elapsed_ms,
248 "reason": case["reason"],
249 })
250 hits = sum(row["pred"] == row["truth"] for row in rows)
251 _dbg("OC_CHAT.MAIN", func, f"{hits}/{len(rows)} agreement", 3)
252 return rows
253
254
255def _accuracy(model: Model, rows: list[dict[str, str]]) -> float:
256 """Share of *rows* the model gets right."""
257
258 if not rows:
259 return 0.0
260 return sum(model.predict(row["command"])[0] == row["truth"] for row in rows) / len(rows)
261
262
263def _build_parser() -> argparse.ArgumentParser:
264 """Construct the CLI argument parser."""
265
266 parser = argparse.ArgumentParser(
267 prog="python bench/argv_policy/baselines/token_classifier.py",
268 description="Train the hashed n-gram floor and score both case sets.",
269 )
270 parser.add_argument("--epochs", type=int, default=DEFAULT_EPOCHS,
271 help=f"Passes over the corpus (default: {DEFAULT_EPOCHS}).")
272 parser.add_argument("--seed", type=int, default=DEFAULT_SEED,
273 help=f"Shuffle seed (default: {DEFAULT_SEED}).")
274 parser.add_argument("--learning-rate", type=float, default=DEFAULT_LEARNING_RATE,
275 help=f"Initial step size (default: {DEFAULT_LEARNING_RATE}).")
276 parser.add_argument("--l2", type=float, default=DEFAULT_L2,
277 help=f"L2 penalty (default: {DEFAULT_L2}).")
278 parser.add_argument("--batch", type=int, default=DEFAULT_BATCH,
279 help=f"Minibatch size (default: {DEFAULT_BATCH}).")
280 parser.add_argument("--out-dir", default=None,
281 help="Where to write result files (default: ../results).")
282 return parser
283
284
285def main(argv: list[str] | None = None) -> int:
286 """CLI entry point: train the floor and write both result files."""
287
288 func = "main"
289 args = _build_parser().parse_args(argv)
290 bench_dir = Path(__file__).resolve().parent.parent
291 out_dir = Path(args.out_dir) if args.out_dir else bench_dir / "results"
292 out_dir.mkdir(parents=True, exist_ok=True)
293
294 corpus = [json.loads(line) for line
295 in (bench_dir / "corpus.jsonl").read_text(encoding="utf-8").splitlines() if line]
296 validation = [json.loads(line) for line
297 in (bench_dir / "corpus_val.jsonl").read_text(encoding="utf-8").splitlines()
298 if line]
299 print(f"token_classifier: training on {len(corpus)} commands, "
300 f"{args.epochs} epochs, hash space {HASH_SPACE}")
301 started = time.perf_counter()
302 model = train(corpus, args.epochs, args.seed, args.learning_rate, args.l2, args.batch)
303 train_seconds = time.perf_counter() - started
304 train_accuracy = _accuracy(model, corpus)
305 validation_accuracy = _accuracy(model, validation)
306 print(f"token_classifier: trained in {train_seconds:.1f}s; "
307 f"train {train_accuracy:.4f}, validation {validation_accuracy:.4f}")
308
309 for condition, case_file in (("hand", "cases.json"), ("hard", "cases_hard.json")):
310 cases = json.loads((bench_dir / case_file).read_text(encoding="utf-8"))
311 rows = evaluate(model, cases)
312 hits = sum(row["pred"] == row["truth"] for row in rows)
313 payload = {
314 "model": "token_classifier",
315 "condition": condition,
316 "results": rows,
317 "provenance": {
318 "route": "CPU, standard library, hashed character n-grams",
319 "case_file": case_file,
320 "train_rows": len(corpus),
321 "validation_rows": len(validation),
322 "epochs": args.epochs,
323 "seed": args.seed,
324 "learning_rate": args.learning_rate,
325 "l2": args.l2,
326 "batch": args.batch,
327 "hash_space": HASH_SPACE,
328 "ngram_sizes": list(NGRAM_SIZES),
329 "train_seconds": train_seconds,
330 "train_accuracy": train_accuracy,
331 "validation_accuracy": validation_accuracy,
332 },
333 }
334 target = out_dir / f"token_classifier_{condition}.json"
335 target.write_text(json.dumps(payload, indent=1, sort_keys=True) + "\n", encoding="utf-8")
336 print(f" {condition:5} {hits}/{len(rows)} agreement -> {target.name}")
337 _dbg("OC_CHAT.MAIN", func, "exit 0", 2)
338 return 0
339
340
341if __name__ == "__main__":
342 sys.exit(main())
tuple[str, dict[str, float]] predict(self, str command)
list[float] probabilities(self, dict[int, float] vector)
list[float] scores(self, dict[int, float] vector)
Model train(list[dict[str, str]] rows, int epochs=DEFAULT_EPOCHS, int seed=DEFAULT_SEED, float learning_rate=DEFAULT_LEARNING_RATE, float l2=DEFAULT_L2, int batch=DEFAULT_BATCH)
int main(list[str]|None argv=None)
argparse.ArgumentParser _build_parser()
float _accuracy(Model model, list[dict[str, str]] rows)
int feature_index(int size, str gram)
list[dict[str, object]] evaluate(Model model, list[dict[str, str]] cases)
dict[int, float] features(str command)