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 ADDED
@@ -0,0 +1,3 @@
1
+ """Static checks for ML code that runs fine and is still wrong."""
2
+
3
+ __version__ = "0.1.0"
trainlint/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from trainlint.cli import main
2
+
3
+ raise SystemExit(main())
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"]
@@ -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
+ )