Spaces:
Runtime error
Runtime error
Arthur_Diaz
feat(ml): CEFR dataset builder and XLM-R training pipeline with MLflow tracking (#2)
14e67ea unverified | """Prediction and evaluation views for any CEFR checkpoint (ours or a baseline). | |
| Two views are reported, matching ADR 0003: | |
| - "document": doc/paragraph/dialogue-format items, chunk probabilities | |
| aggregated per doc_id by expected-rank mean (the headline product metric); | |
| - "sentence": sentence-format items scored individually. | |
| `predict_probs` reorders model outputs to the canonical level order using the | |
| checkpoint's id2label, so a published baseline with a different label order is | |
| evaluated correctly — or fails loudly if its labels are not CEFR levels. | |
| """ | |
| from collections import defaultdict | |
| from collections.abc import Sequence | |
| from tutor.ml.cefr.aggregation import aggregate_chunk_probs | |
| from tutor.ml.cefr.metrics import classification_report | |
| from tutor.ml.cefr.preprocessing import CANONICAL_LEVELS, Passage | |
| LEVEL_TO_RANK = {level: rank for rank, level in enumerate(CANONICAL_LEVELS)} | |
| DOC_FORMATS = {"document-level", "paragraph-level", "dialogue-level"} | |
| def canonical_column_order(id2label: dict[int, str]) -> list[int]: | |
| """For each canonical level, the model output column that carries it.""" | |
| label_to_column = {str(label).strip().upper(): int(col) for col, label in id2label.items()} | |
| missing = [level for level in CANONICAL_LEVELS if level not in label_to_column] | |
| if missing: | |
| msg = ( | |
| f"model labels {sorted(label_to_column)} do not cover the canonical " | |
| f"CEFR levels (missing {missing}); cannot evaluate this checkpoint" | |
| ) | |
| raise ValueError(msg) | |
| return [label_to_column[level] for level in CANONICAL_LEVELS] | |
| def predict_probs( | |
| model, | |
| tokenizer, | |
| passages: Sequence[Passage], | |
| *, | |
| max_length: int, | |
| batch_size: int = 64, | |
| ) -> list[list[float]]: | |
| """One probability row per passage, columns in canonical level order.""" | |
| import torch # lazy: keeps this module importable without the train group | |
| order = canonical_column_order(model.config.id2label) | |
| device = next(model.parameters()).device | |
| model.eval() | |
| probs: list[list[float]] = [] | |
| with torch.no_grad(): | |
| for start in range(0, len(passages), batch_size): | |
| texts = [p.text for p in passages[start : start + batch_size]] | |
| encoded = tokenizer( | |
| texts, | |
| truncation=True, | |
| max_length=max_length, | |
| padding=True, | |
| return_tensors="pt", | |
| ).to(device) | |
| raw = torch.softmax(model(**encoded).logits, dim=-1).cpu().tolist() | |
| probs.extend([[row[column] for column in order] for row in raw]) | |
| return probs | |
| def evaluate_views( | |
| passages: Sequence[Passage], | |
| probs: Sequence[Sequence[float]], | |
| ) -> dict[str, dict[str, float]]: | |
| """Compute the metric bundle for the document and sentence views.""" | |
| doc_rows: dict[str, list[Sequence[float]]] = defaultdict(list) | |
| doc_levels: dict[str, str] = {} | |
| sentence_true: list[int] = [] | |
| sentence_pred: list[int] = [] | |
| for passage, row in zip(passages, probs, strict=True): | |
| if passage.source_format in DOC_FORMATS: | |
| doc_rows[passage.doc_id].append(row) | |
| doc_levels[passage.doc_id] = passage.level | |
| else: | |
| predicted_level, _ = aggregate_chunk_probs([row]) | |
| sentence_true.append(LEVEL_TO_RANK[passage.level]) | |
| sentence_pred.append(LEVEL_TO_RANK[predicted_level]) | |
| document_true: list[int] = [] | |
| document_pred: list[int] = [] | |
| for doc_id, rows in doc_rows.items(): | |
| predicted_level, _ = aggregate_chunk_probs(rows) | |
| document_true.append(LEVEL_TO_RANK[doc_levels[doc_id]]) | |
| document_pred.append(LEVEL_TO_RANK[predicted_level]) | |
| views: dict[str, dict[str, float]] = {} | |
| if document_true: | |
| views["document"] = classification_report(document_true, document_pred) | |
| if sentence_true: | |
| views["sentence"] = classification_report(sentence_true, sentence_pred) | |
| return views | |
| def lang_filtered( | |
| passages: Sequence[Passage], | |
| probs: Sequence[Sequence[float]], | |
| lang: str, | |
| ) -> tuple[list[Passage], list[list[float]]]: | |
| """Subset (passages, probs) to one language, keeping rows aligned.""" | |
| pairs = [(p, list(row)) for p, row in zip(passages, probs, strict=True) if p.lang == lang] | |
| return [p for p, _ in pairs], [row for _, row in pairs] | |