kt-audit 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.
- kt_audit/__init__.py +16 -0
- kt_audit/__main__.py +4 -0
- kt_audit/audit.py +81 -0
- kt_audit/checks/__init__.py +5 -0
- kt_audit/checks/_common.py +45 -0
- kt_audit/checks/alignment.py +179 -0
- kt_audit/checks/multikc.py +160 -0
- kt_audit/checks/temporal.py +106 -0
- kt_audit/cli.py +61 -0
- kt_audit/config.py +51 -0
- kt_audit/errors.py +2 -0
- kt_audit/py.typed +0 -0
- kt_audit/report.py +115 -0
- kt_audit/schema.py +197 -0
- kt_audit-0.1.0.dist-info/METADATA +169 -0
- kt_audit-0.1.0.dist-info/RECORD +19 -0
- kt_audit-0.1.0.dist-info/WHEEL +4 -0
- kt_audit-0.1.0.dist-info/entry_points.txt +2 -0
- kt_audit-0.1.0.dist-info/licenses/LICENSE +21 -0
kt_audit/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .audit import audit_frame, audit_path
|
|
2
|
+
from .config import AuditConfig, load_config
|
|
3
|
+
from .errors import AuditError
|
|
4
|
+
from .schema import Finding, Report
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AuditConfig",
|
|
10
|
+
"AuditError",
|
|
11
|
+
"Finding",
|
|
12
|
+
"Report",
|
|
13
|
+
"audit_frame",
|
|
14
|
+
"audit_path",
|
|
15
|
+
"load_config",
|
|
16
|
+
]
|
kt_audit/__main__.py
ADDED
kt_audit/audit.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from .checks.alignment import check_alignment
|
|
10
|
+
from .checks.multikc import check_multikc
|
|
11
|
+
from .checks.temporal import check_temporal
|
|
12
|
+
from .config import AuditConfig
|
|
13
|
+
from .errors import AuditError
|
|
14
|
+
from .schema import Finding, Report, Verdict, prepare_frame
|
|
15
|
+
|
|
16
|
+
SCHEMA_VERSION = "0.1"
|
|
17
|
+
_SEVERITY_ORDER = {"error": 0, "warn": 1, "info": 2}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def audit_frame(df: pd.DataFrame, config: AuditConfig | None = None) -> Report:
|
|
21
|
+
config = config or AuditConfig()
|
|
22
|
+
prepared = prepare_frame(df, config)
|
|
23
|
+
findings: list[Finding] = []
|
|
24
|
+
if config.temporal:
|
|
25
|
+
findings.extend(check_temporal(prepared, config))
|
|
26
|
+
if config.multikc:
|
|
27
|
+
findings.extend(check_multikc(prepared, config))
|
|
28
|
+
metrics: dict[str, dict[str, object]] = {}
|
|
29
|
+
if config.alignment:
|
|
30
|
+
alignment_findings, metrics = check_alignment(prepared, config)
|
|
31
|
+
findings.extend(alignment_findings)
|
|
32
|
+
findings.sort(key=_finding_sort_key)
|
|
33
|
+
return Report(
|
|
34
|
+
schema_version=SCHEMA_VERSION,
|
|
35
|
+
generated_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
36
|
+
input=_input_summary(prepared),
|
|
37
|
+
findings=tuple(findings),
|
|
38
|
+
metrics=metrics,
|
|
39
|
+
verdict=_verdict(findings),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def audit_path(path: str | Path, config: AuditConfig | None = None) -> Report:
|
|
44
|
+
path = Path(path)
|
|
45
|
+
if not path.exists():
|
|
46
|
+
raise AuditError(f"input not found: {path}")
|
|
47
|
+
suffix = path.suffix.lower()
|
|
48
|
+
if suffix in {".jsonl", ".ndjson"}:
|
|
49
|
+
df = pd.read_json(path, lines=True)
|
|
50
|
+
elif suffix == ".csv":
|
|
51
|
+
df = pd.read_csv(path)
|
|
52
|
+
else:
|
|
53
|
+
raise AuditError(f"unsupported input format: {suffix}; use .csv or .jsonl")
|
|
54
|
+
return audit_frame(df, config)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _finding_sort_key(finding: Finding) -> tuple[int, str, str]:
|
|
58
|
+
return (_SEVERITY_ORDER[finding.severity], finding.check, finding.message)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _verdict(findings: list[Finding]) -> Verdict:
|
|
62
|
+
severities = {finding.severity for finding in findings}
|
|
63
|
+
if "error" in severities:
|
|
64
|
+
return "fail"
|
|
65
|
+
if "warn" in severities:
|
|
66
|
+
return "warn"
|
|
67
|
+
return "pass"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _input_summary(df: pd.DataFrame) -> dict[str, object]:
|
|
71
|
+
splits = {
|
|
72
|
+
str(key): int(value) for key, value in df["split"].value_counts().sort_index().items()
|
|
73
|
+
}
|
|
74
|
+
has_items = bool(df["item_id"].notna().any())
|
|
75
|
+
return {
|
|
76
|
+
"rows": len(df),
|
|
77
|
+
"users": int(cast(int, df["user_id"].nunique())),
|
|
78
|
+
"items": int(cast(int, df["item_id"].nunique())) if has_items else None,
|
|
79
|
+
"folds": int(cast(int, df["_fold"].nunique())),
|
|
80
|
+
"splits": splits,
|
|
81
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from ..schema import jsonable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def iter_folds(df: pd.DataFrame) -> Iterator[tuple[str, pd.DataFrame]]:
|
|
12
|
+
for fold, group in df.groupby("_fold", sort=True, dropna=False):
|
|
13
|
+
yield str(fold), group
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fold_example(fold: str, example: dict[str, Any]) -> dict[str, object]:
|
|
17
|
+
values = {key: jsonable(value) for key, value in example.items()}
|
|
18
|
+
if fold != "__all__":
|
|
19
|
+
return {"fold": fold, **values}
|
|
20
|
+
return values
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def examples_with_fold(
|
|
24
|
+
frame: pd.DataFrame,
|
|
25
|
+
columns: tuple[str, ...],
|
|
26
|
+
max_examples: int,
|
|
27
|
+
) -> tuple[dict[str, object], ...]:
|
|
28
|
+
examples = []
|
|
29
|
+
for _, row in frame.head(max_examples).iterrows():
|
|
30
|
+
values = {column: row[column] for column in columns if column in row.index}
|
|
31
|
+
examples.append(fold_example(str(row["_fold"]), values))
|
|
32
|
+
return tuple(examples)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def examples_from_frame(
|
|
36
|
+
frame: pd.DataFrame,
|
|
37
|
+
columns: tuple[str, ...],
|
|
38
|
+
max_examples: int,
|
|
39
|
+
fold: str,
|
|
40
|
+
) -> tuple[dict[str, object], ...]:
|
|
41
|
+
examples = []
|
|
42
|
+
for _, row in frame.head(max_examples).iterrows():
|
|
43
|
+
values = {column: row[column] for column in columns if column in row.index}
|
|
44
|
+
examples.append(fold_example(fold, values))
|
|
45
|
+
return tuple(examples)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from sklearn.metrics import accuracy_score, roc_auc_score
|
|
8
|
+
|
|
9
|
+
from ..config import AuditConfig
|
|
10
|
+
from ..schema import SPLITS, Finding
|
|
11
|
+
from ._common import examples_with_fold, fold_example, iter_folds
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_alignment(
|
|
15
|
+
df: pd.DataFrame,
|
|
16
|
+
config: AuditConfig,
|
|
17
|
+
) -> tuple[list[Finding], dict[str, dict[str, object]]]:
|
|
18
|
+
if "y_pred" not in df.columns:
|
|
19
|
+
return (
|
|
20
|
+
[
|
|
21
|
+
Finding(
|
|
22
|
+
"alignment",
|
|
23
|
+
"info",
|
|
24
|
+
"y_pred is missing; metric checks skipped",
|
|
25
|
+
0,
|
|
26
|
+
(),
|
|
27
|
+
)
|
|
28
|
+
],
|
|
29
|
+
{},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
findings: list[Finding] = []
|
|
33
|
+
findings.extend(_invalid_findings(df, config))
|
|
34
|
+
findings.extend(_duplicate_findings(df, config))
|
|
35
|
+
findings.extend(_constant_findings(df))
|
|
36
|
+
metrics = _metrics(df)
|
|
37
|
+
findings.extend(_reported_findings(metrics, config))
|
|
38
|
+
return findings, metrics
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _invalid_findings(df: pd.DataFrame, config: AuditConfig) -> list[Finding]:
|
|
42
|
+
pred = df["y_pred"].astype("float64")
|
|
43
|
+
true = df["y_true"].astype("float64")
|
|
44
|
+
invalid_pred = ~np.isfinite(pred) | (pred < 0) | (pred > 1)
|
|
45
|
+
invalid_true = ~true.isin([0.0, 1.0])
|
|
46
|
+
findings: list[Finding] = []
|
|
47
|
+
if bool(invalid_pred.any()):
|
|
48
|
+
examples = examples_with_fold(
|
|
49
|
+
cast(pd.DataFrame, df.loc[invalid_pred]),
|
|
50
|
+
("_row", "user_id", "item_id", "y_pred"),
|
|
51
|
+
config.max_examples,
|
|
52
|
+
)
|
|
53
|
+
findings.append(
|
|
54
|
+
Finding(
|
|
55
|
+
"alignment",
|
|
56
|
+
"error",
|
|
57
|
+
"y_pred has missing, non-finite, or out-of-range values",
|
|
58
|
+
int(np.count_nonzero(invalid_pred)),
|
|
59
|
+
examples,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
if bool(invalid_true.any()):
|
|
63
|
+
examples = examples_with_fold(
|
|
64
|
+
cast(pd.DataFrame, df.loc[invalid_true]),
|
|
65
|
+
("_row", "user_id", "item_id", "y_true"),
|
|
66
|
+
config.max_examples,
|
|
67
|
+
)
|
|
68
|
+
findings.append(
|
|
69
|
+
Finding(
|
|
70
|
+
"alignment",
|
|
71
|
+
"error",
|
|
72
|
+
"y_true is not binary",
|
|
73
|
+
int(np.count_nonzero(invalid_true.to_numpy())),
|
|
74
|
+
examples,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
return findings
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _duplicate_findings(df: pd.DataFrame, config: AuditConfig) -> list[Finding]:
|
|
81
|
+
keys = _key_columns(df)
|
|
82
|
+
extra = df.duplicated(subset=keys, keep="first")
|
|
83
|
+
if not bool(extra.any()):
|
|
84
|
+
return []
|
|
85
|
+
examples = examples_with_fold(
|
|
86
|
+
cast(pd.DataFrame, df.loc[df.duplicated(subset=keys, keep=False)]),
|
|
87
|
+
("_row", "user_id", "item_id", "timestamp"),
|
|
88
|
+
config.max_examples,
|
|
89
|
+
)
|
|
90
|
+
return [
|
|
91
|
+
Finding(
|
|
92
|
+
"alignment",
|
|
93
|
+
"error",
|
|
94
|
+
"duplicate interaction keys",
|
|
95
|
+
int(np.count_nonzero(extra.to_numpy())),
|
|
96
|
+
examples,
|
|
97
|
+
)
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _constant_findings(df: pd.DataFrame) -> list[Finding]:
|
|
102
|
+
findings: list[Finding] = []
|
|
103
|
+
for fold, group in iter_folds(df):
|
|
104
|
+
if group["y_pred"].nunique(dropna=True) <= 1:
|
|
105
|
+
findings.append(
|
|
106
|
+
Finding(
|
|
107
|
+
"alignment",
|
|
108
|
+
"warn",
|
|
109
|
+
"predictions are constant within this fold",
|
|
110
|
+
1,
|
|
111
|
+
(fold_example(fold, {"n": len(group)}),),
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
return findings
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _key_columns(df: pd.DataFrame) -> list[str]:
|
|
118
|
+
if bool(df["item_id"].notna().any()):
|
|
119
|
+
return ["_fold", "user_id", "item_id", "timestamp"]
|
|
120
|
+
if {"sequence_id", "position"}.issubset(df.columns):
|
|
121
|
+
return ["_fold", "user_id", "sequence_id", "position"]
|
|
122
|
+
return ["_fold", "user_id", "timestamp"]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _metrics(df: pd.DataFrame) -> dict[str, dict[str, object]]:
|
|
126
|
+
valid = df.dropna(subset=["y_true", "y_pred"]).copy()
|
|
127
|
+
pred = valid["y_pred"].astype("float64")
|
|
128
|
+
true = valid["y_true"].astype("float64")
|
|
129
|
+
mask = np.isfinite(pred) & pred.between(0, 1) & true.isin([0.0, 1.0])
|
|
130
|
+
valid = cast(pd.DataFrame, valid.loc[mask])
|
|
131
|
+
metrics: dict[str, dict[str, object]] = {}
|
|
132
|
+
for split in SPLITS:
|
|
133
|
+
part = cast(pd.DataFrame, valid[valid["split"] == split])
|
|
134
|
+
if part.empty:
|
|
135
|
+
continue
|
|
136
|
+
y = cast(pd.Series, part["y_true"]).astype(int)
|
|
137
|
+
p = cast(pd.Series, part["y_pred"]).astype(float)
|
|
138
|
+
auc = float(roc_auc_score(y, p)) if y.nunique() == 2 else None
|
|
139
|
+
acc = float(accuracy_score(y, (p >= 0.5).astype(int)))
|
|
140
|
+
metrics[split] = {"auc": auc, "acc": acc, "n": len(part)}
|
|
141
|
+
return metrics
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _reported_findings(
|
|
145
|
+
metrics: dict[str, dict[str, object]],
|
|
146
|
+
config: AuditConfig,
|
|
147
|
+
) -> list[Finding]:
|
|
148
|
+
reported = config.reported_auc
|
|
149
|
+
if reported is None:
|
|
150
|
+
return []
|
|
151
|
+
test_auc = metrics.get("test", {}).get("auc")
|
|
152
|
+
if not isinstance(test_auc, (int, float)):
|
|
153
|
+
return [
|
|
154
|
+
Finding(
|
|
155
|
+
"alignment",
|
|
156
|
+
"warn",
|
|
157
|
+
"reported_auc is set but test AUC cannot be computed",
|
|
158
|
+
0,
|
|
159
|
+
(),
|
|
160
|
+
)
|
|
161
|
+
]
|
|
162
|
+
delta = abs(float(test_auc) - reported)
|
|
163
|
+
if delta <= config.auc_tolerance:
|
|
164
|
+
return []
|
|
165
|
+
return [
|
|
166
|
+
Finding(
|
|
167
|
+
"alignment",
|
|
168
|
+
"error",
|
|
169
|
+
"reported test AUC differs from recomputed AUC",
|
|
170
|
+
1,
|
|
171
|
+
(
|
|
172
|
+
{
|
|
173
|
+
"reported_auc": reported,
|
|
174
|
+
"recomputed_auc": float(test_auc),
|
|
175
|
+
"delta": delta,
|
|
176
|
+
},
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from ..config import AuditConfig
|
|
9
|
+
from ..schema import Finding
|
|
10
|
+
from ._common import fold_example, iter_folds
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_multikc(df: pd.DataFrame, config: AuditConfig) -> list[Finding]:
|
|
14
|
+
findings: list[Finding] = []
|
|
15
|
+
for fold, group in iter_folds(df):
|
|
16
|
+
findings.extend(_check_fold(fold, group, config))
|
|
17
|
+
return findings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _check_fold(fold: str, group: pd.DataFrame, config: AuditConfig) -> list[Finding]:
|
|
21
|
+
if bool(group["item_id"].isna().all()):
|
|
22
|
+
return [
|
|
23
|
+
Finding(
|
|
24
|
+
"multi_kc",
|
|
25
|
+
"info",
|
|
26
|
+
"item_id is missing; multi-KC leakage check skipped",
|
|
27
|
+
0,
|
|
28
|
+
(),
|
|
29
|
+
)
|
|
30
|
+
]
|
|
31
|
+
working = group.dropna(subset=["item_id"])
|
|
32
|
+
findings = _missing_findings(group)
|
|
33
|
+
if working.empty:
|
|
34
|
+
return findings
|
|
35
|
+
item_splits, item_kcs, kc_splits = _aggregate(working)
|
|
36
|
+
overlap_items, cross_items = _leaks(item_splits, item_kcs, kc_splits)
|
|
37
|
+
findings.extend(
|
|
38
|
+
_leak_findings(
|
|
39
|
+
overlap_items,
|
|
40
|
+
cross_items,
|
|
41
|
+
item_kcs,
|
|
42
|
+
item_splits,
|
|
43
|
+
kc_splits,
|
|
44
|
+
fold,
|
|
45
|
+
config,
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
return findings
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _missing_findings(group: pd.DataFrame) -> list[Finding]:
|
|
52
|
+
missing = int(np.count_nonzero(group["item_id"].isna().to_numpy()))
|
|
53
|
+
if not missing:
|
|
54
|
+
return []
|
|
55
|
+
return [
|
|
56
|
+
Finding(
|
|
57
|
+
"multi_kc",
|
|
58
|
+
"warn",
|
|
59
|
+
"rows with missing item_id were skipped",
|
|
60
|
+
missing,
|
|
61
|
+
(),
|
|
62
|
+
)
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _aggregate(working: pd.DataFrame) -> tuple[pd.Series, pd.Series, pd.Series]:
|
|
67
|
+
item_splits = cast(
|
|
68
|
+
pd.Series,
|
|
69
|
+
working.groupby("item_id")["split"].agg(lambda values: tuple(sorted(set(values)))),
|
|
70
|
+
)
|
|
71
|
+
item_kcs = cast(pd.Series, working.groupby("item_id")["kc_ids"].agg(_union_kcs))
|
|
72
|
+
exploded = working.explode("kc_ids")
|
|
73
|
+
kc_splits = cast(
|
|
74
|
+
pd.Series,
|
|
75
|
+
exploded.groupby("kc_ids")["split"].agg(lambda values: frozenset(values)),
|
|
76
|
+
)
|
|
77
|
+
return item_splits, item_kcs, kc_splits
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _leaks(
|
|
81
|
+
item_splits: pd.Series,
|
|
82
|
+
item_kcs: pd.Series,
|
|
83
|
+
kc_splits: pd.Series,
|
|
84
|
+
) -> tuple[list[str], list[str]]:
|
|
85
|
+
overlap_items: list[str] = []
|
|
86
|
+
cross_items: list[str] = []
|
|
87
|
+
for item_id, kcs in item_kcs.items():
|
|
88
|
+
splits = set(item_splits[item_id])
|
|
89
|
+
if not {"train", "test"}.issubset(splits):
|
|
90
|
+
continue
|
|
91
|
+
overlap_items.append(str(item_id))
|
|
92
|
+
train_only = [kc for kc in kcs if set(kc_splits[kc]) == {"train"}]
|
|
93
|
+
test_only = [kc for kc in kcs if set(kc_splits[kc]) == {"test"}]
|
|
94
|
+
if train_only and test_only:
|
|
95
|
+
cross_items.append(str(item_id))
|
|
96
|
+
return overlap_items, cross_items
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _leak_findings(
|
|
100
|
+
overlap_items: list[str],
|
|
101
|
+
cross_items: list[str],
|
|
102
|
+
item_kcs: pd.Series,
|
|
103
|
+
item_splits: pd.Series,
|
|
104
|
+
kc_splits: pd.Series,
|
|
105
|
+
fold: str,
|
|
106
|
+
config: AuditConfig,
|
|
107
|
+
) -> list[Finding]:
|
|
108
|
+
findings: list[Finding] = []
|
|
109
|
+
if overlap_items:
|
|
110
|
+
examples = tuple(
|
|
111
|
+
_example(item, item_kcs, item_splits, kc_splits, fold)
|
|
112
|
+
for item in overlap_items[: config.max_examples]
|
|
113
|
+
)
|
|
114
|
+
findings.append(
|
|
115
|
+
Finding(
|
|
116
|
+
"multi_kc",
|
|
117
|
+
"warn",
|
|
118
|
+
"the same item appears in train and test",
|
|
119
|
+
len(overlap_items),
|
|
120
|
+
examples,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
if cross_items:
|
|
124
|
+
examples = tuple(
|
|
125
|
+
_example(item, item_kcs, item_splits, kc_splits, fold)
|
|
126
|
+
for item in cross_items[: config.max_examples]
|
|
127
|
+
)
|
|
128
|
+
findings.append(
|
|
129
|
+
Finding(
|
|
130
|
+
"multi_kc",
|
|
131
|
+
"error",
|
|
132
|
+
"an item has train-only and test-only knowledge components",
|
|
133
|
+
len(cross_items),
|
|
134
|
+
examples,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
return findings
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _union_kcs(values: pd.Series) -> tuple[str, ...]:
|
|
141
|
+
return tuple(sorted({kc for value in values for kc in value}))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _example(
|
|
145
|
+
item_id: str,
|
|
146
|
+
item_kcs: pd.Series,
|
|
147
|
+
item_splits: pd.Series,
|
|
148
|
+
kc_splits: pd.Series,
|
|
149
|
+
fold: str,
|
|
150
|
+
) -> dict[str, object]:
|
|
151
|
+
kcs = item_kcs[item_id]
|
|
152
|
+
return fold_example(
|
|
153
|
+
fold,
|
|
154
|
+
{
|
|
155
|
+
"item_id": item_id,
|
|
156
|
+
"splits": list(item_splits[item_id]),
|
|
157
|
+
"kc_ids": list(kcs),
|
|
158
|
+
"kc_splits": {kc: sorted(kc_splits[kc]) for kc in kcs},
|
|
159
|
+
},
|
|
160
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from ..config import AuditConfig
|
|
8
|
+
from ..schema import Finding
|
|
9
|
+
from ._common import examples_from_frame, fold_example, iter_folds
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def check_temporal(df: pd.DataFrame, config: AuditConfig) -> list[Finding]:
|
|
13
|
+
findings: list[Finding] = []
|
|
14
|
+
for fold, group in iter_folds(df):
|
|
15
|
+
findings.extend(_check_fold(fold, group, config))
|
|
16
|
+
return findings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _check_fold(fold: str, group: pd.DataFrame, config: AuditConfig) -> list[Finding]:
|
|
20
|
+
train = cast(pd.DataFrame, group[group["split"] == "train"])
|
|
21
|
+
evaluation = cast(pd.DataFrame, group[group["split"].isin(["valid", "test"])])
|
|
22
|
+
if train.empty or evaluation.empty:
|
|
23
|
+
return []
|
|
24
|
+
findings = _global_findings(fold, train, evaluation)
|
|
25
|
+
findings.extend(_overlap_findings(fold, group, config))
|
|
26
|
+
findings.extend(_violation_findings(fold, train, evaluation, config))
|
|
27
|
+
return findings
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _global_findings(
|
|
31
|
+
fold: str,
|
|
32
|
+
train: pd.DataFrame,
|
|
33
|
+
evaluation: pd.DataFrame,
|
|
34
|
+
) -> list[Finding]:
|
|
35
|
+
train_max: Any = train["timestamp"].max()
|
|
36
|
+
eval_min: Any = evaluation["timestamp"].min()
|
|
37
|
+
if train_max < eval_min:
|
|
38
|
+
return []
|
|
39
|
+
example = fold_example(fold, {"train_max": train_max, "eval_min": eval_min})
|
|
40
|
+
return [
|
|
41
|
+
Finding(
|
|
42
|
+
"temporal_split",
|
|
43
|
+
"error",
|
|
44
|
+
"training events are not strictly before evaluation events",
|
|
45
|
+
1,
|
|
46
|
+
(example,),
|
|
47
|
+
)
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _overlap_findings(
|
|
52
|
+
fold: str,
|
|
53
|
+
group: pd.DataFrame,
|
|
54
|
+
config: AuditConfig,
|
|
55
|
+
) -> list[Finding]:
|
|
56
|
+
split_counts = group.groupby("user_id")["split"].nunique()
|
|
57
|
+
multi = cast(pd.Series, split_counts[split_counts > 1])
|
|
58
|
+
if multi.empty:
|
|
59
|
+
return []
|
|
60
|
+
users = (
|
|
61
|
+
group[group["user_id"].isin(multi.index.tolist())]
|
|
62
|
+
.groupby("user_id")["split"]
|
|
63
|
+
.agg(lambda values: tuple(sorted(set(values))))
|
|
64
|
+
)
|
|
65
|
+
examples = tuple(
|
|
66
|
+
fold_example(fold, {"user_id": user, "splits": splits})
|
|
67
|
+
for user, splits in users.head(config.max_examples).items()
|
|
68
|
+
)
|
|
69
|
+
return [
|
|
70
|
+
Finding(
|
|
71
|
+
"temporal_split",
|
|
72
|
+
"warn",
|
|
73
|
+
"users appear in more than one split",
|
|
74
|
+
len(multi),
|
|
75
|
+
examples,
|
|
76
|
+
)
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _violation_findings(
|
|
81
|
+
fold: str,
|
|
82
|
+
train: pd.DataFrame,
|
|
83
|
+
evaluation: pd.DataFrame,
|
|
84
|
+
config: AuditConfig,
|
|
85
|
+
) -> list[Finding]:
|
|
86
|
+
train_max_by_user = train.groupby("user_id")["timestamp"].max()
|
|
87
|
+
eval_min_by_user = evaluation.groupby("user_id")["timestamp"].min()
|
|
88
|
+
merged = pd.DataFrame({"train_max": train_max_by_user, "eval_min": eval_min_by_user}).dropna()
|
|
89
|
+
violations = cast(pd.DataFrame, merged[merged["train_max"] >= merged["eval_min"]])
|
|
90
|
+
if violations.empty:
|
|
91
|
+
return []
|
|
92
|
+
examples = examples_from_frame(
|
|
93
|
+
violations.reset_index(),
|
|
94
|
+
("user_id", "train_max", "eval_min"),
|
|
95
|
+
config.max_examples,
|
|
96
|
+
fold,
|
|
97
|
+
)
|
|
98
|
+
return [
|
|
99
|
+
Finding(
|
|
100
|
+
"temporal_split",
|
|
101
|
+
"error",
|
|
102
|
+
"a user's training events overlap or follow evaluation events",
|
|
103
|
+
len(violations),
|
|
104
|
+
examples,
|
|
105
|
+
)
|
|
106
|
+
]
|
kt_audit/cli.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .audit import audit_path
|
|
10
|
+
from .config import load_config
|
|
11
|
+
from .errors import AuditError
|
|
12
|
+
from .report import report_to_markdown, write_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="kt-audit",
|
|
18
|
+
description="Audit knowledge-tracing evaluation before you trust the AUC.",
|
|
19
|
+
)
|
|
20
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
21
|
+
run = subparsers.add_parser("run", help="audit a CSV or JSONL file")
|
|
22
|
+
run.add_argument("input", type=Path, help="input CSV or JSONL")
|
|
23
|
+
run.add_argument(
|
|
24
|
+
"--out",
|
|
25
|
+
type=Path,
|
|
26
|
+
default=Path("kt-audit-report"),
|
|
27
|
+
help="output directory (default: kt-audit-report)",
|
|
28
|
+
)
|
|
29
|
+
run.add_argument("--config", type=Path, default=None, help="JSON config")
|
|
30
|
+
run.add_argument("--no-temporal", action="store_true", help="skip temporal split check")
|
|
31
|
+
run.add_argument("--no-multikc", action="store_true", help="skip multi-KC check")
|
|
32
|
+
run.add_argument("--no-alignment", action="store_true", help="skip prediction alignment check")
|
|
33
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
34
|
+
return parser
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv: list[str] | None = None) -> int:
|
|
38
|
+
parser = build_parser()
|
|
39
|
+
args = parser.parse_args(argv)
|
|
40
|
+
try:
|
|
41
|
+
config = load_config(args.config)
|
|
42
|
+
config = replace(
|
|
43
|
+
config,
|
|
44
|
+
temporal=config.temporal and not args.no_temporal,
|
|
45
|
+
multikc=config.multikc and not args.no_multikc,
|
|
46
|
+
alignment=config.alignment and not args.no_alignment,
|
|
47
|
+
)
|
|
48
|
+
report = audit_path(args.input, config)
|
|
49
|
+
except (AuditError, FileNotFoundError, ValueError) as exc:
|
|
50
|
+
print(f"kt-audit: {exc}", file=sys.stderr)
|
|
51
|
+
return 2
|
|
52
|
+
|
|
53
|
+
args.out.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
write_json(report, args.out / "report.json")
|
|
55
|
+
(args.out / "report.md").write_text(report_to_markdown(report), encoding="utf-8")
|
|
56
|
+
errors = sum(finding.severity == "error" for finding in report.findings)
|
|
57
|
+
warnings = sum(finding.severity == "warn" for finding in report.findings)
|
|
58
|
+
print(f"verdict: {report.verdict}")
|
|
59
|
+
print(f"findings: {errors} error, {warnings} warn")
|
|
60
|
+
print(f"report: {args.out / 'report.md'}")
|
|
61
|
+
return 1 if report.verdict == "fail" else 0
|
kt_audit/config.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, fields
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from .errors import AuditError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class AuditConfig:
|
|
13
|
+
kc_separator: str = "|"
|
|
14
|
+
temporal: bool = True
|
|
15
|
+
multikc: bool = True
|
|
16
|
+
alignment: bool = True
|
|
17
|
+
max_examples: int = 3
|
|
18
|
+
reported_auc: float | None = None
|
|
19
|
+
auc_tolerance: float = 0.01
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
if not self.kc_separator:
|
|
23
|
+
raise ValueError("kc_separator must not be empty")
|
|
24
|
+
if self.max_examples < 1:
|
|
25
|
+
raise ValueError("max_examples must be >= 1")
|
|
26
|
+
if self.auc_tolerance < 0:
|
|
27
|
+
raise ValueError("auc_tolerance must be >= 0")
|
|
28
|
+
if self.reported_auc is not None and not 0 <= self.reported_auc <= 1:
|
|
29
|
+
raise ValueError("reported_auc must be in [0, 1]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_config(path: str | Path | None) -> AuditConfig:
|
|
33
|
+
if path is None:
|
|
34
|
+
return AuditConfig()
|
|
35
|
+
path = Path(path)
|
|
36
|
+
try:
|
|
37
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
38
|
+
except FileNotFoundError as exc:
|
|
39
|
+
raise AuditError(f"config not found: {path}") from exc
|
|
40
|
+
except json.JSONDecodeError as exc:
|
|
41
|
+
raise AuditError(f"invalid JSON in {path}: {exc}") from exc
|
|
42
|
+
if not isinstance(raw, dict):
|
|
43
|
+
raise AuditError("config must be a JSON object")
|
|
44
|
+
allowed = {item.name for item in fields(AuditConfig)}
|
|
45
|
+
unknown = sorted(set(raw) - allowed)
|
|
46
|
+
if unknown:
|
|
47
|
+
raise AuditError(f"unknown config keys: {', '.join(unknown)}")
|
|
48
|
+
try:
|
|
49
|
+
return AuditConfig(**cast(dict[str, Any], raw))
|
|
50
|
+
except (TypeError, ValueError) as exc:
|
|
51
|
+
raise AuditError(f"invalid config: {exc}") from exc
|
kt_audit/errors.py
ADDED
kt_audit/py.typed
ADDED
|
File without changes
|
kt_audit/report.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .schema import Finding, Report, jsonable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def finding_to_dict(finding: Finding) -> dict[str, object]:
|
|
10
|
+
return {
|
|
11
|
+
"check": finding.check,
|
|
12
|
+
"severity": finding.severity,
|
|
13
|
+
"message": finding.message,
|
|
14
|
+
"count": finding.count,
|
|
15
|
+
"examples": jsonable(finding.examples),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def report_to_dict(report: Report) -> dict[str, object]:
|
|
20
|
+
return {
|
|
21
|
+
"schema_version": report.schema_version,
|
|
22
|
+
"generated_at": report.generated_at,
|
|
23
|
+
"verdict": report.verdict,
|
|
24
|
+
"input": jsonable(report.input),
|
|
25
|
+
"metrics": jsonable(report.metrics),
|
|
26
|
+
"findings": [finding_to_dict(finding) for finding in report.findings],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write_json(report: Report, path: Path) -> None:
|
|
31
|
+
payload = json.dumps(report_to_dict(report), indent=2, ensure_ascii=False)
|
|
32
|
+
path.write_text(payload + "\n", encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def report_to_markdown(report: Report) -> str:
|
|
36
|
+
lines = _header_lines(report)
|
|
37
|
+
lines.extend(["", "## Findings"])
|
|
38
|
+
if not report.findings:
|
|
39
|
+
lines.extend(["", "No findings."])
|
|
40
|
+
for finding in report.findings:
|
|
41
|
+
lines.extend(_finding_lines(finding))
|
|
42
|
+
lines.extend(_metrics_lines(report.metrics))
|
|
43
|
+
lines.extend(_limitation_lines())
|
|
44
|
+
return "\n".join(lines) + "\n"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _header_lines(report: Report) -> list[str]:
|
|
48
|
+
lines = [
|
|
49
|
+
"# kt-audit report",
|
|
50
|
+
"",
|
|
51
|
+
f"**Verdict: {report.verdict.upper()}**",
|
|
52
|
+
"",
|
|
53
|
+
f"- generated: {report.generated_at}",
|
|
54
|
+
f"- schema: {report.schema_version}",
|
|
55
|
+
f"- rows: {report.input.get('rows')}",
|
|
56
|
+
f"- users: {report.input.get('users')}",
|
|
57
|
+
f"- items: {report.input.get('items')}",
|
|
58
|
+
]
|
|
59
|
+
splits = report.input.get("splits")
|
|
60
|
+
if isinstance(splits, dict):
|
|
61
|
+
rendered = ", ".join(f"{key}={value}" for key, value in sorted(splits.items()))
|
|
62
|
+
lines.append(f"- splits: {rendered}")
|
|
63
|
+
return lines
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _finding_lines(finding: Finding) -> list[str]:
|
|
67
|
+
lines = [
|
|
68
|
+
"",
|
|
69
|
+
f"### {finding.severity.upper()} · {finding.check}",
|
|
70
|
+
"",
|
|
71
|
+
f"{finding.message} (count: {finding.count})",
|
|
72
|
+
]
|
|
73
|
+
if finding.examples:
|
|
74
|
+
payload = json.dumps(jsonable(finding.examples), indent=2, ensure_ascii=False)
|
|
75
|
+
lines.extend(["", "```json", payload, "```"])
|
|
76
|
+
return lines
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _metrics_lines(metrics: dict[str, dict[str, object]]) -> list[str]:
|
|
80
|
+
if not metrics:
|
|
81
|
+
return []
|
|
82
|
+
lines = [
|
|
83
|
+
"",
|
|
84
|
+
"## Metrics",
|
|
85
|
+
"",
|
|
86
|
+
"| Split | n | AUC | Accuracy |",
|
|
87
|
+
"| --- | ---: | ---: | ---: |",
|
|
88
|
+
]
|
|
89
|
+
for split in sorted(metrics):
|
|
90
|
+
metric = metrics[split]
|
|
91
|
+
lines.append(
|
|
92
|
+
f"| {split} | {metric.get('n')} | {_format_metric(metric.get('auc'))} "
|
|
93
|
+
f"| {_format_metric(metric.get('acc'))} |"
|
|
94
|
+
)
|
|
95
|
+
return lines
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _limitation_lines() -> list[str]:
|
|
99
|
+
return [
|
|
100
|
+
"",
|
|
101
|
+
"## Limitations",
|
|
102
|
+
"",
|
|
103
|
+
"- Findings are heuristics, not causal proof.",
|
|
104
|
+
"- The multi-KC check needs `item_id`; it is skipped when missing.",
|
|
105
|
+
"- The temporal check assumes timestamps are comparable within a user.",
|
|
106
|
+
"- A passing report does not prove that a model is valid.",
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _format_metric(value: object) -> str:
|
|
111
|
+
if value is None:
|
|
112
|
+
return "n/a"
|
|
113
|
+
if isinstance(value, float):
|
|
114
|
+
return f"{value:.4f}"
|
|
115
|
+
return str(value)
|
kt_audit/schema.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal, cast
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from .config import AuditConfig
|
|
12
|
+
from .errors import AuditError
|
|
13
|
+
|
|
14
|
+
Severity = Literal["error", "warn", "info"]
|
|
15
|
+
Verdict = Literal["pass", "warn", "fail"]
|
|
16
|
+
SPLITS: tuple[str, ...] = ("train", "valid", "test")
|
|
17
|
+
_SPLIT_ALIASES = {
|
|
18
|
+
"train": "train",
|
|
19
|
+
"valid": "valid",
|
|
20
|
+
"validation": "valid",
|
|
21
|
+
"val": "valid",
|
|
22
|
+
"test": "test",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Finding:
|
|
28
|
+
check: str
|
|
29
|
+
severity: Severity
|
|
30
|
+
message: str
|
|
31
|
+
count: int = 0
|
|
32
|
+
examples: tuple[dict[str, object], ...] = ()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Report:
|
|
37
|
+
schema_version: str
|
|
38
|
+
generated_at: str
|
|
39
|
+
input: dict[str, object]
|
|
40
|
+
findings: tuple[Finding, ...]
|
|
41
|
+
metrics: dict[str, dict[str, object]]
|
|
42
|
+
verdict: Verdict
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_missing(value: object) -> bool:
|
|
46
|
+
if value is None or value is pd.NA:
|
|
47
|
+
return True
|
|
48
|
+
if isinstance(value, (list, tuple, set, dict)):
|
|
49
|
+
return False
|
|
50
|
+
try:
|
|
51
|
+
return bool(pd.isna(value))
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def normalize_split(value: object) -> str:
|
|
57
|
+
if _is_missing(value):
|
|
58
|
+
raise AuditError("split is missing")
|
|
59
|
+
text = str(value).strip().lower()
|
|
60
|
+
if text not in _SPLIT_ALIASES:
|
|
61
|
+
raise AuditError(f"invalid split: {value!r}")
|
|
62
|
+
return _SPLIT_ALIASES[text]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_kc_ids(value: object, separator: str) -> tuple[str, ...]:
|
|
66
|
+
if isinstance(value, str):
|
|
67
|
+
text = value.strip()
|
|
68
|
+
if text.startswith(("[", "{")):
|
|
69
|
+
try:
|
|
70
|
+
parsed = json.loads(text)
|
|
71
|
+
except json.JSONDecodeError as exc:
|
|
72
|
+
raise AuditError(f"invalid kc_ids JSON: {value!r}") from exc
|
|
73
|
+
if not isinstance(parsed, list):
|
|
74
|
+
raise AuditError(f"kc_ids JSON must be a list: {value!r}")
|
|
75
|
+
values = parsed
|
|
76
|
+
else:
|
|
77
|
+
values = text.split(separator)
|
|
78
|
+
elif isinstance(value, (list, tuple, set)):
|
|
79
|
+
values = list(value)
|
|
80
|
+
elif _is_missing(value):
|
|
81
|
+
raise AuditError("kc_ids is missing")
|
|
82
|
+
else:
|
|
83
|
+
raise AuditError(f"kc_ids must be a string or list: {value!r}")
|
|
84
|
+
cleaned = tuple(dict.fromkeys(str(item).strip() for item in values if str(item).strip()))
|
|
85
|
+
if not cleaned:
|
|
86
|
+
raise AuditError(f"kc_ids is empty: {value!r}")
|
|
87
|
+
return cleaned
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def jsonable(value: object) -> object:
|
|
91
|
+
if isinstance(value, pd.Timestamp):
|
|
92
|
+
return cast(pd.Timestamp, value).isoformat()
|
|
93
|
+
if isinstance(value, np.datetime64):
|
|
94
|
+
return cast(pd.Timestamp, pd.Timestamp(value)).isoformat()
|
|
95
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
96
|
+
return [jsonable(item) for item in value]
|
|
97
|
+
if isinstance(value, dict):
|
|
98
|
+
return {str(key): jsonable(item) for key, item in value.items()}
|
|
99
|
+
if isinstance(value, np.integer):
|
|
100
|
+
return int(value)
|
|
101
|
+
if isinstance(value, np.floating):
|
|
102
|
+
return float(value)
|
|
103
|
+
if isinstance(value, np.bool_):
|
|
104
|
+
return bool(value)
|
|
105
|
+
if _is_missing(value):
|
|
106
|
+
return None
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def example_from_row(row: pd.Series, columns: tuple[str, ...]) -> dict[str, object]:
|
|
111
|
+
return {column: jsonable(row[column]) for column in columns if column in row.index}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def prepare_frame(df: pd.DataFrame, config: AuditConfig) -> pd.DataFrame:
|
|
115
|
+
if df.empty:
|
|
116
|
+
raise AuditError("input has no rows")
|
|
117
|
+
missing = [column for column in ("user_id", "timestamp", "split", "kc_ids") if column not in df]
|
|
118
|
+
if missing:
|
|
119
|
+
raise AuditError(f"missing required columns: {', '.join(missing)}")
|
|
120
|
+
|
|
121
|
+
out = df.copy().reset_index(drop=True)
|
|
122
|
+
out["_row"] = np.arange(1, len(out) + 1)
|
|
123
|
+
out["user_id"] = _to_string(_series(out, "user_id"), "user_id", required=True)
|
|
124
|
+
out["split"] = _map_rows(_series(out, "split"), normalize_split, "split")
|
|
125
|
+
out["kc_ids"] = _map_rows(
|
|
126
|
+
_series(out, "kc_ids"),
|
|
127
|
+
lambda value: parse_kc_ids(value, config.kc_separator),
|
|
128
|
+
"kc_ids",
|
|
129
|
+
)
|
|
130
|
+
out["timestamp"] = _parse_timestamps(_series(out, "timestamp"))
|
|
131
|
+
|
|
132
|
+
if "item_id" in out.columns:
|
|
133
|
+
out["item_id"] = _to_string(_series(out, "item_id"), "item_id", required=False)
|
|
134
|
+
else:
|
|
135
|
+
out["item_id"] = pd.Series(pd.NA, index=out.index, dtype="string")
|
|
136
|
+
|
|
137
|
+
if "fold" in out.columns:
|
|
138
|
+
out["_fold"] = _to_string(_series(out, "fold"), "fold", required=False).fillna("__all__")
|
|
139
|
+
else:
|
|
140
|
+
out["_fold"] = "__all__"
|
|
141
|
+
|
|
142
|
+
if "sequence_id" in out.columns:
|
|
143
|
+
out["sequence_id"] = _to_string(
|
|
144
|
+
_series(out, "sequence_id"),
|
|
145
|
+
"sequence_id",
|
|
146
|
+
required=False,
|
|
147
|
+
)
|
|
148
|
+
if "position" in out.columns:
|
|
149
|
+
out["position"] = pd.to_numeric(_series(out, "position"), errors="coerce")
|
|
150
|
+
|
|
151
|
+
has_pred = "y_pred" in out.columns
|
|
152
|
+
has_true = "y_true" in out.columns
|
|
153
|
+
if has_pred and not has_true:
|
|
154
|
+
raise AuditError("y_pred requires y_true")
|
|
155
|
+
if has_true:
|
|
156
|
+
out["y_true"] = pd.to_numeric(_series(out, "y_true"), errors="coerce")
|
|
157
|
+
if has_pred:
|
|
158
|
+
out["y_pred"] = pd.to_numeric(_series(out, "y_pred"), errors="coerce")
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _series(df: pd.DataFrame, name: str) -> pd.Series:
|
|
163
|
+
return cast(pd.Series, df[name])
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _to_string(series: pd.Series, name: str, *, required: bool) -> pd.Series:
|
|
167
|
+
if required and bool(series.isna().any()):
|
|
168
|
+
raise AuditError(f"{name} contains missing values")
|
|
169
|
+
return series.astype("string")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _map_rows(
|
|
173
|
+
series: pd.Series,
|
|
174
|
+
func: Callable[[object], object],
|
|
175
|
+
name: str,
|
|
176
|
+
) -> pd.Series:
|
|
177
|
+
values: list[object] = []
|
|
178
|
+
for row_number, value in enumerate(series, start=1):
|
|
179
|
+
try:
|
|
180
|
+
values.append(func(value))
|
|
181
|
+
except AuditError as exc:
|
|
182
|
+
raise AuditError(f"row {row_number} column {name}: {exc}") from exc
|
|
183
|
+
return pd.Series(values, index=series.index, dtype="object")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_timestamps(series: pd.Series) -> pd.Series:
|
|
187
|
+
if bool(series.isna().any()):
|
|
188
|
+
raise AuditError("timestamp contains missing values")
|
|
189
|
+
if pd.api.types.is_numeric_dtype(series):
|
|
190
|
+
return series.astype("float64")
|
|
191
|
+
try:
|
|
192
|
+
parsed = pd.to_datetime(series, errors="raise", utc=True)
|
|
193
|
+
except (TypeError, ValueError) as exc:
|
|
194
|
+
raise AuditError(f"invalid timestamp: {exc}") from exc
|
|
195
|
+
if bool(parsed.isna().any()):
|
|
196
|
+
raise AuditError("timestamp contains unparseable values")
|
|
197
|
+
return parsed
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: kt-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Audit knowledge-tracing evaluation before you trust the AUC.
|
|
5
|
+
Project-URL: Homepage, https://github.com/summeriver13/kt-audit
|
|
6
|
+
Project-URL: Repository, https://github.com/summeriver13/kt-audit
|
|
7
|
+
Project-URL: Issues, https://github.com/summeriver13/kt-audit/issues
|
|
8
|
+
Author: Xia Jiang
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: benchmark,education,evaluation,knowledge-tracing,leakage
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: numpy>=1.26
|
|
24
|
+
Requires-Dist: pandas>=2.1
|
|
25
|
+
Requires-Dist: scikit-learn>=1.4
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: basedpyright>=1.17; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# kt-audit
|
|
34
|
+
|
|
35
|
+
**Before you trust an AUC, audit the evaluation.**
|
|
36
|
+
|
|
37
|
+
`kt-audit` is a small, opinionated audit tool for knowledge-tracing (KT) experiments. It does not train models. It checks whether the evaluation setup behind a reported AUC is trustworthy.
|
|
38
|
+
|
|
39
|
+
Status: **alpha (0.1.0)**. The scope is intentionally narrow; see [docs/charter.md](docs/charter.md) for the scope and non-goals.
|
|
40
|
+
|
|
41
|
+
## Why
|
|
42
|
+
|
|
43
|
+
KT papers and libraries report AUC, but the evaluation protocol is often under-specified:
|
|
44
|
+
|
|
45
|
+
- Are train events strictly before validation/test events for each user?
|
|
46
|
+
- Does the same item appear in train and test?
|
|
47
|
+
- When an item has multiple knowledge components (KCs), are some KCs train-only and others test-only?
|
|
48
|
+
- Do the reported metrics match a recomputation from the released predictions?
|
|
49
|
+
|
|
50
|
+
`kt-audit` turns those questions into a machine-readable report you can attach to a paper artifact, a PR, or a model release.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
Not on PyPI yet. From source:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone https://github.com/summeriver13/kt-audit
|
|
58
|
+
cd kt-audit
|
|
59
|
+
uv sync
|
|
60
|
+
uv run kt-audit --help
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or with pip:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install -e .
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Requires Python 3.10+.
|
|
70
|
+
|
|
71
|
+
## Quickstart
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv run kt-audit run examples/leaky.csv --out /tmp/kt-audit-report
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Output:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
verdict: fail
|
|
81
|
+
findings: 6 error, 2 warn
|
|
82
|
+
report: /tmp/kt-audit-report/report.md
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The command writes:
|
|
86
|
+
|
|
87
|
+
- `report.json` — stable, machine-readable.
|
|
88
|
+
- `report.md` — human-readable, with examples and limitations.
|
|
89
|
+
|
|
90
|
+
Exit codes: `0` pass/warn, `1` fail, `2` input/config error.
|
|
91
|
+
|
|
92
|
+
## What it checks
|
|
93
|
+
|
|
94
|
+
| Check | Catches | Default |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| `temporal_split` | train timestamps after validation/test; users across splits | error / warn |
|
|
97
|
+
| `multi_kc` | same item with train-only and test-only KCs | error |
|
|
98
|
+
| `alignment` | missing/duplicate/out-of-range predictions; constant predictions; reported AUC mismatch | error / warn |
|
|
99
|
+
|
|
100
|
+
Each finding has a `severity`, a `count`, and up to three examples.
|
|
101
|
+
|
|
102
|
+
## Input
|
|
103
|
+
|
|
104
|
+
One row per interaction or prediction. CSV or JSONL.
|
|
105
|
+
|
|
106
|
+
| Column | Required | Notes |
|
|
107
|
+
| --- | :--: | --- |
|
|
108
|
+
| `user_id` | yes | non-null |
|
|
109
|
+
| `timestamp` | yes | ISO8601 or numeric; comparable within a user |
|
|
110
|
+
| `split` | yes | `train` / `valid` / `test` |
|
|
111
|
+
| `kc_ids` | yes | pipe-separated KCs or JSON array |
|
|
112
|
+
| `item_id` | no | needed for multi-KC check |
|
|
113
|
+
| `y_true` | no | 0/1; required with `y_pred` |
|
|
114
|
+
| `y_pred` | no | [0,1]; omit to skip metric checks |
|
|
115
|
+
| `fold` | no | checks run per fold |
|
|
116
|
+
| `sequence_id`, `position` | no | reserved for sequence checks |
|
|
117
|
+
|
|
118
|
+
See [docs/data-contract.md](docs/data-contract.md) for the full contract.
|
|
119
|
+
|
|
120
|
+
## Python API
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from kt_audit import AuditConfig, audit_path
|
|
124
|
+
|
|
125
|
+
report = audit_path("events.csv", AuditConfig(reported_auc=0.80))
|
|
126
|
+
print(report.verdict)
|
|
127
|
+
for finding in report.findings:
|
|
128
|
+
print(finding.severity, finding.check, finding.count)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Design constraints
|
|
132
|
+
|
|
133
|
+
- Runtime dependencies: `numpy`, `pandas`, `scikit-learn`.
|
|
134
|
+
- CLI uses `argparse`; config uses JSON.
|
|
135
|
+
- Each check is a pure function over a prepared `DataFrame`.
|
|
136
|
+
- Synthetic examples only; no dataset is bundled.
|
|
137
|
+
- Findings are heuristics, not causal proof.
|
|
138
|
+
|
|
139
|
+
## Development
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
uv sync --extra dev
|
|
143
|
+
uv run ruff check .
|
|
144
|
+
uv run ruff format --check .
|
|
145
|
+
uv run basedpyright
|
|
146
|
+
uv run pytest --cov=kt_audit --cov-report=term-missing
|
|
147
|
+
bash scripts/wheel-smoke.sh
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Release
|
|
151
|
+
|
|
152
|
+
See [docs/release.md](docs/release.md). TestPyPI and PyPI publish through GitHub Actions trusted publishing.
|
|
153
|
+
|
|
154
|
+
## Roadmap
|
|
155
|
+
|
|
156
|
+
- v0.2: pyKT adapter, bootstrap AUC intervals, HTML report.
|
|
157
|
+
- v0.3: duplicate interactions, cold-start, sequence truncation, label leakage.
|
|
158
|
+
|
|
159
|
+
## AI disclosure
|
|
160
|
+
|
|
161
|
+
AI tools are used in development. The maintainer reviews every check definition, test, and release. Findings generated by the tool are deterministic and do not call an LLM.
|
|
162
|
+
|
|
163
|
+
## Contributing
|
|
164
|
+
|
|
165
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). The v0.1 scope is frozen to the three checks above.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
kt_audit/__init__.py,sha256=3YMGd4msQtq1hRtjMpw3tK9BXJ1iRWdE0Syh5qNgvxc,315
|
|
2
|
+
kt_audit/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
|
|
3
|
+
kt_audit/audit.py,sha256=NvRXC8ErLxSuiy3GY-erSaR3DgaiJcqgH0OPINcoxTc,2698
|
|
4
|
+
kt_audit/cli.py,sha256=knCQr9Nf_jJOvcs4QzpgmefDc6fEcVCeb16z2vOfaDM,2452
|
|
5
|
+
kt_audit/config.py,sha256=9dMJiPAA2e-xEluK5dNjEaIVY81yyV1OOGUOsrcthtk,1737
|
|
6
|
+
kt_audit/errors.py,sha256=u_CUNg6CxePNPjPe2n9cHvn1x0YrlHUIa8GCcXcWDHo,98
|
|
7
|
+
kt_audit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
kt_audit/report.py,sha256=J5IH-6hs-Ki6dHSoRbeuvb2ADfZZZeuCUWFezNKz0Pk,3451
|
|
9
|
+
kt_audit/schema.py,sha256=FZqjFfce_W9DlvPb-QwfmzIVoKzmmCnarKsWd0j6NcU,6460
|
|
10
|
+
kt_audit/checks/__init__.py,sha256=wYQlGZn-einHubcbr7TRMFKo04Uw7s3Nyo9mjPX1iOA,177
|
|
11
|
+
kt_audit/checks/_common.py,sha256=_TL9dre3mXK81AQHaHOFtzCwhl9s3IoMimeVZURZbjI,1334
|
|
12
|
+
kt_audit/checks/alignment.py,sha256=rYipLa5-mc2IW6yqre1swU10Chys0e--NUq14I9jBog,5618
|
|
13
|
+
kt_audit/checks/multikc.py,sha256=GaBj4OUfIOA2oJZCfLxFjakCbMblBUb-U7yFDYZt1UM,4533
|
|
14
|
+
kt_audit/checks/temporal.py,sha256=XaJrMqkBE3yWvyB3rOkUnRm0zWoAexOZpsk3ervTR9k,3193
|
|
15
|
+
kt_audit-0.1.0.dist-info/METADATA,sha256=yiqAS2hb-uhIW5v8XXTijwZVwjnFjg9QQ4CJy1JB_pk,5255
|
|
16
|
+
kt_audit-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
17
|
+
kt_audit-0.1.0.dist-info/entry_points.txt,sha256=0mkO5jFlNxsm2QjnHBNKhsWX6j24XToQi7aD3cEUs34,47
|
|
18
|
+
kt_audit-0.1.0.dist-info/licenses/LICENSE,sha256=Es5McTiFUOG7ZXX6wUvNrgnLJBHt6iZHZjhz9PYbx9g,1066
|
|
19
|
+
kt_audit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Xia Jiang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|