lamossta commited on
Commit
51620d3
·
1 Parent(s): 61af0ed

models and inference classes

Browse files
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()