dejanseo commited on
Commit
5b9c2ac
·
verified ·
1 Parent(s): 7226b8c

Upload inference.py

Browse files
Files changed (1) hide show
  1. inference.py +240 -0
inference.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import torch
3
+ import re
4
+ import os
5
+ from transformers import AutoTokenizer, AutoModelForTokenClassification
6
+
7
+ LABEL2ID = {"O": 0, "B-SPAN": 1, "I-SPAN": 2}
8
+ ID2LABEL = {v: k for k, v in LABEL2ID.items()}
9
+
10
+ import glob
11
+
12
+ MODEL_DIRS = {
13
+ "CE": "./span_model_ce",
14
+ "Focal": "./span_model_focal",
15
+ }
16
+
17
+ def discover_checkpoints(model_dir, prefix):
18
+ found = {}
19
+ for path in sorted(glob.glob(f"{model_dir}/checkpoint-*"), key=lambda p: int(p.split("-")[-1])):
20
+ name = f"{prefix} / {path.split('/')[-1]}"
21
+ found[name] = path
22
+ final_path = f"{model_dir}/final"
23
+ if os.path.exists(final_path):
24
+ found[f"{prefix} / final"] = final_path
25
+ return found
26
+
27
+ CHECKPOINTS = {}
28
+ for prefix, model_dir in MODEL_DIRS.items():
29
+ CHECKPOINTS.update(discover_checkpoints(model_dir, prefix))
30
+ if not CHECKPOINTS:
31
+ st.error("No checkpoints found.")
32
+ st.stop()
33
+
34
+
35
+ _current_model = {"path": None, "model": None, "tokenizer": None}
36
+
37
+ def load_model(checkpoint_path):
38
+ if _current_model["path"] == checkpoint_path:
39
+ return _current_model["tokenizer"], _current_model["model"]
40
+ # Free old model
41
+ if _current_model["model"] is not None:
42
+ del _current_model["model"]
43
+ del _current_model["tokenizer"]
44
+ if torch.cuda.is_available():
45
+ torch.cuda.empty_cache()
46
+ tokenizer = AutoTokenizer.from_pretrained(checkpoint_path)
47
+ model = AutoModelForTokenClassification.from_pretrained(checkpoint_path)
48
+ model.eval()
49
+ if torch.cuda.is_available():
50
+ model = model.cuda()
51
+ _current_model["path"] = checkpoint_path
52
+ _current_model["model"] = model
53
+ _current_model["tokenizer"] = tokenizer
54
+ return tokenizer, model
55
+
56
+
57
+ def strip_md(text):
58
+ text = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', text)
59
+ text = re.sub(r'\*\*([^*]*)\*\*', r'\1', text)
60
+ text = re.sub(r'\*([^*]*)\*', r'\1', text)
61
+ return text
62
+
63
+
64
+ def build_clean_to_original_map(original, cleaned):
65
+ """Build character mapping from cleaned text positions back to original text positions."""
66
+ # Align cleaned to original using simple forward matching
67
+ mapping = []
68
+ j = 0
69
+ for i, ch in enumerate(cleaned):
70
+ while j < len(original) and original[j] != ch:
71
+ j += 1
72
+ mapping.append(j)
73
+ j += 1
74
+ return mapping
75
+
76
+
77
+ def predict_spans(tokenizer, model, title, text, threshold=0.5):
78
+ """Run inference and return list of (text, is_span) tuples for rendering."""
79
+ device = next(model.parameters()).device
80
+
81
+ # Strip markdown for model input, keep original for display
82
+ clean_text = strip_md(text)
83
+
84
+ # Tokenize title and cleaned text
85
+ title_enc = tokenizer(title, add_special_tokens=False)
86
+ text_enc = tokenizer(clean_text, add_special_tokens=False, return_offsets_mapping=True)
87
+
88
+ title_ids = title_enc["input_ids"]
89
+ text_ids = text_enc["input_ids"]
90
+ text_offsets = text_enc["offset_mapping"]
91
+
92
+ # Build input: [CLS] title [SEP] text [SEP]
93
+ input_ids = [tokenizer.cls_token_id] + title_ids + [tokenizer.sep_token_id] + text_ids + [tokenizer.sep_token_id]
94
+ attention_mask = [1] * len(input_ids)
95
+
96
+ # Truncate to model max length
97
+ max_len = tokenizer.model_max_length
98
+ if max_len > 10000:
99
+ max_len = 512
100
+ input_ids = input_ids[:max_len]
101
+ attention_mask = attention_mask[:max_len]
102
+
103
+ text_start = len(title_ids) + 2 # CLS + title + SEP
104
+ text_end = len(input_ids) - 1 # before final SEP
105
+
106
+ inputs = {
107
+ "input_ids": torch.tensor([input_ids], device=device),
108
+ "attention_mask": torch.tensor([attention_mask], device=device),
109
+ }
110
+
111
+ with torch.no_grad():
112
+ logits = model(**inputs).logits[0] # (seq_len, 3)
113
+ probs = torch.softmax(logits, dim=-1)
114
+
115
+ # Map token probs from clean text back to original text
116
+ clean_to_orig = build_clean_to_original_map(text, clean_text)
117
+
118
+ char_labels = [0] * len(text)
119
+ char_probs = [0.0] * len(text)
120
+ all_char_probs = [0.0] * len(text)
121
+ tokens_used = min(len(text_ids), text_end - text_start)
122
+
123
+ for i in range(tokens_used):
124
+ tok_idx = text_start + i
125
+ if tok_idx >= len(probs):
126
+ break
127
+ span_prob = (probs[tok_idx][LABEL2ID["B-SPAN"]] + probs[tok_idx][LABEL2ID["I-SPAN"]]).item()
128
+ if i < len(text_offsets):
129
+ clean_start, clean_end = text_offsets[i]
130
+ for cc in range(clean_start, min(clean_end, len(clean_text))):
131
+ if cc < len(clean_to_orig):
132
+ oc = clean_to_orig[cc]
133
+ if oc < len(text):
134
+ all_char_probs[oc] = max(all_char_probs[oc], span_prob)
135
+ if span_prob >= threshold:
136
+ for cc in range(clean_start, min(clean_end, len(clean_text))):
137
+ if cc < len(clean_to_orig):
138
+ oc = clean_to_orig[cc]
139
+ if oc < len(text):
140
+ char_labels[oc] = 1
141
+ char_probs[oc] = max(char_probs[oc], span_prob)
142
+
143
+ # Expand labeled chars to cover full words (fix subword splits)
144
+ # A "word" is a run of non-whitespace characters
145
+ i = 0
146
+ while i < len(text):
147
+ if text[i].isspace():
148
+ i += 1
149
+ continue
150
+ # Find word boundary
151
+ word_start = i
152
+ while i < len(text) and not text[i].isspace():
153
+ i += 1
154
+ word_end = i
155
+ # If any char in this word is labeled, label the whole word
156
+ if any(char_labels[c] for c in range(word_start, word_end)):
157
+ max_prob = max(char_probs[c] for c in range(word_start, word_end))
158
+ for c in range(word_start, word_end):
159
+ char_labels[c] = 1
160
+ char_probs[c] = max(char_probs[c], max_prob)
161
+
162
+ # Build segments with average confidence per span
163
+ segments = []
164
+ if not text:
165
+ return segments
166
+
167
+ current_label = char_labels[0]
168
+ current_start = 0
169
+
170
+ for i in range(1, len(text)):
171
+ if char_labels[i] != current_label:
172
+ conf = sum(char_probs[current_start:i]) / max(1, i - current_start) if current_label == 1 else 0.0
173
+ segments.append((text[current_start:i], current_label == 1, conf))
174
+ current_start = i
175
+ current_label = char_labels[i]
176
+ conf = sum(char_probs[current_start:]) / max(1, len(text) - current_start) if current_label == 1 else 0.0
177
+ segments.append((text[current_start:], current_label == 1, conf))
178
+
179
+ return segments, all_char_probs
180
+
181
+
182
+ st.set_page_config(page_title="Span Extractor", layout="wide")
183
+ st.title("Span Extractor Inference")
184
+
185
+ checkpoint_names = list(CHECKPOINTS.keys())
186
+ checkpoint = st.selectbox("Checkpoint", checkpoint_names, index=len(checkpoint_names) - 1)
187
+ tokenizer, model = load_model(CHECKPOINTS[checkpoint])
188
+
189
+ threshold = st.slider("Span confidence threshold", 0.0, 1.0, 0.5, 0.05)
190
+
191
+ title = st.text_input("Title", placeholder="Enter article title...")
192
+ text = st.text_area("Text", height=300, placeholder="Enter article text...")
193
+
194
+ if st.button("Extract Spans") and title and text:
195
+ segments, all_char_probs = predict_spans(tokenizer, model, title, text, threshold)
196
+
197
+ if not any(is_span for _, is_span, _ in segments):
198
+ st.warning("No spans predicted.")
199
+ else:
200
+ span_count = sum(1 for seg, is_span, _ in segments if is_span)
201
+ st.caption(f"{span_count} span(s) detected")
202
+
203
+ # Render with green background for spans, tooltips on all words
204
+ html_parts = []
205
+ pos = 0
206
+ for seg, is_span, conf in segments:
207
+ # Split segment into words to add per-word tooltips
208
+ import re as _re
209
+ words = _re.split(r'(\s+)', seg)
210
+ for word in words:
211
+ if not word:
212
+ continue
213
+ escaped = word.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\n", "<br>")
214
+ # Get avg prob for this word's characters
215
+ word_start = pos
216
+ word_end = pos + len(word)
217
+ word_probs = all_char_probs[word_start:word_end]
218
+ avg_prob = sum(word_probs) / max(1, len(word_probs))
219
+ tooltip = f"{avg_prob:.2f}"
220
+ if is_span:
221
+ html_parts.append(f'<span title="{tooltip}" style="background-color: #22c55e; color: white; padding: 1px 3px; border-radius: 3px; cursor: help;">{escaped}</span>')
222
+ else:
223
+ html_parts.append(f'<span title="{tooltip}" style="cursor: help;">{escaped}</span>')
224
+ pos += len(word)
225
+
226
+ html = f'<div style="font-size: 16px; line-height: 1.8; font-family: Georgia, serif;">{"".join(html_parts)}</div>'
227
+ st.markdown(html, unsafe_allow_html=True)
228
+
229
+ # Show extracted spans as dataframe
230
+ st.divider()
231
+ st.subheader("Extracted Spans")
232
+ import pandas as pd
233
+ span_data = [{"span": seg.strip(), "confidence": conf} for seg, is_span, conf in segments if is_span]
234
+ df = pd.DataFrame(span_data)
235
+ st.dataframe(
236
+ df,
237
+ use_container_width=True,
238
+ hide_index=True,
239
+ column_config={"confidence": st.column_config.ProgressColumn(min_value=0, max_value=1, format="%.2f")},
240
+ )