models and inference classes
Browse files- src/models/dataset.py +180 -0
- src/models/distillbert.py +127 -0
- src/models/fasttext.py +197 -0
- src/models/inference.py +175 -0
src/models/dataset.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import numpy as np
|
| 3 |
+
import torch
|
| 4 |
+
from sklearn.model_selection import train_test_split
|
| 5 |
+
from torch.utils.data import Dataset
|
| 6 |
+
from src.schemas.labels import SENTIMENT_LABELS
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def load_data(path: str) -> list[dict]:
|
| 10 |
+
with open(path) as f:
|
| 11 |
+
return [json.loads(line) for line in f]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def deduplicate_positions(samples: list[dict]) -> list[dict]:
|
| 15 |
+
"""Select one position per entity.
|
| 16 |
+
|
| 17 |
+
Prefers the position whose position_text matches entity_text exactly
|
| 18 |
+
(case-insensitive). If none matches, selects the longest position.
|
| 19 |
+
"""
|
| 20 |
+
out = []
|
| 21 |
+
for s in samples:
|
| 22 |
+
new_entities = []
|
| 23 |
+
for e in s["entities"]:
|
| 24 |
+
positions = e["positions"]
|
| 25 |
+
if not positions:
|
| 26 |
+
new_entities.append(e)
|
| 27 |
+
continue
|
| 28 |
+
|
| 29 |
+
exact = [
|
| 30 |
+
p for p in positions
|
| 31 |
+
if p["position_text"].lower() == e["entity_text"].lower()
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
if exact:
|
| 35 |
+
best = max(exact, key=lambda p: p["length"])
|
| 36 |
+
else:
|
| 37 |
+
best = max(positions, key=lambda p: p["length"])
|
| 38 |
+
|
| 39 |
+
new_entities.append({**e, "positions": [best]})
|
| 40 |
+
out.append({**s, "entities": new_entities})
|
| 41 |
+
return out
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def flatten_to_examples(
|
| 45 |
+
samples: list[dict],
|
| 46 |
+
mode: str,
|
| 47 |
+
) -> list[dict]:
|
| 48 |
+
"""Flatten augmented data to one example per (entity, position) pair.
|
| 49 |
+
|
| 50 |
+
Reads pre-computed fields from the augmented JSONL:
|
| 51 |
+
marker -> seg_a = marker_text, seg_b = None
|
| 52 |
+
qa_m -> seg_a = entity_centered_window, seg_b = qa_m_question
|
| 53 |
+
qa_b -> 3 binary examples per position using qa_b_hypotheses
|
| 54 |
+
"""
|
| 55 |
+
sentiments = list(SENTIMENT_LABELS.classes)
|
| 56 |
+
label2id = SENTIMENT_LABELS.label2id
|
| 57 |
+
examples = []
|
| 58 |
+
|
| 59 |
+
for s in samples:
|
| 60 |
+
for e in s["entities"]:
|
| 61 |
+
label_str = e.get("label")
|
| 62 |
+
|
| 63 |
+
base = {
|
| 64 |
+
"sample_id": s["id"],
|
| 65 |
+
"entity_id": e["entity_id"],
|
| 66 |
+
"entity_text": e["entity_text"],
|
| 67 |
+
"entity_type": e["entity_type"],
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
for p in e["positions"]:
|
| 71 |
+
if mode == "marker":
|
| 72 |
+
ex = {**base, "seg_a": p["marker_text"], "seg_b": None}
|
| 73 |
+
if label_str in label2id:
|
| 74 |
+
ex["label"] = label2id[label_str]
|
| 75 |
+
examples.append(ex)
|
| 76 |
+
|
| 77 |
+
elif mode == "qa_m":
|
| 78 |
+
ex = {
|
| 79 |
+
**base,
|
| 80 |
+
"seg_a": p["entity_centered_window"],
|
| 81 |
+
"seg_b": p["qa_m_question"],
|
| 82 |
+
}
|
| 83 |
+
if label_str in label2id:
|
| 84 |
+
ex["label"] = label2id[label_str]
|
| 85 |
+
examples.append(ex)
|
| 86 |
+
|
| 87 |
+
elif mode == "qa_b":
|
| 88 |
+
for sentiment in sentiments:
|
| 89 |
+
ex = {
|
| 90 |
+
**base,
|
| 91 |
+
"seg_a": p["entity_centered_window"],
|
| 92 |
+
"seg_b": p["qa_b_hypotheses"][sentiment],
|
| 93 |
+
"sentiment": sentiment,
|
| 94 |
+
}
|
| 95 |
+
if label_str in label2id:
|
| 96 |
+
ex["label"] = 1 if sentiment == label_str else 0
|
| 97 |
+
examples.append(ex)
|
| 98 |
+
|
| 99 |
+
else:
|
| 100 |
+
raise ValueError(f"Unknown mode: {mode!r}")
|
| 101 |
+
|
| 102 |
+
return examples
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def split_data(
|
| 106 |
+
examples: list[dict], val_frac: float, test_frac: float, seed: int = 42
|
| 107 |
+
) -> tuple[list[dict], list[dict], list[dict]]:
|
| 108 |
+
"""Split at the *sample* level"""
|
| 109 |
+
sample_ids = np.array(list({e["sample_id"] for e in examples}))
|
| 110 |
+
|
| 111 |
+
remaining_ids, test_ids = train_test_split(
|
| 112 |
+
sample_ids, test_size=test_frac, random_state=seed
|
| 113 |
+
)
|
| 114 |
+
val_frac_adj = val_frac / (1.0 - test_frac)
|
| 115 |
+
train_ids, val_ids = train_test_split(
|
| 116 |
+
remaining_ids, test_size=val_frac_adj, random_state=seed
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
train_set = set(train_ids)
|
| 120 |
+
val_set = set(val_ids)
|
| 121 |
+
test_set = set(test_ids)
|
| 122 |
+
|
| 123 |
+
return (
|
| 124 |
+
[e for e in examples if e["sample_id"] in train_set],
|
| 125 |
+
[e for e in examples if e["sample_id"] in val_set],
|
| 126 |
+
[e for e in examples if e["sample_id"] in test_set],
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class EntitySentimentDataset(Dataset):
|
| 131 |
+
def __init__(self, examples: list[dict], tokenizer, max_len: int):
|
| 132 |
+
self.examples = examples
|
| 133 |
+
self.tokenizer = tokenizer
|
| 134 |
+
self.max_len = max_len
|
| 135 |
+
|
| 136 |
+
def __len__(self) -> int:
|
| 137 |
+
return len(self.examples)
|
| 138 |
+
|
| 139 |
+
def __getitem__(self, idx: int) -> dict:
|
| 140 |
+
ex = self.examples[idx]
|
| 141 |
+
seg_a = ex["seg_a"]
|
| 142 |
+
seg_b = ex["seg_b"]
|
| 143 |
+
|
| 144 |
+
if seg_b is None:
|
| 145 |
+
enc = self.tokenizer(
|
| 146 |
+
seg_a,
|
| 147 |
+
max_length=self.max_len,
|
| 148 |
+
truncation=True,
|
| 149 |
+
padding="max_length",
|
| 150 |
+
return_tensors="pt",
|
| 151 |
+
)
|
| 152 |
+
else:
|
| 153 |
+
enc = self.tokenizer(
|
| 154 |
+
seg_a, seg_b,
|
| 155 |
+
max_length=self.max_len,
|
| 156 |
+
truncation="only_first",
|
| 157 |
+
padding="max_length",
|
| 158 |
+
return_tensors="pt",
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
item = {
|
| 162 |
+
"input_ids": enc["input_ids"].squeeze(0),
|
| 163 |
+
"attention_mask": enc["attention_mask"].squeeze(0),
|
| 164 |
+
}
|
| 165 |
+
if "label" in ex:
|
| 166 |
+
item["labels"] = torch.tensor(ex["label"], dtype=torch.long)
|
| 167 |
+
return item
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
class DeduplicatedEntitySentimentDataset(EntitySentimentDataset):
|
| 171 |
+
"""Like EntitySentimentDataset but with one position per entity.
|
| 172 |
+
|
| 173 |
+
Applies deduplicate_positions before flattening, so each entity
|
| 174 |
+
contributes exactly one training example.
|
| 175 |
+
"""
|
| 176 |
+
|
| 177 |
+
def __init__(self, samples: list[dict], mode: str, tokenizer, max_len: int):
|
| 178 |
+
deduped = deduplicate_positions(samples)
|
| 179 |
+
examples = flatten_to_examples(deduped, mode=mode)
|
| 180 |
+
super().__init__(examples, tokenizer, max_len)
|
src/models/distillbert.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections import Counter
|
| 2 |
+
import numpy as np
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn.functional as F
|
| 5 |
+
from sklearn.metrics import f1_score
|
| 6 |
+
from torch import nn
|
| 7 |
+
from torch.utils.data import DataLoader
|
| 8 |
+
from transformers import Trainer
|
| 9 |
+
from src.models.dataset import EntitySentimentDataset
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def compute_class_weights(examples: list[dict], n_classes: int) -> torch.Tensor:
|
| 13 |
+
counts = Counter(e["label"] for e in examples)
|
| 14 |
+
total = sum(counts.values())
|
| 15 |
+
weights = [total / (n_classes * counts.get(i, 1)) for i in range(n_classes)]
|
| 16 |
+
return torch.tensor(weights, dtype=torch.float)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def focal_loss(
|
| 20 |
+
logits: torch.Tensor,
|
| 21 |
+
labels: torch.Tensor,
|
| 22 |
+
weight: torch.Tensor,
|
| 23 |
+
gamma: float = 2.0,
|
| 24 |
+
) -> torch.Tensor:
|
| 25 |
+
ce = F.cross_entropy(logits, labels, weight=weight, reduction="none")
|
| 26 |
+
probs = F.softmax(logits, dim=-1)
|
| 27 |
+
pt = probs.gather(1, labels.unsqueeze(1)).squeeze(1)
|
| 28 |
+
return ((1 - pt) ** gamma * ce).mean()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class WeightedLossTrainer(Trainer):
|
| 32 |
+
|
| 33 |
+
def __init__(self, *args, class_weights: torch.Tensor, loss_fn: str = "cross_entropy", focal_gamma: float = 2.0, **kwargs):
|
| 34 |
+
super().__init__(*args, **kwargs)
|
| 35 |
+
self.class_weights = class_weights
|
| 36 |
+
self.loss_fn = loss_fn
|
| 37 |
+
self.focal_gamma = focal_gamma
|
| 38 |
+
|
| 39 |
+
def compute_loss(self, model, inputs, return_outputs: bool = False, **kwargs):
|
| 40 |
+
labels = inputs.pop("labels")
|
| 41 |
+
outputs = model(**inputs)
|
| 42 |
+
w = self.class_weights.to(outputs.logits.device)
|
| 43 |
+
if self.loss_fn == "focal":
|
| 44 |
+
loss = focal_loss(outputs.logits, labels, weight=w, gamma=self.focal_gamma)
|
| 45 |
+
else:
|
| 46 |
+
loss = nn.CrossEntropyLoss(weight=w)(outputs.logits, labels)
|
| 47 |
+
return (loss, outputs) if return_outputs else loss
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def reconstruct_triplets(
|
| 51 |
+
yes_probs: np.ndarray, bin_labels: np.ndarray
|
| 52 |
+
) -> tuple[list[int], list[int]]:
|
| 53 |
+
"""Group consecutive (neg, neu, pos) triplets and take argmax."""
|
| 54 |
+
preds3, labels3 = [], []
|
| 55 |
+
for i in range(0, len(yes_probs) - 2, 3):
|
| 56 |
+
preds3.append(int(np.argmax(yes_probs[i: i + 3])))
|
| 57 |
+
labels3.append(int(np.argmax(bin_labels[i: i + 3])))
|
| 58 |
+
return preds3, labels3
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def make_compute_metrics(mode: str):
|
| 62 |
+
if mode in ("marker", "qa_m"):
|
| 63 |
+
def compute_metrics(eval_pred):
|
| 64 |
+
logits, labels = eval_pred
|
| 65 |
+
preds = np.argmax(logits, axis=-1)
|
| 66 |
+
macro_f1 = f1_score(labels, preds, average="macro")
|
| 67 |
+
per_class = f1_score(labels, preds, average=None, labels=[0, 1, 2])
|
| 68 |
+
return {
|
| 69 |
+
"macro_f1": macro_f1,
|
| 70 |
+
"f1_negative": per_class[0],
|
| 71 |
+
"f1_neutral": per_class[1],
|
| 72 |
+
"f1_positive": per_class[2],
|
| 73 |
+
}
|
| 74 |
+
else:
|
| 75 |
+
def compute_metrics(eval_pred):
|
| 76 |
+
logits, labels = eval_pred
|
| 77 |
+
preds = np.argmax(logits, axis=-1)
|
| 78 |
+
bin_acc = float((preds == labels).mean())
|
| 79 |
+
bin_f1 = float(f1_score(labels, preds, average="binary", pos_label=1))
|
| 80 |
+
|
| 81 |
+
yes_probs = F.softmax(
|
| 82 |
+
torch.tensor(logits, dtype=torch.float), dim=-1
|
| 83 |
+
)[:, 1].numpy()
|
| 84 |
+
|
| 85 |
+
preds3, labels3 = reconstruct_triplets(yes_probs, labels)
|
| 86 |
+
|
| 87 |
+
macro_f1 = float(f1_score(preds3, labels3, average="macro")) \
|
| 88 |
+
if preds3 else 0.0
|
| 89 |
+
return {
|
| 90 |
+
"macro_f1": macro_f1,
|
| 91 |
+
"bin_accuracy": bin_acc,
|
| 92 |
+
"bin_f1_yes": bin_f1,
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
return compute_metrics
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def evaluate_qa_b_test(
|
| 99 |
+
model,
|
| 100 |
+
tokenizer,
|
| 101 |
+
test_exs: list[dict],
|
| 102 |
+
max_len: int,
|
| 103 |
+
batch_size: int,
|
| 104 |
+
device: torch.device,
|
| 105 |
+
) -> tuple[float, list[int], list[int]]:
|
| 106 |
+
ds = EntitySentimentDataset(test_exs, tokenizer, max_len)
|
| 107 |
+
loader = DataLoader(ds, batch_size=batch_size, shuffle=False)
|
| 108 |
+
|
| 109 |
+
all_yes_probs, all_bin_labels = [], []
|
| 110 |
+
model.eval()
|
| 111 |
+
with torch.no_grad():
|
| 112 |
+
for batch in loader:
|
| 113 |
+
logits = model(
|
| 114 |
+
input_ids=batch["input_ids"].to(device),
|
| 115 |
+
attention_mask=batch["attention_mask"].to(device),
|
| 116 |
+
).logits
|
| 117 |
+
all_yes_probs.extend(
|
| 118 |
+
F.softmax(logits, dim=-1)[:, 1].cpu().tolist()
|
| 119 |
+
)
|
| 120 |
+
all_bin_labels.extend(batch["labels"].tolist())
|
| 121 |
+
|
| 122 |
+
preds3, labels3 = reconstruct_triplets(
|
| 123 |
+
np.array(all_yes_probs), np.array(all_bin_labels)
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
macro_f1 = f1_score(labels3, preds3, average="macro")
|
| 127 |
+
return macro_f1, preds3, labels3
|
src/models/fasttext.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import json
|
| 3 |
+
import tempfile
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import fasttext
|
| 6 |
+
import numpy as np
|
| 7 |
+
from sklearn.metrics import f1_score, classification_report
|
| 8 |
+
from src.models.augment import augment, MAXLEN_TO_WINDOW
|
| 9 |
+
from src.models.dataset import deduplicate_positions, flatten_to_examples, split_data
|
| 10 |
+
from src.schemas.labels import SENTIMENT_LABELS
|
| 11 |
+
|
| 12 |
+
MODE = "marker"
|
| 13 |
+
LABEL_PREFIX = "__label__"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _to_fasttext_line(example: dict) -> str:
|
| 17 |
+
text = example["seg_a"].replace("\n", " ")
|
| 18 |
+
label = SENTIMENT_LABELS.id2label[example["label"]]
|
| 19 |
+
return f"{LABEL_PREFIX}{label} {text}"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _write_fasttext_file(examples: list[dict], path: Path) -> None:
|
| 23 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 24 |
+
for ex in examples:
|
| 25 |
+
f.write(_to_fasttext_line(ex) + "\n")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def prepare_data(
|
| 29 |
+
data_path: str = "data/data_augmented_256.jsonl",
|
| 30 |
+
val_split: float = 0.1,
|
| 31 |
+
test_split: float = 0.1,
|
| 32 |
+
seed: int = 42,
|
| 33 |
+
) -> tuple[list[dict], list[dict], list[dict]]:
|
| 34 |
+
with open(data_path, "r", encoding="utf-8") as f:
|
| 35 |
+
samples = [json.loads(line) for line in f]
|
| 36 |
+
|
| 37 |
+
examples = flatten_to_examples(samples, mode=MODE)
|
| 38 |
+
train_ex, val_ex, test_ex = split_data(examples, val_split, test_split, seed)
|
| 39 |
+
|
| 40 |
+
print(f"Train: {len(train_ex)}, Val: {len(val_ex)}, Test: {len(test_ex)}")
|
| 41 |
+
return train_ex, val_ex, test_ex
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def train(
|
| 45 |
+
train_examples: list[dict],
|
| 46 |
+
val_examples: list[dict],
|
| 47 |
+
output_dir: str = "models/fasttext",
|
| 48 |
+
lr: float = 0.5,
|
| 49 |
+
epoch: int = 25,
|
| 50 |
+
word_ngrams: int = 2,
|
| 51 |
+
dim: int = 100,
|
| 52 |
+
min_count: int = 1,
|
| 53 |
+
) -> fasttext.FastText._FastText:
|
| 54 |
+
output_dir = Path(output_dir)
|
| 55 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 56 |
+
|
| 57 |
+
train_file = output_dir / "train.txt"
|
| 58 |
+
_write_fasttext_file(train_examples, train_file)
|
| 59 |
+
|
| 60 |
+
model = fasttext.train_supervised(
|
| 61 |
+
input=str(train_file),
|
| 62 |
+
lr=lr,
|
| 63 |
+
epoch=epoch,
|
| 64 |
+
wordNgrams=word_ngrams,
|
| 65 |
+
dim=dim,
|
| 66 |
+
minCount=min_count,
|
| 67 |
+
loss="softmax",
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
model.save_model(str(output_dir / "model.bin"))
|
| 71 |
+
print(f"Model saved to {output_dir / 'model.bin'}")
|
| 72 |
+
|
| 73 |
+
evaluate(model, val_examples, split_name="val")
|
| 74 |
+
|
| 75 |
+
return model
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def evaluate(
|
| 79 |
+
model: fasttext.FastText._FastText,
|
| 80 |
+
examples: list[dict],
|
| 81 |
+
split_name: str = "test",
|
| 82 |
+
) -> float:
|
| 83 |
+
sentiments = list(SENTIMENT_LABELS.classes)
|
| 84 |
+
true_labels = []
|
| 85 |
+
pred_labels = []
|
| 86 |
+
|
| 87 |
+
for ex in examples:
|
| 88 |
+
text = ex["seg_a"].replace("\n", " ")
|
| 89 |
+
prediction = model.predict(text)[0][0].replace(LABEL_PREFIX, "")
|
| 90 |
+
pred_labels.append(prediction)
|
| 91 |
+
true_labels.append(SENTIMENT_LABELS.id2label[ex["label"]])
|
| 92 |
+
|
| 93 |
+
macro_f1 = f1_score(true_labels, pred_labels, average="macro", labels=sentiments)
|
| 94 |
+
print(f"\n{split_name} (per-position) macro F1: {macro_f1:.4f}")
|
| 95 |
+
print(classification_report(true_labels, pred_labels, labels=sentiments, digits=4))
|
| 96 |
+
|
| 97 |
+
return macro_f1
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def evaluate_entity_level(
|
| 101 |
+
model: fasttext.FastText._FastText,
|
| 102 |
+
examples: list[dict],
|
| 103 |
+
split_name: str = "test",
|
| 104 |
+
) -> float:
|
| 105 |
+
sentiments = list(SENTIMENT_LABELS.classes)
|
| 106 |
+
|
| 107 |
+
entity_preds: dict[tuple, tuple[str, float]] = {}
|
| 108 |
+
entity_labels: dict[tuple, str] = {}
|
| 109 |
+
|
| 110 |
+
for ex in examples:
|
| 111 |
+
key = (ex["sample_id"], ex["entity_id"])
|
| 112 |
+
text = ex["seg_a"].replace("\n", " ")
|
| 113 |
+
labels, probs = model.predict(text)
|
| 114 |
+
label = labels[0].replace(LABEL_PREFIX, "")
|
| 115 |
+
conf = float(probs[0])
|
| 116 |
+
if key not in entity_preds or conf > entity_preds[key][1]:
|
| 117 |
+
entity_preds[key] = (label, conf)
|
| 118 |
+
entity_labels[key] = SENTIMENT_LABELS.id2label[ex["label"]]
|
| 119 |
+
|
| 120 |
+
true = [entity_labels[k] for k in entity_preds]
|
| 121 |
+
pred = [entity_preds[k][0] for k in entity_preds]
|
| 122 |
+
|
| 123 |
+
macro_f1 = f1_score(true, pred, average="macro", labels=sentiments)
|
| 124 |
+
print(f"\n{split_name} (entity-level) macro F1: {macro_f1:.4f}")
|
| 125 |
+
print(classification_report(true, pred, labels=sentiments, digits=4))
|
| 126 |
+
|
| 127 |
+
return macro_f1
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def predict_samples(
|
| 131 |
+
model: fasttext.FastText._FastText,
|
| 132 |
+
samples: list[dict],
|
| 133 |
+
window_words: int = 70,
|
| 134 |
+
deduplicate: bool = False,
|
| 135 |
+
) -> list[dict]:
|
| 136 |
+
augmented = augment(samples, window_words)
|
| 137 |
+
if deduplicate:
|
| 138 |
+
augmented = deduplicate_positions(augmented)
|
| 139 |
+
examples = flatten_to_examples(augmented, mode=MODE)
|
| 140 |
+
|
| 141 |
+
entity_preds: dict[tuple, tuple[str, float]] = {}
|
| 142 |
+
for ex in examples:
|
| 143 |
+
key = (ex["sample_id"], ex["entity_id"])
|
| 144 |
+
text = ex["seg_a"].replace("\n", " ")
|
| 145 |
+
labels, probs = model.predict(text)
|
| 146 |
+
label = labels[0].replace(LABEL_PREFIX, "")
|
| 147 |
+
conf = float(probs[0])
|
| 148 |
+
if key not in entity_preds or conf > entity_preds[key][1]:
|
| 149 |
+
entity_preds[key] = (label, conf)
|
| 150 |
+
|
| 151 |
+
results = []
|
| 152 |
+
for s in samples:
|
| 153 |
+
entities_out = []
|
| 154 |
+
for e in s["entities"]:
|
| 155 |
+
key = (s["id"], e["entity_id"])
|
| 156 |
+
entities_out.append({
|
| 157 |
+
"entity_id": e["entity_id"],
|
| 158 |
+
"entity_text": e["entity_text"],
|
| 159 |
+
"classification": entity_preds.get(key, ("neutral", 0.0))[0],
|
| 160 |
+
})
|
| 161 |
+
results.append({"id": s["id"], "entities": entities_out})
|
| 162 |
+
|
| 163 |
+
return results
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def main():
|
| 167 |
+
parser = argparse.ArgumentParser(description="fastText baseline for entity sentiment")
|
| 168 |
+
parser.add_argument("--data", default="data/data_augmented_256.jsonl")
|
| 169 |
+
parser.add_argument("--output-dir", default="models/fasttext")
|
| 170 |
+
parser.add_argument("--lr", type=float, default=0.5)
|
| 171 |
+
parser.add_argument("--epoch", type=int, default=25)
|
| 172 |
+
parser.add_argument("--word-ngrams", type=int, default=2)
|
| 173 |
+
parser.add_argument("--dim", type=int, default=100)
|
| 174 |
+
parser.add_argument("--val-split", type=float, default=0.1)
|
| 175 |
+
parser.add_argument("--test-split", type=float, default=0.1)
|
| 176 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 177 |
+
args = parser.parse_args()
|
| 178 |
+
|
| 179 |
+
train_ex, val_ex, test_ex = prepare_data(
|
| 180 |
+
args.data, args.val_split, args.test_split, args.seed,
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
model = train(
|
| 184 |
+
train_ex, val_ex,
|
| 185 |
+
output_dir=args.output_dir,
|
| 186 |
+
lr=args.lr,
|
| 187 |
+
epoch=args.epoch,
|
| 188 |
+
word_ngrams=args.word_ngrams,
|
| 189 |
+
dim=args.dim,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
evaluate(model, test_ex, split_name="test")
|
| 193 |
+
evaluate_entity_level(model, test_ex, split_name="test")
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
if __name__ == "__main__":
|
| 197 |
+
main()
|
src/models/inference.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import json
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import numpy as np
|
| 5 |
+
import onnxruntime as ort
|
| 6 |
+
from transformers import AutoTokenizer
|
| 7 |
+
from src.models.augment import augment, MAXLEN_TO_WINDOW
|
| 8 |
+
from src.models.dataset import deduplicate_positions, flatten_to_examples
|
| 9 |
+
from src.models.distillbert import reconstruct_triplets
|
| 10 |
+
from src.schemas.labels import MARKER_MODE, SENTIMENT_LABELS
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
BASE_TOKENIZER = "distilbert-base-uncased"
|
| 14 |
+
|
| 15 |
+
def build_tokenizer(mode: str):
|
| 16 |
+
tokenizer = AutoTokenizer.from_pretrained(BASE_TOKENIZER)
|
| 17 |
+
if mode == "marker":
|
| 18 |
+
tokenizer.add_special_tokens(
|
| 19 |
+
{"additional_special_tokens": [MARKER_MODE.entity_start, MARKER_MODE.entity_end]}
|
| 20 |
+
)
|
| 21 |
+
return tokenizer
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _softmax(logits: np.ndarray) -> np.ndarray:
|
| 25 |
+
exp = np.exp(logits - logits.max(axis=-1, keepdims=True))
|
| 26 |
+
return exp / exp.sum(axis=-1, keepdims=True)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _tokenize_examples(
|
| 30 |
+
examples: list[dict], tokenizer, max_len: int,
|
| 31 |
+
) -> dict[str, np.ndarray]:
|
| 32 |
+
input_ids, attention_masks = [], []
|
| 33 |
+
for ex in examples:
|
| 34 |
+
seg_a = ex["seg_a"]
|
| 35 |
+
seg_b = ex["seg_b"]
|
| 36 |
+
|
| 37 |
+
if seg_b is None:
|
| 38 |
+
enc = tokenizer(
|
| 39 |
+
seg_a,
|
| 40 |
+
max_length=max_len,
|
| 41 |
+
truncation=True,
|
| 42 |
+
padding="max_length",
|
| 43 |
+
return_tensors="np",
|
| 44 |
+
)
|
| 45 |
+
else:
|
| 46 |
+
enc = tokenizer(
|
| 47 |
+
seg_a, seg_b,
|
| 48 |
+
max_length=max_len,
|
| 49 |
+
truncation="only_first",
|
| 50 |
+
padding="max_length",
|
| 51 |
+
return_tensors="np",
|
| 52 |
+
)
|
| 53 |
+
input_ids.append(enc["input_ids"][0])
|
| 54 |
+
attention_masks.append(enc["attention_mask"][0])
|
| 55 |
+
|
| 56 |
+
return {
|
| 57 |
+
"input_ids": np.array(input_ids, dtype=np.int64),
|
| 58 |
+
"attention_mask": np.array(attention_masks, dtype=np.int64),
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _run_batched(
|
| 63 |
+
session: ort.InferenceSession,
|
| 64 |
+
inputs: dict[str, np.ndarray],
|
| 65 |
+
batch_size: int,
|
| 66 |
+
) -> np.ndarray:
|
| 67 |
+
n = inputs["input_ids"].shape[0]
|
| 68 |
+
all_logits = []
|
| 69 |
+
for start in range(0, n, batch_size):
|
| 70 |
+
end = min(start + batch_size, n)
|
| 71 |
+
batch = {k: v[start:end] for k, v in inputs.items()}
|
| 72 |
+
logits = session.run(None, batch)[0]
|
| 73 |
+
all_logits.append(logits)
|
| 74 |
+
return np.concatenate(all_logits, axis=0)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def predict(
|
| 78 |
+
samples: list[dict],
|
| 79 |
+
session: ort.InferenceSession,
|
| 80 |
+
tokenizer,
|
| 81 |
+
mode: str,
|
| 82 |
+
max_len: int = 256,
|
| 83 |
+
batch_size: int = 32,
|
| 84 |
+
deduplicate: bool = False,
|
| 85 |
+
) -> list[dict]:
|
| 86 |
+
window_words = MAXLEN_TO_WINDOW[max_len]
|
| 87 |
+
augmented = augment(samples, window_words)
|
| 88 |
+
if deduplicate:
|
| 89 |
+
augmented = deduplicate_positions(augmented)
|
| 90 |
+
examples = flatten_to_examples(augmented, mode=mode)
|
| 91 |
+
|
| 92 |
+
if not examples:
|
| 93 |
+
return [{"id": s["id"], "entities": []} for s in samples]
|
| 94 |
+
|
| 95 |
+
inputs = _tokenize_examples(examples, tokenizer, max_len)
|
| 96 |
+
logits = _run_batched(session, inputs, batch_size)
|
| 97 |
+
sentiments = list(SENTIMENT_LABELS.classes)
|
| 98 |
+
|
| 99 |
+
probs = _softmax(logits)
|
| 100 |
+
|
| 101 |
+
if mode in ("marker", "qa_m"):
|
| 102 |
+
preds = np.argmax(probs, axis=-1)
|
| 103 |
+
max_probs = probs.max(axis=-1)
|
| 104 |
+
for ex, pred_id, conf in zip(examples, preds, max_probs):
|
| 105 |
+
ex["predicted_label"] = sentiments[int(pred_id)]
|
| 106 |
+
ex["confidence"] = float(conf)
|
| 107 |
+
|
| 108 |
+
else:
|
| 109 |
+
yes_probs = probs[:, 1]
|
| 110 |
+
preds3, _ = reconstruct_triplets(yes_probs, np.zeros_like(yes_probs))
|
| 111 |
+
|
| 112 |
+
triplet_idx = 0
|
| 113 |
+
i = 0
|
| 114 |
+
while i < len(examples) - 2:
|
| 115 |
+
pred_label = sentiments[preds3[triplet_idx]]
|
| 116 |
+
triplet_conf = float(yes_probs[i:i + 3].max())
|
| 117 |
+
for j in range(3):
|
| 118 |
+
examples[i + j]["predicted_label"] = pred_label
|
| 119 |
+
examples[i + j]["confidence"] = triplet_conf
|
| 120 |
+
triplet_idx += 1
|
| 121 |
+
i += 3
|
| 122 |
+
|
| 123 |
+
entity_preds: dict[tuple, tuple[str, float]] = {}
|
| 124 |
+
for ex in examples:
|
| 125 |
+
key = (ex["sample_id"], ex["entity_id"])
|
| 126 |
+
conf = ex.get("confidence", 0.0)
|
| 127 |
+
if key not in entity_preds or conf > entity_preds[key][1]:
|
| 128 |
+
entity_preds[key] = (ex["predicted_label"], conf)
|
| 129 |
+
|
| 130 |
+
results = []
|
| 131 |
+
for s in samples:
|
| 132 |
+
entities_out = []
|
| 133 |
+
for e in s["entities"]:
|
| 134 |
+
key = (s["id"], e["entity_id"])
|
| 135 |
+
label, _ = entity_preds.get(key, ("neutral", 0.0))
|
| 136 |
+
entities_out.append({
|
| 137 |
+
"entity_id": e["entity_id"],
|
| 138 |
+
"entity_text": e["entity_text"],
|
| 139 |
+
"classification": label,
|
| 140 |
+
})
|
| 141 |
+
results.append({"id": s["id"], "entities": entities_out})
|
| 142 |
+
|
| 143 |
+
return results
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def main():
|
| 147 |
+
parser = argparse.ArgumentParser(description="Run ONNX inference on raw input JSON")
|
| 148 |
+
parser.add_argument("--onnx-path", required=True, help="Path to model.onnx")
|
| 149 |
+
parser.add_argument("--mode", required=True, choices=("marker", "qa_m", "qa_b"))
|
| 150 |
+
parser.add_argument("--data", required=True, help="Path to input JSON (assignment format)")
|
| 151 |
+
parser.add_argument("--output", default=None, help="Output JSON path (default: stdout)")
|
| 152 |
+
parser.add_argument("--max-len", type=int, default=256)
|
| 153 |
+
parser.add_argument("--batch-size", type=int, default=32)
|
| 154 |
+
parser.add_argument("--deduplicate", action="store_true", help="Use one position per entity")
|
| 155 |
+
args = parser.parse_args()
|
| 156 |
+
|
| 157 |
+
mode = args.mode
|
| 158 |
+
tokenizer = build_tokenizer(mode)
|
| 159 |
+
session = ort.InferenceSession(args.onnx_path)
|
| 160 |
+
|
| 161 |
+
with open(args.data, "r", encoding="utf-8") as f:
|
| 162 |
+
samples = json.load(f)
|
| 163 |
+
|
| 164 |
+
results = predict(samples, session, tokenizer, mode, args.max_len, args.batch_size, deduplicate=args.deduplicate)
|
| 165 |
+
|
| 166 |
+
output_json = json.dumps(results, ensure_ascii=False, indent=2)
|
| 167 |
+
if args.output:
|
| 168 |
+
Path(args.output).write_text(output_json, encoding="utf-8")
|
| 169 |
+
print(f"Saved {len(results)} predictions to {args.output}")
|
| 170 |
+
else:
|
| 171 |
+
print(output_json)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
if __name__ == "__main__":
|
| 175 |
+
main()
|