trainproof 0.2.0__tar.gz → 0.3.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/src/trainproof.egg-info → trainproof-0.3.0}/PKG-INFO +18 -3
- trainproof-0.2.0/PKG-INFO → trainproof-0.3.0/README.md +141 -138
- {trainproof-0.2.0 → trainproof-0.3.0}/pyproject.toml +1 -1
- {trainproof-0.2.0 → trainproof-0.3.0}/src/trainproof/cli.py +13 -0
- trainproof-0.3.0/src/trainproof/compare.py +132 -0
- {trainproof-0.2.0 → trainproof-0.3.0}/src/trainproof/epoch.py +4 -1
- {trainproof-0.2.0 → trainproof-0.3.0}/src/trainproof/rules.py +27 -0
- trainproof-0.2.0/README.md → trainproof-0.3.0/src/trainproof.egg-info/PKG-INFO +153 -126
- {trainproof-0.2.0 → trainproof-0.3.0}/src/trainproof.egg-info/SOURCES.txt +2 -0
- trainproof-0.3.0/tests/test_compare.py +47 -0
- {trainproof-0.2.0 → trainproof-0.3.0}/tests/test_epoch.py +8 -0
- {trainproof-0.2.0 → trainproof-0.3.0}/setup.cfg +0 -0
- {trainproof-0.2.0 → trainproof-0.3.0}/src/trainproof/__init__.py +0 -0
- {trainproof-0.2.0 → trainproof-0.3.0}/src/trainproof/adapters.py +0 -0
- {trainproof-0.2.0 → trainproof-0.3.0}/src/trainproof/report.py +0 -0
- {trainproof-0.2.0 → trainproof-0.3.0}/src/trainproof/speech/__init__.py +0 -0
- {trainproof-0.2.0 → trainproof-0.3.0}/src/trainproof/speech/data.py +0 -0
- {trainproof-0.2.0 → trainproof-0.3.0}/src/trainproof/speech/tokenizer.py +0 -0
- {trainproof-0.2.0 → trainproof-0.3.0}/src/trainproof.egg-info/dependency_links.txt +0 -0
- {trainproof-0.2.0 → trainproof-0.3.0}/src/trainproof.egg-info/entry_points.txt +0 -0
- {trainproof-0.2.0 → trainproof-0.3.0}/src/trainproof.egg-info/requires.txt +0 -0
- {trainproof-0.2.0 → trainproof-0.3.0}/src/trainproof.egg-info/top_level.txt +0 -0
- {trainproof-0.2.0 → trainproof-0.3.0}/tests/test_adapters.py +0 -0
- {trainproof-0.2.0 → trainproof-0.3.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.3.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
|
|
|
@@ -99,6 +98,22 @@ trainproof tokenizer my_tokenizer.model transcripts.txt
|
|
|
99
98
|
# 3. Training-run verdict: NaN/divergence/dead-run detection, gradient spikes,
|
|
100
99
|
# LR sanity, throughput — from log files, any framework
|
|
101
100
|
trainproof epoch logs/run.jsonl # exit code 1 on FAIL: CI-ready
|
|
101
|
+
|
|
102
|
+
# 4. Compare against a baseline
|
|
103
|
+
# Catch relative pathologies like the `bad_labels` run that evade single-run rules.
|
|
104
|
+
trainproof compare examples/gallery/bad_labels/trainer_state.json examples/gallery/healthy/trainer_state.json
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
========================================
|
|
109
|
+
TRAINPROOF VERDICT
|
|
110
|
+
========================================
|
|
111
|
+
[FAIL] Critical checks failed:
|
|
112
|
+
[FAIL] loss floor ratio exceeded limit
|
|
113
|
+
Evidence: Run floor 5.592 vs Baseline floor 0.937 (ratio 6.0x > 2.0)
|
|
114
|
+
[FAIL] end loss ratio exceeded limit
|
|
115
|
+
Evidence: Run end 5.750 vs Baseline end 1.082 (ratio 5.3x > 2.0)
|
|
116
|
+
========================================
|
|
102
117
|
```
|
|
103
118
|
|
|
104
119
|
Each command prints the verdict, writes a self-contained HTML report, and sets
|
|
@@ -1,138 +1,141 @@
|
|
|
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
|
-
The
|
|
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
|
-
|
|
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 sets the roadmap: v0.3 is `trainproof compare <run> <baseline>` —
|
|
71
|
+
deterministic ratio rules against the healthy baseline you already have.
|
|
72
|
+
See [ROADMAP.md](ROADMAP.md). (The gallery also improved v0.2 itself: the
|
|
73
|
+
dead-run rule exists because `lr_zero` initially escaped with only a WARN.)
|
|
74
|
+
|
|
75
|
+
## The three commands
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# 1. Dataset preflight (speech/TTS pack): audio integrity, transcript quality,
|
|
79
|
+
# duplicates, text-vs-audio duration mismatches
|
|
80
|
+
trainproof data /path/to/dataset_or_manifest.jsonl
|
|
81
|
+
|
|
82
|
+
# 2. Tokenizer preflight: vocabulary coverage, OOV rate, sequence blowouts,
|
|
83
|
+
# suspicious splits on numbers/dates
|
|
84
|
+
trainproof tokenizer my_tokenizer.model transcripts.txt
|
|
85
|
+
|
|
86
|
+
# 3. Training-run verdict: NaN/divergence/dead-run detection, gradient spikes,
|
|
87
|
+
# LR sanity, throughput — from log files, any framework
|
|
88
|
+
trainproof epoch logs/run.jsonl # exit code 1 on FAIL: CI-ready
|
|
89
|
+
|
|
90
|
+
# 4. Compare against a baseline
|
|
91
|
+
# Catch relative pathologies like the `bad_labels` run that evade single-run rules.
|
|
92
|
+
trainproof compare examples/gallery/bad_labels/trainer_state.json examples/gallery/healthy/trainer_state.json
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
========================================
|
|
97
|
+
TRAINPROOF VERDICT
|
|
98
|
+
========================================
|
|
99
|
+
[FAIL] Critical checks failed:
|
|
100
|
+
[FAIL] loss floor ratio exceeded limit
|
|
101
|
+
Evidence: Run floor 5.592 vs Baseline floor 0.937 (ratio 6.0x > 2.0)
|
|
102
|
+
[FAIL] end loss ratio exceeded limit
|
|
103
|
+
Evidence: Run end 5.750 vs Baseline end 1.082 (ratio 5.3x > 2.0)
|
|
104
|
+
========================================
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Each command prints the verdict, writes a self-contained HTML report, and sets
|
|
108
|
+
the process exit code — so it works as a CI gate out of the box.
|
|
109
|
+
|
|
110
|
+
## Supported log formats
|
|
111
|
+
|
|
112
|
+
- HuggingFace Trainer (`trainer_state.json`)
|
|
113
|
+
- Coqui Trainer text logs (ANSI-colored `trainer_0_log.txt`)
|
|
114
|
+
- Generic JSONL / CSV (columns: step, loss, lr, grad_norm, time — all optional)
|
|
115
|
+
|
|
116
|
+
Auto-detected; override with `--format hf|coqui|jsonl|csv`. TensorBoard event
|
|
117
|
+
files are planned (Lightning console captures are TTY dumps, not logs, and
|
|
118
|
+
will not be supported).
|
|
119
|
+
|
|
120
|
+
## Philosophy
|
|
121
|
+
|
|
122
|
+
1. **Deterministic.** A rule fires or it doesn't. Thresholds live in one
|
|
123
|
+
module, commented, tunable.
|
|
124
|
+
2. **Evidence-cited.** Every finding names the steps and values that triggered
|
|
125
|
+
it.
|
|
126
|
+
3. **Honest about limits.** What the tool cannot detect is documented in the
|
|
127
|
+
README, not discovered by the user in production.
|
|
128
|
+
|
|
129
|
+
## Family
|
|
130
|
+
|
|
131
|
+
trainproof judges training runs. Its sibling [ttsproof](https://github.com/Mormolykos/ttsproof)
|
|
132
|
+
judges TTS model *outputs* (structural audio checks, equivalence-aware WER/CER,
|
|
133
|
+
published method with DOI) — and trainproof builds on it for the speech
|
|
134
|
+
dataset checks.
|
|
135
|
+
|
|
136
|
+
## Author
|
|
137
|
+
|
|
138
|
+
Panagiotis (Panos) Gkilis — [portfolio](https://tts.bedvibe.studio/portfolio/) ·
|
|
139
|
+
[bedvibe.studio](https://bedvibe.studio/)
|
|
140
|
+
|
|
141
|
+
MIT license.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "trainproof"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.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,7 @@ 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 .compare import check_compare
|
|
7
8
|
from .report import print_verdict_console, write_html_report
|
|
8
9
|
|
|
9
10
|
def main():
|
|
@@ -24,6 +25,12 @@ def main():
|
|
|
24
25
|
epoch_parser.add_argument("logfile", type=str, help="Path to JSONL or CSV log file")
|
|
25
26
|
epoch_parser.add_argument("--format", choices=["auto", "hf", "coqui", "jsonl", "csv"], default="auto", help="Log format override")
|
|
26
27
|
|
|
28
|
+
# Compare Command
|
|
29
|
+
compare_parser = subparsers.add_parser("compare", help="Compare a run log against a baseline log.")
|
|
30
|
+
compare_parser.add_argument("run", type=str, help="Path to run log file")
|
|
31
|
+
compare_parser.add_argument("baseline", type=str, help="Path to baseline log file")
|
|
32
|
+
compare_parser.add_argument("--format", choices=["auto", "hf", "coqui", "jsonl", "csv"], default="auto", help="Log format override")
|
|
33
|
+
|
|
27
34
|
args = parser.parse_args()
|
|
28
35
|
|
|
29
36
|
report_dict = {}
|
|
@@ -38,6 +45,12 @@ def main():
|
|
|
38
45
|
except Exception as e:
|
|
39
46
|
print(f"Error checking epoch: {e}")
|
|
40
47
|
sys.exit(1)
|
|
48
|
+
elif args.command == "compare":
|
|
49
|
+
try:
|
|
50
|
+
report_dict = check_compare(args.run, args.baseline, fmt=args.format)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
print(f"Error checking compare: {e}")
|
|
53
|
+
sys.exit(1)
|
|
41
54
|
|
|
42
55
|
print_verdict_console(report_dict.get("verdict", "FAIL"), report_dict.get("findings", []))
|
|
43
56
|
|
|
@@ -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}
|
|
@@ -92,7 +92,10 @@ def check_epoch(log_path: str | Path, fmt: str = "auto") -> dict[str, Any]:
|
|
|
92
92
|
if lrs:
|
|
93
93
|
zeros = sum(1 for lr in lrs if lr <= 0)
|
|
94
94
|
zero_frac = zeros / len(lrs)
|
|
95
|
-
if zero_frac
|
|
95
|
+
if zero_frac >= rules.ZERO_LR_FAIL_FRACTION:
|
|
96
|
+
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"})
|
|
97
|
+
verdict = "FAIL"
|
|
98
|
+
elif zero_frac > rules.MAX_ZERO_LR_FRACTION:
|
|
96
99
|
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
100
|
if verdict == "PASS": verdict = "WARN"
|
|
98
101
|
|
|
@@ -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
|
|
@@ -1,126 +1,153 @@
|
|
|
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
|
-
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trainproof
|
|
3
|
+
Version: 0.3.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 sets the roadmap: v0.3 is `trainproof compare <run> <baseline>` —
|
|
83
|
+
deterministic ratio rules against the healthy baseline you already have.
|
|
84
|
+
See [ROADMAP.md](ROADMAP.md). (The gallery also improved v0.2 itself: the
|
|
85
|
+
dead-run rule exists because `lr_zero` initially escaped with only a WARN.)
|
|
86
|
+
|
|
87
|
+
## The three commands
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# 1. Dataset preflight (speech/TTS pack): audio integrity, transcript quality,
|
|
91
|
+
# duplicates, text-vs-audio duration mismatches
|
|
92
|
+
trainproof data /path/to/dataset_or_manifest.jsonl
|
|
93
|
+
|
|
94
|
+
# 2. Tokenizer preflight: vocabulary coverage, OOV rate, sequence blowouts,
|
|
95
|
+
# suspicious splits on numbers/dates
|
|
96
|
+
trainproof tokenizer my_tokenizer.model transcripts.txt
|
|
97
|
+
|
|
98
|
+
# 3. Training-run verdict: NaN/divergence/dead-run detection, gradient spikes,
|
|
99
|
+
# LR sanity, throughput — from log files, any framework
|
|
100
|
+
trainproof epoch logs/run.jsonl # exit code 1 on FAIL: CI-ready
|
|
101
|
+
|
|
102
|
+
# 4. Compare against a baseline
|
|
103
|
+
# Catch relative pathologies like the `bad_labels` run that evade single-run rules.
|
|
104
|
+
trainproof compare examples/gallery/bad_labels/trainer_state.json examples/gallery/healthy/trainer_state.json
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
========================================
|
|
109
|
+
TRAINPROOF VERDICT
|
|
110
|
+
========================================
|
|
111
|
+
[FAIL] Critical checks failed:
|
|
112
|
+
[FAIL] loss floor ratio exceeded limit
|
|
113
|
+
Evidence: Run floor 5.592 vs Baseline floor 0.937 (ratio 6.0x > 2.0)
|
|
114
|
+
[FAIL] end loss ratio exceeded limit
|
|
115
|
+
Evidence: Run end 5.750 vs Baseline end 1.082 (ratio 5.3x > 2.0)
|
|
116
|
+
========================================
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Each command prints the verdict, writes a self-contained HTML report, and sets
|
|
120
|
+
the process exit code — so it works as a CI gate out of the box.
|
|
121
|
+
|
|
122
|
+
## Supported log formats
|
|
123
|
+
|
|
124
|
+
- HuggingFace Trainer (`trainer_state.json`)
|
|
125
|
+
- Coqui Trainer text logs (ANSI-colored `trainer_0_log.txt`)
|
|
126
|
+
- Generic JSONL / CSV (columns: step, loss, lr, grad_norm, time — all optional)
|
|
127
|
+
|
|
128
|
+
Auto-detected; override with `--format hf|coqui|jsonl|csv`. TensorBoard event
|
|
129
|
+
files are planned (Lightning console captures are TTY dumps, not logs, and
|
|
130
|
+
will not be supported).
|
|
131
|
+
|
|
132
|
+
## Philosophy
|
|
133
|
+
|
|
134
|
+
1. **Deterministic.** A rule fires or it doesn't. Thresholds live in one
|
|
135
|
+
module, commented, tunable.
|
|
136
|
+
2. **Evidence-cited.** Every finding names the steps and values that triggered
|
|
137
|
+
it.
|
|
138
|
+
3. **Honest about limits.** What the tool cannot detect is documented in the
|
|
139
|
+
README, not discovered by the user in production.
|
|
140
|
+
|
|
141
|
+
## Family
|
|
142
|
+
|
|
143
|
+
trainproof judges training runs. Its sibling [ttsproof](https://github.com/Mormolykos/ttsproof)
|
|
144
|
+
judges TTS model *outputs* (structural audio checks, equivalence-aware WER/CER,
|
|
145
|
+
published method with DOI) — and trainproof builds on it for the speech
|
|
146
|
+
dataset checks.
|
|
147
|
+
|
|
148
|
+
## Author
|
|
149
|
+
|
|
150
|
+
Panagiotis (Panos) Gkilis — [portfolio](https://tts.bedvibe.studio/portfolio/) ·
|
|
151
|
+
[bedvibe.studio](https://bedvibe.studio/)
|
|
152
|
+
|
|
153
|
+
MIT license.
|
|
@@ -3,6 +3,7 @@ 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
|
|
@@ -16,5 +17,6 @@ src/trainproof/speech/__init__.py
|
|
|
16
17
|
src/trainproof/speech/data.py
|
|
17
18
|
src/trainproof/speech/tokenizer.py
|
|
18
19
|
tests/test_adapters.py
|
|
20
|
+
tests/test_compare.py
|
|
19
21
|
tests/test_data.py
|
|
20
22
|
tests/test_epoch.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"])
|
|
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
|