gnumanth commited on
Commit
3eb4153
路
verified 路
1 Parent(s): 6e0771f

feat: long-context windowing and multi-numeric contract disambiguation

Browse files
Files changed (1) hide show
  1. modeling_nullspan.py +217 -8
modeling_nullspan.py CHANGED
@@ -76,18 +76,177 @@ _QA_STOPWORDS = {
76
  }
77
 
78
 
79
- def select_context_window(state: str, spec: dict[str, Any]) -> tuple[str, int]:
80
- """Select a focused prose window around the highest IDF-weighted overlap sentence when state > 320 chars."""
81
- if len(state) <= 320 or "\n" in state or spec.get("type", "text") != "text":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  return state, 0
83
- instr = str(spec.get("instructions", ""))
 
 
 
 
 
 
84
  q_toks = [t.lower() for t in re.sub(r"[^\w\s-]", " ", instr).split() if len(t) > 1 and t.lower() not in _QA_STOPWORDS]
85
  if not q_toks:
86
  return state, 0
87
  sentences = list(re.finditer(r"[^.!?\n]+[.!?\n]?", state))
88
  if len(sentences) <= 1:
89
  return state, 0
90
- # Weight each query token by character length / sentence frequency (IDF within passage)
91
  tok_weights: dict[str, float] = {}
92
  for t in q_toks:
93
  stem = t.rstrip("s")
@@ -103,11 +262,17 @@ def select_context_window(state: str, spec: dict[str, Any]) -> tuple[str, int]:
103
  for t in q_toks
104
  if t in s_low or (len(t.rstrip("s")) >= 3 and t.rstrip("s") in s_low)
105
  )
 
 
106
  if sc >= best_sc:
107
  best_sc = sc
108
  best_m = m_s
109
  if best_m is None or best_m.end() <= 260:
 
 
110
  return state, 0
 
 
111
  win_start = max(0, best_m.start() - 30)
112
  while win_start > 0 and state[win_start - 1] not in (" ", "\n"):
113
  win_start -= 1
@@ -404,14 +569,14 @@ class NullspanForExtraction(PreTrainedModel):
404
  state: str,
405
  fields: dict[str, dict[str, Any]],
406
  tokenizer: Any,
407
- max_length: int = 128,
408
  ) -> dict[str, Any]:
409
  """Run a single batched System One forward pass across all requested fields."""
410
  self.eval()
411
  device = next(self.parameters()).device
412
  field_names = list(fields.keys())
413
  queries = [format_field_query(fn, fields[fn]) for fn in field_names]
414
- win_pairs = [select_context_window(state, fields[fn]) for fn in field_names]
415
  states = [wp[0] for wp in win_pairs]
416
  win_offsets = [wp[1] for wp in win_pairs]
417
  num_feats_list = [extract_numeric_context_features(state, fields[fn]) for fn in field_names]
@@ -500,6 +665,44 @@ class NullspanForExtraction(PreTrainedModel):
500
  abstention_reason = "missing_field"
501
 
502
  if ftype == "list":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
  tags = bio_preds[b_i].tolist()
504
  spans_list: list[list[int]] = []
505
  vals_list: list[str] = []
@@ -507,7 +710,7 @@ class NullspanForExtraction(PreTrainedModel):
507
  cur_e: int | None = None
508
  for t_i in valid_indices:
509
  tg = tags[t_i]
510
- ts, te = int(offsets[t_i][0]), int(offsets[t_i][1])
511
  if tg == 1: # B
512
  if cur_s is not None and cur_e is not None:
513
  sub = state[cur_s:cur_e].strip(" .,;:")
@@ -664,6 +867,12 @@ class NullspanForExtraction(PreTrainedModel):
664
  if ftype == "number":
665
  instr_lower = spec.get("instructions", "").lower()
666
  span_nums = [float(x.replace(",", "")) for x in re.findall(r"\d+(?:,\d{3})*(?:\.\d+)?", span_text)]
 
 
 
 
 
 
667
  if (op_id == 2 or "including" in instr_lower) and len(span_nums) >= 2:
668
  num_val = sum(span_nums[:2])
669
  elif op_id == 2 and "plus $" in state:
 
76
  }
77
 
78
 
79
+ _NUM_STOPWORDS = {
80
+ "what", "who", "when", "where", "which", "how", "much", "many",
81
+ "is", "are", "was", "were", "be", "the", "a", "an", "in", "on", "of",
82
+ "to", "for", "with", "from", "by", "at", "under", "this", "that",
83
+ "number", "numeric", "value", "amount", "total", "if", "present",
84
+ "applicable", "specified", "stated", "contract", "agreement", "document",
85
+ "usd", "dollar", "dollars", "percent", "percentage", "pct",
86
+ "day", "days", "month", "months", "year", "years", "hour", "hours",
87
+ }
88
+
89
+
90
+ _GENERIC_MEASURE_WORDS = {
91
+ "rate", "rates", "period", "periods", "fee", "fees", "limit", "limits",
92
+ "level", "levels", "time", "times", "window", "windows", "schedule",
93
+ "schedules", "ratio", "ratios", "charge", "charges", "cost", "costs",
94
+ "price", "prices", "payment", "payments", "duration",
95
+ }
96
+
97
+
98
+ def disambiguate_numeric_field(state: str, fname: str, spec: dict[str, Any]) -> dict[str, Any] | None:
99
+ """Context-aware multi-numeric candidate ranker for contracts and documents with multiple competing numbers."""
100
+ fn_clean = fname.replace("_", " ")
101
+ instr_s = str(spec.get("instructions") or spec.get("instruction") or "")
102
+ q_raw = f"{fn_clean} {instr_s}".lower()
103
+ q_words: list[str] = []
104
+ for w in re.findall(r"[a-z]{2,}", q_raw):
105
+ if w not in _NUM_STOPWORDS and w not in q_words:
106
+ q_words.append(w)
107
+ specific_q_words = [w for w in q_words if w not in _GENERIC_MEASURE_WORDS]
108
+
109
+ u_val = str(spec.get("unit", "")).lower()
110
+ unit_str = f"{q_raw} {u_val}"
111
+ wants_pct = bool(re.search(r"\b(?:pct|percent|percentage|apr|bps)\b|%", unit_str))
112
+ wants_time = None
113
+ if not wants_pct:
114
+ m_tu = re.search(r"\b(day|month|year|hour|minute|week)s?\b", unit_str)
115
+ if m_tu and not re.search(r"\b(?:usd|dollars?|eur|gbp|fee|retainer|cap|cost|price|salary|bonus|deductible)\b|\$", unit_str):
116
+ wants_time = m_tu.group(1)
117
+ wants_currency = (not wants_pct and not wants_time) and bool(
118
+ re.search(
119
+ r"\b(?:usd|dollars?|eur|gbp|fee|retainer|cap|cost|price|credit|surcharge|payment|bonus|deductible|penalty|salary|budget|deposit|subtotal|revenue)\b|\$",
120
+ unit_str,
121
+ )
122
+ )
123
+
124
+ clauses = list(re.finditer(r"[^.!?\n]+[.!?\n]?", state))
125
+
126
+ def get_clause_bounds(pos: int) -> tuple[int, int]:
127
+ for cm in clauses:
128
+ if cm.start() <= pos < cm.end():
129
+ return cm.start(), cm.end()
130
+ return max(0, pos - 100), min(len(state), pos + 100)
131
+
132
+ candidates: list[tuple[float, float, int, float, list[int], str]] = []
133
+ for m in re.finditer(r"(?<![A-Za-z0-9_-])([$鈧B)?(\d+(?:,\d{3})*(?:\.\d+)?)(%)?(?![A-Za-z0-9_]|-\d)", state):
134
+ sym, num_s, pct_s = m.group(1), m.group(2), m.group(3)
135
+ s_idx, e_idx = m.start(), m.end()
136
+ val = float(num_s.replace(",", ""))
137
+
138
+ before_25 = state[max(0, s_idx - 25) : s_idx]
139
+ after_25 = state[e_idx : min(len(state), e_idx + 25)]
140
+
141
+ is_sec_hdr = bool(
142
+ re.search(r"(?:section|clause|article|page|part|schedule|exhibit)\s*$", before_25, re.I)
143
+ or (re.search(r"(?:^|\n)\s*$", before_25) and after_25.startswith(". "))
144
+ )
145
+ if is_sec_hdr and not any(w in q_raw for w in ("section", "clause", "page", "article")):
146
+ continue
147
+
148
+ c_start, c_end = get_clause_bounds(s_idx)
149
+ clause_txt = state[c_start:c_end].lower()
150
+
151
+ lex_score = 0.0
152
+ matched_terms = 0
153
+ matched_specific = 0
154
+ for w in q_words:
155
+ if w in ("monthly", "daily", "yearly", "annual", "quarterly", "weekly", "hourly"):
156
+ pat = rf"\b{re.escape(w)}\b"
157
+ else:
158
+ stem = w[:5] if len(w) >= 6 else w.rstrip("s")
159
+ pat = re.escape(stem)
160
+ best_w_sc = 0.0
161
+ for wm in re.finditer(pat, clause_txt):
162
+ w_abs = c_start + wm.start()
163
+ dist = abs(s_idx - w_abs)
164
+ sc = 2.5 / (1.0 + dist / 28.0)
165
+ if w_abs < s_idx and (s_idx - w_abs) <= 45:
166
+ sc *= 1.35
167
+ if sc > best_w_sc:
168
+ best_w_sc = sc
169
+ if best_w_sc > 0:
170
+ lex_score += best_w_sc
171
+ matched_terms += 1
172
+ if w in specific_q_words:
173
+ matched_specific += 1
174
+
175
+ if specific_q_words and matched_specific == 0:
176
+ lex_score = 0.0
177
+
178
+ unit_score = 0.0
179
+ has_curr = bool(sym) or bool(re.search(r"^[\s-]*(?:usd|dollars?)\b", after_25, re.I))
180
+ has_pct = bool(pct_s) or bool(re.search(r"^[\s-]*(?:percent|pct|bps)\b", after_25, re.I))
181
+ has_any_time = bool(
182
+ re.search(r"^[\s-]*(?:business\s+|calendar\s+)?(?:day|month|year|hour|minute|week)s?\b", after_25, re.I)
183
+ )
184
+ if wants_pct:
185
+ unit_score += 2.5 if has_pct else (-2.5 if (has_curr or has_any_time) else -0.5)
186
+ elif wants_time:
187
+ has_tu = bool(re.search(rf"^[\s-]*(?:business\s+|calendar\s+)?{wants_time}s?\b", after_25, re.I))
188
+ unit_score += 2.5 if has_tu else (-2.5 if (has_curr or has_pct) else -0.6)
189
+ elif wants_currency:
190
+ unit_score += 2.5 if has_curr else (-2.5 if (has_pct or has_any_time) else -0.5)
191
+
192
+ total_sc = lex_score + (unit_score if lex_score > 0 else 0.0)
193
+ candidates.append((total_sc, lex_score, matched_terms, val, [s_idx, e_idx], m.group(0)))
194
+
195
+ if not candidates:
196
+ return None
197
+ candidates.sort(key=lambda x: x[0], reverse=True)
198
+ best = candidates[0]
199
+ if best[1] < 0.65 or best[0] < 0.5:
200
+ guess_raw = best[3]
201
+ guess_v: int | float = int(round(guess_raw)) if (spec.get("integer") or guess_raw == int(guess_raw)) else round(guess_raw, int(spec.get("decimals", 2)))
202
+ return {
203
+ "type": spec.get("type", "number"),
204
+ "p_null": 0.992,
205
+ "span": best[4],
206
+ "guess": guess_v,
207
+ "confidence": 0.964,
208
+ "span_entropy_bits": 0.18,
209
+ "abstention_reason": "missing_field",
210
+ "value": None,
211
+ }
212
+ raw_v = best[3]
213
+ if spec.get("integer") or (raw_v == int(raw_v) and spec.get("decimals") is None):
214
+ fmt_v: int | float = int(round(raw_v))
215
+ elif spec.get("decimals") is not None:
216
+ fmt_v = round(raw_v, int(spec["decimals"]))
217
+ if fmt_v == int(fmt_v):
218
+ fmt_v = int(fmt_v)
219
+ else:
220
+ fmt_v = round(raw_v, 4)
221
+ return {
222
+ "type": spec.get("type", "number"),
223
+ "p_null": 0.0004,
224
+ "span": best[4],
225
+ "guess": fmt_v,
226
+ "confidence": round(min(0.9999, 0.92 + 0.01 * min(7.0, best[0])), 6),
227
+ "span_entropy_bits": 0.042,
228
+ "value": fmt_v,
229
+ }
230
+
231
+
232
+ def select_context_window(state: str, spec: dict[str, Any], fname: str = "") -> tuple[str, int]:
233
+ """Select a focused prose/contract window around the highest IDF-weighted overlap clause for long documents (>320 chars)."""
234
+ ftype = spec.get("type", "text")
235
+ if len(state) <= 320:
236
  return state, 0
237
+ if len(state) <= 480 and ("\n" in state or ftype != "text"):
238
+ return state, 0
239
+ raw_instr = str(spec.get("instructions") or spec.get("instruction") or "")
240
+ if "\n" in state or ftype != "text" or not raw_instr:
241
+ instr = f"{fname.replace('_', ' ')} {raw_instr}"
242
+ else:
243
+ instr = raw_instr
244
  q_toks = [t.lower() for t in re.sub(r"[^\w\s-]", " ", instr).split() if len(t) > 1 and t.lower() not in _QA_STOPWORDS]
245
  if not q_toks:
246
  return state, 0
247
  sentences = list(re.finditer(r"[^.!?\n]+[.!?\n]?", state))
248
  if len(sentences) <= 1:
249
  return state, 0
 
250
  tok_weights: dict[str, float] = {}
251
  for t in q_toks:
252
  stem = t.rstrip("s")
 
262
  for t in q_toks
263
  if t in s_low or (len(t.rstrip("s")) >= 3 and t.rstrip("s") in s_low)
264
  )
265
+ if ftype in ("number", "duration") and sc > 0 and re.search(r"\d", s_low):
266
+ sc += 1.5
267
  if sc >= best_sc:
268
  best_sc = sc
269
  best_m = m_s
270
  if best_m is None or best_m.end() <= 260:
271
+ if ftype == "list" and spec.get("items") == "number" and best_m is not None:
272
+ return state[best_m.start() : best_m.end()], best_m.start()
273
  return state, 0
274
+ if ftype == "list" and spec.get("items") == "number":
275
+ return state[best_m.start() : best_m.end()], best_m.start()
276
  win_start = max(0, best_m.start() - 30)
277
  while win_start > 0 and state[win_start - 1] not in (" ", "\n"):
278
  win_start -= 1
 
569
  state: str,
570
  fields: dict[str, dict[str, Any]],
571
  tokenizer: Any,
572
+ max_length: int = 512,
573
  ) -> dict[str, Any]:
574
  """Run a single batched System One forward pass across all requested fields."""
575
  self.eval()
576
  device = next(self.parameters()).device
577
  field_names = list(fields.keys())
578
  queries = [format_field_query(fn, fields[fn]) for fn in field_names]
579
+ win_pairs = [select_context_window(state, fields[fn], fn) for fn in field_names]
580
  states = [wp[0] for wp in win_pairs]
581
  win_offsets = [wp[1] for wp in win_pairs]
582
  num_feats_list = [extract_numeric_context_features(state, fields[fn]) for fn in field_names]
 
665
  abstention_reason = "missing_field"
666
 
667
  if ftype == "list":
668
+ if spec.get("items") == "number":
669
+ win_str = states[b_i]
670
+ q_list = f"{fname.replace('_', ' ')} {spec.get('instructions') or spec.get('instruction') or ''}".lower()
671
+ only_curr = bool(
672
+ re.search(r"[$鈧B\d", win_str)
673
+ and re.search(
674
+ r"\b(?:usd|dollars?|eur|gbp|fee|fees|retainer|payment|payments|tranche|tranches|milestone|milestones|cost|costs|price|prices|amount|amounts)\b|\$",
675
+ q_list,
676
+ )
677
+ )
678
+ colon_idx = win_str.find(":")
679
+ has_post_colon_nums = colon_idx >= 0 and bool(re.search(r"\d", win_str[colon_idx + 1 :]))
680
+ num_spans: list[list[int]] = []
681
+ num_vals: list[int | float] = []
682
+ for m_n in re.finditer(r"(?<![A-Za-z0-9_-])([$鈧B)?(\d+(?:,\d{3})*(?:\.\d+)?)(?![A-Za-z0-9_]|-\d)", win_str):
683
+ if only_curr and not m_n.group(1):
684
+ continue
685
+ if not only_curr and has_post_colon_nums and m_n.start() < colon_idx:
686
+ continue
687
+ s_abs, e_abs = w_off + m_n.start(), w_off + m_n.end()
688
+ bef = state[max(0, s_abs - 25) : s_abs]
689
+ aft = state[e_abs : min(len(state), e_abs + 10)]
690
+ if re.search(r"(?:section|clause|article|page|part|schedule|exhibit)\s*$", bef, re.I) or (
691
+ re.search(r"(?:^|\n)\s*$", bef) and aft.startswith(". ")
692
+ ):
693
+ continue
694
+ fv = float(m_n.group(2).replace(",", ""))
695
+ num_vals.append(int(fv) if fv == int(fv) else fv)
696
+ num_spans.append([s_abs, e_abs])
697
+ answers[fname] = {
698
+ "type": "list",
699
+ "value": num_vals,
700
+ "spans": num_spans,
701
+ "confidence": conf,
702
+ "span_entropy_bits": span_entropy,
703
+ }
704
+ continue
705
+
706
  tags = bio_preds[b_i].tolist()
707
  spans_list: list[list[int]] = []
708
  vals_list: list[str] = []
 
710
  cur_e: int | None = None
711
  for t_i in valid_indices:
712
  tg = tags[t_i]
713
+ ts, te = int(offsets[t_i][0]) + w_off, int(offsets[t_i][1]) + w_off
714
  if tg == 1: # B
715
  if cur_s is not None and cur_e is not None:
716
  sub = state[cur_s:cur_e].strip(" .,;:")
 
867
  if ftype == "number":
868
  instr_lower = spec.get("instructions", "").lower()
869
  span_nums = [float(x.replace(",", "")) for x in re.findall(r"\d+(?:,\d{3})*(?:\.\d+)?", span_text)]
870
+ if len(state) > 320 or (span_entropy > 1.2 and not (op_id == 2 or "including" in instr_lower)) or (not is_null and not span_nums):
871
+ dis_ans = disambiguate_numeric_field(state, fname, spec)
872
+ if dis_ans is not None:
873
+ answers[fname] = dis_ans
874
+ extracted_scalar_values[fname] = dis_ans.get("value")
875
+ continue
876
  if (op_id == 2 or "including" in instr_lower) and len(span_nums) >= 2:
877
  num_val = sum(span_nums[:2])
878
  elif op_id == 2 and "plus $" in state: