Show code
import urllib.request
urllib.request.urlretrieve("https://osf.io/download/gezat/", "data_category.csv")
print("downloaded data_category.csv")L2 written-error annotation (and human-vs-tool comparison)
What it is. Sentences from Japanese-EFL essays, each with a human gold error label. The same file also stores a published tool’s predictions, so you can compare your LLM to both.
Difficulty of the labeling judgment: ★★★ — hard. Many error types; some sentences have several.
License: see the OSF project (osf.io/jyf3r)
Cite: Mizumoto (2025), Studies in Second Language Acquisition, 47(3), 867–884.
Every dataset in this course is reshaped into the same canonical schema so one notebook works for all of them:
The raw data, though, looks different every time. That difference is the lesson — half of building a gold standard is getting messy real data into a clean, consistent shape.
The annotations live on the OSF project for the paper. We download one CSV directly by its OSF link.
It is a CSV. The columns we care about are Sentence, Human_ErrorCategories (the gold), and AEA_ErrorCategories (the tool’s prediction). A sentence can carry several comma-separated error codes, or NO_ERROR.
The 23 fine error codes are a lot for one task, so we collapse them into 3 broad families (plus No error). Decisions:
COARSE below).NO_ERROR → No error.COARSE = {}
for c in "ART PREP NUM TENSE VFORM WO AGR DET POSS MOD CONJ STRUCT".split():
COARSE[c] = "Grammatical"
for c in "N ADJ ADV V REF EXPR".split():
COARSE[c] = "Lexical"
for c in "SP MIS UNN CWS PUNC".split():
COARSE[c] = "Mechanical"
def to_label(human_field):
codes = [c.strip() for c in human_field.split(",") if c.strip()]
if not codes:
return None
if codes[0] == "NO_ERROR":
return "No error"
families = {COARSE.get(c) for c in codes} - {None}
return families.pop() if len(families) == 1 else None # skip mixed sentences
rows = []
with open("data_category.csv", encoding="utf-8-sig", newline="") as f:
for row in csv.DictReader(f):
text = (row["Sentence"] or "").strip()
label = to_label((row["Human_ErrorCategories"] or "").strip())
if text and label:
rows.append({"text": text, "label": label})
print("sentences:", len(rows))Four classes × 15 = 60 items.
# Build a small BALANCED gold set: an equal number of items per label.
# Balance matters so precision/recall/F1 and the confusion matrix are meaningful.
import random
from collections import defaultdict
PER_LABEL = 15 # how many items per label
random.seed(42) # fixed seed = same sample every run (reproducible)
by_label = defaultdict(list)
for item in rows:
by_label[item["label"]].append(item)
gold = []
for label in sorted(by_label):
bucket = by_label[label]
random.shuffle(bucket)
gold.extend(bucket[:PER_LABEL])
random.shuffle(gold)
gold = [{"id": i + 1, "text": x["text"], "label": x["label"]} for i, x in enumerate(gold)]
from collections import Counter
print("items:", len(gold), "| per label:", dict(Counter(x["label"] for x in gold)))You built l2_errors.json. Because the raw file also has the tool’s predictions, a great mini-project is to compare your LLM vs. the human gold vs. the original tool.