stefhooy commited on
Commit
deefbae
·
1 Parent(s): 28e0f73

Created src/dataset.py — converts Debate objects into PyTorch tensors for RoBERTa

Browse files

Created src/model.py — loads roberta-base configured for 4-class classification
Created src/train.py — full training loop with class weights, AdamW optimizer, validation, checkpoint saving
Created src/__init__.py — predict() and predict_debate() functions for other team members to use
Fixed Person 1's IBM loader bug (was ignoring stance_WA, labeling everything as premise)
Trained a first model (wrong labels, now discarded)
What still needs to be done right now:

Retrain with the fixed labels — run python -m src.train --epochs 3 --batch-size 8
Sanity check the model once training finishes — run a quick predict test to confirm it returns different labels for different inputs
Commit and push the fixed code to the repo so the rest of the team can use predict() and predict_debate()

Files changed (4) hide show
  1. data/loaders/ibm.py +16 -6
  2. models/train_config.json +0 -0
  3. src/__init__.py +43 -18
  4. src/train.py +68 -33
data/loaders/ibm.py CHANGED
@@ -6,7 +6,9 @@ HuggingFace: ibm/argument_quality_ranking_30k
6
 
7
  We map this to the unified schema:
8
  - topic → Debate with a single claim (the motion)
9
- - argumentArgument of type 'premise' attached to that claim
 
 
10
  - WA (weighted average quality) stored in metadata
11
  """
12
 
@@ -14,6 +16,14 @@ from typing import List, Dict
14
 
15
  from data.schema import Argument, Debate
16
 
 
 
 
 
 
 
 
 
17
 
18
  def load_ibm(split: str = "train") -> List[Debate]:
19
  try:
@@ -40,15 +50,15 @@ def load_ibm(split: str = "train") -> List[Debate]:
40
  text=topic,
41
  arg_type="claim",
42
  )
43
- premises = [
44
  Argument(
45
  id=f"{topic_id}__{i}",
46
  text=row["argument"],
47
- arg_type="premise",
48
  parent_id=claim.id,
49
  metadata={
50
- "quality_score": row.get("WA"),
51
- "stance": row.get("stance"),
52
  },
53
  )
54
  for i, row in enumerate(rows)
@@ -58,7 +68,7 @@ def load_ibm(split: str = "train") -> List[Debate]:
58
  id=topic_id,
59
  title=topic,
60
  source="ibm",
61
- arguments=[claim] + premises,
62
  )
63
  )
64
  return debates
 
6
 
7
  We map this to the unified schema:
8
  - topic → Debate with a single claim (the motion)
9
+ - stance_WA = 1 → premise (argument supports the topic)
10
+ - stance_WA = -1 → counter_claim (argument opposes the topic)
11
+ - WA < 0.3 → unknown (very low quality / unclear)
12
  - WA (weighted average quality) stored in metadata
13
  """
14
 
 
16
 
17
  from data.schema import Argument, Debate
18
 
19
+ _UNKNOWN_QUALITY_THRESHOLD = 0.3
20
+
21
+
22
+ def _arg_type(stance_wa: int, quality: float) -> str:
23
+ if quality < _UNKNOWN_QUALITY_THRESHOLD:
24
+ return "unknown"
25
+ return "premise" if stance_wa == 1 else "counter_claim"
26
+
27
 
28
  def load_ibm(split: str = "train") -> List[Debate]:
29
  try:
 
50
  text=topic,
51
  arg_type="claim",
52
  )
53
+ arguments = [
54
  Argument(
55
  id=f"{topic_id}__{i}",
56
  text=row["argument"],
57
+ arg_type=_arg_type(row["stance_WA"], row["WA"]),
58
  parent_id=claim.id,
59
  metadata={
60
+ "quality_score": row["WA"],
61
+ "stance_wa": row["stance_WA"],
62
  },
63
  )
64
  for i, row in enumerate(rows)
 
68
  id=topic_id,
69
  title=topic,
70
  source="ibm",
71
+ arguments=[claim] + arguments,
72
  )
73
  )
74
  return debates
models/train_config.json ADDED
File without changes
src/__init__.py CHANGED
@@ -1,43 +1,62 @@
1
  from __future__ import annotations
2
 
3
- import torch
4
- from transformers import RobertaForSequenceClassification, RobertaTokenizerFast
5
-
6
  from data.schema import Argument, Debate
7
- from .dataset import ID2LABEL, LABEL2ID
 
 
8
 
9
  _model = None
10
  _tokenizer = None
11
  _loaded_checkpoint: str | None = None
12
- _device = "cuda" if torch.cuda.is_available() else "cpu"
13
 
14
 
15
  def _load(checkpoint_dir: str) -> None:
16
- global _model, _tokenizer, _loaded_checkpoint
17
  if _loaded_checkpoint != checkpoint_dir:
 
 
 
 
 
 
18
  _tokenizer = RobertaTokenizerFast.from_pretrained(checkpoint_dir)
19
- _model = RobertaForSequenceClassification.from_pretrained(checkpoint_dir)
 
 
20
  _model.eval()
21
  _model.to(_device)
22
  _loaded_checkpoint = checkpoint_dir
23
 
24
 
25
- def predict(text: str, parent_text: str = "", checkpoint_dir: str = "models/best") -> str:
 
 
 
 
26
  """Classify a single argument text.
27
 
28
  Returns one of: 'claim', 'counter_claim', 'premise', 'unknown'.
29
- Pass parent_text when the comment is a reply — the model uses both together.
30
  """
 
31
  _load(checkpoint_dir)
32
  if parent_text:
33
  enc = _tokenizer(
34
- parent_text, text,
35
- return_tensors="pt", truncation=True, max_length=256, padding="max_length",
 
 
 
 
36
  )
37
  else:
38
  enc = _tokenizer(
39
  text,
40
- return_tensors="pt", truncation=True, max_length=256, padding="max_length",
 
 
 
41
  )
42
  enc = {k: v.to(_device) for k, v in enc.items()}
43
  with torch.no_grad():
@@ -45,12 +64,14 @@ def predict(text: str, parent_text: str = "", checkpoint_dir: str = "models/best
45
  return ID2LABEL[logits.argmax(dim=-1).item()]
46
 
47
 
48
- def predict_debate(debate: Debate, checkpoint_dir: str = "models/best") -> Debate:
49
- """Classify every argument in a debate and return a new Debate with predicted labels.
 
 
 
50
 
51
- This is the main entry point for Person 3 (eval) and Person 4 (failure analysis).
52
- The returned Debate has the same structure and parent_id links just with
53
- arg_type replaced by the model's predictions.
54
  """
55
  _load(checkpoint_dir)
56
  arg_map = {a.id: a for a in debate.arguments}
@@ -60,7 +81,11 @@ def predict_debate(debate: Debate, checkpoint_dir: str = "models/best") -> Debat
60
  labeled.append(Argument(
61
  id=arg.id,
62
  text=arg.text,
63
- arg_type=predict(arg.text, parent.text if parent else "", checkpoint_dir),
 
 
 
 
64
  parent_id=arg.parent_id,
65
  author=arg.author,
66
  score=arg.score,
 
1
  from __future__ import annotations
2
 
 
 
 
3
  from data.schema import Argument, Debate
4
+
5
+ LABEL2ID = {"claim": 0, "counter_claim": 1, "premise": 2, "unknown": 3}
6
+ ID2LABEL = {v: k for k, v in LABEL2ID.items()}
7
 
8
  _model = None
9
  _tokenizer = None
10
  _loaded_checkpoint: str | None = None
11
+ _device: str | None = None
12
 
13
 
14
  def _load(checkpoint_dir: str) -> None:
15
+ global _model, _tokenizer, _loaded_checkpoint, _device
16
  if _loaded_checkpoint != checkpoint_dir:
17
+ import torch
18
+ from transformers import (
19
+ RobertaForSequenceClassification,
20
+ RobertaTokenizerFast,
21
+ )
22
+ _device = "cuda" if torch.cuda.is_available() else "cpu"
23
  _tokenizer = RobertaTokenizerFast.from_pretrained(checkpoint_dir)
24
+ _model = RobertaForSequenceClassification.from_pretrained(
25
+ checkpoint_dir
26
+ )
27
  _model.eval()
28
  _model.to(_device)
29
  _loaded_checkpoint = checkpoint_dir
30
 
31
 
32
+ def predict(
33
+ text: str,
34
+ parent_text: str = "",
35
+ checkpoint_dir: str = "models/best",
36
+ ) -> str:
37
  """Classify a single argument text.
38
 
39
  Returns one of: 'claim', 'counter_claim', 'premise', 'unknown'.
40
+ Pass parent_text when the comment is a reply.
41
  """
42
+ import torch
43
  _load(checkpoint_dir)
44
  if parent_text:
45
  enc = _tokenizer(
46
+ parent_text,
47
+ text,
48
+ return_tensors="pt",
49
+ truncation=True,
50
+ max_length=256,
51
+ padding="max_length",
52
  )
53
  else:
54
  enc = _tokenizer(
55
  text,
56
+ return_tensors="pt",
57
+ truncation=True,
58
+ max_length=256,
59
+ padding="max_length",
60
  )
61
  enc = {k: v.to(_device) for k, v in enc.items()}
62
  with torch.no_grad():
 
64
  return ID2LABEL[logits.argmax(dim=-1).item()]
65
 
66
 
67
+ def predict_debate(
68
+ debate: Debate,
69
+ checkpoint_dir: str = "models/best",
70
+ ) -> Debate:
71
+ """Label every argument in a debate, return new Debate with predictions.
72
 
73
+ Main entry point for Person 3 (eval) and Person 4 (failure analysis).
74
+ Preserves structure and parent_id links; only arg_type is replaced.
 
75
  """
76
  _load(checkpoint_dir)
77
  arg_map = {a.id: a for a in debate.arguments}
 
81
  labeled.append(Argument(
82
  id=arg.id,
83
  text=arg.text,
84
+ arg_type=predict(
85
+ arg.text,
86
+ parent.text if parent else "",
87
+ checkpoint_dir,
88
+ ),
89
  parent_id=arg.parent_id,
90
  author=arg.author,
91
  score=arg.score,
src/train.py CHANGED
@@ -6,41 +6,38 @@ import os
6
  from collections import Counter
7
  from pathlib import Path
8
 
9
- import torch
10
- from torch.utils.data import DataLoader, random_split
11
- from torch.optim import AdamW
12
- from transformers import RobertaTokenizerFast, get_linear_schedule_with_warmup
13
- from sklearn.metrics import f1_score, accuracy_score
14
- from tqdm import tqdm
15
-
16
  from data import load_ibm, clean_debates
17
- from .dataset import ArgumentDataset, NUM_LABELS
18
- from .model import MODEL_NAME, build_model
19
 
20
- DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
21
 
22
 
23
- def _class_weights(dataset: ArgumentDataset) -> torch.Tensor:
24
- """Inverse-frequency weights so rare labels (counter_claim, unknown) aren't ignored."""
 
25
  counts = Counter(ex["label"] for ex in dataset.examples)
26
  total = sum(counts.values())
27
  weights = torch.ones(NUM_LABELS)
28
  for label_id, count in counts.items():
29
  weights[label_id] = total / (NUM_LABELS * count)
30
- return weights
31
 
32
 
33
- def evaluate(model, loader) -> tuple[float, float, float]:
 
 
34
  model.eval()
35
  all_preds, all_labels = [], []
36
  total_loss = 0.0
37
  loss_fn = torch.nn.CrossEntropyLoss()
38
  with torch.no_grad():
39
  for batch in loader:
40
- input_ids = batch["input_ids"].to(DEVICE)
41
- attention_mask = batch["attention_mask"].to(DEVICE)
42
- labels = batch["label"].to(DEVICE)
43
- logits = model(input_ids=input_ids, attention_mask=attention_mask).logits
 
 
 
44
  total_loss += loss_fn(logits, labels).item()
45
  all_preds.extend(logits.argmax(dim=-1).cpu().numpy())
46
  all_labels.extend(labels.cpu().numpy())
@@ -61,7 +58,7 @@ def train(
61
  ) -> str:
62
  Path(output_dir).mkdir(parents=True, exist_ok=True)
63
 
64
- print(f"Device: {DEVICE}")
65
  print("Loading data...")
66
  debates = clean_debates(load_ibm("train"))
67
  print(f" IBM Debater: {len(debates)} debates")
@@ -70,9 +67,37 @@ def train(
70
  from data import load_cmv
71
  cmv = clean_debates(load_cmv("train"))
72
  debates += cmv
73
- print(f" CMV: {len(cmv)} debates (total: {len(debates)})")
74
- except Exception:
75
- print(" CMV not found — training on IBM only")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
  tokenizer = RobertaTokenizerFast.from_pretrained(MODEL_NAME)
78
  dataset = ArgumentDataset(debates, tokenizer, max_length)
@@ -84,11 +109,15 @@ def train(
84
  [len(dataset) - val_size, val_size],
85
  generator=torch.Generator().manual_seed(42),
86
  )
87
- train_loader = DataLoader(train_set, batch_size=batch_size, shuffle=True, num_workers=0)
88
- val_loader = DataLoader(val_set, batch_size=batch_size, shuffle=False, num_workers=0)
 
 
 
 
89
 
90
- model = build_model().to(DEVICE)
91
- weights = _class_weights(dataset).to(DEVICE)
92
  loss_fn = torch.nn.CrossEntropyLoss(weight=weights)
93
 
94
  optimizer = AdamW(model.parameters(), lr=lr, weight_decay=0.01)
@@ -106,12 +135,15 @@ def train(
106
  model.train()
107
  total_loss = 0.0
108
  for batch in tqdm(train_loader, desc=f"Epoch {epoch}/{epochs}"):
109
- input_ids = batch["input_ids"].to(DEVICE)
110
- attention_mask = batch["attention_mask"].to(DEVICE)
111
- labels = batch["label"].to(DEVICE)
112
 
113
  optimizer.zero_grad()
114
- logits = model(input_ids=input_ids, attention_mask=attention_mask).logits
 
 
 
115
  loss = loss_fn(logits, labels)
116
  loss.backward()
117
  torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
@@ -119,7 +151,7 @@ def train(
119
  scheduler.step()
120
  total_loss += loss.item()
121
 
122
- val_loss, val_acc, val_f1 = evaluate(model, val_loader)
123
  print(
124
  f"Epoch {epoch}: "
125
  f"train_loss={total_loss / len(train_loader):.4f} "
@@ -132,7 +164,10 @@ def train(
132
  best_f1 = val_f1
133
  model.save_pretrained(best_ckpt)
134
  tokenizer.save_pretrained(best_ckpt)
135
- print(f" → Saved best checkpoint (F1={best_f1:.4f}) to {best_ckpt}/")
 
 
 
136
 
137
  with open(os.path.join(output_dir, "train_config.json"), "w") as f:
138
  json.dump(
@@ -152,7 +187,7 @@ def train(
152
 
153
 
154
  if __name__ == "__main__":
155
- p = argparse.ArgumentParser(description="Train argument classifier. Run as: python -m src.train")
156
  p.add_argument("--epochs", type=int, default=3)
157
  p.add_argument("--batch-size", type=int, default=16)
158
  p.add_argument("--lr", type=float, default=2e-5)
 
6
  from collections import Counter
7
  from pathlib import Path
8
 
 
 
 
 
 
 
 
9
  from data import load_ibm, clean_debates
 
 
10
 
11
+ NUM_LABELS = 4
12
 
13
 
14
+ def _class_weights(dataset, device):
15
+ """Inverse-frequency weights so rare labels aren't ignored."""
16
+ import torch
17
  counts = Counter(ex["label"] for ex in dataset.examples)
18
  total = sum(counts.values())
19
  weights = torch.ones(NUM_LABELS)
20
  for label_id, count in counts.items():
21
  weights[label_id] = total / (NUM_LABELS * count)
22
+ return weights.to(device)
23
 
24
 
25
+ def evaluate(model, loader, device):
26
+ import torch
27
+ from sklearn.metrics import f1_score, accuracy_score
28
  model.eval()
29
  all_preds, all_labels = [], []
30
  total_loss = 0.0
31
  loss_fn = torch.nn.CrossEntropyLoss()
32
  with torch.no_grad():
33
  for batch in loader:
34
+ input_ids = batch["input_ids"].to(device)
35
+ attention_mask = batch["attention_mask"].to(device)
36
+ labels = batch["label"].to(device)
37
+ logits = model(
38
+ input_ids=input_ids,
39
+ attention_mask=attention_mask,
40
+ ).logits
41
  total_loss += loss_fn(logits, labels).item()
42
  all_preds.extend(logits.argmax(dim=-1).cpu().numpy())
43
  all_labels.extend(labels.cpu().numpy())
 
58
  ) -> str:
59
  Path(output_dir).mkdir(parents=True, exist_ok=True)
60
 
61
+ # --- load data first, before touching CUDA ---
62
  print("Loading data...")
63
  debates = clean_debates(load_ibm("train"))
64
  print(f" IBM Debater: {len(debates)} debates")
 
67
  from data import load_cmv
68
  cmv = clean_debates(load_cmv("train"))
69
  debates += cmv
70
+ print(f" CMV (file): {len(cmv)} debates (total: {len(debates)})")
71
+ except FileNotFoundError:
72
+ try:
73
+ from data import scrape_cmv
74
+ print(" CMV file missing — scraping live from Reddit (limit=150)…")
75
+ cmv = clean_debates(scrape_cmv(limit=150, sort="top", time_filter="all"))
76
+ if cmv:
77
+ debates += cmv
78
+ print(f" CMV (Reddit live): {len(cmv)} debates (total: {len(debates)})")
79
+ else:
80
+ print(" Reddit scraper returned 0 debates — training on IBM only")
81
+ except Exception as e2:
82
+ print(f" CMV not available ({e2.__class__.__name__}: {e2}) — training on IBM only")
83
+ except Exception as e:
84
+ print(f" CMV load error ({e}) — training on IBM only")
85
+
86
+ # Heavy imports after data loading so torch/CUDA init
87
+ # doesn't compete with dataset memory usage
88
+ import torch
89
+ from torch.utils.data import DataLoader, random_split
90
+ from torch.optim import AdamW
91
+ from transformers import (
92
+ RobertaTokenizerFast,
93
+ get_linear_schedule_with_warmup,
94
+ )
95
+ from tqdm import tqdm
96
+ from .dataset import ArgumentDataset
97
+ from .model import MODEL_NAME, build_model
98
+
99
+ device = "cuda" if torch.cuda.is_available() else "cpu"
100
+ print(f"Device: {device}")
101
 
102
  tokenizer = RobertaTokenizerFast.from_pretrained(MODEL_NAME)
103
  dataset = ArgumentDataset(debates, tokenizer, max_length)
 
109
  [len(dataset) - val_size, val_size],
110
  generator=torch.Generator().manual_seed(42),
111
  )
112
+ train_loader = DataLoader(
113
+ train_set, batch_size=batch_size, shuffle=True, num_workers=0,
114
+ )
115
+ val_loader = DataLoader(
116
+ val_set, batch_size=batch_size, shuffle=False, num_workers=0,
117
+ )
118
 
119
+ model = build_model().to(device)
120
+ weights = _class_weights(dataset, device)
121
  loss_fn = torch.nn.CrossEntropyLoss(weight=weights)
122
 
123
  optimizer = AdamW(model.parameters(), lr=lr, weight_decay=0.01)
 
135
  model.train()
136
  total_loss = 0.0
137
  for batch in tqdm(train_loader, desc=f"Epoch {epoch}/{epochs}"):
138
+ input_ids = batch["input_ids"].to(device)
139
+ attention_mask = batch["attention_mask"].to(device)
140
+ labels = batch["label"].to(device)
141
 
142
  optimizer.zero_grad()
143
+ logits = model(
144
+ input_ids=input_ids,
145
+ attention_mask=attention_mask,
146
+ ).logits
147
  loss = loss_fn(logits, labels)
148
  loss.backward()
149
  torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
 
151
  scheduler.step()
152
  total_loss += loss.item()
153
 
154
+ val_loss, val_acc, val_f1 = evaluate(model, val_loader, device)
155
  print(
156
  f"Epoch {epoch}: "
157
  f"train_loss={total_loss / len(train_loader):.4f} "
 
164
  best_f1 = val_f1
165
  model.save_pretrained(best_ckpt)
166
  tokenizer.save_pretrained(best_ckpt)
167
+ print(
168
+ f" → Saved best checkpoint "
169
+ f"(F1={best_f1:.4f}) to {best_ckpt}/"
170
+ )
171
 
172
  with open(os.path.join(output_dir, "train_config.json"), "w") as f:
173
  json.dump(
 
187
 
188
 
189
  if __name__ == "__main__":
190
+ p = argparse.ArgumentParser()
191
  p.add_argument("--epochs", type=int, default=3)
192
  p.add_argument("--batch-size", type=int, default=16)
193
  p.add_argument("--lr", type=float, default=2e-5)