trainproof 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {trainproof-0.3.0/src/trainproof.egg-info → trainproof-0.4.0}/PKG-INFO +67 -6
- trainproof-0.3.0/PKG-INFO → trainproof-0.4.0/README.md +202 -153
- {trainproof-0.3.0 → trainproof-0.4.0}/pyproject.toml +1 -1
- {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/cli.py +12 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/epoch.py +8 -3
- trainproof-0.4.0/src/trainproof/integrations/__init__.py +3 -0
- trainproof-0.4.0/src/trainproof/integrations/hf.py +89 -0
- trainproof-0.4.0/src/trainproof/watch.py +57 -0
- trainproof-0.3.0/README.md → trainproof-0.4.0/src/trainproof.egg-info/PKG-INFO +214 -141
- {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof.egg-info/SOURCES.txt +6 -1
- trainproof-0.4.0/tests/test_hf_callback.py +66 -0
- trainproof-0.4.0/tests/test_watch.py +42 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/setup.cfg +0 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/__init__.py +0 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/adapters.py +0 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/compare.py +0 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/report.py +0 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/rules.py +0 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/speech/__init__.py +0 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/speech/data.py +0 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof/speech/tokenizer.py +0 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof.egg-info/dependency_links.txt +0 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof.egg-info/entry_points.txt +0 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof.egg-info/requires.txt +0 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/src/trainproof.egg-info/top_level.txt +0 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/tests/test_adapters.py +0 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/tests/test_compare.py +0 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/tests/test_data.py +0 -0
- {trainproof-0.3.0 → trainproof-0.4.0}/tests/test_epoch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trainproof
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A deterministic linter for ML training runs: dataset, tokenizer, and epoch logs.
|
|
5
5
|
Author-email: "Panagiotis (Panos) Gkilis" <bedvibe@bedvibe.studio>
|
|
6
6
|
License: MIT
|
|
@@ -79,12 +79,18 @@ famously fit random labels). **No single-run, loss-only rule can catch this
|
|
|
79
79
|
class of failure** — its real signature is *relative*: a loss floor ~6x higher
|
|
80
80
|
than a known-good run of the same task (5.59 vs 0.94).
|
|
81
81
|
|
|
82
|
-
That finding
|
|
83
|
-
deterministic ratio rules against the healthy baseline you already have
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
That finding produced v0.3: `trainproof compare <run> <baseline>` —
|
|
83
|
+
deterministic ratio rules against the healthy baseline you already have —
|
|
84
|
+
which catches `bad_labels` at a 6x loss-floor ratio, in 3 seeds out of 3.
|
|
85
|
+
The full study was repeated with three random seeds (15 runs):
|
|
86
|
+
see [EVIDENCE_MATRIX.md](EVIDENCE_MATRIX.md) for every verdict, including the
|
|
87
|
+
honest miss (compare alone overlooks one lr_zero seed — the single-run
|
|
88
|
+
zero-LR fatality rule owns that case; the two commands cover each other's
|
|
89
|
+
blind spots). The gallery also improved the tool itself twice: the dead-run
|
|
90
|
+
rule and the total-zero-LR fatality rule both exist because runs escaped
|
|
91
|
+
earlier rule versions. See [ROADMAP.md](ROADMAP.md).
|
|
86
92
|
|
|
87
|
-
## The
|
|
93
|
+
## The commands
|
|
88
94
|
|
|
89
95
|
```bash
|
|
90
96
|
# 1. Dataset preflight (speech/TTS pack): audio integrity, transcript quality,
|
|
@@ -116,9 +122,64 @@ TRAINPROOF VERDICT
|
|
|
116
122
|
========================================
|
|
117
123
|
```
|
|
118
124
|
|
|
125
|
+
```
|
|
126
|
+
|
|
119
127
|
Each command prints the verdict, writes a self-contained HTML report, and sets
|
|
120
128
|
the process exit code — so it works as a CI gate out of the box.
|
|
121
129
|
|
|
130
|
+
## Live guardian (v0.4)
|
|
131
|
+
|
|
132
|
+
Don't wait for the post-mortem — catch a doomed run *while it is still burning
|
|
133
|
+
GPU*. Add one line to a HuggingFace `Trainer`:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from transformers import Trainer
|
|
137
|
+
from trainproof.integrations.hf import TrainproofCallback
|
|
138
|
+
|
|
139
|
+
trainer = Trainer(
|
|
140
|
+
...,
|
|
141
|
+
callbacks=[TrainproofCallback(policy="stop_on_fail")], # or policy="warn"
|
|
142
|
+
)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Run against a real diverging QLoRA fine-tune (learning rate 100x too high), the
|
|
146
|
+
guardian aborts it 20 steps into a 300-step schedule — on its own:
|
|
147
|
+
|
|
148
|
+
```text
|
|
149
|
+
{'loss': '1.784', 'grad_norm': '9.634', 'learning_rate': '0.007'}
|
|
150
|
+
{'loss': '4.282', 'grad_norm': '53.76', 'learning_rate': '0.009'}
|
|
151
|
+
{'loss': '10.6', 'grad_norm': '13.34', 'learning_rate': '0.011'}
|
|
152
|
+
{'loss': '31.67', 'grad_norm': '76.67', 'learning_rate': '0.013'}
|
|
153
|
+
...
|
|
154
|
+
TRAINPROOF ABORT - stopping training at step 20. Findings:
|
|
155
|
+
[FAIL] Loss curve is diverging.
|
|
156
|
+
Evidence: End loss 22.952 vs Min loss 1.358
|
|
157
|
+
[FAIL] Loss never improved over the run (dead run).
|
|
158
|
+
Evidence: median of first 5 losses 1.502 vs last 5 22.952
|
|
159
|
+
|
|
160
|
+
scheduled steps : 300
|
|
161
|
+
stopped at step : 20
|
|
162
|
+
run saved : 93% of the scheduled steps never ran
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
On a two-day pre-training run, that fraction is days of GPU time. Or watch a
|
|
166
|
+
growing log file from outside the process (CI-friendly, exits non-zero on FAIL):
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
trainproof watch logs/run.jsonl --interval 10 --until-fail
|
|
170
|
+
# [21:37:44] warming up (5 records)
|
|
171
|
+
# [21:37:44] n_records=15 verdict=PASS findings=1
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**The default is safe.** `policy="warn"` (the default) only observes and reports
|
|
175
|
+
— it never interrupts your run, so you can leave it on even for experiments you
|
|
176
|
+
expect to fail. Aborting is strictly opt-in via `policy="stop_on_fail"`, the one
|
|
177
|
+
mode that takes an irreversible action. trainproof does not make that decision
|
|
178
|
+
for you unless you ask.
|
|
179
|
+
|
|
180
|
+
The guardian applies the same deterministic rules as `trainproof epoch`, so it
|
|
181
|
+
inherits their documented single-run limitations.
|
|
182
|
+
|
|
122
183
|
## Supported log formats
|
|
123
184
|
|
|
124
185
|
- HuggingFace Trainer (`trainer_state.json`)
|
|
@@ -1,153 +1,202 @@
|
|
|
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
|
-
|
|
1
|
+
# trainproof
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/trainproof/)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
**A deterministic linter for ML training runs.** Point it at your dataset, your
|
|
7
|
+
tokenizer, or your first-epoch logs — it returns a PASS / WARN / FAIL verdict
|
|
8
|
+
with named findings and cited evidence, before you burn days of GPU time on a
|
|
9
|
+
run that was doomed at step 50.
|
|
10
|
+
|
|
11
|
+
No ML judging ML. No invented "confidence 97%". Every rule is a deterministic
|
|
12
|
+
threshold in [one auditable module](src/trainproof/rules.py), and every finding
|
|
13
|
+
cites the numbers that triggered it.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install trainproof
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## See a verdict in 60 seconds
|
|
20
|
+
|
|
21
|
+
This repo ships the real logs of five QLoRA fine-tuning runs (Qwen2.5-3B,
|
|
22
|
+
RTX 5080 — see the gallery below). Judge one right now:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
trainproof epoch examples/gallery/lr_hot/trainer_state.json --format hf
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
[FAIL] Critical checks failed:
|
|
30
|
+
[FAIL] Loss curve is diverging.
|
|
31
|
+
Evidence: End loss 7.492 vs Min loss 1.398
|
|
32
|
+
[WARN] Gradient norm spikes detected.
|
|
33
|
+
Evidence: Max gn 2649.75 > 10.0x median (0.55)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Why this exists
|
|
37
|
+
|
|
38
|
+
The author lost a real 11-hour fine-tune to a failure nothing warned about.
|
|
39
|
+
Pointed retroactively at that run's 1MB Coqui Trainer log (2,501 logged steps),
|
|
40
|
+
trainproof's verdict: **FAIL — diverging**. The loss reached its minimum at 82%
|
|
41
|
+
of the run and ended 1.9x above it. Translation: the final two hours of GPU
|
|
42
|
+
time made the model measurably worse, and the checkpoint worth keeping had
|
|
43
|
+
already existed for hours. No tool in the stack said a word.
|
|
44
|
+
|
|
45
|
+
## The fault-injection gallery
|
|
46
|
+
|
|
47
|
+
To validate the rules, the same QLoRA fine-tune (Qwen2.5-3B-Instruct, 4-bit,
|
|
48
|
+
LoRA r=16, 300 steps on Alpaca-cleaned) was run five times — once healthy, four
|
|
49
|
+
times with exactly one knob deliberately broken. Real runs, real logs, all
|
|
50
|
+
shipped in [`examples/gallery/`](examples/gallery/):
|
|
51
|
+
|
|
52
|
+
| Run | Sabotage | Verdict | Key evidence |
|
|
53
|
+
|---|---|---|---|
|
|
54
|
+
| `healthy` | none | **PASS** | loss 1.52 → 0.94, stable gradients |
|
|
55
|
+
| `lr_hot` | LR x100 (2e-2) | **FAIL** | diverging: end 7.49 vs min 1.40; grad spike 2650 vs median 0.55 |
|
|
56
|
+
| `lr_zero` | LR = 0 | **FAIL** | dead run: first-5 median 1.52 vs last-5 1.49 (<5% improvement); lr=0 on 100% of steps |
|
|
57
|
+
| `fp16_nan` | fp16 + hot LR, no clipping | **FAIL** | diverging: end 7.21 vs min 1.09 (grad scaling absorbed the intended NaN — the run diverged instead; reported as observed) |
|
|
58
|
+
| `bad_labels` | labels shuffled per-sequence | **WARN only** (single-run) — caught by `trainproof compare` (v0.3) | grad spike 23.3 vs median 1.09 |
|
|
59
|
+
|
|
60
|
+
### The honest finding: loss curves cannot see corrupted data
|
|
61
|
+
|
|
62
|
+
The `bad_labels` run — whose shuffled labels make real learning impossible —
|
|
63
|
+
*reduced its loss by 62%* (18.9 → 5.75). The model was genuinely learning: not
|
|
64
|
+
the task, but the marginal token statistics of the garbage. From its own loss
|
|
65
|
+
curve, that is indistinguishable from healthy training (neural networks
|
|
66
|
+
famously fit random labels). **No single-run, loss-only rule can catch this
|
|
67
|
+
class of failure** — its real signature is *relative*: a loss floor ~6x higher
|
|
68
|
+
than a known-good run of the same task (5.59 vs 0.94).
|
|
69
|
+
|
|
70
|
+
That finding produced v0.3: `trainproof compare <run> <baseline>` —
|
|
71
|
+
deterministic ratio rules against the healthy baseline you already have —
|
|
72
|
+
which catches `bad_labels` at a 6x loss-floor ratio, in 3 seeds out of 3.
|
|
73
|
+
The full study was repeated with three random seeds (15 runs):
|
|
74
|
+
see [EVIDENCE_MATRIX.md](EVIDENCE_MATRIX.md) for every verdict, including the
|
|
75
|
+
honest miss (compare alone overlooks one lr_zero seed — the single-run
|
|
76
|
+
zero-LR fatality rule owns that case; the two commands cover each other's
|
|
77
|
+
blind spots). The gallery also improved the tool itself twice: the dead-run
|
|
78
|
+
rule and the total-zero-LR fatality rule both exist because runs escaped
|
|
79
|
+
earlier rule versions. See [ROADMAP.md](ROADMAP.md).
|
|
80
|
+
|
|
81
|
+
## The commands
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# 1. Dataset preflight (speech/TTS pack): audio integrity, transcript quality,
|
|
85
|
+
# duplicates, text-vs-audio duration mismatches
|
|
86
|
+
trainproof data /path/to/dataset_or_manifest.jsonl
|
|
87
|
+
|
|
88
|
+
# 2. Tokenizer preflight: vocabulary coverage, OOV rate, sequence blowouts,
|
|
89
|
+
# suspicious splits on numbers/dates
|
|
90
|
+
trainproof tokenizer my_tokenizer.model transcripts.txt
|
|
91
|
+
|
|
92
|
+
# 3. Training-run verdict: NaN/divergence/dead-run detection, gradient spikes,
|
|
93
|
+
# LR sanity, throughput — from log files, any framework
|
|
94
|
+
trainproof epoch logs/run.jsonl # exit code 1 on FAIL: CI-ready
|
|
95
|
+
|
|
96
|
+
# 4. Compare against a baseline
|
|
97
|
+
# Catch relative pathologies like the `bad_labels` run that evade single-run rules.
|
|
98
|
+
trainproof compare examples/gallery/bad_labels/trainer_state.json examples/gallery/healthy/trainer_state.json
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```text
|
|
102
|
+
========================================
|
|
103
|
+
TRAINPROOF VERDICT
|
|
104
|
+
========================================
|
|
105
|
+
[FAIL] Critical checks failed:
|
|
106
|
+
[FAIL] loss floor ratio exceeded limit
|
|
107
|
+
Evidence: Run floor 5.592 vs Baseline floor 0.937 (ratio 6.0x > 2.0)
|
|
108
|
+
[FAIL] end loss ratio exceeded limit
|
|
109
|
+
Evidence: Run end 5.750 vs Baseline end 1.082 (ratio 5.3x > 2.0)
|
|
110
|
+
========================================
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Each command prints the verdict, writes a self-contained HTML report, and sets
|
|
116
|
+
the process exit code — so it works as a CI gate out of the box.
|
|
117
|
+
|
|
118
|
+
## Live guardian (v0.4)
|
|
119
|
+
|
|
120
|
+
Don't wait for the post-mortem — catch a doomed run *while it is still burning
|
|
121
|
+
GPU*. Add one line to a HuggingFace `Trainer`:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from transformers import Trainer
|
|
125
|
+
from trainproof.integrations.hf import TrainproofCallback
|
|
126
|
+
|
|
127
|
+
trainer = Trainer(
|
|
128
|
+
...,
|
|
129
|
+
callbacks=[TrainproofCallback(policy="stop_on_fail")], # or policy="warn"
|
|
130
|
+
)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Run against a real diverging QLoRA fine-tune (learning rate 100x too high), the
|
|
134
|
+
guardian aborts it 20 steps into a 300-step schedule — on its own:
|
|
135
|
+
|
|
136
|
+
```text
|
|
137
|
+
{'loss': '1.784', 'grad_norm': '9.634', 'learning_rate': '0.007'}
|
|
138
|
+
{'loss': '4.282', 'grad_norm': '53.76', 'learning_rate': '0.009'}
|
|
139
|
+
{'loss': '10.6', 'grad_norm': '13.34', 'learning_rate': '0.011'}
|
|
140
|
+
{'loss': '31.67', 'grad_norm': '76.67', 'learning_rate': '0.013'}
|
|
141
|
+
...
|
|
142
|
+
TRAINPROOF ABORT - stopping training at step 20. Findings:
|
|
143
|
+
[FAIL] Loss curve is diverging.
|
|
144
|
+
Evidence: End loss 22.952 vs Min loss 1.358
|
|
145
|
+
[FAIL] Loss never improved over the run (dead run).
|
|
146
|
+
Evidence: median of first 5 losses 1.502 vs last 5 22.952
|
|
147
|
+
|
|
148
|
+
scheduled steps : 300
|
|
149
|
+
stopped at step : 20
|
|
150
|
+
run saved : 93% of the scheduled steps never ran
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
On a two-day pre-training run, that fraction is days of GPU time. Or watch a
|
|
154
|
+
growing log file from outside the process (CI-friendly, exits non-zero on FAIL):
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
trainproof watch logs/run.jsonl --interval 10 --until-fail
|
|
158
|
+
# [21:37:44] warming up (5 records)
|
|
159
|
+
# [21:37:44] n_records=15 verdict=PASS findings=1
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**The default is safe.** `policy="warn"` (the default) only observes and reports
|
|
163
|
+
— it never interrupts your run, so you can leave it on even for experiments you
|
|
164
|
+
expect to fail. Aborting is strictly opt-in via `policy="stop_on_fail"`, the one
|
|
165
|
+
mode that takes an irreversible action. trainproof does not make that decision
|
|
166
|
+
for you unless you ask.
|
|
167
|
+
|
|
168
|
+
The guardian applies the same deterministic rules as `trainproof epoch`, so it
|
|
169
|
+
inherits their documented single-run limitations.
|
|
170
|
+
|
|
171
|
+
## Supported log formats
|
|
172
|
+
|
|
173
|
+
- HuggingFace Trainer (`trainer_state.json`)
|
|
174
|
+
- Coqui Trainer text logs (ANSI-colored `trainer_0_log.txt`)
|
|
175
|
+
- Generic JSONL / CSV (columns: step, loss, lr, grad_norm, time — all optional)
|
|
176
|
+
|
|
177
|
+
Auto-detected; override with `--format hf|coqui|jsonl|csv`. TensorBoard event
|
|
178
|
+
files are planned (Lightning console captures are TTY dumps, not logs, and
|
|
179
|
+
will not be supported).
|
|
180
|
+
|
|
181
|
+
## Philosophy
|
|
182
|
+
|
|
183
|
+
1. **Deterministic.** A rule fires or it doesn't. Thresholds live in one
|
|
184
|
+
module, commented, tunable.
|
|
185
|
+
2. **Evidence-cited.** Every finding names the steps and values that triggered
|
|
186
|
+
it.
|
|
187
|
+
3. **Honest about limits.** What the tool cannot detect is documented in the
|
|
188
|
+
README, not discovered by the user in production.
|
|
189
|
+
|
|
190
|
+
## Family
|
|
191
|
+
|
|
192
|
+
trainproof judges training runs. Its sibling [ttsproof](https://github.com/Mormolykos/ttsproof)
|
|
193
|
+
judges TTS model *outputs* (structural audio checks, equivalence-aware WER/CER,
|
|
194
|
+
published method with DOI) — and trainproof builds on it for the speech
|
|
195
|
+
dataset checks.
|
|
196
|
+
|
|
197
|
+
## Author
|
|
198
|
+
|
|
199
|
+
Panagiotis (Panos) Gkilis — [portfolio](https://tts.bedvibe.studio/portfolio/) ·
|
|
200
|
+
[bedvibe.studio](https://bedvibe.studio/)
|
|
201
|
+
|
|
202
|
+
MIT license.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "trainproof"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "A deterministic linter for ML training runs: dataset, tokenizer, and epoch logs."
|
|
5
5
|
authors = [{name = "Panagiotis (Panos) Gkilis", email = "bedvibe@bedvibe.studio"}]
|
|
6
6
|
readme = "README.md"
|
|
@@ -4,7 +4,9 @@ from pathlib import Path
|
|
|
4
4
|
from .speech.data import check_data
|
|
5
5
|
from .speech.tokenizer import check_tokenizer
|
|
6
6
|
from .epoch import check_epoch
|
|
7
|
+
from .epoch import check_epoch
|
|
7
8
|
from .compare import check_compare
|
|
9
|
+
from .watch import watch_loop
|
|
8
10
|
from .report import print_verdict_console, write_html_report
|
|
9
11
|
|
|
10
12
|
def main():
|
|
@@ -31,6 +33,13 @@ def main():
|
|
|
31
33
|
compare_parser.add_argument("baseline", type=str, help="Path to baseline log file")
|
|
32
34
|
compare_parser.add_argument("--format", choices=["auto", "hf", "coqui", "jsonl", "csv"], default="auto", help="Log format override")
|
|
33
35
|
|
|
36
|
+
# Watch Command
|
|
37
|
+
watch_parser = subparsers.add_parser("watch", help="Live guardian: poll a training log.")
|
|
38
|
+
watch_parser.add_argument("logfile", type=str, help="Path to run log file")
|
|
39
|
+
watch_parser.add_argument("--interval", type=int, default=10, help="Polling interval in seconds")
|
|
40
|
+
watch_parser.add_argument("--format", choices=["auto", "hf", "coqui", "jsonl", "csv"], default="auto", help="Log format override")
|
|
41
|
+
watch_parser.add_argument("--until-fail", action="store_true", help="Exit with code 1 as soon as verdict becomes FAIL")
|
|
42
|
+
|
|
34
43
|
args = parser.parse_args()
|
|
35
44
|
|
|
36
45
|
report_dict = {}
|
|
@@ -51,6 +60,9 @@ def main():
|
|
|
51
60
|
except Exception as e:
|
|
52
61
|
print(f"Error checking compare: {e}")
|
|
53
62
|
sys.exit(1)
|
|
63
|
+
elif args.command == "watch":
|
|
64
|
+
watch_loop(args.logfile, interval=args.interval, fmt=args.format, until_fail=args.until_fail)
|
|
65
|
+
sys.exit(0)
|
|
54
66
|
|
|
55
67
|
print_verdict_console(report_dict.get("verdict", "FAIL"), report_dict.get("findings", []))
|
|
56
68
|
|
|
@@ -4,13 +4,12 @@ from typing import Any
|
|
|
4
4
|
from . import rules
|
|
5
5
|
from .adapters import parse_log_with_format
|
|
6
6
|
|
|
7
|
-
def
|
|
8
|
-
records = parse_log_with_format(log_path, fmt)
|
|
7
|
+
def check_records(records: list[dict]) -> dict[str, Any]:
|
|
9
8
|
findings = []
|
|
10
9
|
verdict = "PASS"
|
|
11
10
|
|
|
12
11
|
if not records:
|
|
13
|
-
return {"verdict": "FAIL", "findings": [{"level": "FAIL", "message": "No valid log records found.", "evidence":
|
|
12
|
+
return {"verdict": "FAIL", "findings": [{"level": "FAIL", "message": "No valid log records found.", "evidence": ""}]}
|
|
14
13
|
|
|
15
14
|
# Find relevant keys
|
|
16
15
|
def get_val(row, aliases):
|
|
@@ -112,3 +111,9 @@ def check_epoch(log_path: str | Path, fmt: str = "auto") -> dict[str, Any]:
|
|
|
112
111
|
findings.append({"level": "PASS", "message": "Loss curve shows healthy shape, grad norms are stable.", "evidence": f"{len(valid_losses)} steps analyzed."})
|
|
113
112
|
|
|
114
113
|
return {"verdict": verdict, "findings": findings}
|
|
114
|
+
|
|
115
|
+
def check_epoch(log_path: str | Path, fmt: str = "auto") -> dict[str, Any]:
|
|
116
|
+
records = parse_log_with_format(log_path, fmt)
|
|
117
|
+
if not records:
|
|
118
|
+
return {"verdict": "FAIL", "findings": [{"level": "FAIL", "message": "No valid log records found.", "evidence": str(log_path)}]}
|
|
119
|
+
return check_records(records)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from trainproof.epoch import check_records
|
|
2
|
+
|
|
3
|
+
def _convert_state_to_records(state) -> list[dict]:
|
|
4
|
+
history = getattr(state, "log_history", [])
|
|
5
|
+
records = []
|
|
6
|
+
for entry in history:
|
|
7
|
+
if not isinstance(entry, dict) or "loss" not in entry:
|
|
8
|
+
continue
|
|
9
|
+
record = {}
|
|
10
|
+
for k in ["loss", "grad_norm", "step"]:
|
|
11
|
+
if k in entry and entry[k] is not None:
|
|
12
|
+
try:
|
|
13
|
+
record[k] = float(entry[k])
|
|
14
|
+
except (ValueError, TypeError):
|
|
15
|
+
pass
|
|
16
|
+
if "learning_rate" in entry and entry["learning_rate"] is not None:
|
|
17
|
+
try:
|
|
18
|
+
record["lr"] = float(entry["learning_rate"])
|
|
19
|
+
except (ValueError, TypeError):
|
|
20
|
+
pass
|
|
21
|
+
records.append(record)
|
|
22
|
+
return records
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from transformers import TrainerCallback
|
|
26
|
+
except ImportError:
|
|
27
|
+
class TrainerCallback:
|
|
28
|
+
pass
|
|
29
|
+
_HAS_TRANSFORMERS = False
|
|
30
|
+
else:
|
|
31
|
+
_HAS_TRANSFORMERS = True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TrainproofCallback(TrainerCallback):
|
|
35
|
+
"""Judge a live HuggingFace training run with trainproof's deterministic rules.
|
|
36
|
+
|
|
37
|
+
policy:
|
|
38
|
+
"warn" (DEFAULT): observe and report only. On a FAIL verdict it prints the
|
|
39
|
+
findings and lets training continue. It NEVER interrupts your run — safe
|
|
40
|
+
to leave on by default, including for experiments you expect to fail.
|
|
41
|
+
"stop_on_fail": opt-in. Additionally sets control.should_training_stop on a
|
|
42
|
+
FAIL verdict, aborting the run to save GPU time. This is the only mode
|
|
43
|
+
that takes an irreversible action, and you must ask for it explicitly.
|
|
44
|
+
|
|
45
|
+
check_every: minimum steps between checks. min_points: minimum logged loss
|
|
46
|
+
points before any verdict is issued (avoids judging warm-up noise).
|
|
47
|
+
A FAIL is announced once, not re-announced every subsequent check.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, policy="warn", check_every=25, min_points=10):
|
|
51
|
+
if not _HAS_TRANSFORMERS:
|
|
52
|
+
raise ImportError("pip install transformers is required to use TrainproofCallback")
|
|
53
|
+
self.policy = policy
|
|
54
|
+
self.check_every = check_every
|
|
55
|
+
self.min_points = min_points
|
|
56
|
+
self.last_checked_step = 0
|
|
57
|
+
self.last_verdict = None
|
|
58
|
+
|
|
59
|
+
def on_log(self, args, state, control, **kwargs):
|
|
60
|
+
step = getattr(state, "global_step", 0)
|
|
61
|
+
if step < self.last_checked_step + self.check_every:
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
records = _convert_state_to_records(state)
|
|
65
|
+
if len(records) < self.min_points:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
self.last_checked_step = step
|
|
69
|
+
|
|
70
|
+
report = check_records(records)
|
|
71
|
+
verdict = report.get("verdict", "UNKNOWN")
|
|
72
|
+
findings = report.get("findings", [])
|
|
73
|
+
|
|
74
|
+
if verdict == "FAIL" and self.last_verdict != "FAIL":
|
|
75
|
+
if self.policy == "stop_on_fail":
|
|
76
|
+
setattr(control, "should_training_stop", True)
|
|
77
|
+
print(f"\nTRAINPROOF ABORT - stopping training at step {step}. Findings:")
|
|
78
|
+
else:
|
|
79
|
+
print("\nTRAINPROOF WARNING - this run looks doomed:")
|
|
80
|
+
|
|
81
|
+
for f in findings:
|
|
82
|
+
level = f.get("level", "INFO")
|
|
83
|
+
msg = f.get("message", "")
|
|
84
|
+
ev = f.get("evidence", "")
|
|
85
|
+
print(f" [{level}] {msg}")
|
|
86
|
+
if ev:
|
|
87
|
+
print(f" Evidence: {ev}")
|
|
88
|
+
|
|
89
|
+
self.last_verdict = verdict
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import sys
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from .adapters import parse_log_with_format
|
|
6
|
+
from .epoch import check_records
|
|
7
|
+
from .report import print_verdict_console
|
|
8
|
+
|
|
9
|
+
def poll_once(path: str | Path, fmt: str, prev_verdict: str | None) -> tuple[str | None, bool, int]:
|
|
10
|
+
try:
|
|
11
|
+
records = parse_log_with_format(path, fmt)
|
|
12
|
+
except Exception:
|
|
13
|
+
records = []
|
|
14
|
+
|
|
15
|
+
n_records = len(records)
|
|
16
|
+
|
|
17
|
+
valid_losses = 0
|
|
18
|
+
for r in records:
|
|
19
|
+
if "loss" in r and r["loss"] is not None:
|
|
20
|
+
valid_losses += 1
|
|
21
|
+
|
|
22
|
+
now_str = datetime.now().strftime("%H:%M:%S")
|
|
23
|
+
|
|
24
|
+
if valid_losses < 10:
|
|
25
|
+
print(f"[{now_str}] warming up ({n_records} records)")
|
|
26
|
+
return (None, False, n_records)
|
|
27
|
+
|
|
28
|
+
report = check_records(records)
|
|
29
|
+
verdict = report.get("verdict", "UNKNOWN")
|
|
30
|
+
n_findings = len(report.get("findings", []))
|
|
31
|
+
|
|
32
|
+
changed = (verdict != prev_verdict)
|
|
33
|
+
|
|
34
|
+
print(f"[{now_str}] n_records={n_records} verdict={verdict} findings={n_findings}")
|
|
35
|
+
|
|
36
|
+
if changed:
|
|
37
|
+
print_verdict_console(verdict, report.get("findings", []))
|
|
38
|
+
|
|
39
|
+
return (verdict, changed, n_records)
|
|
40
|
+
|
|
41
|
+
def watch_loop(path: str | Path, interval: int = 10, fmt: str = "auto", until_fail: bool = False):
|
|
42
|
+
prev_verdict = None
|
|
43
|
+
try:
|
|
44
|
+
while True:
|
|
45
|
+
verdict, changed, n = poll_once(path, fmt, prev_verdict)
|
|
46
|
+
if verdict is not None:
|
|
47
|
+
prev_verdict = verdict
|
|
48
|
+
|
|
49
|
+
if until_fail and verdict == "FAIL":
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
|
|
52
|
+
time.sleep(interval)
|
|
53
|
+
except KeyboardInterrupt:
|
|
54
|
+
print(f"\nStopped watching. Final verdict: {prev_verdict}")
|
|
55
|
+
if prev_verdict == "FAIL":
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
sys.exit(0)
|
|
@@ -1,141 +1,214 @@
|
|
|
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
|
-
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trainproof
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: A deterministic linter for ML training runs: dataset, tokenizer, and epoch logs.
|
|
5
|
+
Author-email: "Panagiotis (Panos) Gkilis" <bedvibe@bedvibe.studio>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: numpy
|
|
10
|
+
Requires-Dist: soundfile
|
|
11
|
+
Requires-Dist: ttsproof
|
|
12
|
+
|
|
13
|
+
# trainproof
|
|
14
|
+
|
|
15
|
+
[](https://pypi.org/project/trainproof/)
|
|
16
|
+
[](LICENSE)
|
|
17
|
+
|
|
18
|
+
**A deterministic linter for ML training runs.** Point it at your dataset, your
|
|
19
|
+
tokenizer, or your first-epoch logs — it returns a PASS / WARN / FAIL verdict
|
|
20
|
+
with named findings and cited evidence, before you burn days of GPU time on a
|
|
21
|
+
run that was doomed at step 50.
|
|
22
|
+
|
|
23
|
+
No ML judging ML. No invented "confidence 97%". Every rule is a deterministic
|
|
24
|
+
threshold in [one auditable module](src/trainproof/rules.py), and every finding
|
|
25
|
+
cites the numbers that triggered it.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install trainproof
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## See a verdict in 60 seconds
|
|
32
|
+
|
|
33
|
+
This repo ships the real logs of five QLoRA fine-tuning runs (Qwen2.5-3B,
|
|
34
|
+
RTX 5080 — see the gallery below). Judge one right now:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
trainproof epoch examples/gallery/lr_hot/trainer_state.json --format hf
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
[FAIL] Critical checks failed:
|
|
42
|
+
[FAIL] Loss curve is diverging.
|
|
43
|
+
Evidence: End loss 7.492 vs Min loss 1.398
|
|
44
|
+
[WARN] Gradient norm spikes detected.
|
|
45
|
+
Evidence: Max gn 2649.75 > 10.0x median (0.55)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Why this exists
|
|
49
|
+
|
|
50
|
+
The author lost a real 11-hour fine-tune to a failure nothing warned about.
|
|
51
|
+
Pointed retroactively at that run's 1MB Coqui Trainer log (2,501 logged steps),
|
|
52
|
+
trainproof's verdict: **FAIL — diverging**. The loss reached its minimum at 82%
|
|
53
|
+
of the run and ended 1.9x above it. Translation: the final two hours of GPU
|
|
54
|
+
time made the model measurably worse, and the checkpoint worth keeping had
|
|
55
|
+
already existed for hours. No tool in the stack said a word.
|
|
56
|
+
|
|
57
|
+
## The fault-injection gallery
|
|
58
|
+
|
|
59
|
+
To validate the rules, the same QLoRA fine-tune (Qwen2.5-3B-Instruct, 4-bit,
|
|
60
|
+
LoRA r=16, 300 steps on Alpaca-cleaned) was run five times — once healthy, four
|
|
61
|
+
times with exactly one knob deliberately broken. Real runs, real logs, all
|
|
62
|
+
shipped in [`examples/gallery/`](examples/gallery/):
|
|
63
|
+
|
|
64
|
+
| Run | Sabotage | Verdict | Key evidence |
|
|
65
|
+
|---|---|---|---|
|
|
66
|
+
| `healthy` | none | **PASS** | loss 1.52 → 0.94, stable gradients |
|
|
67
|
+
| `lr_hot` | LR x100 (2e-2) | **FAIL** | diverging: end 7.49 vs min 1.40; grad spike 2650 vs median 0.55 |
|
|
68
|
+
| `lr_zero` | LR = 0 | **FAIL** | dead run: first-5 median 1.52 vs last-5 1.49 (<5% improvement); lr=0 on 100% of steps |
|
|
69
|
+
| `fp16_nan` | fp16 + hot LR, no clipping | **FAIL** | diverging: end 7.21 vs min 1.09 (grad scaling absorbed the intended NaN — the run diverged instead; reported as observed) |
|
|
70
|
+
| `bad_labels` | labels shuffled per-sequence | **WARN only** (single-run) — caught by `trainproof compare` (v0.3) | grad spike 23.3 vs median 1.09 |
|
|
71
|
+
|
|
72
|
+
### The honest finding: loss curves cannot see corrupted data
|
|
73
|
+
|
|
74
|
+
The `bad_labels` run — whose shuffled labels make real learning impossible —
|
|
75
|
+
*reduced its loss by 62%* (18.9 → 5.75). The model was genuinely learning: not
|
|
76
|
+
the task, but the marginal token statistics of the garbage. From its own loss
|
|
77
|
+
curve, that is indistinguishable from healthy training (neural networks
|
|
78
|
+
famously fit random labels). **No single-run, loss-only rule can catch this
|
|
79
|
+
class of failure** — its real signature is *relative*: a loss floor ~6x higher
|
|
80
|
+
than a known-good run of the same task (5.59 vs 0.94).
|
|
81
|
+
|
|
82
|
+
That finding produced v0.3: `trainproof compare <run> <baseline>` —
|
|
83
|
+
deterministic ratio rules against the healthy baseline you already have —
|
|
84
|
+
which catches `bad_labels` at a 6x loss-floor ratio, in 3 seeds out of 3.
|
|
85
|
+
The full study was repeated with three random seeds (15 runs):
|
|
86
|
+
see [EVIDENCE_MATRIX.md](EVIDENCE_MATRIX.md) for every verdict, including the
|
|
87
|
+
honest miss (compare alone overlooks one lr_zero seed — the single-run
|
|
88
|
+
zero-LR fatality rule owns that case; the two commands cover each other's
|
|
89
|
+
blind spots). The gallery also improved the tool itself twice: the dead-run
|
|
90
|
+
rule and the total-zero-LR fatality rule both exist because runs escaped
|
|
91
|
+
earlier rule versions. See [ROADMAP.md](ROADMAP.md).
|
|
92
|
+
|
|
93
|
+
## The commands
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# 1. Dataset preflight (speech/TTS pack): audio integrity, transcript quality,
|
|
97
|
+
# duplicates, text-vs-audio duration mismatches
|
|
98
|
+
trainproof data /path/to/dataset_or_manifest.jsonl
|
|
99
|
+
|
|
100
|
+
# 2. Tokenizer preflight: vocabulary coverage, OOV rate, sequence blowouts,
|
|
101
|
+
# suspicious splits on numbers/dates
|
|
102
|
+
trainproof tokenizer my_tokenizer.model transcripts.txt
|
|
103
|
+
|
|
104
|
+
# 3. Training-run verdict: NaN/divergence/dead-run detection, gradient spikes,
|
|
105
|
+
# LR sanity, throughput — from log files, any framework
|
|
106
|
+
trainproof epoch logs/run.jsonl # exit code 1 on FAIL: CI-ready
|
|
107
|
+
|
|
108
|
+
# 4. Compare against a baseline
|
|
109
|
+
# Catch relative pathologies like the `bad_labels` run that evade single-run rules.
|
|
110
|
+
trainproof compare examples/gallery/bad_labels/trainer_state.json examples/gallery/healthy/trainer_state.json
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```text
|
|
114
|
+
========================================
|
|
115
|
+
TRAINPROOF VERDICT
|
|
116
|
+
========================================
|
|
117
|
+
[FAIL] Critical checks failed:
|
|
118
|
+
[FAIL] loss floor ratio exceeded limit
|
|
119
|
+
Evidence: Run floor 5.592 vs Baseline floor 0.937 (ratio 6.0x > 2.0)
|
|
120
|
+
[FAIL] end loss ratio exceeded limit
|
|
121
|
+
Evidence: Run end 5.750 vs Baseline end 1.082 (ratio 5.3x > 2.0)
|
|
122
|
+
========================================
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Each command prints the verdict, writes a self-contained HTML report, and sets
|
|
128
|
+
the process exit code — so it works as a CI gate out of the box.
|
|
129
|
+
|
|
130
|
+
## Live guardian (v0.4)
|
|
131
|
+
|
|
132
|
+
Don't wait for the post-mortem — catch a doomed run *while it is still burning
|
|
133
|
+
GPU*. Add one line to a HuggingFace `Trainer`:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from transformers import Trainer
|
|
137
|
+
from trainproof.integrations.hf import TrainproofCallback
|
|
138
|
+
|
|
139
|
+
trainer = Trainer(
|
|
140
|
+
...,
|
|
141
|
+
callbacks=[TrainproofCallback(policy="stop_on_fail")], # or policy="warn"
|
|
142
|
+
)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Run against a real diverging QLoRA fine-tune (learning rate 100x too high), the
|
|
146
|
+
guardian aborts it 20 steps into a 300-step schedule — on its own:
|
|
147
|
+
|
|
148
|
+
```text
|
|
149
|
+
{'loss': '1.784', 'grad_norm': '9.634', 'learning_rate': '0.007'}
|
|
150
|
+
{'loss': '4.282', 'grad_norm': '53.76', 'learning_rate': '0.009'}
|
|
151
|
+
{'loss': '10.6', 'grad_norm': '13.34', 'learning_rate': '0.011'}
|
|
152
|
+
{'loss': '31.67', 'grad_norm': '76.67', 'learning_rate': '0.013'}
|
|
153
|
+
...
|
|
154
|
+
TRAINPROOF ABORT - stopping training at step 20. Findings:
|
|
155
|
+
[FAIL] Loss curve is diverging.
|
|
156
|
+
Evidence: End loss 22.952 vs Min loss 1.358
|
|
157
|
+
[FAIL] Loss never improved over the run (dead run).
|
|
158
|
+
Evidence: median of first 5 losses 1.502 vs last 5 22.952
|
|
159
|
+
|
|
160
|
+
scheduled steps : 300
|
|
161
|
+
stopped at step : 20
|
|
162
|
+
run saved : 93% of the scheduled steps never ran
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
On a two-day pre-training run, that fraction is days of GPU time. Or watch a
|
|
166
|
+
growing log file from outside the process (CI-friendly, exits non-zero on FAIL):
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
trainproof watch logs/run.jsonl --interval 10 --until-fail
|
|
170
|
+
# [21:37:44] warming up (5 records)
|
|
171
|
+
# [21:37:44] n_records=15 verdict=PASS findings=1
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**The default is safe.** `policy="warn"` (the default) only observes and reports
|
|
175
|
+
— it never interrupts your run, so you can leave it on even for experiments you
|
|
176
|
+
expect to fail. Aborting is strictly opt-in via `policy="stop_on_fail"`, the one
|
|
177
|
+
mode that takes an irreversible action. trainproof does not make that decision
|
|
178
|
+
for you unless you ask.
|
|
179
|
+
|
|
180
|
+
The guardian applies the same deterministic rules as `trainproof epoch`, so it
|
|
181
|
+
inherits their documented single-run limitations.
|
|
182
|
+
|
|
183
|
+
## Supported log formats
|
|
184
|
+
|
|
185
|
+
- HuggingFace Trainer (`trainer_state.json`)
|
|
186
|
+
- Coqui Trainer text logs (ANSI-colored `trainer_0_log.txt`)
|
|
187
|
+
- Generic JSONL / CSV (columns: step, loss, lr, grad_norm, time — all optional)
|
|
188
|
+
|
|
189
|
+
Auto-detected; override with `--format hf|coqui|jsonl|csv`. TensorBoard event
|
|
190
|
+
files are planned (Lightning console captures are TTY dumps, not logs, and
|
|
191
|
+
will not be supported).
|
|
192
|
+
|
|
193
|
+
## Philosophy
|
|
194
|
+
|
|
195
|
+
1. **Deterministic.** A rule fires or it doesn't. Thresholds live in one
|
|
196
|
+
module, commented, tunable.
|
|
197
|
+
2. **Evidence-cited.** Every finding names the steps and values that triggered
|
|
198
|
+
it.
|
|
199
|
+
3. **Honest about limits.** What the tool cannot detect is documented in the
|
|
200
|
+
README, not discovered by the user in production.
|
|
201
|
+
|
|
202
|
+
## Family
|
|
203
|
+
|
|
204
|
+
trainproof judges training runs. Its sibling [ttsproof](https://github.com/Mormolykos/ttsproof)
|
|
205
|
+
judges TTS model *outputs* (structural audio checks, equivalence-aware WER/CER,
|
|
206
|
+
published method with DOI) — and trainproof builds on it for the speech
|
|
207
|
+
dataset checks.
|
|
208
|
+
|
|
209
|
+
## Author
|
|
210
|
+
|
|
211
|
+
Panagiotis (Panos) Gkilis — [portfolio](https://tts.bedvibe.studio/portfolio/) ·
|
|
212
|
+
[bedvibe.studio](https://bedvibe.studio/)
|
|
213
|
+
|
|
214
|
+
MIT license.
|
|
@@ -7,16 +7,21 @@ src/trainproof/compare.py
|
|
|
7
7
|
src/trainproof/epoch.py
|
|
8
8
|
src/trainproof/report.py
|
|
9
9
|
src/trainproof/rules.py
|
|
10
|
+
src/trainproof/watch.py
|
|
10
11
|
src/trainproof.egg-info/PKG-INFO
|
|
11
12
|
src/trainproof.egg-info/SOURCES.txt
|
|
12
13
|
src/trainproof.egg-info/dependency_links.txt
|
|
13
14
|
src/trainproof.egg-info/entry_points.txt
|
|
14
15
|
src/trainproof.egg-info/requires.txt
|
|
15
16
|
src/trainproof.egg-info/top_level.txt
|
|
17
|
+
src/trainproof/integrations/__init__.py
|
|
18
|
+
src/trainproof/integrations/hf.py
|
|
16
19
|
src/trainproof/speech/__init__.py
|
|
17
20
|
src/trainproof/speech/data.py
|
|
18
21
|
src/trainproof/speech/tokenizer.py
|
|
19
22
|
tests/test_adapters.py
|
|
20
23
|
tests/test_compare.py
|
|
21
24
|
tests/test_data.py
|
|
22
|
-
tests/test_epoch.py
|
|
25
|
+
tests/test_epoch.py
|
|
26
|
+
tests/test_hf_callback.py
|
|
27
|
+
tests/test_watch.py
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import trainproof.integrations.hf as hf_mod
|
|
2
|
+
from trainproof.integrations.hf import _convert_state_to_records, TrainproofCallback
|
|
3
|
+
|
|
4
|
+
# Patch so tests can run without transformers installed
|
|
5
|
+
hf_mod._HAS_TRANSFORMERS = True
|
|
6
|
+
class MockState:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.global_step = 0
|
|
9
|
+
self.log_history = []
|
|
10
|
+
|
|
11
|
+
class MockControl:
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.should_training_stop = False
|
|
14
|
+
|
|
15
|
+
def test_convert_state_to_records():
|
|
16
|
+
state = MockState()
|
|
17
|
+
state.log_history = [
|
|
18
|
+
{"loss": 1.0, "step": 10, "learning_rate": 0.01, "eval_loss": 2.0},
|
|
19
|
+
{"eval_loss": 1.5, "step": 10},
|
|
20
|
+
{"loss": 0.5, "step": 20, "grad_norm": 0.1}
|
|
21
|
+
]
|
|
22
|
+
records = _convert_state_to_records(state)
|
|
23
|
+
assert len(records) == 2
|
|
24
|
+
assert records[0] == {"loss": 1.0, "step": 10.0, "lr": 0.01}
|
|
25
|
+
assert records[1] == {"loss": 0.5, "step": 20.0, "grad_norm": 0.1}
|
|
26
|
+
|
|
27
|
+
def test_hf_callback_warn_policy():
|
|
28
|
+
callback = TrainproofCallback(policy="warn", check_every=10, min_points=10)
|
|
29
|
+
state = MockState()
|
|
30
|
+
control = MockControl()
|
|
31
|
+
|
|
32
|
+
# Add < 10 points -> should do nothing
|
|
33
|
+
for i in range(5):
|
|
34
|
+
state.log_history.append({"loss": 10.0, "step": i})
|
|
35
|
+
state.global_step = 10
|
|
36
|
+
callback.on_log(None, state, control)
|
|
37
|
+
assert callback.last_verdict is None
|
|
38
|
+
|
|
39
|
+
# Add up to 15 points (healthy) -> PASS
|
|
40
|
+
for i in range(5, 15):
|
|
41
|
+
state.log_history.append({"loss": 1.0, "step": i, "learning_rate": 1e-4})
|
|
42
|
+
state.global_step = 20
|
|
43
|
+
callback.on_log(None, state, control)
|
|
44
|
+
assert callback.last_verdict == "PASS"
|
|
45
|
+
assert not control.should_training_stop
|
|
46
|
+
|
|
47
|
+
# Diverge -> FAIL but don't stop because policy=warn
|
|
48
|
+
for i in range(15, 25):
|
|
49
|
+
state.log_history.append({"loss": 50.0, "step": i, "learning_rate": 1e-4})
|
|
50
|
+
state.global_step = 30
|
|
51
|
+
callback.on_log(None, state, control)
|
|
52
|
+
assert callback.last_verdict == "FAIL"
|
|
53
|
+
assert not control.should_training_stop
|
|
54
|
+
|
|
55
|
+
def test_hf_callback_stop_on_fail():
|
|
56
|
+
callback = TrainproofCallback(policy="stop_on_fail", check_every=10, min_points=10)
|
|
57
|
+
state = MockState()
|
|
58
|
+
control = MockControl()
|
|
59
|
+
|
|
60
|
+
for i in range(20):
|
|
61
|
+
state.log_history.append({"loss": 50.0, "step": i, "learning_rate": 1e-4})
|
|
62
|
+
state.global_step = 20
|
|
63
|
+
|
|
64
|
+
callback.on_log(None, state, control)
|
|
65
|
+
assert callback.last_verdict == "FAIL"
|
|
66
|
+
assert control.should_training_stop
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from trainproof.watch import poll_once
|
|
4
|
+
|
|
5
|
+
def test_watch_poll_once(tmp_path: Path):
|
|
6
|
+
log_file = tmp_path / "test.jsonl"
|
|
7
|
+
|
|
8
|
+
# Phase 1: warming up (less than 10 valid losses)
|
|
9
|
+
logs = []
|
|
10
|
+
for i in range(5):
|
|
11
|
+
logs.append(json.dumps({"loss": 5.0, "step": i, "lr": 1e-4}))
|
|
12
|
+
log_file.write_text("\n".join(logs))
|
|
13
|
+
|
|
14
|
+
verdict, changed, n = poll_once(log_file, "jsonl", None)
|
|
15
|
+
assert verdict is None
|
|
16
|
+
assert not changed
|
|
17
|
+
assert n == 5
|
|
18
|
+
|
|
19
|
+
# Phase 2: healthy phase -> PASS
|
|
20
|
+
for i in range(5, 15):
|
|
21
|
+
logs.append(json.dumps({"loss": 1.0, "step": i, "lr": 1e-4}))
|
|
22
|
+
log_file.write_text("\n".join(logs))
|
|
23
|
+
|
|
24
|
+
verdict, changed, n = poll_once(log_file, "jsonl", None)
|
|
25
|
+
assert verdict == "PASS"
|
|
26
|
+
assert changed
|
|
27
|
+
assert n == 15
|
|
28
|
+
|
|
29
|
+
# Check that it doesn't say "changed" if prev_verdict was PASS
|
|
30
|
+
verdict2, changed2, n2 = poll_once(log_file, "jsonl", "PASS")
|
|
31
|
+
assert verdict2 == "PASS"
|
|
32
|
+
assert not changed2
|
|
33
|
+
|
|
34
|
+
# Phase 3: append diverging losses -> verdict changes to FAIL
|
|
35
|
+
for i in range(15, 25):
|
|
36
|
+
logs.append(json.dumps({"loss": 20.0, "step": i, "lr": 1e-4}))
|
|
37
|
+
log_file.write_text("\n".join(logs))
|
|
38
|
+
|
|
39
|
+
verdict3, changed3, n3 = poll_once(log_file, "jsonl", "PASS")
|
|
40
|
+
assert verdict3 == "FAIL"
|
|
41
|
+
assert changed3
|
|
42
|
+
assert n3 == 25
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|