BERT_cht_intent / finetune.py
mattzhang's picture
Upload folder using huggingface_hub
1a671ff verified
Raw
History Blame Contribute Delete
15.9 kB
"""
Fine-tune DistilBERT for Samsung Health Chart Intent Classification
===================================================================
Optimized for Apple Silicon (M5/M4/M3/M2/M1) with MPS acceleration.
Usage:
python finetune.py
Requirements:
pip install torch transformers datasets scikit-learn pandas
"""
import os
import json
import pandas as pd
import numpy as np
from pathlib import Path
import torch
from torch.utils.data import Dataset, DataLoader
from transformers import (
AutoTokenizer,
AutoModelForSequenceClassification,
get_linear_schedule_with_warmup,
)
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, confusion_matrix
# ─────────────────────────────────────────────────────────
# STEP 0: Detect device
# ─────────────────────────────────────────────────────────
def get_device():
if torch.cuda.is_available():
device = torch.device("cuda")
print(f"Using CUDA GPU: {torch.cuda.get_device_name(0)}")
elif torch.backends.mps.is_available():
device = torch.device("mps")
print("Using Apple Silicon MPS (Metal Performance Shaders)")
else:
device = torch.device("cpu")
print("Using CPU")
return device
# ─────────────────────────────────────────────────────────
# STEP 1: Config β€” all hyperparameters in one place
# ─────────────────────────────────────────────────────────
CONFIG = {
# Model
# distilbert-base-multilingual-cased: 134M params, handles English well,
# and will generalize better if you later add Chinese/mixed queries.
# If your queries will always be English-only, you can swap to:
# "distilbert-base-uncased" (66M params, slightly faster, English only)
"model_name": "distilbert-base-multilingual-cased",
# Data
"data_path": "samsung_health_intent.csv",
"max_length": 64, # User queries are short; 64 is plenty and ~8x faster than 512
"test_size": 0.15, # 15% test set (~15 samples from 100)
"val_size": 0.15, # 15% val set (~15 samples from 100)
# Training β€” tuned for M5 MPS
"batch_size": 16, # Safe for 16GB+ unified memory; lower to 8 if you get OOM
"epochs": 8, # More epochs needed for small dataset (100 samples)
"learning_rate": 3e-5,
"weight_decay": 0.01,
"warmup_ratio": 0.1,
# MPS-specific
"num_workers": 0, # MUST be 0 for MPS (multiprocessing + MPS = deadlock)
"fp16": False, # MPS does NOT support fp16 training
# Output
"output_dir": "./chart_intent_model",
"seed": 42,
}
# ─────────────────────────────────────────────────────────
# STEP 2: Dataset class
# ─────────────────────────────────────────────────────────
class HealthIntentDataset(Dataset):
def __init__(self, texts, labels, tokenizer, max_length):
self.encodings = tokenizer(
texts,
truncation=True,
padding="max_length",
max_length=max_length,
return_tensors="pt",
)
self.labels = torch.tensor(labels, dtype=torch.long)
def __len__(self):
return len(self.labels)
def __getitem__(self, idx):
return {
"input_ids": self.encodings["input_ids"][idx],
"attention_mask": self.encodings["attention_mask"][idx],
"labels": self.labels[idx],
}
# ─────────────────────────────────────────────────────────
# STEP 3: Metrics
# ─────────────────────────────────────────────────────────
def compute_metrics(preds, labels):
preds = np.array(preds)
labels = np.array(labels)
acc = (preds == labels).mean()
report = classification_report(
labels, preds,
target_names=["no_chart", "chart"],
output_dict=True,
)
return {
"accuracy": acc,
"f1_chart": report["chart"]["f1-score"],
"precision": report["chart"]["precision"],
"recall": report["chart"]["recall"],
}
# ─────────────────────────────────────────────────────────
# STEP 4: Training loop
# ─────────────────────────────────────────────────────────
def train_epoch(model, loader, optimizer, scheduler, device):
model.train()
total_loss = 0
all_preds, all_labels = [], []
for batch in loader:
input_ids = batch["input_ids"].to(device)
attention_mask = batch["attention_mask"].to(device)
labels = batch["labels"].to(device)
optimizer.zero_grad()
outputs = model(input_ids=input_ids, attention_mask=attention_mask, labels=labels)
loss = outputs.loss
loss.backward()
# Gradient clipping β€” stabilizes training on small datasets
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
scheduler.step()
total_loss += loss.item()
preds = outputs.logits.argmax(dim=-1).cpu().numpy()
all_preds.extend(preds)
all_labels.extend(labels.cpu().numpy())
metrics = compute_metrics(all_preds, all_labels)
metrics["loss"] = total_loss / len(loader)
return metrics
@torch.no_grad()
def evaluate(model, loader, device):
model.eval()
total_loss = 0
all_preds, all_labels = [], []
for batch in loader:
input_ids = batch["input_ids"].to(device)
attention_mask = batch["attention_mask"].to(device)
labels = batch["labels"].to(device)
outputs = model(input_ids=input_ids, attention_mask=attention_mask, labels=labels)
total_loss += outputs.loss.item()
preds = outputs.logits.argmax(dim=-1).cpu().numpy()
all_preds.extend(preds)
all_labels.extend(labels.cpu().numpy())
metrics = compute_metrics(all_preds, all_labels)
metrics["loss"] = total_loss / len(loader)
return metrics, all_preds, all_labels
# ─────────────────────────────────────────────────────────
# STEP 5: Inference helper (run after training)
# ─────────────────────────────────────────────────────────
class ChartIntentClassifier:
"""
Load the fine-tuned model and classify new queries.
Use this in your Samsung Health chatbot.
"""
def __init__(self, model_dir: str, device=None):
self.device = device or get_device()
self.tokenizer = AutoTokenizer.from_pretrained(model_dir)
self.model = AutoModelForSequenceClassification.from_pretrained(model_dir)
self.model.to(self.device)
self.model.eval()
@torch.no_grad()
def predict(self, text: str) -> dict:
inputs = self.tokenizer(
text,
return_tensors="pt",
truncation=True,
max_length=CONFIG["max_length"],
padding=True,
).to(self.device)
logits = self.model(**inputs).logits
probs = torch.softmax(logits, dim=-1).cpu().numpy()[0]
label = int(probs.argmax())
return {
"text": text,
"label": label, # 0 or 1
"intent": "chart" if label == 1 else "no_chart",
"confidence": float(probs[label]),
"prob_chart": float(probs[1]),
}
def predict_batch(self, texts: list) -> list:
return [self.predict(t) for t in texts]
# ─────────────────────────────────────────────────────────
# MAIN
# ─────────────────────────────────────────────────────────
def main():
torch.manual_seed(CONFIG["seed"])
np.random.seed(CONFIG["seed"])
device = get_device()
# ── Load data ──────────────────────────────────────────
print("\n── Loading data ──────────────────────────────────")
df = pd.read_csv(CONFIG["data_path"])
print(f"Total samples : {len(df)}")
print(f"Label dist : {df['label'].value_counts().to_dict()}")
texts = df["text"].tolist()
labels = df["label"].tolist()
# Train / val / test split (stratified)
X_train, X_temp, y_train, y_temp = train_test_split(
texts, labels,
test_size=CONFIG["test_size"] + CONFIG["val_size"],
stratify=labels,
random_state=CONFIG["seed"],
)
val_ratio_of_temp = CONFIG["val_size"] / (CONFIG["test_size"] + CONFIG["val_size"])
X_val, X_test, y_val, y_test = train_test_split(
X_temp, y_temp,
test_size=1 - val_ratio_of_temp,
stratify=y_temp,
random_state=CONFIG["seed"],
)
print(f"Train: {len(X_train)}, Val: {len(X_val)}, Test: {len(X_test)}")
# ── Tokenizer & model ──────────────────────────────────
print(f"\n── Loading model: {CONFIG['model_name']} ────────────")
print("(Downloading ~500MB on first run, cached after that)")
tokenizer = AutoTokenizer.from_pretrained(CONFIG["model_name"])
model = AutoModelForSequenceClassification.from_pretrained(
CONFIG["model_name"],
num_labels=2,
id2label={0: "no_chart", 1: "chart"},
label2id={"no_chart": 0, "chart": 1},
)
model.to(device)
total_params = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Parameters: {total_params:,} total, {trainable:,} trainable")
# ── Datasets & loaders ─────────────────────────────────
print("\n── Tokenizing ────────────────────────────────────")
train_ds = HealthIntentDataset(X_train, y_train, tokenizer, CONFIG["max_length"])
val_ds = HealthIntentDataset(X_val, y_val, tokenizer, CONFIG["max_length"])
test_ds = HealthIntentDataset(X_test, y_test, tokenizer, CONFIG["max_length"])
train_loader = DataLoader(train_ds, batch_size=CONFIG["batch_size"],
shuffle=True, num_workers=CONFIG["num_workers"])
val_loader = DataLoader(val_ds, batch_size=CONFIG["batch_size"],
shuffle=False, num_workers=CONFIG["num_workers"])
test_loader = DataLoader(test_ds, batch_size=CONFIG["batch_size"],
shuffle=False, num_workers=CONFIG["num_workers"])
# ── Optimizer & scheduler ──────────────────────────────
optimizer = torch.optim.AdamW(
model.parameters(),
lr=CONFIG["learning_rate"],
weight_decay=CONFIG["weight_decay"],
)
total_steps = len(train_loader) * CONFIG["epochs"]
warmup_steps = int(total_steps * CONFIG["warmup_ratio"])
scheduler = get_linear_schedule_with_warmup(
optimizer,
num_warmup_steps=warmup_steps,
num_training_steps=total_steps,
)
# ── Training loop ──────────────────────────────────────
print(f"\n── Training for {CONFIG['epochs']} epochs ────────────────────")
best_val_f1 = 0
best_epoch = 0
history = []
for epoch in range(1, CONFIG["epochs"] + 1):
train_m = train_epoch(model, train_loader, optimizer, scheduler, device)
val_m, _, _ = evaluate(model, val_loader, device)
history.append({"epoch": epoch, "train": train_m, "val": val_m})
print(
f"Epoch {epoch:2d}/{CONFIG['epochs']} β”‚ "
f"Train loss={train_m['loss']:.3f} acc={train_m['accuracy']:.3f} β”‚ "
f"Val loss={val_m['loss']:.3f} acc={val_m['accuracy']:.3f} f1={val_m['f1_chart']:.3f}"
)
# Save best checkpoint
if val_m["f1_chart"] >= best_val_f1:
best_val_f1 = val_m["f1_chart"]
best_epoch = epoch
Path(CONFIG["output_dir"]).mkdir(parents=True, exist_ok=True)
model.save_pretrained(CONFIG["output_dir"])
tokenizer.save_pretrained(CONFIG["output_dir"])
print(f"\nBest model: epoch {best_epoch}, val F1={best_val_f1:.4f}")
# ── Test set evaluation (load best model) ──────────────
print("\n── Test set evaluation ───────────────────────────")
best_model = AutoModelForSequenceClassification.from_pretrained(CONFIG["output_dir"])
best_model.to(device)
test_m, test_preds, test_labels = evaluate(best_model, test_loader, device)
print(f"Test accuracy : {test_m['accuracy']:.4f}")
print(f"Test F1 (chart): {test_m['f1_chart']:.4f}")
print()
print(classification_report(test_labels, test_preds,
target_names=["no_chart", "chart"]))
print("Confusion matrix:")
print(confusion_matrix(test_labels, test_preds))
# ── Save training history ──────────────────────────────
with open(f"{CONFIG['output_dir']}/history.json", "w") as f:
json.dump(history, f, indent=2)
# ── Quick inference demo ───────────────────────────────
print("\n── Inference demo ────────────────────────────────")
clf = ChartIntentClassifier(CONFIG["output_dir"], device)
demo_queries = [
"plot my heart rate over the last 7 days",
"what is my resting heart rate",
"show me a chart of my weekly step count",
"how many calories did I burn today",
"visualize my sleep stages breakdown",
"did I hit my step goal yesterday",
]
for q in demo_queries:
result = clf.predict(q)
bar = "β–ˆ" * int(result["prob_chart"] * 20)
print(f" [{result['intent']:10s}] ({result['confidence']:.2f}) {bar:<20} {q}")
print(f"\nModel saved to: {CONFIG['output_dir']}/")
print("Done.")
if __name__ == "__main__":
main()