trainlint 0.1.0__py3-none-any.whl
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.
- trainlint/__init__.py +3 -0
- trainlint/__main__.py +3 -0
- trainlint/astutil.py +122 -0
- trainlint/cli.py +118 -0
- trainlint/engine.py +132 -0
- trainlint/report.py +72 -0
- trainlint/rules/__init__.py +19 -0
- trainlint/rules/base.py +21 -0
- trainlint/rules/checkpoints.py +68 -0
- trainlint/rules/evaluation.py +117 -0
- trainlint/rules/leakage.py +115 -0
- trainlint/rules/mixed_precision.py +85 -0
- trainlint/rules/training.py +302 -0
- trainlint/skills/ml-checkpoint-resume/SKILL.md +70 -0
- trainlint/skills/ml-data-leakage/SKILL.md +73 -0
- trainlint/skills/ml-metrics-eval/SKILL.md +53 -0
- trainlint/skills/ml-training-loop/SKILL.md +98 -0
- trainlint/skills_install.py +36 -0
- trainlint-0.1.0.dist-info/METADATA +204 -0
- trainlint-0.1.0.dist-info/RECORD +23 -0
- trainlint-0.1.0.dist-info/WHEEL +4 -0
- trainlint-0.1.0.dist-info/entry_points.txt +2 -0
- trainlint-0.1.0.dist-info/licenses/LICENSE +21 -0
trainlint/__init__.py
ADDED
trainlint/__main__.py
ADDED
trainlint/astutil.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Helpers for matching calls by name. Imports are not resolved, so `F.softmax`,
|
|
2
|
+
`torch.softmax` and a bare `softmax` all have the tail `softmax`."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Iterable, Iterator
|
|
9
|
+
|
|
10
|
+
_NESTED_SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
|
|
11
|
+
|
|
12
|
+
# Variable names are the only signal available for which split a value belongs to.
|
|
13
|
+
_TRAIN_NAME = re.compile(r"(?:^|_)(?:x|y)?(?:train|trn|tr)(?:$|_|\d)", re.IGNORECASE)
|
|
14
|
+
_EVAL_NAME = re.compile(
|
|
15
|
+
r"(?:^|_)(?:x|y)?(?:test|val|valid|validation|eval|dev|holdout)(?:$|_|\d)", re.IGNORECASE
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def dotted(node: ast.AST) -> str | None:
|
|
20
|
+
"""Return 'a.b.c' for a chain of names and attributes, otherwise None."""
|
|
21
|
+
parts: list[str] = []
|
|
22
|
+
while isinstance(node, ast.Attribute):
|
|
23
|
+
parts.append(node.attr)
|
|
24
|
+
node = node.value
|
|
25
|
+
if isinstance(node, ast.Name):
|
|
26
|
+
parts.append(node.id)
|
|
27
|
+
return ".".join(reversed(parts))
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def call_tail(call: ast.Call) -> str | None:
|
|
32
|
+
"""Last component of the callee: 'softmax' for both F.softmax(x) and softmax(x)."""
|
|
33
|
+
func = call.func
|
|
34
|
+
if isinstance(func, ast.Attribute):
|
|
35
|
+
return func.attr
|
|
36
|
+
if isinstance(func, ast.Name):
|
|
37
|
+
return func.id
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def receiver(call: ast.Call) -> str | None:
|
|
42
|
+
"""Dotted name a method is called on: 'self.opt' for self.opt.step()."""
|
|
43
|
+
if isinstance(call.func, ast.Attribute):
|
|
44
|
+
return dotted(call.func.value)
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def root_name(node: ast.AST) -> str | None:
|
|
49
|
+
"""Base variable of X, X[idx], X.values and X[cols].values."""
|
|
50
|
+
while isinstance(node, ast.Attribute | ast.Subscript):
|
|
51
|
+
node = node.value
|
|
52
|
+
return node.id if isinstance(node, ast.Name) else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def target_names(target: ast.AST) -> list[str]:
|
|
56
|
+
"""Names bound by an assignment target, including tuple unpacking."""
|
|
57
|
+
if isinstance(target, ast.Name):
|
|
58
|
+
return [target.id]
|
|
59
|
+
if isinstance(target, ast.Tuple | ast.List):
|
|
60
|
+
return [name for elt in target.elts for name in target_names(elt)]
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def call_targets(nodes: Iterable[ast.AST]) -> dict[int, set[str]]:
|
|
65
|
+
"""Names each call's result is assigned to, keyed by id(call)."""
|
|
66
|
+
bound: dict[int, set[str]] = {}
|
|
67
|
+
for node in nodes:
|
|
68
|
+
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
|
|
69
|
+
bound[id(node.value)] = {n for t in node.targets for n in target_names(t)}
|
|
70
|
+
return bound
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def kwarg(call: ast.Call, name: str) -> ast.expr | None:
|
|
74
|
+
for keyword in call.keywords:
|
|
75
|
+
if keyword.arg == name:
|
|
76
|
+
return keyword.value
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_const(node: ast.AST | None, value: object) -> bool:
|
|
81
|
+
return (
|
|
82
|
+
isinstance(node, ast.Constant) and type(node.value) is type(value) and node.value == value
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_train_name(name: str) -> bool:
|
|
87
|
+
return bool(_TRAIN_NAME.search(name))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_eval_name(name: str) -> bool:
|
|
91
|
+
return bool(_EVAL_NAME.search(name))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def by_position(nodes: Iterable[ast.AST]) -> list[ast.AST]:
|
|
95
|
+
return sorted(nodes, key=lambda n: (n.lineno, n.col_offset))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def scopes(tree: ast.Module) -> Iterator[ast.Module | ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
99
|
+
"""The module and every function in it."""
|
|
100
|
+
yield tree
|
|
101
|
+
for node in ast.walk(tree):
|
|
102
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
103
|
+
yield node
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def walk_scope(scope: ast.AST) -> Iterator[ast.AST]:
|
|
107
|
+
"""Walk a scope without entering nested functions or classes."""
|
|
108
|
+
stack = list(ast.iter_child_nodes(scope))
|
|
109
|
+
while stack:
|
|
110
|
+
node = stack.pop()
|
|
111
|
+
yield node
|
|
112
|
+
if not isinstance(node, _NESTED_SCOPES):
|
|
113
|
+
stack.extend(ast.iter_child_nodes(node))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def calls_in(nodes: Iterable[ast.AST]) -> list[ast.Call]:
|
|
117
|
+
"""Calls among `nodes` in source order."""
|
|
118
|
+
return by_position(n for n in nodes if isinstance(n, ast.Call))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def all_calls(tree: ast.AST) -> list[ast.Call]:
|
|
122
|
+
return calls_in(ast.walk(tree))
|
trainlint/cli.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from trainlint import __version__
|
|
10
|
+
from trainlint.engine import lint_paths, select_rules
|
|
11
|
+
from trainlint.report import render_json, render_text
|
|
12
|
+
from trainlint.rules import ALL_RULES
|
|
13
|
+
from trainlint.skills_install import AGENT_DIRS, default_destination, install
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="trainlint",
|
|
19
|
+
description="Find ML training and evaluation bugs that do not crash.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("--version", action="version", version=f"trainlint {__version__}")
|
|
22
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
23
|
+
|
|
24
|
+
check = commands.add_parser("check", help="check Python files or directories")
|
|
25
|
+
check.add_argument("paths", nargs="*", type=Path, default=[Path()], help="default: .")
|
|
26
|
+
check.add_argument("--select", metavar="RULES", help="comma-separated rule ids to run")
|
|
27
|
+
check.add_argument("--ignore", metavar="RULES", help="comma-separated rule ids to skip")
|
|
28
|
+
check.add_argument("--format", choices=("text", "json"), default="text")
|
|
29
|
+
check.add_argument(
|
|
30
|
+
"--color", choices=("auto", "always", "never"), default="auto", help="default: auto"
|
|
31
|
+
)
|
|
32
|
+
check.add_argument("--exit-zero", action="store_true", help="exit 0 even if problems are found")
|
|
33
|
+
check.set_defaults(run=_check)
|
|
34
|
+
|
|
35
|
+
rules = commands.add_parser("rules", help="list the available rules")
|
|
36
|
+
rules.set_defaults(run=_rules)
|
|
37
|
+
|
|
38
|
+
skills = commands.add_parser("skills", help="manage the bundled agent skills")
|
|
39
|
+
skill_commands = skills.add_subparsers(dest="skills_command", required=True)
|
|
40
|
+
install = skill_commands.add_parser(
|
|
41
|
+
"install", help="copy the skills into a project or user skills folder"
|
|
42
|
+
)
|
|
43
|
+
install.add_argument(
|
|
44
|
+
"--agent",
|
|
45
|
+
choices=sorted(AGENT_DIRS),
|
|
46
|
+
default="claude",
|
|
47
|
+
help="which agent's skills folder to use (default: claude)",
|
|
48
|
+
)
|
|
49
|
+
install.add_argument(
|
|
50
|
+
"--global", dest="user_wide", action="store_true", help="install for the current user"
|
|
51
|
+
)
|
|
52
|
+
install.add_argument("--dest", type=Path, help="install into this folder instead")
|
|
53
|
+
install.add_argument("--force", action="store_true", help="overwrite skills already there")
|
|
54
|
+
install.set_defaults(run=_install_skills)
|
|
55
|
+
return parser
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
59
|
+
args = build_parser().parse_args(argv)
|
|
60
|
+
stdout_reconfigure = getattr(sys.stdout, "reconfigure", None)
|
|
61
|
+
if stdout_reconfigure:
|
|
62
|
+
stdout_reconfigure(errors="replace")
|
|
63
|
+
try:
|
|
64
|
+
return args.run(args)
|
|
65
|
+
except (FileNotFoundError, ValueError) as error:
|
|
66
|
+
print(f"trainlint: error: {error}", file=sys.stderr)
|
|
67
|
+
return 2
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _split(value: str | None) -> list[str]:
|
|
71
|
+
return [part.strip() for part in value.split(",") if part.strip()] if value else []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check(args: argparse.Namespace) -> int:
|
|
75
|
+
rules = select_rules(_split(args.select), _split(args.ignore))
|
|
76
|
+
report = lint_paths(args.paths, rules)
|
|
77
|
+
for path, reason in report.skipped:
|
|
78
|
+
print(f"trainlint: skipped {path} ({reason})", file=sys.stderr)
|
|
79
|
+
if args.format == "json":
|
|
80
|
+
print(render_json(report))
|
|
81
|
+
else:
|
|
82
|
+
print(render_text(report, color=_use_color(args.color)))
|
|
83
|
+
return 1 if report.findings and not args.exit_zero else 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _rules(_: argparse.Namespace) -> int:
|
|
87
|
+
for rule in ALL_RULES:
|
|
88
|
+
print(f"{rule.id} {rule.title}")
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _install_skills(args: argparse.Namespace) -> int:
|
|
93
|
+
destination = args.dest or default_destination(args.agent, args.user_wide)
|
|
94
|
+
installed, skipped = install(destination, force=args.force)
|
|
95
|
+
for path in installed:
|
|
96
|
+
print(f"installed {path}")
|
|
97
|
+
for path in skipped:
|
|
98
|
+
print(f"skipped {path} (already exists, use --force to replace)")
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _use_color(mode: str) -> bool:
|
|
103
|
+
if mode != "auto":
|
|
104
|
+
return mode == "always"
|
|
105
|
+
if "NO_COLOR" in os.environ or not sys.stdout.isatty():
|
|
106
|
+
return False
|
|
107
|
+
return os.name != "nt" or _enable_windows_ansi()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _enable_windows_ansi() -> bool:
|
|
111
|
+
import ctypes
|
|
112
|
+
|
|
113
|
+
kernel32 = ctypes.windll.kernel32
|
|
114
|
+
handle = kernel32.GetStdHandle(-11)
|
|
115
|
+
mode = ctypes.c_ulong()
|
|
116
|
+
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
117
|
+
return False
|
|
118
|
+
return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004))
|
trainlint/engine.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Parse files, run rules, apply inline suppressions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tokenize
|
|
9
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from trainlint.rules import ALL_RULES, Rule
|
|
14
|
+
|
|
15
|
+
SKIP_DIRS = frozenset(
|
|
16
|
+
{
|
|
17
|
+
".git",
|
|
18
|
+
".hg",
|
|
19
|
+
".venv",
|
|
20
|
+
"venv",
|
|
21
|
+
"env",
|
|
22
|
+
"node_modules",
|
|
23
|
+
"__pycache__",
|
|
24
|
+
".tox",
|
|
25
|
+
".mypy_cache",
|
|
26
|
+
".ruff_cache",
|
|
27
|
+
".pytest_cache",
|
|
28
|
+
"build",
|
|
29
|
+
"dist",
|
|
30
|
+
"site-packages",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_IGNORE_COMMENT = re.compile(r"#\s*trainlint:\s*ignore(?:\[([^\]]*)\])?", re.IGNORECASE)
|
|
35
|
+
_LINE_BREAK = re.compile(r"\r\n|\r|\n")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, order=True)
|
|
39
|
+
class Finding:
|
|
40
|
+
path: str
|
|
41
|
+
line: int
|
|
42
|
+
col: int
|
|
43
|
+
end_col: int
|
|
44
|
+
rule: str
|
|
45
|
+
message: str
|
|
46
|
+
hint: str
|
|
47
|
+
text: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Report:
|
|
52
|
+
findings: list[Finding] = field(default_factory=list)
|
|
53
|
+
skipped: list[tuple[str, str]] = field(default_factory=list)
|
|
54
|
+
files_checked: int = 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def select_rules(select: Iterable[str] = (), ignore: Iterable[str] = ()) -> list[Rule]:
|
|
58
|
+
"""Rules to run. An empty `select` means every rule."""
|
|
59
|
+
known = {rule.id for rule in ALL_RULES}
|
|
60
|
+
wanted = {code.upper() for code in select}
|
|
61
|
+
dropped = {code.upper() for code in ignore}
|
|
62
|
+
unknown = sorted((wanted | dropped) - known)
|
|
63
|
+
if unknown:
|
|
64
|
+
raise ValueError(f"unknown rule {', '.join(unknown)} (see `trainlint rules`)")
|
|
65
|
+
return [r for r in ALL_RULES if (not wanted or r.id in wanted) and r.id not in dropped]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def lint_source(source: str, path: str, rules: Sequence[Rule] = ALL_RULES) -> list[Finding]:
|
|
69
|
+
"""Check one file's source. Raises SyntaxError if it does not parse."""
|
|
70
|
+
tree = ast.parse(source, filename=path)
|
|
71
|
+
lines = _LINE_BREAK.split(source)
|
|
72
|
+
findings = []
|
|
73
|
+
for rule in rules:
|
|
74
|
+
for hit in rule.check(tree):
|
|
75
|
+
line = hit.node.lineno
|
|
76
|
+
text = lines[line - 1]
|
|
77
|
+
if _suppressed(text, rule.id):
|
|
78
|
+
continue
|
|
79
|
+
col, end_col = _columns(hit.node, text)
|
|
80
|
+
findings.append(Finding(path, line, col, end_col, rule.id, hit.message, hit.hint, text))
|
|
81
|
+
return sorted(findings)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def lint_paths(paths: Iterable[Path], rules: Sequence[Rule] = ALL_RULES) -> Report:
|
|
85
|
+
report = Report()
|
|
86
|
+
for file in python_files(paths):
|
|
87
|
+
name = file.as_posix()
|
|
88
|
+
try:
|
|
89
|
+
with tokenize.open(file) as handle:
|
|
90
|
+
source = handle.read()
|
|
91
|
+
report.findings.extend(lint_source(source, name, rules))
|
|
92
|
+
except (SyntaxError, UnicodeDecodeError, ValueError) as error:
|
|
93
|
+
report.skipped.append((name, f"{type(error).__name__}: {error}"))
|
|
94
|
+
continue
|
|
95
|
+
report.files_checked += 1
|
|
96
|
+
report.findings.sort()
|
|
97
|
+
return report
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def python_files(paths: Iterable[Path]) -> Iterator[Path]:
|
|
101
|
+
"""Files named on the command line, plus *.py found under directories."""
|
|
102
|
+
for path in paths:
|
|
103
|
+
if path.is_dir():
|
|
104
|
+
for root, dirs, files in os.walk(path):
|
|
105
|
+
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
|
|
106
|
+
for name in sorted(files):
|
|
107
|
+
if name.endswith(".py"):
|
|
108
|
+
yield Path(root, name)
|
|
109
|
+
elif path.is_file():
|
|
110
|
+
yield path
|
|
111
|
+
else:
|
|
112
|
+
raise FileNotFoundError(f"no such file or directory: {path}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _suppressed(line_text: str, rule_id: str) -> bool:
|
|
116
|
+
match = _IGNORE_COMMENT.search(line_text)
|
|
117
|
+
if match is None:
|
|
118
|
+
return False
|
|
119
|
+
codes = match.group(1)
|
|
120
|
+
return codes is None or rule_id in {code.strip().upper() for code in codes.split(",")}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _columns(node: ast.AST, text: str) -> tuple[int, int]:
|
|
124
|
+
"""1-based start and end columns in characters. ast offsets are UTF-8 bytes."""
|
|
125
|
+
start = node.col_offset
|
|
126
|
+
end = node.end_col_offset
|
|
127
|
+
if node.end_lineno != node.lineno:
|
|
128
|
+
end = len(text.encode("utf-8"))
|
|
129
|
+
raw = text.encode("utf-8")
|
|
130
|
+
start_col = len(raw[:start].decode("utf-8", errors="ignore")) + 1
|
|
131
|
+
end_col = len(raw[:end].decode("utf-8", errors="ignore")) + 1
|
|
132
|
+
return start_col, max(end_col, start_col + 1)
|
trainlint/report.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Render a Report as terminal text or JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from trainlint import __version__
|
|
8
|
+
from trainlint.engine import Finding, Report
|
|
9
|
+
|
|
10
|
+
_CODES = {"bold": "1", "red": "31", "cyan": "36", "dim": "2"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _paint(text: str, style: str, enabled: bool) -> str:
|
|
14
|
+
return f"\x1b[{_CODES[style]}m{text}\x1b[0m" if enabled else text
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _caret_line(finding: Finding) -> str:
|
|
18
|
+
# Reuse tabs from the source line so the carets stay under the right characters.
|
|
19
|
+
indent = "".join(c if c == "\t" else " " for c in finding.text[: finding.col - 1])
|
|
20
|
+
return indent + "^" * (finding.end_col - finding.col)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render_text(report: Report, color: bool = False) -> str:
|
|
24
|
+
out: list[str] = []
|
|
25
|
+
for f in report.findings:
|
|
26
|
+
gutter = " " * len(str(f.line))
|
|
27
|
+
location = _paint(f"{f.path}:{f.line}:{f.col}", "bold", color)
|
|
28
|
+
out.append(f"{location}: {_paint(f.rule, 'red', color)} {f.message}")
|
|
29
|
+
out.append(f"{gutter} |")
|
|
30
|
+
out.append(f"{f.line} | {f.text}")
|
|
31
|
+
out.append(f"{gutter} | {_paint(_caret_line(f), 'red', color)}")
|
|
32
|
+
out.append(f"{gutter} = {_paint('help:', 'cyan', color)} {f.hint}")
|
|
33
|
+
out.append("")
|
|
34
|
+
out.append(_summary(report))
|
|
35
|
+
return "\n".join(out)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _summary(report: Report) -> str:
|
|
39
|
+
checked = _plural(report.files_checked, "file")
|
|
40
|
+
if not report.findings:
|
|
41
|
+
return f"No problems found in {checked}."
|
|
42
|
+
flagged = len({f.path for f in report.findings})
|
|
43
|
+
return (
|
|
44
|
+
f"Found {_plural(len(report.findings), 'problem')} in "
|
|
45
|
+
f"{_plural(flagged, 'file')} (checked {checked})."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _plural(count: int, noun: str) -> str:
|
|
50
|
+
return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render_json(report: Report) -> str:
|
|
54
|
+
findings = [
|
|
55
|
+
{
|
|
56
|
+
"path": f.path,
|
|
57
|
+
"line": f.line,
|
|
58
|
+
"column": f.col,
|
|
59
|
+
"rule": f.rule,
|
|
60
|
+
"message": f.message,
|
|
61
|
+
"hint": f.hint,
|
|
62
|
+
}
|
|
63
|
+
for f in report.findings
|
|
64
|
+
]
|
|
65
|
+
skipped = [{"path": path, "reason": reason} for path, reason in report.skipped]
|
|
66
|
+
payload = {
|
|
67
|
+
"version": __version__,
|
|
68
|
+
"files_checked": report.files_checked,
|
|
69
|
+
"findings": findings,
|
|
70
|
+
"skipped": skipped,
|
|
71
|
+
}
|
|
72
|
+
return json.dumps(payload, indent=2)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from trainlint.rules import checkpoints, evaluation, leakage, mixed_precision, training
|
|
4
|
+
from trainlint.rules.base import Hit, Rule
|
|
5
|
+
|
|
6
|
+
ALL_RULES: tuple[Rule, ...] = tuple(
|
|
7
|
+
sorted(
|
|
8
|
+
[
|
|
9
|
+
*leakage.RULES,
|
|
10
|
+
*training.RULES,
|
|
11
|
+
*mixed_precision.RULES,
|
|
12
|
+
*checkpoints.RULES,
|
|
13
|
+
*evaluation.RULES,
|
|
14
|
+
],
|
|
15
|
+
key=lambda rule: rule.id,
|
|
16
|
+
)
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = ["ALL_RULES", "Hit", "Rule"]
|
trainlint/rules/base.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections.abc import Callable, Iterator
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Hit:
|
|
10
|
+
"""A spot a rule objects to, before the engine attaches file and line text."""
|
|
11
|
+
|
|
12
|
+
node: ast.expr | ast.stmt
|
|
13
|
+
message: str
|
|
14
|
+
hint: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Rule:
|
|
19
|
+
id: str
|
|
20
|
+
title: str
|
|
21
|
+
check: Callable[[ast.Module], Iterator[Hit]]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""TL007: checkpoints that cannot restore a training run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
|
|
8
|
+
from trainlint.astutil import all_calls, call_tail, dotted
|
|
9
|
+
from trainlint.rules.base import Hit, Rule
|
|
10
|
+
|
|
11
|
+
_OPTIMIZER_CLASSES = frozenset({"sgd", "adam", "adamw", "rmsprop", "adagrad", "adadelta", "lbfgs"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _makes_optimizer(call: ast.Call) -> bool:
|
|
15
|
+
func = ast.unparse(call.func).lower()
|
|
16
|
+
return "optim" in func or (call_tail(call) or "").lower() in _OPTIMIZER_CLASSES
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _words(node: ast.AST) -> str:
|
|
20
|
+
"""The identifier or string a node carries, if any."""
|
|
21
|
+
if isinstance(node, ast.Name):
|
|
22
|
+
return node.id
|
|
23
|
+
if isinstance(node, ast.arg):
|
|
24
|
+
return node.arg
|
|
25
|
+
if isinstance(node, ast.Attribute):
|
|
26
|
+
return node.attr
|
|
27
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
28
|
+
return node.value
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _mentions_resume(tree: ast.Module) -> bool:
|
|
33
|
+
words = (_words(node).lower() for node in ast.walk(tree))
|
|
34
|
+
return any("resume" in w or "start_epoch" in w for w in words)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _saves_optimizer_state(calls: list[ast.Call]) -> bool:
|
|
38
|
+
return any(
|
|
39
|
+
call_tail(c) == "state_dict"
|
|
40
|
+
and isinstance(c.func, ast.Attribute)
|
|
41
|
+
and "opt" in ast.unparse(c.func.value).lower()
|
|
42
|
+
for c in calls
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _resume_without_optimizer_state(tree: ast.Module) -> Iterator[Hit]:
|
|
47
|
+
calls = all_calls(tree)
|
|
48
|
+
saves = [c for c in calls if dotted(c.func) == "torch.save"]
|
|
49
|
+
if not saves or not _mentions_resume(tree):
|
|
50
|
+
return
|
|
51
|
+
if not any(_makes_optimizer(c) for c in calls) or _saves_optimizer_state(calls):
|
|
52
|
+
return
|
|
53
|
+
yield Hit(
|
|
54
|
+
saves[0],
|
|
55
|
+
"this script resumes from checkpoints but never saves optimizer state, so momentum "
|
|
56
|
+
"and adaptive learning rates restart from zero after a resume",
|
|
57
|
+
"Save model, optimizer, scheduler, GradScaler and epoch together, for example "
|
|
58
|
+
"{'model': model.state_dict(), 'optimizer': optimizer.state_dict(), 'epoch': epoch}.",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
RULES = (
|
|
63
|
+
Rule(
|
|
64
|
+
"TL007",
|
|
65
|
+
"Resumable script saves checkpoints without optimizer state",
|
|
66
|
+
_resume_without_optimizer_state,
|
|
67
|
+
),
|
|
68
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""TL011 and TL012: evaluation code that measures the wrong thing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
|
|
8
|
+
from trainlint.astutil import (
|
|
9
|
+
by_position,
|
|
10
|
+
call_tail,
|
|
11
|
+
call_targets,
|
|
12
|
+
calls_in,
|
|
13
|
+
is_const,
|
|
14
|
+
is_eval_name,
|
|
15
|
+
is_train_name,
|
|
16
|
+
kwarg,
|
|
17
|
+
receiver,
|
|
18
|
+
root_name,
|
|
19
|
+
scopes,
|
|
20
|
+
walk_scope,
|
|
21
|
+
)
|
|
22
|
+
from trainlint.rules.base import Hit, Rule
|
|
23
|
+
|
|
24
|
+
# Metrics where swapping y_true and y_pred changes the answer.
|
|
25
|
+
_ORDER_SENSITIVE = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"f1_score",
|
|
28
|
+
"fbeta_score",
|
|
29
|
+
"precision_score",
|
|
30
|
+
"recall_score",
|
|
31
|
+
"precision_recall_fscore_support",
|
|
32
|
+
"balanced_accuracy_score",
|
|
33
|
+
"confusion_matrix",
|
|
34
|
+
"classification_report",
|
|
35
|
+
"r2_score",
|
|
36
|
+
"explained_variance_score",
|
|
37
|
+
"log_loss",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
_NEEDS_SCORES = frozenset({"roc_auc_score", "average_precision_score"})
|
|
41
|
+
_HARD_LABEL_CALLS = frozenset({"predict", "argmax"})
|
|
42
|
+
# Boosting libraries return probabilities from Booster.predict for binary objectives.
|
|
43
|
+
_BOOSTER_HINTS = ("booster", "bst", "gbm", "lgb", "xgb", "catboost")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _swapped_arguments(call: ast.Call) -> bool:
|
|
47
|
+
if len(call.args) < 2 or call_tail(call) not in _ORDER_SENSITIVE | _NEEDS_SCORES:
|
|
48
|
+
return False
|
|
49
|
+
first, second = root_name(call.args[0]), root_name(call.args[1])
|
|
50
|
+
return bool(first and second and "pred" in first.lower() and "pred" not in second.lower())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _latest_value(nodes: list[ast.AST], name: str, before: ast.AST) -> ast.expr | None:
|
|
54
|
+
assignments = [
|
|
55
|
+
n
|
|
56
|
+
for n in nodes
|
|
57
|
+
if isinstance(n, ast.Assign)
|
|
58
|
+
and any(isinstance(t, ast.Name) and t.id == name for t in n.targets)
|
|
59
|
+
and (n.lineno, n.col_offset) < (before.lineno, before.col_offset)
|
|
60
|
+
]
|
|
61
|
+
return by_position(assignments)[-1].value if assignments else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_hard_labels(value: ast.expr | None) -> bool:
|
|
65
|
+
if not isinstance(value, ast.Call) or call_tail(value) not in _HARD_LABEL_CALLS:
|
|
66
|
+
return False
|
|
67
|
+
owner = (receiver(value) or "").lower()
|
|
68
|
+
return not any(hint in owner for hint in _BOOSTER_HINTS)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _metric_misuse(tree: ast.Module) -> Iterator[Hit]:
|
|
72
|
+
for scope in scopes(tree):
|
|
73
|
+
nodes = list(walk_scope(scope))
|
|
74
|
+
for call in calls_in(nodes):
|
|
75
|
+
tail = call_tail(call)
|
|
76
|
+
if _swapped_arguments(call):
|
|
77
|
+
yield Hit(
|
|
78
|
+
call,
|
|
79
|
+
f"`{tail}` takes (y_true, y_pred), but the first argument looks like "
|
|
80
|
+
"predictions",
|
|
81
|
+
"Pass the ground-truth labels first and the predictions second.",
|
|
82
|
+
)
|
|
83
|
+
elif tail in _NEEDS_SCORES and len(call.args) >= 2:
|
|
84
|
+
scores = call.args[1]
|
|
85
|
+
if isinstance(scores, ast.Name):
|
|
86
|
+
scores = _latest_value(nodes, scores.id, call) or scores
|
|
87
|
+
if _is_hard_labels(scores):
|
|
88
|
+
yield Hit(
|
|
89
|
+
call,
|
|
90
|
+
f"`{tail}` is given class labels from predict(), which collapses the "
|
|
91
|
+
"curve to a single point",
|
|
92
|
+
"Pass scores instead: predict_proba(X)[:, 1] or decision_function(X).",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _eval_loader_drops_samples(tree: ast.Module) -> Iterator[Hit]:
|
|
97
|
+
for scope in scopes(tree):
|
|
98
|
+
nodes = list(walk_scope(scope))
|
|
99
|
+
bound = call_targets(nodes)
|
|
100
|
+
for call in calls_in(nodes):
|
|
101
|
+
if call_tail(call) != "DataLoader" or not is_const(kwarg(call, "drop_last"), True):
|
|
102
|
+
continue
|
|
103
|
+
dataset = root_name(call.args[0]) if call.args else None
|
|
104
|
+
names = [*bound.get(id(call), ()), *([dataset] if dataset else [])]
|
|
105
|
+
if any(is_eval_name(n) and not is_train_name(n) for n in names):
|
|
106
|
+
yield Hit(
|
|
107
|
+
call,
|
|
108
|
+
"this evaluation DataLoader uses drop_last=True, so the last partial "
|
|
109
|
+
"batch is never scored",
|
|
110
|
+
"Use drop_last=False for validation and test loaders.",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
RULES = (
|
|
115
|
+
Rule("TL011", "Metric called with swapped arguments or hard labels", _metric_misuse),
|
|
116
|
+
Rule("TL012", "Evaluation DataLoader drops the last batch", _eval_loader_drops_samples),
|
|
117
|
+
)
|