Skip to content

Commit 0ff00be

Browse files
feat: add fairness report pack (#46)
1 parent 964596e commit 0ff00be

4 files changed

Lines changed: 295 additions & 0 deletions

File tree

data/fixtures/fairness_report.json

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
{
2+
"suite": "length_matched_en_ko_ja",
3+
"model": "ToyAbilityModel",
4+
"skill": "writing",
5+
"languages": [
6+
"en",
7+
"ko",
8+
"ja"
9+
],
10+
"rows": [
11+
{
12+
"id": "en_daily_notes",
13+
"language": "en",
14+
"char_count": 63,
15+
"token_count": 14,
16+
"score": 53.48,
17+
"cefr": "B1",
18+
"script_bonus": 4.0
19+
},
20+
{
21+
"id": "en_weekend_plan",
22+
"language": "en",
23+
"char_count": 61,
24+
"token_count": 13,
25+
"score": 53.46,
26+
"cefr": "B1",
27+
"script_bonus": 4.0
28+
},
29+
{
30+
"id": "ko_daily_notes",
31+
"language": "ko",
32+
"char_count": 27,
33+
"token_count": 11,
34+
"score": 51.66,
35+
"cefr": "B1",
36+
"script_bonus": 8.0
37+
},
38+
{
39+
"id": "ko_weekend_plan",
40+
"language": "ko",
41+
"char_count": 26,
42+
"token_count": 9,
43+
"score": 50.74,
44+
"cefr": "B1",
45+
"script_bonus": 8.0
46+
},
47+
{
48+
"id": "ja_daily_notes",
49+
"language": "ja",
50+
"char_count": 32,
51+
"token_count": 3,
52+
"score": 58.05,
53+
"cefr": "B1",
54+
"script_bonus": 8.0
55+
},
56+
{
57+
"id": "ja_weekend_plan",
58+
"language": "ja",
59+
"char_count": 35,
60+
"token_count": 3,
61+
"score": 58.05,
62+
"cefr": "B1",
63+
"script_bonus": 8.0
64+
}
65+
],
66+
"by_language": {
67+
"en": {
68+
"n": 2,
69+
"mean_chars": 62,
70+
"mean_tokens": 13.5,
71+
"mean_score": 53.47,
72+
"mean_script_bonus": 4.0
73+
},
74+
"ko": {
75+
"n": 2,
76+
"mean_chars": 26.5,
77+
"mean_tokens": 10,
78+
"mean_score": 51.2,
79+
"mean_script_bonus": 8.0
80+
},
81+
"ja": {
82+
"n": 2,
83+
"mean_chars": 33.5,
84+
"mean_tokens": 3,
85+
"mean_score": 58.05,
86+
"mean_script_bonus": 8.0
87+
}
88+
},
89+
"metrics": {
90+
"score_spread": 6.85,
91+
"token_spread": 10.5,
92+
"max_mean_score_language": "ja",
93+
"min_mean_score_language": "ko"
94+
},
95+
"bias_notes": [
96+
"Token counts vary despite matched content length, indicating tokenizer sensitivity."
97+
],
98+
"mitigations": [
99+
"Track score spread on length-matched multilingual fixtures before releases.",
100+
"Review script-specific tokenization because CJK/Hangul characters are tokenized differently.",
101+
"Calibrate language-specific priors with labeled learner samples before production use."
102+
]
103+
}

docs/FAIRNESS.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Fairness Pack
2+
3+
This pack is a lightweight regression suite for score stability across English, Korean, and Japanese writing-style inputs with similar content length.
4+
5+
Run:
6+
7+
```bash
8+
python -m nokaman.eval.fairness --out data/fixtures/fairness_report.json
9+
```
10+
11+
The generated report includes per-sample rows, per-language means, and two headline metrics:
12+
13+
- `score_spread`: difference between the highest and lowest language mean score.
14+
- `token_spread`: difference between the highest and lowest language mean token count.
15+
16+
The current suite is intentionally small. It is designed to catch obvious regressions in script handling, tokenizer behavior, and language priors before release. It is not a production fairness audit.
17+
18+
Mitigation guidance:
19+
20+
- Keep length-matched multilingual fixtures in CI and review large score spreads before release.
21+
- Inspect tokenizer behavior for Hangul and Japanese scripts because character-based token counts can diverge from English word counts.
22+
- Replace heuristic priors with calibration from labeled learner samples when moving beyond the demo model.

src/nokaman/eval/fairness.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import json
5+
from pathlib import Path
6+
from statistics import mean
7+
8+
from nokaman.config import RUNS_DIR
9+
from nokaman.models.toy import ToyAbilityModel
10+
11+
LANGUAGES = ("en", "ko", "ja")
12+
13+
LENGTH_MATCHED_TEXTS = {
14+
"en": [
15+
(
16+
"en_daily_notes",
17+
"I study each morning, review new words, and write a short diary after class.",
18+
),
19+
(
20+
"en_weekend_plan",
21+
"On Saturday I meet a friend, buy groceries, and explain my plans clearly.",
22+
),
23+
],
24+
"ko": [
25+
(
26+
"ko_daily_notes",
27+
"\uc800\ub294 \ub9e4\uc77c \uc544\uce68 \uacf5\ubd80\ud558\uace0 \uc0c8 \ub2e8\uc5b4\ub97c \ubcf5\uc2b5\ud55c \ub4a4 \uc9e7\uc740 \uc77c\uae30\ub97c \uc501\ub2c8\ub2e4.",
28+
),
29+
(
30+
"ko_weekend_plan",
31+
"\ud1a0\uc694\uc77c\uc5d0 \uce5c\uad6c\ub97c \ub9cc\ub098\uace0 \uc7a5\uc744 \ubcf4\uba70 \ub0b4 \uacc4\ud68d\uc744 \uc27d\uac8c \uc124\uba85\ud569\ub2c8\ub2e4.",
32+
),
33+
],
34+
"ja": [
35+
(
36+
"ja_daily_notes",
37+
"\u6bce\u671d\u52c9\u5f37\u3057\u3066\u3001\u65b0\u3057\u3044\u5358\u8a9e\u3092\u5fa9\u7fd2\u3057\u3001\u6388\u696d\u306e\u5f8c\u306b\u77ed\u3044\u65e5\u8a18\u3092\u66f8\u304d\u307e\u3059\u3002",
38+
),
39+
(
40+
"ja_weekend_plan",
41+
"\u571f\u66dc\u65e5\u306b\u53cb\u9054\u3068\u4f1a\u3044\u3001\u8cb7\u3044\u7269\u3092\u3057\u3066\u3001\u81ea\u5206\u306e\u4e88\u5b9a\u3092\u308f\u304b\u308a\u3084\u3059\u304f\u8aac\u660e\u3057\u307e\u3059\u3002",
42+
),
43+
],
44+
}
45+
46+
47+
def build_fairness_report() -> dict:
48+
rows = []
49+
for language in LANGUAGES:
50+
model = ToyAbilityModel(language)
51+
for sample_id, text in LENGTH_MATCHED_TEXTS[language]:
52+
scored = model.score_text(text, skill="writing")
53+
rows.append(
54+
{
55+
"id": sample_id,
56+
"language": language,
57+
"char_count": _content_chars(text),
58+
"token_count": scored["features"]["tokens"],
59+
"score": scored["score"],
60+
"cefr": scored["cefr"],
61+
"script_bonus": scored["features"]["script_bonus"],
62+
}
63+
)
64+
65+
by_language = {}
66+
for language in LANGUAGES:
67+
lang_rows = [row for row in rows if row["language"] == language]
68+
by_language[language] = {
69+
"n": len(lang_rows),
70+
"mean_chars": round(mean(row["char_count"] for row in lang_rows), 2),
71+
"mean_tokens": round(mean(row["token_count"] for row in lang_rows), 2),
72+
"mean_score": round(mean(row["score"] for row in lang_rows), 2),
73+
"mean_script_bonus": round(mean(row["script_bonus"] for row in lang_rows), 2),
74+
}
75+
76+
mean_scores = [item["mean_score"] for item in by_language.values()]
77+
mean_tokens = [item["mean_tokens"] for item in by_language.values()]
78+
metrics = {
79+
"score_spread": round(max(mean_scores) - min(mean_scores), 2),
80+
"token_spread": round(max(mean_tokens) - min(mean_tokens), 2),
81+
"max_mean_score_language": max(by_language, key=lambda lang: by_language[lang]["mean_score"]),
82+
"min_mean_score_language": min(by_language, key=lambda lang: by_language[lang]["mean_score"]),
83+
}
84+
return {
85+
"suite": "length_matched_en_ko_ja",
86+
"model": "ToyAbilityModel",
87+
"skill": "writing",
88+
"languages": list(LANGUAGES),
89+
"rows": rows,
90+
"by_language": by_language,
91+
"metrics": metrics,
92+
"bias_notes": _bias_notes(metrics),
93+
"mitigations": [
94+
"Track score spread on length-matched multilingual fixtures before releases.",
95+
"Review script-specific tokenization because CJK/Hangul characters are tokenized differently.",
96+
"Calibrate language-specific priors with labeled learner samples before production use.",
97+
],
98+
}
99+
100+
101+
def write_fairness_report(path: Path | None = None) -> Path:
102+
out_path = path or (RUNS_DIR / "fairness_report.json")
103+
out_path.parent.mkdir(parents=True, exist_ok=True)
104+
out_path.write_text(
105+
json.dumps(build_fairness_report(), indent=2, ensure_ascii=True) + "\n",
106+
encoding="utf-8",
107+
)
108+
return out_path
109+
110+
111+
def main(argv: list[str] | None = None) -> int:
112+
parser = argparse.ArgumentParser(description="Generate EN/KO/JA length-matched fairness report.")
113+
parser.add_argument("--out", type=Path, default=RUNS_DIR / "fairness_report.json")
114+
args = parser.parse_args(argv)
115+
path = write_fairness_report(args.out)
116+
print(path)
117+
return 0
118+
119+
120+
def _bias_notes(metrics: dict) -> list[str]:
121+
notes = []
122+
if metrics["score_spread"] >= 10:
123+
notes.append(
124+
"Mean score spread is at least 10 points, so reviewers should inspect language priors."
125+
)
126+
if metrics["token_spread"] >= 10:
127+
notes.append(
128+
"Token counts vary despite matched content length, indicating tokenizer sensitivity."
129+
)
130+
if not notes:
131+
notes.append("No large spread detected in this small fixture; keep monitoring with more data.")
132+
return notes
133+
134+
135+
def _content_chars(text: str) -> int:
136+
return len("".join(ch for ch in text if not ch.isspace()))
137+
138+
139+
if __name__ == "__main__": # pragma: no cover
140+
raise SystemExit(main())

tests/test_fairness.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
6+
from nokaman.eval.fairness import build_fairness_report, write_fairness_report
7+
8+
9+
def test_build_fairness_report_covers_en_ko_ja() -> None:
10+
report = build_fairness_report()
11+
assert report["suite"] == "length_matched_en_ko_ja"
12+
assert set(report["languages"]) == {"en", "ko", "ja"}
13+
assert len(report["rows"]) == 6
14+
assert report["metrics"]["score_spread"] >= 0
15+
assert report["bias_notes"]
16+
17+
18+
def test_write_fairness_report(tmp_path) -> None:
19+
out = write_fairness_report(tmp_path / "fairness_report.json")
20+
payload = json.loads(out.read_text(encoding="utf-8"))
21+
assert payload["model"] == "ToyAbilityModel"
22+
assert "by_language" in payload
23+
assert payload["mitigations"]
24+
25+
26+
def test_committed_fairness_fixture_matches_schema() -> None:
27+
fixture = Path("data/fixtures/fairness_report.json")
28+
payload = json.loads(fixture.read_text(encoding="utf-8"))
29+
assert payload["suite"] == "length_matched_en_ko_ja"
30+
assert set(payload["by_language"]) == {"en", "ko", "ja"}

0 commit comments

Comments
 (0)