polyglot-tutor / src /tutor /ml /cefr /preprocessing.py
Arthur_Diaz
feat(ml): CEFR dataset builder and XLM-R training pipeline with MLflow tracking (#2)
14e67ea unverified
Raw
History Blame Contribute Delete
4.63 kB
"""Label normalisation and passage chunking for the CEFR classifier (ADR 0003).
Lives in the runtime package (not training/) on purpose: inference must apply
the *exact* same preprocessing as training (no train/serve skew), and these
functions are dependency-free so they run in CI without the train group.
"""
import re
from dataclasses import dataclass
from tutor.domain.models import CEFRLevel
CANONICAL_LEVELS: tuple[str, ...] = tuple(level.value for level in CEFRLevel)
# Evidence for the drop list: docs/evals/m1_data_eda_all.md ("odd labels" detail).
# Bare macro levels (A/B/C) are ambiguous between two canonical levels -> dropped.
_DROP_LABELS = {"", "NA", "N/A", "EMPTY", "UNRATED", "UNASSESSABLE", "A", "B", "C"}
_SENTENCE_BOUNDARY = re.compile(r"(?<=[.!?])\s+")
def normalize_level(raw: str | None) -> str | None:
"""Map a raw label to a canonical level, or ``None`` if the row must be dropped.
"X+" maps to "X": under an ordinal reading, X+ sits between X and the next
level, so flooring is the conservative choice (keeps 114 elg_en and 1,505
elg_nl rows).
"""
if raw is None:
return None
label = raw.strip().upper()
if label in _DROP_LABELS:
return None
if label.endswith("+"):
label = label[:-1]
return label if label in CANONICAL_LEVELS else None
@dataclass(frozen=True)
class Passage:
"""The training/inference unit: a sentence, or a chunk of a longer text."""
text: str
level: str
lang: str
corpus: str
doc_id: str
source_format: str
def split_sentences(text: str) -> list[str]:
"""Naive sentence split on terminal punctuation; good enough for packing."""
return [s for s in _SENTENCE_BOUNDARY.split(text.strip()) if s]
def _hard_split(sentence: str, max_words: int) -> list[str]:
"""Last resort for a single 'sentence' longer than max_words (lists, no punctuation)."""
words = sentence.split()
return [" ".join(words[i : i + max_words]) for i in range(0, len(words), max_words)]
def chunk_text(
text: str,
*,
target_words: int = 200,
max_words: int = 300,
min_tail_words: int = 50,
) -> list[str]:
"""Greedy sentence packing into ~target_words chunks (never above max_words).
A short final chunk (< min_tail_words) is merged into the previous one so no
chunk is uninformatively small. Texts already within max_words pass through.
"""
stripped = text.strip()
if not stripped:
return []
if len(stripped.split()) <= max_words:
return [stripped]
sentences: list[str] = []
for sentence in split_sentences(stripped):
if len(sentence.split()) > max_words:
sentences.extend(_hard_split(sentence, max_words))
else:
sentences.append(sentence)
chunks: list[list[str]] = []
current: list[str] = []
current_words = 0
for sentence in sentences:
n_words = len(sentence.split())
if current and current_words + n_words > max_words:
chunks.append(current)
current, current_words = [], 0
current.append(sentence)
current_words += n_words
if current_words >= target_words:
chunks.append(current)
current, current_words = [], 0
if current:
if chunks and current_words < min_tail_words:
chunks[-1].extend(current)
else:
chunks.append(current)
return [" ".join(chunk) for chunk in chunks]
def passages_from_record(
*,
text: str | None,
level_raw: str | None,
lang: str,
corpus: str,
doc_id: str,
source_format: str,
chunking: bool = True,
target_words: int = 200,
max_words: int = 300,
) -> list[Passage]:
"""Apply the full ADR 0003 record policy: label mapping, dropping, chunking.
Sentence-level rows always pass through unchunked. Longer formats are
chunked (chunks inherit the document label: weak labels, accepted and
documented) unless ``chunking=False`` (the truncation experiment arm).
"""
level = normalize_level(level_raw)
if level is None or not text or not text.strip():
return []
def passage(chunk: str) -> Passage:
return Passage(
text=chunk,
level=level,
lang=lang,
corpus=corpus,
doc_id=doc_id,
source_format=source_format,
)
if not chunking or source_format == "sentence-level":
return [passage(text.strip())]
return [
passage(chunk) for chunk in chunk_text(text, target_words=target_words, max_words=max_words)
]