Spaces:
Running
Running
| """Fine-tune a Hugging Face token classifier for open place-chat slots. | |
| The input files are JSONL. Each non-empty line has this shape:: | |
| { | |
| "text": "Quiero donas artesanales sin ruido", | |
| "spans": [ | |
| {"start": 7, "end": 25, "slot": "CATEGORY"}, | |
| {"start": 30, "end": 35, "slot": "EXCLUSION"} | |
| ] | |
| } | |
| Offsets use Python's half-open character convention: ``text[start:end]``. | |
| Only domain-independent slot types are accepted. Category values remain raw | |
| text, so this training path does not recreate a closed business taxonomy. | |
| Imports for Transformers and PyTorch are deliberately lazy. Importing this | |
| module, validating data, and running ``--help`` do not require the optional ML | |
| runtime. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import inspect | |
| import json | |
| import math | |
| from dataclasses import dataclass | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Any, Mapping, Sequence | |
| SLOT_TYPES: tuple[str, ...] = ( | |
| "CATEGORY", | |
| "PREFERENCE", | |
| "EXCLUSION", | |
| "LOCATION", | |
| "REFERENCE", | |
| "RADIUS", | |
| ) | |
| LABELS: tuple[str, ...] = ( | |
| "O", | |
| *(label for slot in SLOT_TYPES for label in (f"B-{slot}", f"I-{slot}")), | |
| ) | |
| LABEL_TO_ID: Mapping[str, int] = {label: index for index, label in enumerate(LABELS)} | |
| ID_TO_LABEL: Mapping[int, str] = {index: label for label, index in LABEL_TO_ID.items()} | |
| IGNORED_LABEL_ID = -100 | |
| class LabeledSpan: | |
| """One validated, open-value slot annotation.""" | |
| start: int | |
| end: int | |
| slot: str | |
| class IntentTrainingExample: | |
| """One validated token-classification example.""" | |
| text: str | |
| spans: tuple[LabeledSpan, ...] | |
| def validate_training_example( | |
| payload: Any, | |
| *, | |
| context: str = "example", | |
| ) -> IntentTrainingExample: | |
| """Validate one JSON-compatible example and normalize slot casing. | |
| Spans must be disjoint. This is stricter than silently selecting one label | |
| for an overlapping token and makes annotation errors fail before training. | |
| """ | |
| if not isinstance(payload, Mapping): | |
| raise ValueError(f"{context}: expected a JSON object") | |
| text = payload.get("text") | |
| if not isinstance(text, str) or not text.strip(): | |
| raise ValueError(f"{context}: text must be a non-empty string") | |
| raw_spans = payload.get("spans") | |
| if not isinstance(raw_spans, list): | |
| raise ValueError(f"{context}: spans must be a list") | |
| spans: list[LabeledSpan] = [] | |
| for index, raw_span in enumerate(raw_spans): | |
| span_context = f"{context}, span {index}" | |
| if not isinstance(raw_span, Mapping): | |
| raise ValueError(f"{span_context}: expected a JSON object") | |
| start = _integer_offset(raw_span.get("start"), "start", span_context) | |
| end = _integer_offset(raw_span.get("end"), "end", span_context) | |
| if start < 0 or end <= start or end > len(text): | |
| raise ValueError( | |
| f"{span_context}: offsets must satisfy " | |
| f"0 <= start < end <= {len(text)}; got start={start}, end={end}" | |
| ) | |
| if not text[start:end].strip(): | |
| raise ValueError(f"{span_context}: annotated text cannot be blank") | |
| raw_slot = raw_span.get("slot", raw_span.get("label")) | |
| if not isinstance(raw_slot, str) or not raw_slot.strip(): | |
| raise ValueError(f"{span_context}: slot must be a non-empty string") | |
| slot = raw_slot.strip().upper() | |
| if slot not in SLOT_TYPES: | |
| raise ValueError( | |
| f"{span_context}: unsupported slot {raw_slot!r}; expected one of " | |
| + ", ".join(SLOT_TYPES) | |
| ) | |
| spans.append(LabeledSpan(start=start, end=end, slot=slot)) | |
| spans.sort(key=lambda span: (span.start, span.end, span.slot)) | |
| for previous, current in zip(spans, spans[1:]): | |
| if current.start < previous.end: | |
| raise ValueError( | |
| f"{context}: overlapping spans " | |
| f"[{previous.start}, {previous.end}) and " | |
| f"[{current.start}, {current.end})" | |
| ) | |
| return IntentTrainingExample(text=text, spans=tuple(spans)) | |
| def read_jsonl(path: Path) -> list[IntentTrainingExample]: | |
| """Read and validate a UTF-8 JSONL dataset with contextual errors.""" | |
| if not path.is_file(): | |
| raise FileNotFoundError(f"Dataset not found: {path}") | |
| examples: list[IntentTrainingExample] = [] | |
| with path.open("r", encoding="utf-8") as source: | |
| for line_number, raw_line in enumerate(source, start=1): | |
| if not raw_line.strip(): | |
| continue | |
| try: | |
| payload = json.loads(raw_line) | |
| except json.JSONDecodeError as exc: | |
| raise ValueError( | |
| f"{path}, line {line_number}: invalid JSON: {exc.msg}" | |
| ) from exc | |
| examples.append( | |
| validate_training_example( | |
| payload, | |
| context=f"{path}, line {line_number}", | |
| ) | |
| ) | |
| if not examples: | |
| raise ValueError(f"Dataset has no examples: {path}") | |
| return examples | |
| def align_spans_to_token_offsets( | |
| text: str, | |
| spans: Sequence[LabeledSpan], | |
| token_offsets: Sequence[Sequence[int]], | |
| *, | |
| label_to_id: Mapping[str, int] = LABEL_TO_ID, | |
| ) -> list[int]: | |
| """Align character spans to tokenizer offsets using IOB labels. | |
| Any token with a non-empty intersection with a span receives that slot. | |
| This handles subword tokenizers while preserving character-level source | |
| annotations. Special/padding tokens, represented by ``(0, 0)``, receive | |
| the standard ``-100`` ignore label. | |
| Every annotated span must be covered by at least one token. Consequently, | |
| truncation cannot silently turn a positive span into ``O``. | |
| """ | |
| if not isinstance(text, str): | |
| raise TypeError("text must be str") | |
| _validate_label_mapping(label_to_id) | |
| normalized_spans = tuple(spans) | |
| _validate_span_objects(text, normalized_spans) | |
| seen_span_indexes: set[int] = set() | |
| labels: list[int] = [] | |
| previous_token_start = -1 | |
| for token_index, raw_offset in enumerate(token_offsets): | |
| token_start, token_end = _token_offset(raw_offset, token_index, len(text)) | |
| if token_start == token_end == 0: | |
| labels.append(IGNORED_LABEL_ID) | |
| continue | |
| if token_start < previous_token_start: | |
| raise ValueError("token offsets must be ordered by start position") | |
| previous_token_start = token_start | |
| matching = [ | |
| span_index | |
| for span_index, span in enumerate(normalized_spans) | |
| if token_start < span.end and span.start < token_end | |
| ] | |
| if len(matching) > 1: | |
| raise ValueError( | |
| f"token {token_index} [{token_start}, {token_end}) intersects " | |
| "multiple annotated spans" | |
| ) | |
| if not matching: | |
| labels.append(label_to_id["O"]) | |
| continue | |
| span_index = matching[0] | |
| span = normalized_spans[span_index] | |
| prefix = "I" if span_index in seen_span_indexes else "B" | |
| labels.append(label_to_id[f"{prefix}-{span.slot}"]) | |
| seen_span_indexes.add(span_index) | |
| missing = [ | |
| span | |
| for span_index, span in enumerate(normalized_spans) | |
| if span_index not in seen_span_indexes | |
| ] | |
| if missing: | |
| details = ", ".join( | |
| f"{span.slot}[{span.start}, {span.end})" for span in missing | |
| ) | |
| raise ValueError( | |
| "annotated spans were not covered by tokenizer offsets " | |
| f"(possibly truncated): {details}" | |
| ) | |
| return labels | |
| def encode_examples( | |
| examples: Sequence[IntentTrainingExample], | |
| tokenizer: Any, | |
| *, | |
| max_length: int, | |
| ) -> list[dict[str, Any]]: | |
| """Tokenize and align examples without depending on a dataset library.""" | |
| if isinstance(max_length, bool) or not isinstance(max_length, int) or max_length <= 0: | |
| raise ValueError("max_length must be a positive integer") | |
| encoded_examples: list[dict[str, Any]] = [] | |
| for example_index, example in enumerate(examples): | |
| encoding = tokenizer( | |
| example.text, | |
| truncation=True, | |
| max_length=max_length, | |
| return_offsets_mapping=True, | |
| ) | |
| if not isinstance(encoding, Mapping): | |
| raise TypeError( | |
| f"tokenizer output for example {example_index} must be a mapping" | |
| ) | |
| if "offset_mapping" not in encoding: | |
| raise ValueError( | |
| "tokenizer did not return offset_mapping; a fast tokenizer is required" | |
| ) | |
| offsets = encoding["offset_mapping"] | |
| if not isinstance(offsets, Sequence) or isinstance(offsets, (str, bytes)): | |
| raise ValueError( | |
| f"tokenizer offset_mapping for example {example_index} " | |
| "must be a sequence" | |
| ) | |
| feature = { | |
| key: value for key, value in encoding.items() if key != "offset_mapping" | |
| } | |
| feature["labels"] = align_spans_to_token_offsets( | |
| example.text, | |
| example.spans, | |
| offsets, | |
| ) | |
| input_ids = feature.get("input_ids") | |
| if not isinstance(input_ids, Sequence) or isinstance(input_ids, (str, bytes)): | |
| raise ValueError( | |
| f"tokenizer output for example {example_index} has no input_ids sequence" | |
| ) | |
| if len(input_ids) != len(feature["labels"]): | |
| raise ValueError( | |
| f"tokenizer output for example {example_index} has mismatched " | |
| "input_ids and offset_mapping lengths" | |
| ) | |
| encoded_examples.append(feature) | |
| return encoded_examples | |
| def main(argv: Sequence[str] | None = None) -> None: | |
| args = build_parser().parse_args(argv) | |
| _run_training(args) | |
| def build_parser() -> argparse.ArgumentParser: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--train-file", required=True, help="Training JSONL path") | |
| parser.add_argument( | |
| "--validation-file", | |
| help="Optional validation JSONL path", | |
| ) | |
| parser.add_argument("--output-dir", required=True) | |
| parser.add_argument( | |
| "--base-model", | |
| default="dccuchile/bert-base-spanish-wwm-cased", | |
| help="Hugging Face model id or local model directory", | |
| ) | |
| parser.add_argument("--epochs", type=_positive_float, default=3.0) | |
| parser.add_argument("--batch-size", type=_positive_int, default=16) | |
| parser.add_argument("--learning-rate", type=_positive_float, default=2e-5) | |
| parser.add_argument("--max-length", type=_positive_int, default=256) | |
| parser.add_argument("--seed", type=int, default=42) | |
| return parser | |
| def _run_training(args: argparse.Namespace) -> None: | |
| # Optional heavyweight imports stay behind argument parsing so ``--help`` | |
| # remains available in API and CI environments without the ML toolchain. | |
| try: | |
| import transformers | |
| from transformers import ( | |
| AutoModelForTokenClassification, | |
| AutoTokenizer, | |
| DataCollatorForTokenClassification, | |
| Trainer, | |
| TrainingArguments, | |
| set_seed, | |
| ) | |
| except ImportError as exc: | |
| raise RuntimeError( | |
| "Training requires transformers with its PyTorch backend. " | |
| "Install the optional ML dependencies before running this script." | |
| ) from exc | |
| train_path = Path(args.train_file) | |
| validation_path = Path(args.validation_file) if args.validation_file else None | |
| train_examples = read_jsonl(train_path) | |
| validation_examples = read_jsonl(validation_path) if validation_path else [] | |
| set_seed(args.seed) | |
| tokenizer = AutoTokenizer.from_pretrained(args.base_model, use_fast=True) | |
| if not getattr(tokenizer, "is_fast", False): | |
| raise RuntimeError( | |
| "The selected model does not provide a fast tokenizer with offsets" | |
| ) | |
| encoded_train = encode_examples( | |
| train_examples, | |
| tokenizer, | |
| max_length=args.max_length, | |
| ) | |
| encoded_validation = encode_examples( | |
| validation_examples, | |
| tokenizer, | |
| max_length=args.max_length, | |
| ) | |
| model = AutoModelForTokenClassification.from_pretrained( | |
| args.base_model, | |
| num_labels=len(LABELS), | |
| label2id=dict(LABEL_TO_ID), | |
| id2label=dict(ID_TO_LABEL), | |
| ignore_mismatched_sizes=True, | |
| ) | |
| output_dir = Path(args.output_dir) | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| has_validation = bool(encoded_validation) | |
| strategy = "epoch" if has_validation else "no" | |
| training_kwargs: dict[str, Any] = { | |
| "output_dir": str(output_dir), | |
| "num_train_epochs": args.epochs, | |
| "per_device_train_batch_size": args.batch_size, | |
| "per_device_eval_batch_size": args.batch_size, | |
| "learning_rate": args.learning_rate, | |
| "seed": args.seed, | |
| "data_seed": args.seed, | |
| "save_strategy": "epoch", | |
| "logging_strategy": "steps", | |
| "logging_steps": 25, | |
| "report_to": [], | |
| "load_best_model_at_end": has_validation, | |
| } | |
| parameter_names = inspect.signature(TrainingArguments.__init__).parameters | |
| strategy_parameter = ( | |
| "eval_strategy" if "eval_strategy" in parameter_names else "evaluation_strategy" | |
| ) | |
| training_kwargs[strategy_parameter] = strategy | |
| trainer = Trainer( | |
| model=model, | |
| args=TrainingArguments(**training_kwargs), | |
| train_dataset=encoded_train, | |
| eval_dataset=encoded_validation if has_validation else None, | |
| data_collator=DataCollatorForTokenClassification(tokenizer=tokenizer), | |
| tokenizer=tokenizer, | |
| ) | |
| train_result = trainer.train() | |
| trainer.save_model(str(output_dir)) | |
| tokenizer.save_pretrained(str(output_dir)) | |
| manifest = { | |
| "schema_version": 1, | |
| "task": "token-classification", | |
| "architecture": "bert-open-place-intent", | |
| "base_model": args.base_model, | |
| "slots": list(SLOT_TYPES), | |
| "labels": list(LABELS), | |
| "label_to_id": dict(LABEL_TO_ID), | |
| "training_examples": len(train_examples), | |
| "validation_examples": len(validation_examples), | |
| "epochs": args.epochs, | |
| "batch_size": args.batch_size, | |
| "learning_rate": args.learning_rate, | |
| "max_length": args.max_length, | |
| "seed": args.seed, | |
| "train_loss": _finite_or_none(getattr(train_result, "training_loss", None)), | |
| "transformers_version": getattr(transformers, "__version__", "unknown"), | |
| "trained_at": datetime.now(timezone.utc).isoformat(), | |
| } | |
| (output_dir / "place_intent_training_manifest.json").write_text( | |
| json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", | |
| encoding="utf-8", | |
| ) | |
| def _integer_offset(value: Any, field: str, context: str) -> int: | |
| if isinstance(value, bool) or not isinstance(value, int): | |
| raise ValueError(f"{context}: {field} must be an integer") | |
| return value | |
| def _validate_span_objects(text: str, spans: Sequence[LabeledSpan]) -> None: | |
| previous: LabeledSpan | None = None | |
| for index, span in enumerate(spans): | |
| if not isinstance(span, LabeledSpan): | |
| raise TypeError(f"span {index} must be LabeledSpan") | |
| if span.slot not in SLOT_TYPES: | |
| raise ValueError(f"span {index} has unsupported slot {span.slot!r}") | |
| if span.start < 0 or span.end <= span.start or span.end > len(text): | |
| raise ValueError(f"span {index} has invalid offsets") | |
| if previous is not None and span.start < previous.end: | |
| raise ValueError("spans must be sorted and non-overlapping") | |
| previous = span | |
| def _validate_label_mapping(label_to_id: Mapping[str, int]) -> None: | |
| missing = [label for label in LABELS if label not in label_to_id] | |
| if missing: | |
| raise ValueError("label_to_id is missing labels: " + ", ".join(missing)) | |
| def _token_offset( | |
| raw_offset: Sequence[int], | |
| token_index: int, | |
| text_length: int, | |
| ) -> tuple[int, int]: | |
| if ( | |
| isinstance(raw_offset, (str, bytes)) | |
| or not isinstance(raw_offset, Sequence) | |
| or len(raw_offset) != 2 | |
| ): | |
| raise ValueError(f"token offset {token_index} must contain start and end") | |
| start, end = raw_offset | |
| if ( | |
| isinstance(start, bool) | |
| or isinstance(end, bool) | |
| or not isinstance(start, int) | |
| or not isinstance(end, int) | |
| ): | |
| raise ValueError(f"token offset {token_index} must contain integers") | |
| if start == end == 0: | |
| return 0, 0 | |
| if start < 0 or end <= start or end > text_length: | |
| raise ValueError( | |
| f"token offset {token_index} is invalid for text length {text_length}" | |
| ) | |
| return start, end | |
| def _positive_int(value: str) -> int: | |
| parsed = int(value) | |
| if parsed <= 0: | |
| raise argparse.ArgumentTypeError("must be greater than zero") | |
| return parsed | |
| def _positive_float(value: str) -> float: | |
| parsed = float(value) | |
| if not math.isfinite(parsed) or parsed <= 0: | |
| raise argparse.ArgumentTypeError("must be a finite value greater than zero") | |
| return parsed | |
| def _finite_or_none(value: Any) -> float | None: | |
| try: | |
| parsed = float(value) | |
| except (TypeError, ValueError): | |
| return None | |
| return parsed if math.isfinite(parsed) else None | |
| if __name__ == "__main__": | |
| main() | |