article-topic-service / Training /train_classifier.py
Ian-Khalzov's picture
Add training materials and sample PDF
5fb9d8c verified
Raw
History Blame Contribute Delete
17.2 kB
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
import os
from datetime import datetime, timezone
from pathlib import Path
import evaluate
import numpy as np
import torch
from datasets import DatasetDict, load_dataset
from sklearn.metrics import classification_report, confusion_matrix
from transformers import (
AutoModelForSequenceClassification,
AutoTokenizer,
DataCollatorWithPadding,
EarlyStoppingCallback,
Trainer,
TrainingArguments,
)
DEFAULT_MODEL_NAME = "allenai/scibert_scivocab_uncased"
DEFAULT_PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_DATA_DIR = DEFAULT_PROJECT_ROOT / "data" / "processed"
DEFAULT_OUTPUT_DIR = DEFAULT_PROJECT_ROOT / "artifacts" / "scibert_topics12"
DEFAULT_MAX_LENGTH = 256
DEFAULT_TRAIN_BATCH_SIZE = 4
DEFAULT_EVAL_BATCH_SIZE = 8
DEFAULT_GRAD_ACCUM_STEPS = 8
DEFAULT_NUM_EPOCHS = 12
DEFAULT_LEARNING_RATE = 2e-5
DEFAULT_WEIGHT_DECAY = 0.01
DEFAULT_WARMUP_RATIO = 0.1
DEFAULT_LABEL_SMOOTHING = 0.05
DEFAULT_TITLE_ONLY_PROB = 0.2
DEFAULT_EARLY_STOPPING_PATIENCE = 3
DEFAULT_SEED = 42
AUTO_RESUME_TOKEN = "auto"
MODEL_FEATURE_COLUMNS = {"input_ids", "attention_mask", "token_type_ids", "labels"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Train a local article topic classifier on the prepared CSV dataset."
)
parser.add_argument(
"--data-dir",
type=Path,
default=DEFAULT_DATA_DIR,
help="Directory with train.csv / validation.csv / test.csv / dataset_meta.json.",
)
parser.add_argument(
"--output-dir",
type=Path,
default=DEFAULT_OUTPUT_DIR,
help="Directory where checkpoints and final artifacts will be stored.",
)
parser.add_argument(
"--model-name",
default=DEFAULT_MODEL_NAME,
help="HF model name to fine-tune.",
)
parser.add_argument(
"--max-length",
type=int,
default=DEFAULT_MAX_LENGTH,
help="Tokenizer max_length.",
)
parser.add_argument(
"--per-device-train-batch-size",
type=int,
default=DEFAULT_TRAIN_BATCH_SIZE,
help="Micro-batch size on one GPU.",
)
parser.add_argument(
"--per-device-eval-batch-size",
type=int,
default=DEFAULT_EVAL_BATCH_SIZE,
help="Eval micro-batch size on one GPU.",
)
parser.add_argument(
"--gradient-accumulation-steps",
type=int,
default=DEFAULT_GRAD_ACCUM_STEPS,
help="Gradient accumulation steps.",
)
parser.add_argument(
"--num-train-epochs",
type=float,
default=DEFAULT_NUM_EPOCHS,
help="Maximum number of epochs.",
)
parser.add_argument(
"--learning-rate",
type=float,
default=DEFAULT_LEARNING_RATE,
help="AdamW learning rate.",
)
parser.add_argument(
"--weight-decay",
type=float,
default=DEFAULT_WEIGHT_DECAY,
help="AdamW weight decay.",
)
parser.add_argument(
"--warmup-ratio",
type=float,
default=DEFAULT_WARMUP_RATIO,
help="Warmup ratio for cosine scheduler.",
)
parser.add_argument(
"--label-smoothing-factor",
type=float,
default=DEFAULT_LABEL_SMOOTHING,
help="Label smoothing factor.",
)
parser.add_argument(
"--title-only-prob",
type=float,
default=DEFAULT_TITLE_ONLY_PROB,
help="Fraction of train examples that will be converted to title-only inputs.",
)
parser.add_argument(
"--early-stopping-patience",
type=int,
default=DEFAULT_EARLY_STOPPING_PATIENCE,
help="Number of eval rounds without improvement before stopping.",
)
parser.add_argument(
"--seed",
type=int,
default=DEFAULT_SEED,
help="Random seed.",
)
parser.add_argument(
"--dataloader-num-workers",
type=int,
default=2,
help="Number of dataloader workers.",
)
parser.add_argument(
"--expected-taxonomy-profile",
default="topics12",
help="Abort if prepared data was built with another taxonomy profile.",
)
parser.add_argument(
"--resume-from-checkpoint",
default=None,
help=(
"Checkpoint path to resume from, or 'auto' to reuse the latest "
"checkpoint already present in output-dir."
),
)
parser.add_argument(
"--skip-train",
action="store_true",
help="Skip fine-tuning and only run evaluation with the loaded model.",
)
return parser.parse_args()
def build_input_text(title: str, abstract: str, title_only: bool) -> str:
title = (title or "").strip()
abstract = (abstract or "").strip()
if title_only or not abstract:
return f"Title: {title}"
return f"Title: {title}\n\nAbstract: {abstract}"
def should_use_title_only(paper_id: str, probability: float, seed: int) -> bool:
if probability <= 0:
return False
if probability >= 1:
return True
digest = hashlib.md5(f"{paper_id}:{seed}".encode("utf-8")).hexdigest()
bucket = int(digest[:8], 16) / 0xFFFFFFFF
return bucket < probability
def load_metadata(path: Path) -> dict:
if not path.exists():
raise FileNotFoundError(f"Missing dataset metadata: {path}")
return json.loads(path.read_text(encoding="utf-8"))
def resolve_resume_checkpoint(output_dir: Path, checkpoint_arg: str | None) -> str | None:
if not checkpoint_arg:
return None
if checkpoint_arg != AUTO_RESUME_TOKEN:
checkpoint_path = Path(checkpoint_arg)
if not checkpoint_path.exists():
raise FileNotFoundError(f"Checkpoint does not exist: {checkpoint_path}")
return str(checkpoint_path)
checkpoints = sorted(
output_dir.glob("checkpoint-*"),
key=lambda path: int(path.name.split("-")[-1]),
)
if not checkpoints:
return None
return str(checkpoints[-1])
def build_datasets(
data_dir: Path,
tokenizer,
max_length: int,
title_only_prob: float,
seed: int,
) -> tuple[DatasetDict, DatasetDict]:
data_files = {
"train": str(data_dir / "train.csv"),
"validation": str(data_dir / "validation.csv"),
"test": str(data_dir / "test.csv"),
}
raw = load_dataset("csv", data_files=data_files)
def add_text(example, split_name: str):
title_only = split_name == "train" and should_use_title_only(
example["paper_id"], title_only_prob, seed
)
return {
"model_text": build_input_text(
example.get("title", ""),
example.get("abstract", ""),
title_only=title_only,
),
"labels": int(example["label_id"]),
}
text_ready = DatasetDict()
for split_name, split_dataset in raw.items():
text_ready[split_name] = split_dataset.map(
lambda row, split_name=split_name: add_text(row, split_name),
desc=f"Building model text for {split_name}",
)
def tokenize_batch(batch):
return tokenizer(
batch["model_text"],
truncation=True,
max_length=max_length,
)
tokenized = text_ready.map(
tokenize_batch,
batched=True,
desc="Tokenizing",
)
for split_name, split_dataset in tokenized.items():
removable = [
column_name
for column_name in split_dataset.column_names
if column_name not in MODEL_FEATURE_COLUMNS
]
tokenized[split_name] = split_dataset.remove_columns(removable)
return text_ready, tokenized
def main() -> None:
args = parse_args()
os.environ["TOKENIZERS_PARALLELISM"] = "false"
if torch.cuda.is_available():
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
torch.set_float32_matmul_precision("high")
metadata = load_metadata(args.data_dir / "dataset_meta.json")
taxonomy_profile = metadata.get("taxonomy_profile")
if args.expected_taxonomy_profile and taxonomy_profile != args.expected_taxonomy_profile:
raise ValueError(
f"Prepared dataset taxonomy_profile={taxonomy_profile!r}, "
f"expected {args.expected_taxonomy_profile!r}"
)
label_order = metadata["label_order"]
label_display_names = metadata["label_display_names"]
id2label = {
idx: label_display_names[label]
for idx, label in enumerate(label_order)
}
label2id = {display_name: idx for idx, display_name in id2label.items()}
output_dir = args.output_dir
output_dir.mkdir(parents=True, exist_ok=True)
resume_checkpoint = resolve_resume_checkpoint(
output_dir=output_dir,
checkpoint_arg=args.resume_from_checkpoint,
)
tokenizer = AutoTokenizer.from_pretrained(args.model_name, use_fast=True)
text_ready, tokenized = build_datasets(
data_dir=args.data_dir,
tokenizer=tokenizer,
max_length=args.max_length,
title_only_prob=args.title_only_prob,
seed=args.seed,
)
model = AutoModelForSequenceClassification.from_pretrained(
args.model_name,
num_labels=len(label_order),
id2label=id2label,
label2id=label2id,
)
accuracy_metric = evaluate.load("accuracy")
f1_metric = evaluate.load("f1")
def compute_metrics(eval_pred):
predictions, labels = eval_pred
if predictions.ndim > 1:
predictions = predictions.argmax(axis=-1)
return {
"accuracy": accuracy_metric.compute(
predictions=predictions, references=labels
)["accuracy"],
"macro_f1": f1_metric.compute(
predictions=predictions, references=labels, average="macro"
)["f1"],
}
def preprocess_logits_for_metrics(logits, labels):
if isinstance(logits, tuple):
logits = logits[0]
return logits.argmax(dim=-1)
use_bf16 = torch.cuda.is_available() and torch.cuda.is_bf16_supported()
use_fp16 = torch.cuda.is_available() and not use_bf16
training_args = TrainingArguments(
output_dir=str(output_dir),
do_train=True,
do_eval=True,
eval_strategy="epoch",
save_strategy="epoch",
logging_strategy="steps",
logging_steps=50,
save_total_limit=2,
per_device_train_batch_size=args.per_device_train_batch_size,
per_device_eval_batch_size=args.per_device_eval_batch_size,
gradient_accumulation_steps=args.gradient_accumulation_steps,
num_train_epochs=args.num_train_epochs,
learning_rate=args.learning_rate,
lr_scheduler_type="cosine",
warmup_ratio=args.warmup_ratio,
weight_decay=args.weight_decay,
label_smoothing_factor=args.label_smoothing_factor,
bf16=use_bf16,
fp16=use_fp16,
tf32=True if torch.cuda.is_available() else None,
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
eval_accumulation_steps=8,
torch_empty_cache_steps=100,
max_grad_norm=1.0,
metric_for_best_model="macro_f1",
greater_is_better=True,
load_best_model_at_end=True,
seed=args.seed,
data_seed=args.seed,
dataloader_num_workers=args.dataloader_num_workers,
report_to="none",
save_only_model=False,
remove_unused_columns=True,
disable_tqdm=False,
logging_first_step=True,
)
data_collator = DataCollatorWithPadding(tokenizer=tokenizer, pad_to_multiple_of=8)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized["train"],
eval_dataset=tokenized["validation"],
data_collator=data_collator,
processing_class=tokenizer,
compute_metrics=compute_metrics,
preprocess_logits_for_metrics=preprocess_logits_for_metrics,
callbacks=[
EarlyStoppingCallback(
early_stopping_patience=args.early_stopping_patience
)
],
)
run_config = {
"prepared_at_utc": datetime.now(timezone.utc).isoformat(),
"model_name": args.model_name,
"max_length": args.max_length,
"per_device_train_batch_size": args.per_device_train_batch_size,
"per_device_eval_batch_size": args.per_device_eval_batch_size,
"gradient_accumulation_steps": args.gradient_accumulation_steps,
"num_train_epochs": args.num_train_epochs,
"learning_rate": args.learning_rate,
"weight_decay": args.weight_decay,
"warmup_ratio": args.warmup_ratio,
"label_smoothing_factor": args.label_smoothing_factor,
"title_only_prob": args.title_only_prob,
"early_stopping_patience": args.early_stopping_patience,
"seed": args.seed,
"resume_from_checkpoint": resume_checkpoint,
"use_bf16": use_bf16,
"use_fp16": use_fp16,
"taxonomy_profile": taxonomy_profile,
"num_labels": len(label_order),
"label_order": label_order,
}
run_config_path = output_dir / (
"eval_run_config.json" if args.skip_train else "run_config.json"
)
run_config_path.write_text(
json.dumps(run_config, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
if args.skip_train:
train_result_metrics = {"skipped_train": True}
else:
train_result = trainer.train(resume_from_checkpoint=resume_checkpoint)
train_result_metrics = train_result.metrics
trainer.save_model()
tokenizer.save_pretrained(output_dir)
metrics = {
"train": trainer.evaluate(tokenized["train"], metric_key_prefix="train"),
"validation": trainer.evaluate(
tokenized["validation"], metric_key_prefix="validation"
),
"test_full": trainer.evaluate(tokenized["test"], metric_key_prefix="test_full"),
}
title_only_test = text_ready["test"].map(
lambda row: {
"model_text": build_input_text(
row.get("title", ""),
row.get("abstract", ""),
title_only=True,
)
},
desc="Building title-only test view",
)
tokenized_title_only_test = title_only_test.map(
lambda batch: tokenizer(
batch["model_text"],
truncation=True,
max_length=args.max_length,
),
batched=True,
desc="Tokenizing title-only test view",
)
removable = [
column_name
for column_name in tokenized_title_only_test.column_names
if column_name not in MODEL_FEATURE_COLUMNS
]
tokenized_title_only_test = tokenized_title_only_test.remove_columns(removable)
metrics["test_title_only"] = trainer.evaluate(
tokenized_title_only_test,
metric_key_prefix="test_title_only",
)
predictions = trainer.predict(tokenized["test"], metric_key_prefix="predict_test_full")
full_pred_ids = predictions.predictions
if full_pred_ids.ndim > 1:
full_pred_ids = full_pred_ids.argmax(axis=-1)
full_report = classification_report(
predictions.label_ids,
full_pred_ids,
target_names=[id2label[idx] for idx in range(len(label_order))],
digits=4,
output_dict=True,
zero_division=0,
)
full_confusion = confusion_matrix(
predictions.label_ids,
full_pred_ids,
).tolist()
title_only_predictions = trainer.predict(
tokenized_title_only_test,
metric_key_prefix="predict_test_title_only",
)
title_only_pred_ids = title_only_predictions.predictions
if title_only_pred_ids.ndim > 1:
title_only_pred_ids = title_only_pred_ids.argmax(axis=-1)
title_only_report = classification_report(
title_only_predictions.label_ids,
title_only_pred_ids,
target_names=[id2label[idx] for idx in range(len(label_order))],
digits=4,
output_dict=True,
zero_division=0,
)
title_only_confusion = confusion_matrix(
title_only_predictions.label_ids,
title_only_pred_ids,
).tolist()
summary = {
"train_runtime": train_result_metrics,
"eval_metrics": metrics,
"test_full_classification_report": full_report,
"test_full_confusion_matrix": full_confusion,
"test_title_only_classification_report": title_only_report,
"test_title_only_confusion_matrix": title_only_confusion,
}
(output_dir / "metrics_summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
(output_dir / "metrics_summary.txt").write_text(
json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(json.dumps(summary, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()