trainproof 0.2.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.2.0 → trainproof-0.4.0}/PKG-INFO +84 -8
- {trainproof-0.2.0 → trainproof-0.4.0}/README.md +83 -7
- {trainproof-0.2.0 → trainproof-0.4.0}/pyproject.toml +1 -1
- {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/cli.py +25 -0
- trainproof-0.4.0/src/trainproof/compare.py +132 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/epoch.py +12 -4
- trainproof-0.4.0/src/trainproof/integrations/__init__.py +3 -0
- trainproof-0.4.0/src/trainproof/integrations/hf.py +89 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/rules.py +27 -0
- trainproof-0.4.0/src/trainproof/watch.py +57 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof.egg-info/PKG-INFO +84 -8
- {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof.egg-info/SOURCES.txt +8 -1
- trainproof-0.4.0/tests/test_compare.py +47 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/tests/test_epoch.py +8 -0
- trainproof-0.4.0/tests/test_hf_callback.py +66 -0
- trainproof-0.4.0/tests/test_watch.py +42 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/setup.cfg +0 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/__init__.py +0 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/adapters.py +0 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/report.py +0 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/speech/__init__.py +0 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/speech/data.py +0 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof/speech/tokenizer.py +0 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof.egg-info/dependency_links.txt +0 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof.egg-info/entry_points.txt +0 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof.egg-info/requires.txt +0 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/src/trainproof.egg-info/top_level.txt +0 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/tests/test_adapters.py +0 -0
- {trainproof-0.2.0 → trainproof-0.4.0}/tests/test_data.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
|
|
@@ -41,7 +41,6 @@ trainproof epoch examples/gallery/lr_hot/trainer_state.json --format hf
|
|
|
41
41
|
[FAIL] Critical checks failed:
|
|
42
42
|
[FAIL] Loss curve is diverging.
|
|
43
43
|
Evidence: End loss 7.492 vs Min loss 1.398
|
|
44
|
-
[FAIL] Loss never improved over the run (dead run).
|
|
45
44
|
[WARN] Gradient norm spikes detected.
|
|
46
45
|
Evidence: Max gn 2649.75 > 10.0x median (0.55)
|
|
47
46
|
```
|
|
@@ -68,7 +67,7 @@ shipped in [`examples/gallery/`](examples/gallery/):
|
|
|
68
67
|
| `lr_hot` | LR x100 (2e-2) | **FAIL** | diverging: end 7.49 vs min 1.40; grad spike 2650 vs median 0.55 |
|
|
69
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 |
|
|
70
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) |
|
|
71
|
-
| `bad_labels` | labels shuffled per-sequence | **WARN only** —
|
|
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 |
|
|
72
71
|
|
|
73
72
|
### The honest finding: loss curves cannot see corrupted data
|
|
74
73
|
|
|
@@ -80,12 +79,18 @@ famously fit random labels). **No single-run, loss-only rule can catch this
|
|
|
80
79
|
class of failure** — its real signature is *relative*: a loss floor ~6x higher
|
|
81
80
|
than a known-good run of the same task (5.59 vs 0.94).
|
|
82
81
|
|
|
83
|
-
That finding
|
|
84
|
-
deterministic ratio rules against the healthy baseline you already have
|
|
85
|
-
|
|
86
|
-
|
|
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).
|
|
87
92
|
|
|
88
|
-
## The
|
|
93
|
+
## The commands
|
|
89
94
|
|
|
90
95
|
```bash
|
|
91
96
|
# 1. Dataset preflight (speech/TTS pack): audio integrity, transcript quality,
|
|
@@ -99,11 +104,82 @@ trainproof tokenizer my_tokenizer.model transcripts.txt
|
|
|
99
104
|
# 3. Training-run verdict: NaN/divergence/dead-run detection, gradient spikes,
|
|
100
105
|
# LR sanity, throughput — from log files, any framework
|
|
101
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
|
+
|
|
102
125
|
```
|
|
103
126
|
|
|
104
127
|
Each command prints the verdict, writes a self-contained HTML report, and sets
|
|
105
128
|
the process exit code — so it works as a CI gate out of the box.
|
|
106
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
|
+
|
|
107
183
|
## Supported log formats
|
|
108
184
|
|
|
109
185
|
- HuggingFace Trainer (`trainer_state.json`)
|
|
@@ -29,7 +29,6 @@ trainproof epoch examples/gallery/lr_hot/trainer_state.json --format hf
|
|
|
29
29
|
[FAIL] Critical checks failed:
|
|
30
30
|
[FAIL] Loss curve is diverging.
|
|
31
31
|
Evidence: End loss 7.492 vs Min loss 1.398
|
|
32
|
-
[FAIL] Loss never improved over the run (dead run).
|
|
33
32
|
[WARN] Gradient norm spikes detected.
|
|
34
33
|
Evidence: Max gn 2649.75 > 10.0x median (0.55)
|
|
35
34
|
```
|
|
@@ -56,7 +55,7 @@ shipped in [`examples/gallery/`](examples/gallery/):
|
|
|
56
55
|
| `lr_hot` | LR x100 (2e-2) | **FAIL** | diverging: end 7.49 vs min 1.40; grad spike 2650 vs median 0.55 |
|
|
57
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 |
|
|
58
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) |
|
|
59
|
-
| `bad_labels` | labels shuffled per-sequence | **WARN only** —
|
|
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 |
|
|
60
59
|
|
|
61
60
|
### The honest finding: loss curves cannot see corrupted data
|
|
62
61
|
|
|
@@ -68,12 +67,18 @@ famously fit random labels). **No single-run, loss-only rule can catch this
|
|
|
68
67
|
class of failure** — its real signature is *relative*: a loss floor ~6x higher
|
|
69
68
|
than a known-good run of the same task (5.59 vs 0.94).
|
|
70
69
|
|
|
71
|
-
That finding
|
|
72
|
-
deterministic ratio rules against the healthy baseline you already have
|
|
73
|
-
|
|
74
|
-
|
|
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).
|
|
75
80
|
|
|
76
|
-
## The
|
|
81
|
+
## The commands
|
|
77
82
|
|
|
78
83
|
```bash
|
|
79
84
|
# 1. Dataset preflight (speech/TTS pack): audio integrity, transcript quality,
|
|
@@ -87,11 +92,82 @@ trainproof tokenizer my_tokenizer.model transcripts.txt
|
|
|
87
92
|
# 3. Training-run verdict: NaN/divergence/dead-run detection, gradient spikes,
|
|
88
93
|
# LR sanity, throughput — from log files, any framework
|
|
89
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
|
+
|
|
90
113
|
```
|
|
91
114
|
|
|
92
115
|
Each command prints the verdict, writes a self-contained HTML report, and sets
|
|
93
116
|
the process exit code — so it works as a CI gate out of the box.
|
|
94
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
|
+
|
|
95
171
|
## Supported log formats
|
|
96
172
|
|
|
97
173
|
- HuggingFace Trainer (`trainer_state.json`)
|
|
@@ -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,6 +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
|
|
8
|
+
from .compare import check_compare
|
|
9
|
+
from .watch import watch_loop
|
|
7
10
|
from .report import print_verdict_console, write_html_report
|
|
8
11
|
|
|
9
12
|
def main():
|
|
@@ -24,6 +27,19 @@ def main():
|
|
|
24
27
|
epoch_parser.add_argument("logfile", type=str, help="Path to JSONL or CSV log file")
|
|
25
28
|
epoch_parser.add_argument("--format", choices=["auto", "hf", "coqui", "jsonl", "csv"], default="auto", help="Log format override")
|
|
26
29
|
|
|
30
|
+
# Compare Command
|
|
31
|
+
compare_parser = subparsers.add_parser("compare", help="Compare a run log against a baseline log.")
|
|
32
|
+
compare_parser.add_argument("run", type=str, help="Path to run log file")
|
|
33
|
+
compare_parser.add_argument("baseline", type=str, help="Path to baseline log file")
|
|
34
|
+
compare_parser.add_argument("--format", choices=["auto", "hf", "coqui", "jsonl", "csv"], default="auto", help="Log format override")
|
|
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
|
+
|
|
27
43
|
args = parser.parse_args()
|
|
28
44
|
|
|
29
45
|
report_dict = {}
|
|
@@ -38,6 +54,15 @@ def main():
|
|
|
38
54
|
except Exception as e:
|
|
39
55
|
print(f"Error checking epoch: {e}")
|
|
40
56
|
sys.exit(1)
|
|
57
|
+
elif args.command == "compare":
|
|
58
|
+
try:
|
|
59
|
+
report_dict = check_compare(args.run, args.baseline, fmt=args.format)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
print(f"Error checking compare: {e}")
|
|
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)
|
|
41
66
|
|
|
42
67
|
print_verdict_console(report_dict.get("verdict", "FAIL"), report_dict.get("findings", []))
|
|
43
68
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
from . import rules
|
|
5
|
+
from .adapters import parse_log_with_format
|
|
6
|
+
from .epoch import check_epoch
|
|
7
|
+
|
|
8
|
+
def _get_val(row, aliases):
|
|
9
|
+
for a in aliases:
|
|
10
|
+
for k in row:
|
|
11
|
+
if a in k: return row[k]
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
def extract_metrics(records):
|
|
15
|
+
losses = []
|
|
16
|
+
grad_norms = []
|
|
17
|
+
|
|
18
|
+
for r in records:
|
|
19
|
+
loss = _get_val(r, ["loss", "train_loss"])
|
|
20
|
+
gn = _get_val(r, ["grad_norm", "gnorm", "grad"])
|
|
21
|
+
|
|
22
|
+
if loss is not None and not math.isnan(loss) and not math.isinf(loss):
|
|
23
|
+
losses.append(loss)
|
|
24
|
+
if gn is not None and not math.isnan(gn) and not math.isinf(gn):
|
|
25
|
+
grad_norms.append(gn)
|
|
26
|
+
|
|
27
|
+
if len(losses) < 10:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
floor = min(losses)
|
|
31
|
+
|
|
32
|
+
w = rules.LOSS_IMPROVEMENT_WINDOW
|
|
33
|
+
start_med = sorted(losses[:w])[w // 2]
|
|
34
|
+
end_med = sorted(losses[-w:])[w // 2]
|
|
35
|
+
|
|
36
|
+
improvement = 1.0 - (end_med / start_med) if start_med > 0 else 0.0
|
|
37
|
+
|
|
38
|
+
gn_median = None
|
|
39
|
+
if len(grad_norms) > 5:
|
|
40
|
+
sorted_gns = sorted(grad_norms)
|
|
41
|
+
gn_median = sorted_gns[len(sorted_gns) // 2]
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"floor": floor,
|
|
45
|
+
"start_med": start_med,
|
|
46
|
+
"end_med": end_med,
|
|
47
|
+
"improvement": improvement,
|
|
48
|
+
"gn_median": gn_median,
|
|
49
|
+
"losses_len": len(losses)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def check_compare(run_path: str | Path, base_path: str | Path, fmt: str = "auto") -> dict[str, Any]:
|
|
53
|
+
findings = []
|
|
54
|
+
verdict = "PASS"
|
|
55
|
+
|
|
56
|
+
# Baseline sanity — a suspect baseline downgrades the whole comparison
|
|
57
|
+
base_epoch_res = check_epoch(base_path, fmt=fmt)
|
|
58
|
+
if base_epoch_res["verdict"] == "FAIL":
|
|
59
|
+
verdict = "WARN"
|
|
60
|
+
findings.append({
|
|
61
|
+
"level": "WARN",
|
|
62
|
+
"message": "baseline itself fails single-run checks - comparison may be meaningless",
|
|
63
|
+
"evidence": str(base_path)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
run_records = parse_log_with_format(run_path, fmt)
|
|
67
|
+
base_records = parse_log_with_format(base_path, fmt)
|
|
68
|
+
|
|
69
|
+
run_metrics = extract_metrics(run_records) if run_records else None
|
|
70
|
+
base_metrics = extract_metrics(base_records) if base_records else None
|
|
71
|
+
|
|
72
|
+
if not run_metrics:
|
|
73
|
+
return {"verdict": "FAIL", "findings": [{"level": "FAIL", "message": "Run log has fewer than 10 valid loss points.", "evidence": str(run_path)}]}
|
|
74
|
+
if not base_metrics:
|
|
75
|
+
return {"verdict": "FAIL", "findings": [{"level": "FAIL", "message": "Baseline log has fewer than 10 valid loss points.", "evidence": str(base_path)}]}
|
|
76
|
+
|
|
77
|
+
# Floor ratio
|
|
78
|
+
if run_metrics["floor"] > base_metrics["floor"] * rules.MAX_FLOOR_RATIO:
|
|
79
|
+
verdict = "FAIL"
|
|
80
|
+
findings.append({
|
|
81
|
+
"level": "FAIL",
|
|
82
|
+
"message": "loss floor ratio exceeded limit",
|
|
83
|
+
"evidence": f"Run floor {run_metrics['floor']:.3f} vs Baseline floor {base_metrics['floor']:.3f} (ratio {(run_metrics['floor'] / base_metrics['floor']):.1f}x > {rules.MAX_FLOOR_RATIO})"
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
# End-loss ratio: where the run landed vs where the baseline landed.
|
|
87
|
+
# Robust to corrupted starts (see rules.MAX_END_RATIO comment).
|
|
88
|
+
if base_metrics["end_med"] > 0 and run_metrics["end_med"] > base_metrics["end_med"] * rules.MAX_END_RATIO:
|
|
89
|
+
verdict = "FAIL"
|
|
90
|
+
findings.append({
|
|
91
|
+
"level": "FAIL",
|
|
92
|
+
"message": "end loss ratio exceeded limit",
|
|
93
|
+
"evidence": f"Run end {run_metrics['end_med']:.3f} vs Baseline end {base_metrics['end_med']:.3f} (ratio {(run_metrics['end_med'] / base_metrics['end_med']):.1f}x > {rules.MAX_END_RATIO})"
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
# Improvement deficit
|
|
97
|
+
run_imp = run_metrics["improvement"]
|
|
98
|
+
base_imp = base_metrics["improvement"]
|
|
99
|
+
|
|
100
|
+
if run_imp < 0:
|
|
101
|
+
verdict = "FAIL"
|
|
102
|
+
findings.append({
|
|
103
|
+
"level": "FAIL",
|
|
104
|
+
"message": "negative improvement",
|
|
105
|
+
"evidence": f"Run improvement is {run_imp * 100:.1f}% vs Baseline {base_imp * 100:.1f}%"
|
|
106
|
+
})
|
|
107
|
+
elif base_imp > 0 and run_imp < base_imp * rules.MIN_IMPROVEMENT_FRACTION:
|
|
108
|
+
verdict = "FAIL"
|
|
109
|
+
findings.append({
|
|
110
|
+
"level": "FAIL",
|
|
111
|
+
"message": "improvement deficit",
|
|
112
|
+
"evidence": f"Run improvement {run_imp * 100:.1f}% vs Baseline {base_imp * 100:.1f}% (ratio {(run_imp / base_imp):.2f}x < {rules.MIN_IMPROVEMENT_FRACTION})"
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
# Gradnorm median ratio
|
|
116
|
+
if run_metrics["gn_median"] is not None and base_metrics["gn_median"] is not None and base_metrics["gn_median"] > 0:
|
|
117
|
+
if run_metrics["gn_median"] > base_metrics["gn_median"] * rules.MAX_GRADNORM_MEDIAN_RATIO:
|
|
118
|
+
if verdict == "PASS": verdict = "WARN"
|
|
119
|
+
findings.append({
|
|
120
|
+
"level": "WARN",
|
|
121
|
+
"message": "gradient norm median significantly higher than baseline",
|
|
122
|
+
"evidence": f"Run gn median {run_metrics['gn_median']:.2f} vs Baseline gn median {base_metrics['gn_median']:.2f} (ratio {(run_metrics['gn_median'] / base_metrics['gn_median']):.1f}x > {rules.MAX_GRADNORM_MEDIAN_RATIO})"
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
if verdict == "PASS" and not any(f["level"] == "WARN" for f in findings):
|
|
126
|
+
findings.append({
|
|
127
|
+
"level": "PASS",
|
|
128
|
+
"message": "Run compares favorably to baseline.",
|
|
129
|
+
"evidence": f"Floor ratio {(run_metrics['floor'] / base_metrics['floor']):.2f}x, Improvement {run_imp*100:.1f}% vs {base_imp*100:.1f}%"
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
return {"verdict": verdict, "findings": findings}
|
|
@@ -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):
|
|
@@ -92,7 +91,10 @@ def check_epoch(log_path: str | Path, fmt: str = "auto") -> dict[str, Any]:
|
|
|
92
91
|
if lrs:
|
|
93
92
|
zeros = sum(1 for lr in lrs if lr <= 0)
|
|
94
93
|
zero_frac = zeros / len(lrs)
|
|
95
|
-
if zero_frac
|
|
94
|
+
if zero_frac >= rules.ZERO_LR_FAIL_FRACTION:
|
|
95
|
+
findings.append({"level": "FAIL", "message": "Learning rate is zero for the entire run - the optimizer never steps.", "evidence": f"{zero_frac*100:.1f}% of steps have lr=0"})
|
|
96
|
+
verdict = "FAIL"
|
|
97
|
+
elif zero_frac > rules.MAX_ZERO_LR_FRACTION:
|
|
96
98
|
findings.append({"level": "WARN", "message": "Learning rate is zero for a large fraction of the run.", "evidence": f"{zero_frac*100:.1f}% of steps have lr=0"})
|
|
97
99
|
if verdict == "PASS": verdict = "WARN"
|
|
98
100
|
|
|
@@ -109,3 +111,9 @@ def check_epoch(log_path: str | Path, fmt: str = "auto") -> dict[str, Any]:
|
|
|
109
111
|
findings.append({"level": "PASS", "message": "Loss curve shows healthy shape, grad norms are stable.", "evidence": f"{len(valid_losses)} steps analyzed."})
|
|
110
112
|
|
|
111
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
|
|
@@ -52,6 +52,12 @@ MAX_GRAD_NORM_SPIKE_RATIO = 10.0
|
|
|
52
52
|
# If the learning rate is strictly zero for more than this fraction of the epoch, it's a warning.
|
|
53
53
|
MAX_ZERO_LR_FRACTION = 0.1
|
|
54
54
|
|
|
55
|
+
# If the learning rate is zero for essentially the ENTIRE run, the optimizer
|
|
56
|
+
# never steps — the run cannot train, regardless of how the loss wiggles
|
|
57
|
+
# (multi-seed testing showed batch-order noise can fake >5% "improvement"
|
|
58
|
+
# under lr=0). Total zero-LR is a fatality, not a warning.
|
|
59
|
+
ZERO_LR_FAIL_FRACTION = 0.99
|
|
60
|
+
|
|
55
61
|
# Dead-run detection (added in v0.2 after fault-injection testing showed that
|
|
56
62
|
# zero-LR and garbage-label runs — which can never learn — only earned WARNs):
|
|
57
63
|
# the median of the last LOSS_IMPROVEMENT_WINDOW losses must be at least
|
|
@@ -61,3 +67,24 @@ MAX_ZERO_LR_FRACTION = 0.1
|
|
|
61
67
|
MIN_LOSS_IMPROVEMENT = 0.05
|
|
62
68
|
LOSS_IMPROVEMENT_WINDOW = 5
|
|
63
69
|
MIN_POINTS_FOR_IMPROVEMENT_CHECK = 10
|
|
70
|
+
|
|
71
|
+
# -----------------
|
|
72
|
+
# COMPARE SUBCOMMAND
|
|
73
|
+
# -----------------
|
|
74
|
+
|
|
75
|
+
# FAIL "loss floor ratio" if run_floor / baseline_floor > 2.0
|
|
76
|
+
MAX_FLOOR_RATIO = 2.0
|
|
77
|
+
|
|
78
|
+
# FAIL "end loss ratio" if the run's final loss (median of last window) lands
|
|
79
|
+
# more than this factor above the baseline's. Judges where the run LANDED —
|
|
80
|
+
# robust both to noisy first steps and to corruption inside the first window
|
|
81
|
+
# (e.g. an LR explosion at step 10 inflates the run's own start median, making
|
|
82
|
+
# self-relative improvement look positive; the end ratio still catches it).
|
|
83
|
+
MAX_END_RATIO = 2.0
|
|
84
|
+
|
|
85
|
+
# FAIL "improvement deficit" if the run's relative improvement is < 25% of the baseline's
|
|
86
|
+
# (only evaluated when baseline improvement > 0). A run with NEGATIVE improvement always trips this rule.
|
|
87
|
+
MIN_IMPROVEMENT_FRACTION = 0.25
|
|
88
|
+
|
|
89
|
+
# WARN if run's grad-norm median > 5x baseline's (skip if either lacks grad norms)
|
|
90
|
+
MAX_GRADNORM_MEDIAN_RATIO = 5.0
|
|
@@ -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,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
|
|
@@ -41,7 +41,6 @@ trainproof epoch examples/gallery/lr_hot/trainer_state.json --format hf
|
|
|
41
41
|
[FAIL] Critical checks failed:
|
|
42
42
|
[FAIL] Loss curve is diverging.
|
|
43
43
|
Evidence: End loss 7.492 vs Min loss 1.398
|
|
44
|
-
[FAIL] Loss never improved over the run (dead run).
|
|
45
44
|
[WARN] Gradient norm spikes detected.
|
|
46
45
|
Evidence: Max gn 2649.75 > 10.0x median (0.55)
|
|
47
46
|
```
|
|
@@ -68,7 +67,7 @@ shipped in [`examples/gallery/`](examples/gallery/):
|
|
|
68
67
|
| `lr_hot` | LR x100 (2e-2) | **FAIL** | diverging: end 7.49 vs min 1.40; grad spike 2650 vs median 0.55 |
|
|
69
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 |
|
|
70
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) |
|
|
71
|
-
| `bad_labels` | labels shuffled per-sequence | **WARN only** —
|
|
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 |
|
|
72
71
|
|
|
73
72
|
### The honest finding: loss curves cannot see corrupted data
|
|
74
73
|
|
|
@@ -80,12 +79,18 @@ famously fit random labels). **No single-run, loss-only rule can catch this
|
|
|
80
79
|
class of failure** — its real signature is *relative*: a loss floor ~6x higher
|
|
81
80
|
than a known-good run of the same task (5.59 vs 0.94).
|
|
82
81
|
|
|
83
|
-
That finding
|
|
84
|
-
deterministic ratio rules against the healthy baseline you already have
|
|
85
|
-
|
|
86
|
-
|
|
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).
|
|
87
92
|
|
|
88
|
-
## The
|
|
93
|
+
## The commands
|
|
89
94
|
|
|
90
95
|
```bash
|
|
91
96
|
# 1. Dataset preflight (speech/TTS pack): audio integrity, transcript quality,
|
|
@@ -99,11 +104,82 @@ trainproof tokenizer my_tokenizer.model transcripts.txt
|
|
|
99
104
|
# 3. Training-run verdict: NaN/divergence/dead-run detection, gradient spikes,
|
|
100
105
|
# LR sanity, throughput — from log files, any framework
|
|
101
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
|
+
|
|
102
125
|
```
|
|
103
126
|
|
|
104
127
|
Each command prints the verdict, writes a self-contained HTML report, and sets
|
|
105
128
|
the process exit code — so it works as a CI gate out of the box.
|
|
106
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
|
+
|
|
107
183
|
## Supported log formats
|
|
108
184
|
|
|
109
185
|
- HuggingFace Trainer (`trainer_state.json`)
|
|
@@ -3,18 +3,25 @@ pyproject.toml
|
|
|
3
3
|
src/trainproof/__init__.py
|
|
4
4
|
src/trainproof/adapters.py
|
|
5
5
|
src/trainproof/cli.py
|
|
6
|
+
src/trainproof/compare.py
|
|
6
7
|
src/trainproof/epoch.py
|
|
7
8
|
src/trainproof/report.py
|
|
8
9
|
src/trainproof/rules.py
|
|
10
|
+
src/trainproof/watch.py
|
|
9
11
|
src/trainproof.egg-info/PKG-INFO
|
|
10
12
|
src/trainproof.egg-info/SOURCES.txt
|
|
11
13
|
src/trainproof.egg-info/dependency_links.txt
|
|
12
14
|
src/trainproof.egg-info/entry_points.txt
|
|
13
15
|
src/trainproof.egg-info/requires.txt
|
|
14
16
|
src/trainproof.egg-info/top_level.txt
|
|
17
|
+
src/trainproof/integrations/__init__.py
|
|
18
|
+
src/trainproof/integrations/hf.py
|
|
15
19
|
src/trainproof/speech/__init__.py
|
|
16
20
|
src/trainproof/speech/data.py
|
|
17
21
|
src/trainproof/speech/tokenizer.py
|
|
18
22
|
tests/test_adapters.py
|
|
23
|
+
tests/test_compare.py
|
|
19
24
|
tests/test_data.py
|
|
20
|
-
tests/test_epoch.py
|
|
25
|
+
tests/test_epoch.py
|
|
26
|
+
tests/test_hf_callback.py
|
|
27
|
+
tests/test_watch.py
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from trainproof.compare import check_compare
|
|
4
|
+
|
|
5
|
+
def get_gallery_log(name: str) -> str:
|
|
6
|
+
path = Path(__file__).parent.parent / "examples" / "gallery" / name / "trainer_state.json"
|
|
7
|
+
return str(path.resolve())
|
|
8
|
+
|
|
9
|
+
def test_compare_healthy_vs_healthy():
|
|
10
|
+
healthy = get_gallery_log("healthy")
|
|
11
|
+
res = check_compare(healthy, healthy, fmt="hf")
|
|
12
|
+
assert res["verdict"] == "PASS"
|
|
13
|
+
|
|
14
|
+
def test_compare_bad_labels_vs_healthy():
|
|
15
|
+
bad_labels = get_gallery_log("bad_labels")
|
|
16
|
+
healthy = get_gallery_log("healthy")
|
|
17
|
+
res = check_compare(bad_labels, healthy, fmt="hf")
|
|
18
|
+
assert res["verdict"] == "FAIL"
|
|
19
|
+
findings = [f for f in res["findings"] if "loss floor ratio" in f["message"]]
|
|
20
|
+
assert len(findings) > 0
|
|
21
|
+
|
|
22
|
+
def test_compare_lr_zero_vs_healthy():
|
|
23
|
+
lr_zero = get_gallery_log("lr_zero")
|
|
24
|
+
healthy = get_gallery_log("healthy")
|
|
25
|
+
res = check_compare(lr_zero, healthy, fmt="hf")
|
|
26
|
+
assert res["verdict"] == "FAIL"
|
|
27
|
+
findings = [f for f in res["findings"] if "improvement deficit" in f["message"]]
|
|
28
|
+
assert len(findings) > 0
|
|
29
|
+
|
|
30
|
+
def test_compare_lr_hot_vs_healthy():
|
|
31
|
+
# lr_hot's explosion happens INSIDE the first-5 window, inflating its own
|
|
32
|
+
# start median — self-relative improvement looks positive (+62%). Only the
|
|
33
|
+
# end-loss ratio (where the run LANDED: ~7x the baseline) catches it.
|
|
34
|
+
lr_hot = get_gallery_log("lr_hot")
|
|
35
|
+
healthy = get_gallery_log("healthy")
|
|
36
|
+
res = check_compare(lr_hot, healthy, fmt="hf")
|
|
37
|
+
assert res["verdict"] == "FAIL"
|
|
38
|
+
findings = [f for f in res["findings"] if "end loss ratio" in f["message"]]
|
|
39
|
+
assert len(findings) > 0
|
|
40
|
+
|
|
41
|
+
def test_compare_fp16_nan_vs_healthy():
|
|
42
|
+
fp16_nan = get_gallery_log("fp16_nan")
|
|
43
|
+
healthy = get_gallery_log("healthy")
|
|
44
|
+
res = check_compare(fp16_nan, healthy, fmt="hf")
|
|
45
|
+
assert res["verdict"] == "FAIL"
|
|
46
|
+
findings = [f for f in res["findings"] if "negative improvement" in f["message"]]
|
|
47
|
+
assert len(findings) > 0
|
|
@@ -41,3 +41,11 @@ def test_epoch_dead_noisy():
|
|
|
41
41
|
report = check_epoch(FIXTURES_DIR / "dead_noisy.jsonl")
|
|
42
42
|
assert report["verdict"] == "FAIL"
|
|
43
43
|
assert any("never improved" in str(f) for f in report["findings"])
|
|
44
|
+
|
|
45
|
+
def test_epoch_total_zero_lr_is_fatal():
|
|
46
|
+
# lr=0 on every step: the optimizer never moves — FAIL regardless of how
|
|
47
|
+
# batch-order noise makes the loss wiggle (found by multi-seed testing)
|
|
48
|
+
gallery = Path(__file__).parent.parent / "examples" / "gallery"
|
|
49
|
+
report = check_epoch(gallery / "lr_zero" / "trainer_state.json", fmt="hf")
|
|
50
|
+
assert report["verdict"] == "FAIL"
|
|
51
|
+
assert any("optimizer never steps" in str(f) for f in report["findings"])
|
|
@@ -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
|