zofiasmolenasana commited on
Commit
c62fcf5
·
unverified ·
1 Parent(s): bcd8eee

Add inline comments, comment linking, unlimited tables/levels

Browse files

- New 'comment' label (N key) with cell-to-cell linking mode for inline footnotes
- Extract Excel native comments from cells (orange triangle indicator in UI)
- Smart comment attribution in chunks: notes on headers, values, metadata, attributes
- Remove artificial limits on table count (was T9) and header levels (was L9)
- Multi-digit keyboard input for T and L (e.g. T12, L15) with 500ms buffer
- has_comment and has_inline_note GNN features
- Updated LABELING_GUIDE.md and in-app help (EN/PL) with comment instructions

Made-with: Cursor

Files changed (10) hide show
  1. LABELING_GUIDE.md +10 -10
  2. chunk_builder.py +31 -8
  3. features.py +71 -1
  4. models.py +1 -1
  5. notes.md +129 -2
  6. predict.py +10 -1
  7. static/index.html +68 -42
  8. train_baseline.py +6 -0
  9. train_compare.py +23 -14
  10. train_gnn.py +328 -28
LABELING_GUIDE.md CHANGED
@@ -45,9 +45,9 @@ Each sheet can contain one or more **tables** (separate data grids). You must te
45
  Set the active table before labeling cells:
46
 
47
  - Click a table button in the **Table** bar, or
48
- - Press **T** then a digit **0**–**9**.
49
 
50
- To add more tables, click the **[+]** button. The system supports up to T9.
51
 
52
  Every cell you label is stamped with the currently active table ID. If you label a cell as `value` while T2 is active, that cell belongs to table 2.
53
 
@@ -62,7 +62,7 @@ Row and column headers can have multiple levels of hierarchy. The **Level** bar
62
  Set the active level:
63
 
64
  - Click a level button in the **Level** bar, or
65
- - Press **L** then a digit **1**–**9**.
66
 
67
  To add more levels, click the **[+]** button.
68
 
@@ -439,8 +439,8 @@ Before pressing Enter, quickly verify:
439
  | **E** | Label as empty |
440
  | **J** | Label as junk (discarded from chunks) |
441
  | **X** | Clear label |
442
- | **T** then **0**–**9** | Set active table |
443
- | **L** then **1**–**9** | Set active header level |
444
  | **Esc** | Cancel comment linking |
445
  | **Enter** | Save & load next sheet |
446
  | **Shift+click** | Range select |
@@ -496,9 +496,9 @@ Każdy arkusz może zawierać jedną lub więcej **tabel** (oddzielnych siatek d
496
  Ustaw aktywną tabelę przed labelowaniem:
497
 
498
  - Kliknij przycisk tabeli w pasku **Table**, lub
499
- - Naciśnij **T**, potem cyfrę **0**–**9**.
500
 
501
- Aby dodać więcej tabel, kliknij **[+]**. System obsługuje do T9.
502
 
503
  Każda etykietowana komórka otrzymuje ID aktywnej tabeli. Jeśli oznaczysz komórkę jako `value` przy aktywnym T2, ta komórka trafia do tabeli 2.
504
 
@@ -513,7 +513,7 @@ Nagłówki wierszy i kolumn mogą mieć wiele poziomów hierarchii. Pasek **Leve
513
  Ustaw aktywny poziom:
514
 
515
  - Kliknij przycisk w pasku **Level**, lub
516
- - Naciśnij **L**, potem cyfrę **1**–**9**.
517
 
518
  Aby dodać więcej poziomów, kliknij **[+]**.
519
 
@@ -890,8 +890,8 @@ Przed naciśnięciem Enter szybko sprawdź:
890
  | **E** | Oznacz jako empty (pusta) |
891
  | **J** | Oznacz jako junk (śmieć, odrzucone z chunków) |
892
  | **X** | Wyczyść etykietę |
893
- | **T** potem **0**–**9** | Ustaw aktywną tabelę |
894
- | **L** potem **1**–**9** | Ustaw aktywny poziom nagłówka |
895
  | **Esc** | Anuluj linkowanie komentarza |
896
  | **Enter** | Zapisz i wczytaj następny arkusz |
897
  | **Shift+klik** | Zaznaczanie zakresu |
 
45
  Set the active table before labeling cells:
46
 
47
  - Click a table button in the **Table** bar, or
48
+ - Press **T** then type the table number (e.g. **T** **1** **2** for T12).
49
 
50
+ To add more tables, click the **[+]** button. There is no limit on the number of tables.
51
 
52
  Every cell you label is stamped with the currently active table ID. If you label a cell as `value` while T2 is active, that cell belongs to table 2.
53
 
 
62
  Set the active level:
63
 
64
  - Click a level button in the **Level** bar, or
65
+ - Press **L** then type the level number (e.g. **L** **1** **2** for L12).
66
 
67
  To add more levels, click the **[+]** button.
68
 
 
439
  | **E** | Label as empty |
440
  | **J** | Label as junk (discarded from chunks) |
441
  | **X** | Clear label |
442
+ | **T** then digits | Set active table (e.g. T, 1, 2 for T12) |
443
+ | **L** then digits | Set active header level (e.g. L, 5 for L5) |
444
  | **Esc** | Cancel comment linking |
445
  | **Enter** | Save & load next sheet |
446
  | **Shift+click** | Range select |
 
496
  Ustaw aktywną tabelę przed labelowaniem:
497
 
498
  - Kliknij przycisk tabeli w pasku **Table**, lub
499
+ - Naciśnij **T**, potem wpisz numer tabeli (np. **T** **1** **2** dla T12).
500
 
501
+ Aby dodać więcej tabel, kliknij **[+]**. Nie ma limitu liczby tabel.
502
 
503
  Każda etykietowana komórka otrzymuje ID aktywnej tabeli. Jeśli oznaczysz komórkę jako `value` przy aktywnym T2, ta komórka trafia do tabeli 2.
504
 
 
513
  Ustaw aktywny poziom:
514
 
515
  - Kliknij przycisk w pasku **Level**, lub
516
+ - Naciśnij **L**, potem wpisz numer poziomu (np. **L** **1** **2** dla L12).
517
 
518
  Aby dodać więcej poziomów, kliknij **[+]**.
519
 
 
890
  | **E** | Oznacz jako empty (pusta) |
891
  | **J** | Oznacz jako junk (śmieć, odrzucone z chunków) |
892
  | **X** | Wyczyść etykietę |
893
+ | **T** potem cyfry | Ustaw aktywną tabelę (np. T, 1, 2 dla T12) |
894
+ | **L** potem cyfry | Ustaw aktywny poziom nagłówka (np. L, 5 dla L5) |
895
  | **Esc** | Anuluj linkowanie komentarza |
896
  | **Enter** | Zapisz i wczytaj następny arkusz |
897
  | **Shift+klik** | Zaznaczanie zakresu |
chunk_builder.py CHANGED
@@ -102,18 +102,35 @@ def _header_text_with_comment(cell: dict | None) -> str:
102
  return text
103
 
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  def _collect_col_headers(
106
  row: int, col: int, by_coord: dict, by_label: dict, by_table: dict,
107
  table_id: int, *, direction: str = "up", max_row: int = 0,
108
  ) -> list[str]:
109
  """Walk along a column to collect column headers, outermost first.
110
 
 
 
 
111
  *direction* controls the scan axis: ``"up"`` (default, classic layout) or
112
- ``"down"`` (headers sit below data). When scanning in the non-default
113
- direction we skip data cells instead of stopping at them, because the
114
- headers are expected to be on the far side of the data region.
115
  """
116
- headers = []
 
117
  if direction == "down":
118
  scan_range = range(row + 1, max_row)
119
  else:
@@ -129,14 +146,20 @@ def _collect_col_headers(
129
  if direction == "up" and is_row_header(lbl):
130
  break
131
  if is_col_header(lbl):
 
132
  cell = by_coord.get((scan_r, col))
133
  text = _header_text_with_comment(cell)
134
  if text:
135
- headers.append(text)
 
 
 
 
136
 
137
- if direction == "up":
138
- headers.reverse()
139
- return headers
 
140
 
141
 
142
  def _collect_row_headers(
 
102
  return text
103
 
104
 
105
+ def _find_row_header_for_header_row(
106
+ row: int, col: int, by_coord: dict, by_label: dict, by_table: dict,
107
+ table_id: int,
108
+ ) -> str:
109
+ """Scan left from a column header cell to find its row header on the same row."""
110
+ for scan_c in range(col - 1, -1, -1):
111
+ lbl = by_label.get((row, scan_c), "")
112
+ tbl = by_table.get((row, scan_c), 0)
113
+ if table_id != 0 and tbl != 0 and tbl != table_id:
114
+ break
115
+ if is_row_header(lbl):
116
+ return _header_text_with_comment(by_coord.get((row, scan_c)))
117
+ return ""
118
+
119
+
120
  def _collect_col_headers(
121
  row: int, col: int, by_coord: dict, by_label: dict, by_table: dict,
122
  table_id: int, *, direction: str = "up", max_row: int = 0,
123
  ) -> list[str]:
124
  """Walk along a column to collect column headers, outermost first.
125
 
126
+ Same-level headers are concatenated with ", ". When a row header sits
127
+ on the same row as a column header it is prefixed as "row_hdr: col_hdr".
128
+
129
  *direction* controls the scan axis: ``"up"`` (default, classic layout) or
130
+ ``"down"`` (headers sit below data).
 
 
131
  """
132
+ headers_by_level: dict[int, list[str]] = defaultdict(list)
133
+
134
  if direction == "down":
135
  scan_range = range(row + 1, max_row)
136
  else:
 
146
  if direction == "up" and is_row_header(lbl):
147
  break
148
  if is_col_header(lbl):
149
+ level = header_level(lbl)
150
  cell = by_coord.get((scan_r, col))
151
  text = _header_text_with_comment(cell)
152
  if text:
153
+ row_hdr = _find_row_header_for_header_row(
154
+ scan_r, col, by_coord, by_label, by_table, table_id,
155
+ )
156
+ entry = f"{row_hdr}: {text}" if row_hdr else text
157
+ headers_by_level[level].append(entry)
158
 
159
+ result = []
160
+ for level in sorted(headers_by_level.keys(), reverse=True):
161
+ result.append(", ".join(headers_by_level[level]))
162
+ return result
163
 
164
 
165
  def _collect_row_headers(
features.py CHANGED
@@ -24,6 +24,17 @@ FORMULA_TYPES = ["sum", "average", "count", "vlookup", "hlookup",
24
  FTYPE_TO_IDX = {ft: i + 1 for i, ft in enumerate(FORMULA_TYPES)}
25
 
26
 
 
 
 
 
 
 
 
 
 
 
 
27
  def _content_features(value: str) -> dict:
28
  """String-level features for a cell value."""
29
  n = len(value)
@@ -32,6 +43,14 @@ def _content_features(value: str) -> dict:
32
  "is_empty": True, "is_numeric": False, "is_date": False,
33
  "string_length": 0, "digit_ratio": 0.0, "letter_ratio": 0.0,
34
  "upper_ratio": 0.0, "has_special_chars": False, "word_count": 0,
 
 
 
 
 
 
 
 
35
  }
36
 
37
  digits = sum(c.isdigit() for c in value)
@@ -51,6 +70,42 @@ def _content_features(value: str) -> dict:
51
  )
52
  is_date = bool(date_pattern.search(value))
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  return {
55
  "is_empty": False,
56
  "is_numeric": is_numeric,
@@ -60,7 +115,22 @@ def _content_features(value: str) -> dict:
60
  "letter_ratio": letters / n,
61
  "upper_ratio": uppers / max(letters, 1),
62
  "has_special_chars": bool(re.search(r"[^\w\s.,%-]", value)),
63
- "word_count": len(value.split()),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  }
65
 
66
 
 
24
  FTYPE_TO_IDX = {ft: i + 1 for i, ft in enumerate(FORMULA_TYPES)}
25
 
26
 
27
+ _FOOTNOTE_MARKERS = {"*", "**", "***", "†", "‡", "§", "¶", "||"}
28
+
29
+ _UNIT_INDICATORS_WORD = {
30
+ "usd", "eur", "pln", "jpy", "gbp", "chf", "cad", "aud", "cny", "krw",
31
+ "mln", "tys", "bln", "trn",
32
+ "millions", "thousands", "billions", "units",
33
+ "bps", "pp", "kg", "mt", "mwh", "gwh", "kwh",
34
+ }
35
+ _UNIT_INDICATORS_SUBSTR = {"p.p.", "000s"}
36
+
37
+
38
  def _content_features(value: str) -> dict:
39
  """String-level features for a cell value."""
40
  n = len(value)
 
43
  "is_empty": True, "is_numeric": False, "is_date": False,
44
  "string_length": 0, "digit_ratio": 0.0, "letter_ratio": 0.0,
45
  "upper_ratio": 0.0, "has_special_chars": False, "word_count": 0,
46
+ "starts_footnote": False, "has_footnote_ref": False,
47
+ "ends_colon": False, "has_parenthetical": False,
48
+ "is_all_caps": False, "has_numbering": False,
49
+ "has_unit": False, "char_diversity": 0.0,
50
+ "special_char_ratio": 0.0, "line_count": 1,
51
+ "token_len_var": 0.0, "leading_spaces": 0,
52
+ "starts_paren": False, "ends_paren": False,
53
+ "has_internal_colon": False,
54
  }
55
 
56
  digits = sum(c.isdigit() for c in value)
 
70
  )
71
  is_date = bool(date_pattern.search(value))
72
 
73
+ val_stripped = value.strip()
74
+ val_lower = value.lower().strip()
75
+
76
+ starts_footnote = (val_stripped in _FOOTNOTE_MARKERS
77
+ or any(val_stripped.startswith(m) for m in _FOOTNOTE_MARKERS))
78
+ has_footnote_ref = bool(
79
+ re.search(r"\[\d+\]|\(\d+\)|\[[a-z]\]|[\u00B9\u00B2\u00B3\u2070-\u2079]", value))
80
+ ends_colon = val_stripped.endswith(":")
81
+ has_parenthetical = bool(re.search(r"\([a-zA-Z%][a-zA-Z0-9% .,]*\)", value))
82
+
83
+ letter_chars = [ch for ch in value if ch.isalpha()]
84
+ is_all_caps = len(letter_chars) > 0 and all(ch.isupper() for ch in letter_chars)
85
+
86
+ has_numbering = bool(
87
+ re.match(r"^(\d{1,3}[\.\)]\s|[a-zA-Z][\.\)]\s|[ivxIVX]+[\.\)]\s)", val_stripped))
88
+
89
+ val_lower_words = set(re.findall(r"[a-z0-9.]+", val_lower))
90
+ has_unit = (bool(val_lower_words & _UNIT_INDICATORS_WORD)
91
+ or any(u in val_lower for u in _UNIT_INDICATORS_SUBSTR)
92
+ or any(sym in value for sym in ("$", "£", "€", "¥", "₩", "₽", "zł")))
93
+
94
+ char_diversity = len(set(value)) / n
95
+ special_chars = sum(1 for ch in value if not ch.isalnum() and not ch.isspace())
96
+ special_char_ratio = special_chars / n
97
+ line_count = value.count("\n") + 1
98
+
99
+ words = value.split()
100
+ if len(words) > 1:
101
+ wlens = [len(w) for w in words]
102
+ mean_wl = sum(wlens) / len(wlens)
103
+ token_len_var = sum((wl - mean_wl) ** 2 for wl in wlens) / len(wlens)
104
+ else:
105
+ token_len_var = 0.0
106
+
107
+ leading_spaces = len(value) - len(value.lstrip(" "))
108
+
109
  return {
110
  "is_empty": False,
111
  "is_numeric": is_numeric,
 
115
  "letter_ratio": letters / n,
116
  "upper_ratio": uppers / max(letters, 1),
117
  "has_special_chars": bool(re.search(r"[^\w\s.,%-]", value)),
118
+ "word_count": len(words),
119
+ "starts_footnote": starts_footnote,
120
+ "has_footnote_ref": has_footnote_ref,
121
+ "ends_colon": ends_colon,
122
+ "has_parenthetical": has_parenthetical,
123
+ "is_all_caps": is_all_caps,
124
+ "has_numbering": has_numbering,
125
+ "has_unit": has_unit,
126
+ "char_diversity": char_diversity,
127
+ "special_char_ratio": special_char_ratio,
128
+ "line_count": line_count,
129
+ "token_len_var": token_len_var,
130
+ "leading_spaces": leading_spaces,
131
+ "starts_paren": val_stripped.startswith("("),
132
+ "ends_paren": val_stripped.endswith(")"),
133
+ "has_internal_colon": ":" in value and not val_stripped.endswith(":"),
134
  }
135
 
136
 
models.py CHANGED
@@ -28,7 +28,7 @@ from torch_geometric.nn import GATConv, GCNConv
28
 
29
  from label_utils import NUM_CLASSES
30
 
31
- EDGE_TYPE_DIM = 3
32
 
33
 
34
  # ═══════════════════════════════════════════════════════════════════════════
 
28
 
29
  from label_utils import NUM_CLASSES
30
 
31
+ EDGE_TYPE_DIM = 21 # 5 edge types (one-hot) + 16 relative position encoding
32
 
33
 
34
  # ═══════════════════════════════════════════════════════════════════════════
notes.md CHANGED
@@ -389,7 +389,7 @@ This run adds:
389
  - Group C — keywords (11 new): kw_total, kw_sum_avg, kw_date_time, kw_note_source, kw_unnamed, kw_ref_error, kw_asterisk, kw_na, kw_item_desc, kw_financial, kw_month
390
  - **Total input dim**: 56 structural + 768 embeddings = 824
391
 
392
- Feature index map in `_cell_features()`:
393
  - [0-2] position (3d)
394
  - [3-11] content stats (9d)
395
  - [12-27] formatting (16d: 8 original + 8 new)
@@ -398,6 +398,20 @@ Feature index map in `_cell_features()`:
398
  - [44-54] keywords (11d)
399
  - [55] merge span (1d)
400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  ### Setup
402
 
403
  Identical to first run except for input features:
@@ -453,9 +467,122 @@ Best individual model: GAT fold 0 (chunk F1 = 1.000), promoted to `data/models/b
453
 
454
  7. **Feature expansion + embeddings provide a consistent improvement** across all architectures and both metrics. The combined delta is largest for models without graph structure (MLP), suggesting the new features encode neighborhood-level signal that GNNs can partially learn from structure alone.
455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  ## Plotting / figure-generation caveat
457
 
458
- `plot_results.py` now has `data/experiments/all_results.json` from the second training run (expanded features + embeddings).
459
 
460
  Still missing:
461
 
 
389
  - Group C — keywords (11 new): kw_total, kw_sum_avg, kw_date_time, kw_note_source, kw_unnamed, kw_ref_error, kw_asterisk, kw_na, kw_item_desc, kw_financial, kw_month
390
  - **Total input dim**: 56 structural + 768 embeddings = 824
391
 
392
+ Feature index map in `_cell_features()` (as of run 2, before textual expansion):
393
  - [0-2] position (3d)
394
  - [3-11] content stats (9d)
395
  - [12-27] formatting (16d: 8 original + 8 new)
 
398
  - [44-54] keywords (11d)
399
  - [55] merge span (1d)
400
 
401
+ **Run 3 expansion (2026-04-04):** added 15 textual/punctuation features (total: 71 structural + 768 embeddings = 839-d). Keywords also expanded to multilingual matching. Updated index map:
402
+ - [0-2] position (3d)
403
+ - [3-11] content stats (9d)
404
+ - [12-27] formatting (16d)
405
+ - [28-31] formula (4d)
406
+ - [32-43] content type (12d)
407
+ - [44-54] keywords (11d, now multilingual: total/sum/avg/date/source/month keywords match 15+ languages)
408
+ - [55] merge span (1d)
409
+ - [56-70] textual / punctuation features (15d): starts_footnote, has_footnote_ref, ends_colon, has_parenthetical, is_all_caps, has_numbering, has_unit, char_diversity, special_char_ratio, line_count, token_len_var, leading_spaces, starts_paren, ends_paren, has_internal_colon
410
+
411
+ Scientific basis for the new features: Koci et al. (IC3K 2016) selected 43 features for cell classification including STARTS WITH SPECIAL?, CONTAINS COLON?, IS UPPER CASE?, CONTAINS PUNCTUATIONS?, and WORDS LIKE TOTAL? (48/49 positive cases were Metadata). Ghasemi-Gol et al. (KAIS 2021) showed content-only features transfer better cross-domain than stylistic features. TUTA (Wang & Dong et al., 2021) built the first table-specific vocabulary. Multilingual keywords sourced from Excel-Translator (excel-translator.de) and Aspose.Cells GlobalizationSettings. Footnote marker conventions follow APA/Chicago standard sequence: *, †, ‡, §, ¶, ||.
412
+
413
+ New ablation variant `"no_textual"` added to `train_compare.py` FEATURE_GROUPS to measure the contribution of indices [56-70].
414
+
415
  ### Setup
416
 
417
  Identical to first run except for input features:
 
467
 
468
  7. **Feature expansion + embeddings provide a consistent improvement** across all architectures and both metrics. The combined delta is largest for models without graph structure (MLP), suggesting the new features encode neighborhood-level signal that GNNs can partially learn from structure alone.
469
 
470
+ ## Run 4: Full dataset + textual features (2026-04-05)
471
+
472
+ ### What changed from Run 3
473
+
474
+ - **25 more labeled sheets** added to dataset (94 usable, up from 69)
475
+ - **276,651 labeled cells** (up from ~91K in Run 3)
476
+ - E5 embeddings regenerated for all 95 files (24 new + 71 existing)
477
+ - Same 71 structural features (including textual/punctuation features and multilingual keywords from Run 3)
478
+ - Total input dim: 839 (71 structural + 768 embeddings)
479
+ - Same training config: 5-fold CV, seed 42, 100 epochs, Adam lr=1e-3, cosine schedule
480
+
481
+ ### Per-sheet mean results (94 sheets, 5-fold CV)
482
+
483
+ | Arch | L1 Acc | L1 Macro F1 | L3 ARI | L4 Chunk F1 | L4 Content EM | Params |
484
+ |------|--------|-------------|--------|-------------|---------------|--------|
485
+ | GAT | 0.802 ± 0.204 | 0.664 ± 0.251 | 0.379 ± 0.389 | 0.787 ± 0.337 | 0.090 ± 0.282 | 582K |
486
+ | GCN | 0.811 ± 0.196 | 0.667 ± 0.239 | 0.338 ± 0.371 | 0.795 ± 0.332 | 0.074 ± 0.252 | 233K |
487
+ | MLP | 0.812 ± 0.198 | 0.643 ± 0.246 | 0.336 ± 0.360 | 0.812 ± 0.312 | 0.096 ± 0.294 | 216K |
488
+
489
+ Per-fold chunk F1:
490
+
491
+ | Fold | GAT | GCN | MLP |
492
+ |------|-------|-------|-------|
493
+ | 0 | 0.867 | 0.866 | 0.919 |
494
+ | 1 | 0.723 | 0.752 | 0.819 |
495
+ | 2 | 0.796 | 0.804 | 0.798 |
496
+ | 3 | 0.761 | 0.754 | 0.685 |
497
+ | 4 | 0.791 | 0.799 | 0.841 |
498
+
499
+ Statistical significance: no pairwise comparisons are significant (MLP vs GAT p=0.16, MLP vs GCN p=0.19).
500
+
501
+ Best individual model: MLP fold 0 (chunk F1 = 0.919), promoted to `data/models/best_model.pt`.
502
+
503
+ ### Cross-run comparison
504
+
505
+ | Metric | Run 1 (24f, no emb, 69 sheets) | Run 3 (71f + emb, 69 sheets) | Run 4 (71f + emb, 94 sheets) | Delta Run3→4 |
506
+ |--------|--------------------------------|------------------------------|------------------------------|-------------|
507
+ | GAT Macro F1 | 0.588 | 0.632 | 0.664 | +0.032 |
508
+ | GCN Macro F1 | 0.595 | 0.613 | 0.667 | +0.054 |
509
+ | MLP Macro F1 | 0.544 | 0.599 | 0.643 | +0.044 |
510
+ | GAT Chunk F1 | 0.785 | 0.837 | 0.787 | -0.050 |
511
+ | GCN Chunk F1 | 0.805 | 0.810 | 0.795 | -0.015 |
512
+ | MLP Chunk F1 | 0.784 | 0.837 | 0.812 | -0.025 |
513
+
514
+ ### Observations
515
+
516
+ 1. **Macro F1 improved across all architectures** (+0.032 to +0.054 vs Run 3). The 25 additional sheets provide more diverse training examples, improving generalization on rare classes. GCN benefits most (+0.054).
517
+
518
+ 2. **Chunk F1 decreased slightly** (-0.015 to -0.050 vs Run 3). This is expected: the new sheets include complex multi-table layouts from a single multi-sheet workbook (17 sheets from `1lU94qWhkzftOvuY97incFADLaV30LgWV`), which increase the difficulty of the test folds. The model now evaluates on harder, more realistic data.
519
+
520
+ 3. **Val accuracy improved substantially**: fold 0-1 achieve 93-95% accuracy (vs 67-83% in Run 2). The larger training set helps convergence.
521
+
522
+ 4. **MLP is now the best architecture** on chunk F1 (0.812), overtaking GAT (0.787) and GCN (0.795). With 71 rich features + embeddings, the per-node signal is strong enough that graph message passing adds minimal value. MLP also has the fewest parameters (216K).
523
+
524
+ 5. **ARI remains low** (0.34-0.38), indicating table assignment is still the weakest part of the pipeline. This is consistent across all architectures and likely requires explicit table-boundary detection improvements.
525
+
526
+ 6. **Content exact match remains very low** (0.07-0.10). Position-based chunk matching succeeds but exact content reproduction is still poor, suggesting chunk text generation needs improvement independent of classification accuracy.
527
+
528
+ 7. **Training artifacts**: all 15 model checkpoints saved in `data/experiments/`, full 4-level per-sheet metrics available, model ranking in `model_ranking.json`, results accessible via `/api/train/experiments` and `/train` dashboard.
529
+
530
+ ## Architecture analysis: why macro F1 is stuck at 0.64 (2026-04-05)
531
+
532
+ ### The graph structure adds almost no value (currently)
533
+
534
+ MLP (no graph edges at all) matches GAT and GCN on both chunk F1 and macro F1 across all runs. This is the single most important diagnostic finding: the edges as currently constructed are not providing useful signal. Three root causes:
535
+
536
+ 1. **Spatial edges are 4-neighbor only.** Each GNN layer extends the receptive field by exactly 1 cell. With 3 layers, a cell can "see" at most 3 hops away. A column header at row 1 cannot influence a data cell at row 40 — the information would need 39 hops to arrive, but the model has only 3. This is why value/attribute cells get confused with row_header — the model literally cannot see the header.
537
+
538
+ 2. **GCN ignores edge types.** Only GAT uses the edge-type one-hot (spatial vs sheet-to-table). GCN aggregates uniformly. Since edge types carry structural information (is this a grid neighbor or a metadata-to-table link?), GCN discards a key signal.
539
+
540
+ 3. **Hierarchical edges are not used for message passing.** The ground-truth header-to-data edges are built (`hier_edge_index`) but only used as targets for a secondary link-prediction head. They are NOT added to `edge_index` for convolution. The model must discover hierarchy from local spatial adjacency alone — which is asking it to solve the problem as a side effect of solving it.
541
+
542
+ ### No row/column reasoning
543
+
544
+ Spreadsheets have a fundamental property: cells in the same column usually share a semantic role (all values under the same col_header). Same for rows. The current architecture has no mechanism to exploit this — a cell only sees its 4 immediate neighbors, not its entire row or column.
545
+
546
+ ### Class imbalance is poorly handled
547
+
548
+ `value` (39%) + `attribute` (55%) = 94% of cells. Weighted cross-entropy scales the loss but doesn't change the learning dynamics. The model can minimize total loss by perfecting the dominant classes. Rare classes like `junk` (0.06%), `col_header_3` (0.002%), `comment` (0.19%) have near-zero F1 because the gradient signal from their loss is drowned out. Focal loss (Lin et al., 2017) addresses this by down-weighting easy/well-classified examples — giving near-zero loss for confident correct predictions, so gradient budget is spent on hard misclassifications.
549
+
550
+ ### The 0.64 macro F1 is not an accuracy problem
551
+
552
+ Val accuracy per fold is 0.88-0.95. Macro F1 averages F1 across all 13 classes equally. When 4-5 classes have F1 near zero (because they have <100 training examples or the model never predicts them), the average drops to ~0.64 even though dominant classes are classified well.
553
+
554
+ ### Existing but untested: global attention models
555
+
556
+ Two architectures already exist in `models.py` but have never been trained on the full dataset:
557
+ - **GraphTransformerModel** (GPS-style): local GAT + full global self-attention per layer. Every cell attends to every other cell, giving the model the ability to directly link headers to distant data cells.
558
+ - **SequenceTransformerModel**: standard Transformer encoder in row-major order with 2D sinusoidal positional encoding. Also provides global attention.
559
+
560
+ These should be tested first before adding new edge types, as they may already provide the long-range connectivity the model lacks.
561
+
562
+ ### Proposed architectural improvements (priority order)
563
+
564
+ 1. **Test graph_transformer and seq_transformer** — zero code changes, may already show significant gains from global attention.
565
+
566
+ 2. **Focal loss** — replace weighted CE. Expected to lift rare-class F1 significantly (BAT, ICML 2024 showed up to 46% improvement over class reweighting for graph imbalance).
567
+
568
+ 3. **Row/column edges** — add edges connecting all cells in the same row and all cells in the same column (within a table). More targeted than full global attention (O(rows + cols) vs O(n^2)). Tag with new edge types EDGE_TYPE_ROW and EDGE_TYPE_COL. This directly models the "cells in a column share a header" pattern.
569
+
570
+ 4. **Relative position encoding on edges** — instead of absolute (row, col) node features, encode relative offsets (row_dst - row_src, col_dst - col_src) as sinusoidal edge features. The model learns "the cell 1 row above tends to be a header" rather than "row 0 cells tend to be headers."
571
+
572
+ 5. **Larger hidden_dim** (128 → 256) — current encoder compresses 839-d input to 128-d, losing ~85% of information immediately.
573
+
574
+ 6. **Hierarchical edges in the conv graph** — during training (where labels are known), add ground-truth hierarchical edges as additional message-passing paths, not just as link-prediction targets.
575
+
576
+ ### References for these improvements
577
+
578
+ - **TUTA** (Wang & Dong et al., 2021) uses tree-based attention with spatial + hierarchical position. The row/column edge proposal follows the same intuition.
579
+ - **Focal loss** (Lin et al., ICCV 2017) originally for object detection; applied to graphs by BAT (ICML 2024).
580
+ - **GPS** (Rampášek et al., NeurIPS 2022) — General, Powerful, Scalable graph transformers. Our `GraphTransformerModel` follows this design.
581
+ - **Relative position bias** is standard in modern Transformers (ALiBi, RoPE, Swin Transformer). For graphs, encoding relative positions as edge features is the natural equivalent.
582
+
583
  ## Plotting / figure-generation caveat
584
 
585
+ `plot_results.py` now has `data/experiments/all_results.json` from Run 4 (94 sheets, full features + embeddings).
586
 
587
  Still missing:
588
 
predict.py CHANGED
@@ -72,13 +72,21 @@ def load_model(version: Optional[str] = None) -> Optional[SpreadsheetGNN]:
72
  cfg = checkpoint["config"]
73
  num_classes = cfg.get("num_classes", 9)
74
 
 
 
 
 
 
 
75
  model = SpreadsheetGNN(
76
  in_dim=cfg["in_dim"],
77
  hidden_dim=cfg["hidden_dim"],
78
  num_classes=num_classes,
79
  )
 
80
  model.load_state_dict(checkpoint["model_state_dict"], strict=False)
81
  model.eval()
 
82
 
83
  if "cell_labels" in checkpoint:
84
  output_labels = checkpoint["cell_labels"]
@@ -357,7 +365,8 @@ def predict_sheet(
357
  return None
358
 
359
  edge_index = torch.tensor(edges, dtype=torch.long)
360
- edge_attr = torch.zeros(edge_index.size(1), EDGE_TYPE_DIM)
 
361
  edge_attr[:, 0] = 1.0
362
 
363
  h = model(x, edge_index, edge_attr)
 
72
  cfg = checkpoint["config"]
73
  num_classes = cfg.get("num_classes", 9)
74
 
75
+ from models import EDGE_TYPE_DIM as _current_etd
76
+ saved_etd = cfg.get("edge_type_dim", 3)
77
+ import models as _models_mod
78
+ _old_etd = _models_mod.EDGE_TYPE_DIM
79
+ _models_mod.EDGE_TYPE_DIM = saved_etd
80
+
81
  model = SpreadsheetGNN(
82
  in_dim=cfg["in_dim"],
83
  hidden_dim=cfg["hidden_dim"],
84
  num_classes=num_classes,
85
  )
86
+ _models_mod.EDGE_TYPE_DIM = _old_etd
87
  model.load_state_dict(checkpoint["model_state_dict"], strict=False)
88
  model.eval()
89
+ model._edge_type_dim = saved_etd
90
 
91
  if "cell_labels" in checkpoint:
92
  output_labels = checkpoint["cell_labels"]
 
365
  return None
366
 
367
  edge_index = torch.tensor(edges, dtype=torch.long)
368
+ model_etd = getattr(model, "_edge_type_dim", EDGE_TYPE_DIM)
369
+ edge_attr = torch.zeros(edge_index.size(1), model_etd)
370
  edge_attr[:, 0] = 1.0
371
 
372
  h = model(x, edge_index, edge_attr)
static/index.html CHANGED
@@ -322,7 +322,7 @@
322
  lbl_junk: "Junk",
323
  lbl_clear: "Clear",
324
  link_banner_text: "Click the cell this comment refers to (or press Esc to cancel)",
325
- hint: 'Click to select \u00b7 <kbd>Shift</kbd>+click for range \u00b7 <kbd>Ctrl/Cmd</kbd>+click to toggle \u00b7 <kbd>R</kbd>=RowHdr \u00b7 <kbd>C</kbd>=ColHdr (at active level) \u00b7 <kbd>N</kbd>=Comment \u00b7 <kbd>J</kbd>=Junk \u00b7 <kbd>T</kbd>+<kbd>0\u20119</kbd> table \u00b7 <kbd>L</kbd>+<kbd>1\u20119</kbd> level \u00b7 <kbd>Enter</kbd> = Save & Next',
326
  error_prefix: "Error: ",
327
  save_error: "Save error: ",
328
  help_title: "Labeling Guide",
@@ -369,7 +369,7 @@
369
  lbl_junk: "Śmieć",
370
  lbl_clear: "Wyczy\u015b\u0107",
371
  link_banner_text: "Kliknij kom\u00f3rk\u0119, do kt\u00f3rej odnosi si\u0119 ten komentarz (lub naci\u015bnij Esc, aby anulowa\u0107)",
372
- hint: 'Kliknij, aby zaznaczy\u0107 \u00b7 <kbd>Shift</kbd>+klik = zakres \u00b7 <kbd>Ctrl/Cmd</kbd>+klik = prze\u0142\u0105cz \u00b7 <kbd>R</kbd>=NagWier \u00b7 <kbd>C</kbd>=NagKol (na aktywnym poziomie) \u00b7 <kbd>N</kbd>=Komentarz \u00b7 <kbd>J</kbd>=Śmieć \u00b7 <kbd>T</kbd>+<kbd>0\u20119</kbd> tabela \u00b7 <kbd>L</kbd>+<kbd>1\u20119</kbd> poziom \u00b7 <kbd>Enter</kbd> = Zapisz i dalej',
373
  error_prefix: "B\u0142\u0105d: ",
374
  save_error: "B\u0142\u0105d zapisu: ",
375
  help_title: "Instrukcja labelowania",
@@ -424,16 +424,16 @@
424
 
425
  function colHeaderColor(level) {
426
  const base = [255, 235, 156];
427
- const step = Math.min(level - 1, 8);
428
- const darken = step * 18;
429
- return `rgb(${base[0] - darken}, ${base[1] - darken * 1.2 | 0}, ${base[2] - darken * 2 | 0})`;
430
  }
431
 
432
  function rowHeaderColor(level) {
433
  const base = [244, 176, 132];
434
- const step = Math.min(level - 1, 8);
435
- const darken = step * 16;
436
- return `rgb(${base[0] - darken}, ${base[1] - darken * 1.3 | 0}, ${base[2] - darken * 1.5 | 0})`;
437
  }
438
 
439
  /* ── State ─────────────────────────────────────────────────────────── */
@@ -595,10 +595,14 @@
595
  const dynamicStyle = document.createElement("style");
596
  document.head.appendChild(dynamicStyle);
597
 
 
 
 
 
598
  function refreshDynamicCSS() {
599
  let css = "";
600
- for (let i = 0; i < TABLE_COLORS.length; i++) {
601
- const c = TABLE_COLORS[i];
602
  css += `.t${i}-active{border-color:${c}!important;background:${c}!important}\n`;
603
  css += `.tid-${i}{background:${c}}\n`;
604
  }
@@ -626,11 +630,9 @@
626
  addBtn.textContent = "+";
627
  addBtn.title = t("add_table");
628
  addBtn.addEventListener("click", () => {
629
- if (maxTableId < 9) {
630
- maxTableId++;
631
- renderTableBar();
632
- setActiveTable(maxTableId);
633
- }
634
  });
635
  tableBar.appendChild(addBtn);
636
  }
@@ -670,13 +672,11 @@
670
  addBtn.textContent = "+";
671
  addBtn.title = t("add_level");
672
  addBtn.addEventListener("click", () => {
673
- if (maxLevel < 9) {
674
- maxLevel++;
675
- refreshDynamicCSS();
676
- renderLevelBar();
677
- renderLegend();
678
- setActiveLevel(maxLevel);
679
- }
680
  });
681
  levelBar.appendChild(addBtn);
682
  }
@@ -1046,6 +1046,22 @@
1046
  /* ── Keyboard labeling ─────────────────────────────────────────────── */
1047
  let waitingForTableNum = false;
1048
  let waitingForLevelNum = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1049
 
1050
  const SIMPLE_LABELS = {
1051
  v: "value",
@@ -1060,37 +1076,47 @@
1060
  document.addEventListener("keydown", (e) => {
1061
  if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
1062
 
1063
- // T + digit for table selection
1064
  if (waitingForTableNum) {
1065
- waitingForTableNum = false;
1066
- const n = parseInt(e.key);
1067
- if (!isNaN(n) && n >= 0 && n <= 9) {
1068
  e.preventDefault();
1069
- setActiveTable(n);
 
 
1070
  return;
1071
  }
 
 
 
1072
  }
1073
  if (e.key === "t" && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) {
1074
  e.preventDefault();
1075
  waitingForTableNum = true;
1076
  waitingForLevelNum = false;
 
 
1077
  return;
1078
  }
1079
 
1080
- // L + digit for level selection
1081
  if (waitingForLevelNum) {
1082
- waitingForLevelNum = false;
1083
- const n = parseInt(e.key);
1084
- if (!isNaN(n) && n >= 1 && n <= 9) {
1085
  e.preventDefault();
1086
- setActiveLevel(n);
 
 
1087
  return;
1088
  }
 
 
 
1089
  }
1090
  if (e.key === "l" && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) {
1091
  e.preventDefault();
1092
  waitingForLevelNum = true;
1093
  waitingForTableNum = false;
 
 
1094
  return;
1095
  }
1096
 
@@ -1579,8 +1605,8 @@ Your labels are used for: <strong>(1)</strong> training a graph neural network t
1579
  <ul>
1580
  <li><strong>T0</strong> = sheet-level (titles, notes for the whole sheet).</li>
1581
  <li><strong>T1, T2, &hellip;</strong> = individual tables.</li>
1582
- <li>Set active table: click in the <strong>Table</strong> bar or <kbd>T</kbd> + digit <kbd>0</kbd>&ndash;<kbd>9</kbd>.</li>
1583
- <li>Click <strong>[+]</strong> to add more tables (up to T9).</li>
1584
  </ul>
1585
  <p class="warn">T0 cells do NOT produce chunks. Only T1+ cells generate data. Never put value/attribute/header cells in T0.</p>
1586
 
@@ -1590,7 +1616,7 @@ Your labels are used for: <strong>(1)</strong> training a graph neural network t
1590
  <li><strong>L1</strong> = innermost (most specific, closest to data). Start here.</li>
1591
  <li><strong>L2</strong> = broader grouping that contains L1.</li>
1592
  <li><strong>L3, L4, &hellip;</strong> = even broader.</li>
1593
- <li>Set level: click in the <strong>Level</strong> bar or <kbd>L</kbd> + digit <kbd>1</kbd>&ndash;<kbd>9</kbd>.</li>
1594
  </ul>
1595
 
1596
  <h4>Row headers (R) &mdash; multi-level (horizontal)</h4>
@@ -1710,8 +1736,8 @@ Examples: <code>*estimated</code>, <code>see note 3</code>, <code>revised Q3</co
1710
  <tr><td><kbd>E</kbd></td><td>Label as empty</td></tr>
1711
  <tr><td><kbd>J</kbd></td><td>Label as junk (discarded from chunks)</td></tr>
1712
  <tr><td><kbd>X</kbd></td><td>Clear label</td></tr>
1713
- <tr><td><kbd>T</kbd> + <kbd>0</kbd>&ndash;<kbd>9</kbd></td><td>Set active table</td></tr>
1714
- <tr><td><kbd>L</kbd> + <kbd>1</kbd>&ndash;<kbd>9</kbd></td><td>Set active level</td></tr>
1715
  <tr><td><kbd>Esc</kbd></td><td>Cancel comment linking</td></tr>
1716
  <tr><td><kbd>Enter</kbd></td><td>Save &amp; load next sheet</td></tr>
1717
  <tr><td><kbd>Shift</kbd>+click</td><td>Range select</td></tr>
@@ -1756,8 +1782,8 @@ Twoje etykiety służą do: <strong>(1)</strong> trenowania sieci neuronowej prz
1756
  <ul>
1757
  <li><strong>T0</strong> = poziom arkusza (tytuły, notatki dla całego arkusza).</li>
1758
  <li><strong>T1, T2, &hellip;</strong> = poszczeg&oacute;lne tabele.</li>
1759
- <li>Ustaw aktywną tabelę: kliknij w pasku <strong>Table</strong> lub <kbd>T</kbd> + cyfra <kbd>0</kbd>&ndash;<kbd>9</kbd>.</li>
1760
- <li>Kliknij <strong>[+]</strong>, aby dodać więcej (do T9).</li>
1761
  </ul>
1762
  <p class="warn">Kom&oacute;rki T0 NIE generują chunk&oacute;w. Tylko T1+ generuje dane. Nigdy nie umieszczaj value/attribute/header w T0.</p>
1763
 
@@ -1767,7 +1793,7 @@ Twoje etykiety służą do: <strong>(1)</strong> trenowania sieci neuronowej prz
1767
  <li><strong>L1</strong> = najbardziej szczeg&oacute;łowy (najbliższy danych). Zacznij tutaj.</li>
1768
  <li><strong>L2</strong> = szersze grupowanie obejmujące L1.</li>
1769
  <li><strong>L3, L4, &hellip;</strong> = jeszcze szersze grupowania.</li>
1770
- <li>Ustaw poziom: kliknij w pasku <strong>Level</strong> lub <kbd>L</kbd> + cyfra <kbd>1</kbd>&ndash;<kbd>9</kbd>.</li>
1771
  </ul>
1772
 
1773
  <h4>Nagł&oacute;wki wierszy (R) &mdash; wielopoziomowe (poziome)</h4>
@@ -1887,8 +1913,8 @@ Przykłady: <code>*dane szacunkowe</code>, <code>patrz nota 3</code>, <code>skor
1887
  <tr><td><kbd>E</kbd></td><td>Oznacz jako empty (pusta)</td></tr>
1888
  <tr><td><kbd>J</kbd></td><td>Oznacz jako junk (śmieć, odrzucone z chunków)</td></tr>
1889
  <tr><td><kbd>X</kbd></td><td>Wyczyść etykietę</td></tr>
1890
- <tr><td><kbd>T</kbd> + <kbd>0</kbd>&ndash;<kbd>9</kbd></td><td>Ustaw aktywną tabelę</td></tr>
1891
- <tr><td><kbd>L</kbd> + <kbd>1</kbd>&ndash;<kbd>9</kbd></td><td>Ustaw aktywny poziom</td></tr>
1892
  <tr><td><kbd>Esc</kbd></td><td>Anuluj linkowanie komentarza</td></tr>
1893
  <tr><td><kbd>Enter</kbd></td><td>Zapisz i wczytaj następny</td></tr>
1894
  <tr><td><kbd>Shift</kbd>+klik</td><td>Zaznaczanie zakresu</td></tr>
 
322
  lbl_junk: "Junk",
323
  lbl_clear: "Clear",
324
  link_banner_text: "Click the cell this comment refers to (or press Esc to cancel)",
325
+ hint: 'Click to select \u00b7 <kbd>Shift</kbd>+click for range \u00b7 <kbd>Ctrl/Cmd</kbd>+click to toggle \u00b7 <kbd>R</kbd>=RowHdr \u00b7 <kbd>C</kbd>=ColHdr (at active level) \u00b7 <kbd>N</kbd>=Comment \u00b7 <kbd>J</kbd>=Junk \u00b7 <kbd>T</kbd>+digits = table \u00b7 <kbd>L</kbd>+digits = level \u00b7 <kbd>Enter</kbd> = Save & Next',
326
  error_prefix: "Error: ",
327
  save_error: "Save error: ",
328
  help_title: "Labeling Guide",
 
369
  lbl_junk: "Śmieć",
370
  lbl_clear: "Wyczy\u015b\u0107",
371
  link_banner_text: "Kliknij kom\u00f3rk\u0119, do kt\u00f3rej odnosi si\u0119 ten komentarz (lub naci\u015bnij Esc, aby anulowa\u0107)",
372
+ hint: 'Kliknij, aby zaznaczy\u0107 \u00b7 <kbd>Shift</kbd>+klik = zakres \u00b7 <kbd>Ctrl/Cmd</kbd>+klik = prze\u0142\u0105cz \u00b7 <kbd>R</kbd>=NagWier \u00b7 <kbd>C</kbd>=NagKol (na aktywnym poziomie) \u00b7 <kbd>N</kbd>=Komentarz \u00b7 <kbd>J</kbd>=Śmieć \u00b7 <kbd>T</kbd>+cyfry = tabela \u00b7 <kbd>L</kbd>+cyfry = poziom \u00b7 <kbd>Enter</kbd> = Zapisz i dalej',
373
  error_prefix: "B\u0142\u0105d: ",
374
  save_error: "B\u0142\u0105d zapisu: ",
375
  help_title: "Instrukcja labelowania",
 
424
 
425
  function colHeaderColor(level) {
426
  const base = [255, 235, 156];
427
+ const cycle = (level - 1) % 12;
428
+ const darken = cycle * 14;
429
+ return `rgb(${Math.max(base[0] - darken, 80)}, ${Math.max(base[1] - (darken * 1.2 | 0), 60)}, ${Math.max(base[2] - (darken * 2 | 0), 20)})`;
430
  }
431
 
432
  function rowHeaderColor(level) {
433
  const base = [244, 176, 132];
434
+ const cycle = (level - 1) % 12;
435
+ const darken = cycle * 12;
436
+ return `rgb(${Math.max(base[0] - darken, 80)}, ${Math.max(base[1] - (darken * 1.3 | 0), 50)}, ${Math.max(base[2] - (darken * 1.5 | 0), 20)})`;
437
  }
438
 
439
  /* ── State ─────────────────────────────────────────────────────────── */
 
595
  const dynamicStyle = document.createElement("style");
596
  document.head.appendChild(dynamicStyle);
597
 
598
+ function tableColor(i) {
599
+ return TABLE_COLORS[i % TABLE_COLORS.length];
600
+ }
601
+
602
  function refreshDynamicCSS() {
603
  let css = "";
604
+ for (let i = 0; i <= maxTableId; i++) {
605
+ const c = tableColor(i);
606
  css += `.t${i}-active{border-color:${c}!important;background:${c}!important}\n`;
607
  css += `.tid-${i}{background:${c}}\n`;
608
  }
 
630
  addBtn.textContent = "+";
631
  addBtn.title = t("add_table");
632
  addBtn.addEventListener("click", () => {
633
+ maxTableId++;
634
+ renderTableBar();
635
+ setActiveTable(maxTableId);
 
 
636
  });
637
  tableBar.appendChild(addBtn);
638
  }
 
672
  addBtn.textContent = "+";
673
  addBtn.title = t("add_level");
674
  addBtn.addEventListener("click", () => {
675
+ maxLevel++;
676
+ refreshDynamicCSS();
677
+ renderLevelBar();
678
+ renderLegend();
679
+ setActiveLevel(maxLevel);
 
 
680
  });
681
  levelBar.appendChild(addBtn);
682
  }
 
1046
  /* ── Keyboard labeling ─────────────────────────────────────────────── */
1047
  let waitingForTableNum = false;
1048
  let waitingForLevelNum = false;
1049
+ let digitBuffer = "";
1050
+ let digitTimer = null;
1051
+
1052
+ function flushTableDigits() {
1053
+ const n = parseInt(digitBuffer);
1054
+ digitBuffer = "";
1055
+ digitTimer = null;
1056
+ if (!isNaN(n) && n >= 0) setActiveTable(n);
1057
+ }
1058
+
1059
+ function flushLevelDigits() {
1060
+ const n = parseInt(digitBuffer);
1061
+ digitBuffer = "";
1062
+ digitTimer = null;
1063
+ if (!isNaN(n) && n >= 1) setActiveLevel(n);
1064
+ }
1065
 
1066
  const SIMPLE_LABELS = {
1067
  v: "value",
 
1076
  document.addEventListener("keydown", (e) => {
1077
  if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
1078
 
1079
+ // T + digits for table selection (multi-digit: T12, T25, etc.)
1080
  if (waitingForTableNum) {
1081
+ if (/^\d$/.test(e.key)) {
 
 
1082
  e.preventDefault();
1083
+ digitBuffer += e.key;
1084
+ clearTimeout(digitTimer);
1085
+ digitTimer = setTimeout(flushTableDigits, 500);
1086
  return;
1087
  }
1088
+ clearTimeout(digitTimer);
1089
+ flushTableDigits();
1090
+ waitingForTableNum = false;
1091
  }
1092
  if (e.key === "t" && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) {
1093
  e.preventDefault();
1094
  waitingForTableNum = true;
1095
  waitingForLevelNum = false;
1096
+ digitBuffer = "";
1097
+ clearTimeout(digitTimer);
1098
  return;
1099
  }
1100
 
1101
+ // L + digits for level selection (multi-digit: L10, L12, etc.)
1102
  if (waitingForLevelNum) {
1103
+ if (/^\d$/.test(e.key)) {
 
 
1104
  e.preventDefault();
1105
+ digitBuffer += e.key;
1106
+ clearTimeout(digitTimer);
1107
+ digitTimer = setTimeout(flushLevelDigits, 500);
1108
  return;
1109
  }
1110
+ clearTimeout(digitTimer);
1111
+ flushLevelDigits();
1112
+ waitingForLevelNum = false;
1113
  }
1114
  if (e.key === "l" && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) {
1115
  e.preventDefault();
1116
  waitingForLevelNum = true;
1117
  waitingForTableNum = false;
1118
+ digitBuffer = "";
1119
+ clearTimeout(digitTimer);
1120
  return;
1121
  }
1122
 
 
1605
  <ul>
1606
  <li><strong>T0</strong> = sheet-level (titles, notes for the whole sheet).</li>
1607
  <li><strong>T1, T2, &hellip;</strong> = individual tables.</li>
1608
+ <li>Set active table: click in the <strong>Table</strong> bar or <kbd>T</kbd> + digits (e.g. <kbd>T</kbd><kbd>1</kbd><kbd>2</kbd> for T12).</li>
1609
+ <li>Click <strong>[+]</strong> to add more tables (no limit).</li>
1610
  </ul>
1611
  <p class="warn">T0 cells do NOT produce chunks. Only T1+ cells generate data. Never put value/attribute/header cells in T0.</p>
1612
 
 
1616
  <li><strong>L1</strong> = innermost (most specific, closest to data). Start here.</li>
1617
  <li><strong>L2</strong> = broader grouping that contains L1.</li>
1618
  <li><strong>L3, L4, &hellip;</strong> = even broader.</li>
1619
+ <li>Set level: click in the <strong>Level</strong> bar or <kbd>L</kbd> + digits (e.g. L12).</li>
1620
  </ul>
1621
 
1622
  <h4>Row headers (R) &mdash; multi-level (horizontal)</h4>
 
1736
  <tr><td><kbd>E</kbd></td><td>Label as empty</td></tr>
1737
  <tr><td><kbd>J</kbd></td><td>Label as junk (discarded from chunks)</td></tr>
1738
  <tr><td><kbd>X</kbd></td><td>Clear label</td></tr>
1739
+ <tr><td><kbd>T</kbd> + digits</td><td>Set active table (e.g. T12)</td></tr>
1740
+ <tr><td><kbd>L</kbd> + digits</td><td>Set active level (e.g. L5)</td></tr>
1741
  <tr><td><kbd>Esc</kbd></td><td>Cancel comment linking</td></tr>
1742
  <tr><td><kbd>Enter</kbd></td><td>Save &amp; load next sheet</td></tr>
1743
  <tr><td><kbd>Shift</kbd>+click</td><td>Range select</td></tr>
 
1782
  <ul>
1783
  <li><strong>T0</strong> = poziom arkusza (tytuły, notatki dla całego arkusza).</li>
1784
  <li><strong>T1, T2, &hellip;</strong> = poszczeg&oacute;lne tabele.</li>
1785
+ <li>Ustaw aktywną tabelę: kliknij w pasku <strong>Table</strong> lub <kbd>T</kbd> + cyfry (np. <kbd>T</kbd><kbd>1</kbd><kbd>2</kbd> dla T12).</li>
1786
+ <li>Kliknij <strong>[+]</strong>, aby dodać więcej (bez limitu).</li>
1787
  </ul>
1788
  <p class="warn">Kom&oacute;rki T0 NIE generują chunk&oacute;w. Tylko T1+ generuje dane. Nigdy nie umieszczaj value/attribute/header w T0.</p>
1789
 
 
1793
  <li><strong>L1</strong> = najbardziej szczeg&oacute;łowy (najbliższy danych). Zacznij tutaj.</li>
1794
  <li><strong>L2</strong> = szersze grupowanie obejmujące L1.</li>
1795
  <li><strong>L3, L4, &hellip;</strong> = jeszcze szersze grupowania.</li>
1796
+ <li>Ustaw poziom: kliknij w pasku <strong>Level</strong> lub <kbd>L</kbd> + cyfry (np. L12).</li>
1797
  </ul>
1798
 
1799
  <h4>Nagł&oacute;wki wierszy (R) &mdash; wielopoziomowe (poziome)</h4>
 
1913
  <tr><td><kbd>E</kbd></td><td>Oznacz jako empty (pusta)</td></tr>
1914
  <tr><td><kbd>J</kbd></td><td>Oznacz jako junk (śmieć, odrzucone z chunków)</td></tr>
1915
  <tr><td><kbd>X</kbd></td><td>Wyczyść etykietę</td></tr>
1916
+ <tr><td><kbd>T</kbd> + cyfry</td><td>Ustaw aktywną tabelę (np. T12)</td></tr>
1917
+ <tr><td><kbd>L</kbd> + cyfry</td><td>Ustaw aktywny poziom (np. L5)</td></tr>
1918
  <tr><td><kbd>Esc</kbd></td><td>Anuluj linkowanie komentarza</td></tr>
1919
  <tr><td><kbd>Enter</kbd></td><td>Zapisz i wczytaj następny</td></tr>
1920
  <tr><td><kbd>Shift</kbd>+klik</td><td>Zaznaczanie zakresu</td></tr>
train_baseline.py CHANGED
@@ -43,6 +43,12 @@ FEATURE_COLS = [
43
  "neighbor_right_empty", "neighbor_right_numeric", "neighbor_right_type",
44
  "empty_neighbor_count", "numeric_neighbor_count",
45
  "at_top_edge", "at_left_edge", "at_bottom_edge", "at_right_edge",
 
 
 
 
 
 
46
  ]
47
 
48
 
 
43
  "neighbor_right_empty", "neighbor_right_numeric", "neighbor_right_type",
44
  "empty_neighbor_count", "numeric_neighbor_count",
45
  "at_top_edge", "at_left_edge", "at_bottom_edge", "at_right_edge",
46
+ # textual / punctuation features
47
+ "starts_footnote", "has_footnote_ref", "ends_colon", "has_parenthetical",
48
+ "is_all_caps", "has_numbering", "has_unit",
49
+ "char_diversity", "special_char_ratio", "line_count",
50
+ "token_len_var", "leading_spaces",
51
+ "starts_paren", "ends_paren", "has_internal_colon",
52
  ]
53
 
54
 
train_compare.py CHANGED
@@ -38,6 +38,7 @@ from train_gnn import (
38
  load_all_graphs,
39
  compute_class_weights,
40
  sample_negative_edges,
 
41
  )
42
  from evaluate import evaluate_sheet, evaluate_ablation_variant
43
 
@@ -103,7 +104,7 @@ def _train_one(
103
  device: torch.device,
104
  num_epochs: int = 100,
105
  lr: float = 1e-3,
106
- hidden_dim: int = 128,
107
  feature_mask: torch.Tensor | None = None,
108
  run_name: str = "",
109
  ) -> dict:
@@ -117,7 +118,7 @@ def _train_one(
117
 
118
  optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4)
119
  scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=num_epochs)
120
- cw = class_weights.to(device)
121
 
122
  best_acc = 0.0
123
  best_state = None
@@ -134,7 +135,7 @@ def _train_one(
134
 
135
  mask = g.train_mask
136
  node_loss = (
137
- F.cross_entropy(model.classify_nodes(h)[mask], g.y[mask], weight=cw)
138
  if mask.sum() > 0 else torch.tensor(0.0, device=device)
139
  )
140
 
@@ -283,32 +284,37 @@ def paired_bootstrap_test(
283
  # Feature ablation masks
284
  # ═══════════════════════════════════════════════════════════════════════════
285
 
286
- STRUCTURAL_DIM = 56
287
 
288
- # Feature index ranges within the 56-d structural vector:
289
  # [0-2] position (3)
290
  # [3-11] content stats (9)
291
  # [12-27] formatting (16)
292
  # [28-31] formula (4)
293
  # [32-43] content type (12)
294
- # [44-54] keywords (11)
295
  # [55] merge span (1)
 
296
  _POS = list(range(0, 3))
297
  _CONTENT = list(range(3, 12))
298
  _FORMAT = list(range(12, 28))
299
  _FORMULA = list(range(28, 32))
300
  _CTYPE = list(range(32, 44))
301
  _KEYWORDS = list(range(44, 56))
 
302
  _EMBED = list(range(STRUCTURAL_DIM, STRUCTURAL_DIM + 768))
303
 
 
 
304
  FEATURE_GROUPS = {
305
  "full": None,
306
  "no_text_embed": list(range(STRUCTURAL_DIM)),
307
- "no_formatting": _POS + _CONTENT + _FORMULA + _CTYPE + _KEYWORDS + _EMBED,
308
- "no_formula": _POS + _CONTENT + _FORMAT + _CTYPE + _KEYWORDS + _EMBED,
309
- "no_position": _CONTENT + _FORMAT + _FORMULA + _CTYPE + _KEYWORDS + _EMBED,
310
- "no_keywords": _POS + _CONTENT + _FORMAT + _FORMULA + _CTYPE + _EMBED,
311
- "no_content_type": _POS + _CONTENT + _FORMAT + _FORMULA + _KEYWORDS + _EMBED,
 
312
  "structural_only": list(range(STRUCTURAL_DIM)),
313
  "embeddings_only": _EMBED,
314
  }
@@ -342,7 +348,9 @@ def run_experiments(
342
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
343
  print(f"Device: {device}")
344
 
345
- graphs = load_all_graphs()
 
 
346
  if len(graphs) < 2:
347
  print(f"Need >= 2 labeled sheets, found {len(graphs)}.")
348
  return {}
@@ -420,8 +428,9 @@ def run_experiments(
420
  exp_dir.mkdir(parents=True, exist_ok=True)
421
  torch.save({
422
  "model_state_dict": model.state_dict(),
423
- "config": {"in_dim": in_dim, "hidden_dim": 128,
424
- "num_classes": NUM_CLASSES},
 
425
  "cell_labels": CELL_LABELS,
426
  "arch": arch,
427
  }, exp_dir / "best_model.pt")
 
38
  load_all_graphs,
39
  compute_class_weights,
40
  sample_negative_edges,
41
+ FocalLoss,
42
  )
43
  from evaluate import evaluate_sheet, evaluate_ablation_variant
44
 
 
104
  device: torch.device,
105
  num_epochs: int = 100,
106
  lr: float = 1e-3,
107
+ hidden_dim: int = 256,
108
  feature_mask: torch.Tensor | None = None,
109
  run_name: str = "",
110
  ) -> dict:
 
118
 
119
  optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4)
120
  scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=num_epochs)
121
+ focal_loss_fn = FocalLoss(alpha=class_weights, gamma=2.0).to(device)
122
 
123
  best_acc = 0.0
124
  best_state = None
 
135
 
136
  mask = g.train_mask
137
  node_loss = (
138
+ focal_loss_fn(model.classify_nodes(h)[mask], g.y[mask])
139
  if mask.sum() > 0 else torch.tensor(0.0, device=device)
140
  )
141
 
 
284
  # Feature ablation masks
285
  # ═══════════════════════════════════════════════════════════════════════════
286
 
287
+ STRUCTURAL_DIM = 71
288
 
289
+ # Feature index ranges within the 71-d structural vector:
290
  # [0-2] position (3)
291
  # [3-11] content stats (9)
292
  # [12-27] formatting (16)
293
  # [28-31] formula (4)
294
  # [32-43] content type (12)
295
+ # [44-54] keywords (11, multilingual)
296
  # [55] merge span (1)
297
+ # [56-70] textual / punctuation features (15)
298
  _POS = list(range(0, 3))
299
  _CONTENT = list(range(3, 12))
300
  _FORMAT = list(range(12, 28))
301
  _FORMULA = list(range(28, 32))
302
  _CTYPE = list(range(32, 44))
303
  _KEYWORDS = list(range(44, 56))
304
+ _TEXTUAL = list(range(56, 71))
305
  _EMBED = list(range(STRUCTURAL_DIM, STRUCTURAL_DIM + 768))
306
 
307
+ _ALL_STRUCT = _POS + _CONTENT + _FORMAT + _FORMULA + _CTYPE + _KEYWORDS + _TEXTUAL
308
+
309
  FEATURE_GROUPS = {
310
  "full": None,
311
  "no_text_embed": list(range(STRUCTURAL_DIM)),
312
+ "no_formatting": _POS + _CONTENT + _FORMULA + _CTYPE + _KEYWORDS + _TEXTUAL + _EMBED,
313
+ "no_formula": _POS + _CONTENT + _FORMAT + _CTYPE + _KEYWORDS + _TEXTUAL + _EMBED,
314
+ "no_position": _CONTENT + _FORMAT + _FORMULA + _CTYPE + _KEYWORDS + _TEXTUAL + _EMBED,
315
+ "no_keywords": _POS + _CONTENT + _FORMAT + _FORMULA + _CTYPE + _TEXTUAL + _EMBED,
316
+ "no_content_type": _POS + _CONTENT + _FORMAT + _FORMULA + _KEYWORDS + _TEXTUAL + _EMBED,
317
+ "no_textual": _POS + _CONTENT + _FORMAT + _FORMULA + _CTYPE + _KEYWORDS + _EMBED,
318
  "structural_only": list(range(STRUCTURAL_DIM)),
319
  "embeddings_only": _EMBED,
320
  }
 
348
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
349
  print(f"Device: {device}")
350
 
351
+ has_attention = any(a in architectures for a in ("graph_transformer", "seq_transformer"))
352
+ max_nodes = 5000 if has_attention else 0
353
+ graphs = load_all_graphs(max_nodes=max_nodes)
354
  if len(graphs) < 2:
355
  print(f"Need >= 2 labeled sheets, found {len(graphs)}.")
356
  return {}
 
428
  exp_dir.mkdir(parents=True, exist_ok=True)
429
  torch.save({
430
  "model_state_dict": model.state_dict(),
431
+ "config": {"in_dim": in_dim, "hidden_dim": 256,
432
+ "num_classes": NUM_CLASSES,
433
+ "edge_type_dim": EDGE_TYPE_DIM},
434
  "cell_labels": CELL_LABELS,
435
  "arch": arch,
436
  }, exp_dir / "best_model.pt")
train_gnn.py CHANGED
@@ -10,7 +10,9 @@ Text embeddings (from embed_text.py) are concatenated with structural features.
10
  from __future__ import annotations
11
 
12
  import json
 
13
  import random
 
14
  from collections import defaultdict
15
  from pathlib import Path
16
  from typing import Optional, Tuple
@@ -31,35 +33,173 @@ from label_utils import (
31
  normalize_label, is_known_label, clamp_header_level,
32
  )
33
 
34
- STRUCTURAL_FEAT_DIM = 56
35
- EDGE_TYPE_DIM = 3
36
  EMBED_DIM = 768
37
 
38
  EDGE_TYPE_SPATIAL = 0
39
  EDGE_TYPE_HIERARCHICAL = 1
40
  EDGE_TYPE_SHEET_TO_TABLE = 2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
 
43
  # ═══════════════════════════════════════════════════════════════════════════
44
  # Data loading
45
  # ═══════════════════════════════════════════════════════════════════════════
46
 
47
- _MONTH_ABBREVS = {"jan", "feb", "mar", "apr", "may", "jun",
48
- "jul", "aug", "sep", "oct", "nov", "dec"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
 
51
  def _cell_features(cell: dict, max_row: int, max_col: int,
52
  median_font_size: float = 11.0) -> list[float]:
53
  """Build a fixed-size numeric feature vector for a cell node.
54
 
55
- 56 dimensions total:
56
  [0-2] position (3)
57
  [3-11] content stats (9)
58
  [12-27] formatting (16)
59
  [28-31] formula (4)
60
  [32-43] content type (12)
61
- [44-54] keywords (11)
62
  [55] merge span (1)
 
63
 
64
  Does NOT include table_id, is_sheet_metadata, or header_level — those
65
  are ground-truth labels and would cause leakage if used as input features.
@@ -110,11 +250,11 @@ def _cell_features(cell: dict, max_row: int, max_col: int,
110
  colspan = min(cell.get("colspan", 1), 20) / 20.0
111
  is_merge_origin = float(cell.get("is_merge_origin", False))
112
 
113
- # --- Group C: keywords ---
114
- kw_total = float(any(w in val_lower for w in ("total", "subtotal", "grand total")))
115
- kw_sum_avg = float(any(w in val_lower for w in ("sum", "average", "avg", "mean", "count")))
116
- kw_date_time = float(any(w in val_lower for w in ("date", "year", "month", "quarter")))
117
- kw_note_source = float(any(w in val_lower for w in ("note", "notes", "source", "*see")))
118
  kw_unnamed = float("unnamed:" in val_lower)
119
  kw_ref_error = float(any(w in val_lower for w in ("#ref!", "#n/a", "#value!", "#div!")))
120
  kw_asterisk = float(val_lower.startswith("*"))
@@ -124,6 +264,48 @@ def _cell_features(cell: dict, max_row: int, max_col: int,
124
  "amount", "price", "cost", "revenue", "profit", "loss", "net", "gross")))
125
  kw_month = float(any(m in val_lower.split() for m in _MONTH_ABBREVS))
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  return [
128
  # [0-2] position (3)
129
  r / max(max_row, 1),
@@ -169,7 +351,7 @@ def _cell_features(cell: dict, max_row: int, max_col: int,
169
  has_nf,
170
  nf_number, nf_date, nf_currency, nf_percent,
171
  is_merge_origin,
172
- # [44-54] keywords (11)
173
  kw_total,
174
  kw_sum_avg,
175
  kw_date_time,
@@ -183,6 +365,22 @@ def _cell_features(cell: dict, max_row: int, max_col: int,
183
  kw_month,
184
  # [55] merge span (1)
185
  (rowspan + colspan) / 2.0,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  ]
187
 
188
 
@@ -240,6 +438,61 @@ def _build_spatial_edges(
240
  return [src, dst], edge_types
241
 
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  def _build_hierarchical_ground_truth(
244
  cells: list[dict], idx_map: dict, table_groups: dict[int, set]
245
  ) -> Tuple[list[list[int]], list[int]]:
@@ -454,28 +707,59 @@ def json_to_pyg(filepath: Path) -> Optional[Data]:
454
  # Spatial edges (within-table — used for GNN message passing)
455
  spatial_edges, spatial_types = _build_spatial_edges(all_cells, idx_map, table_groups)
456
 
 
 
 
457
  # Hierarchical edges (within-table)
458
  hier_edges, hier_types = _build_hierarchical_ground_truth(all_cells, idx_map, table_groups)
459
 
460
  # Sheet-to-table edges
461
  s2t_edges, s2t_types = _build_sheet_to_table_edges(all_cells, idx_map, table_groups)
462
 
463
- # Combine all edges for GNN message passing
464
- all_src = spatial_edges[0] + s2t_edges[0]
465
- all_dst = spatial_edges[1] + s2t_edges[1]
466
- all_types = spatial_types + s2t_types
 
467
 
468
  if not all_src:
469
  return None
470
 
471
  edge_index = torch.tensor([all_src, all_dst], dtype=torch.long)
472
 
473
- # Edge type features (one-hot)
474
- edge_type_tensor = torch.zeros(len(all_types), EDGE_TYPE_DIM)
475
- for i, et in enumerate(all_types):
476
- edge_type_tensor[i, et] = 1.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
 
478
- # Hierarchical ground truth for edge prediction training
479
  if hier_edges[0]:
480
  hier_edge_index = torch.tensor(hier_edges, dtype=torch.long)
481
  else:
@@ -513,13 +797,23 @@ def json_to_pyg(filepath: Path) -> Optional[Data]:
513
  )
514
 
515
 
516
- def load_all_graphs() -> list[Data]:
517
- """Load all labeled JSONs as PyG Data objects."""
 
 
 
 
518
  graphs = []
 
519
  for fp in sorted(config.LABELED_DIR.glob("*.json")):
520
  g = json_to_pyg(fp)
521
  if g is not None:
 
 
 
522
  graphs.append(g)
 
 
523
  return graphs
524
 
525
 
@@ -573,7 +867,8 @@ def sample_negative_edges(num_nodes: int, positive_edges: torch.Tensor, num_neg:
573
  return torch.tensor([neg_src, neg_dst], dtype=torch.long)
574
 
575
 
576
- def train_epoch(model, graphs, optimizer, device, class_weights=None):
 
577
  model.train()
578
  total_node_loss = 0
579
  total_edge_loss = 0
@@ -587,11 +882,13 @@ def train_epoch(model, graphs, optimizer, device, class_weights=None):
587
  edge_attr = g.edge_attr if hasattr(g, "edge_attr") and g.edge_attr is not None else None
588
  h = model(g.x, g.edge_index, edge_attr)
589
 
590
- # --- node classification loss (class-weighted) ---
591
  node_logits = model.classify_nodes(h)
592
  mask = g.train_mask
593
  if mask.sum() > 0:
594
- node_loss = F.cross_entropy(node_logits[mask], g.y[mask], weight=cw)
 
 
 
595
  else:
596
  node_loss = torch.tensor(0.0, device=device)
597
 
@@ -705,9 +1002,10 @@ def train_model(num_epochs: int = 100, lr: float = 1e-3, skip_predict: bool = Fa
705
 
706
  print(f"Train graphs: {len(train_graphs)}, Val graphs: {len(val_graphs)}")
707
 
708
- model = SpreadsheetGNN(in_dim=in_dim, hidden_dim=128).to(device)
709
  optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4)
710
  scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=num_epochs)
 
711
 
712
  best_val_acc = 0
713
  best_state = None
@@ -716,6 +1014,7 @@ def train_model(num_epochs: int = 100, lr: float = 1e-3, skip_predict: bool = Fa
716
  random.shuffle(train_graphs)
717
  node_loss, edge_loss, table_loss = train_epoch(
718
  model, train_graphs, optimizer, device, class_weights,
 
719
  )
720
  scheduler.step()
721
 
@@ -742,7 +1041,8 @@ def train_model(num_epochs: int = 100, lr: float = 1e-3, skip_predict: bool = Fa
742
  version = _next_model_version()
743
  checkpoint = {
744
  "model_state_dict": model.state_dict(),
745
- "config": {"in_dim": in_dim, "hidden_dim": 128, "num_classes": NUM_CLASSES},
 
746
  "cell_labels": CELL_LABELS,
747
  "version": version,
748
  "num_training_sheets": len(graphs),
 
10
  from __future__ import annotations
11
 
12
  import json
13
+ import math
14
  import random
15
+ import re
16
  from collections import defaultdict
17
  from pathlib import Path
18
  from typing import Optional, Tuple
 
33
  normalize_label, is_known_label, clamp_header_level,
34
  )
35
 
36
+ STRUCTURAL_FEAT_DIM = 71
37
+ EDGE_TYPE_DIM = 21 # 5 edge types (one-hot) + 16 relative position encoding
38
  EMBED_DIM = 768
39
 
40
  EDGE_TYPE_SPATIAL = 0
41
  EDGE_TYPE_HIERARCHICAL = 1
42
  EDGE_TYPE_SHEET_TO_TABLE = 2
43
+ EDGE_TYPE_ROW = 3
44
+ EDGE_TYPE_COL = 4
45
+
46
+
47
+ class FocalLoss(torch.nn.Module):
48
+ """Focal loss (Lin et al., ICCV 2017) with per-class alpha weights.
49
+
50
+ FL(p_t) = -alpha_t * (1 - p_t)^gamma * log(p_t)
51
+
52
+ When gamma=0 this reduces to weighted cross-entropy.
53
+ """
54
+
55
+ def __init__(self, alpha: torch.Tensor | None = None, gamma: float = 2.0,
56
+ reduction: str = "mean"):
57
+ super().__init__()
58
+ self.gamma = gamma
59
+ self.reduction = reduction
60
+ self.register_buffer("alpha", alpha)
61
+
62
+ def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
63
+ ce_loss = F.cross_entropy(logits, targets, reduction="none")
64
+ p_t = torch.exp(-ce_loss)
65
+ focal_weight = (1 - p_t) ** self.gamma
66
+ if self.alpha is not None:
67
+ alpha_t = self.alpha.to(logits.device)[targets]
68
+ focal_weight = alpha_t * focal_weight
69
+ loss = focal_weight * ce_loss
70
+ if self.reduction == "mean":
71
+ return loss.mean()
72
+ elif self.reduction == "sum":
73
+ return loss.sum()
74
+ return loss
75
 
76
 
77
  # ═══════════════════════════════════════════════════════════════════════════
78
  # Data loading
79
  # ═══════════════════════════════════════════════════════════════════════════
80
 
81
+ _MONTH_ABBREVS = {
82
+ # English
83
+ "jan", "feb", "mar", "apr", "may", "jun",
84
+ "jul", "aug", "sep", "oct", "nov", "dec",
85
+ # German
86
+ "mär", "mai", "okt", "dez",
87
+ # French
88
+ "janv", "févr", "mars", "avr", "juin", "juil", "août", "sept", "déc",
89
+ # Spanish
90
+ "ene", "abr", "ago", "dic",
91
+ # Polish
92
+ "sty", "lut", "kwi", "maj", "cze", "lip", "sie", "wrz", "paź", "lis", "gru",
93
+ }
94
+
95
+ _KW_TOTAL = {
96
+ # English
97
+ "total", "subtotal", "grand total",
98
+ # German
99
+ "gesamt", "summe", "zwischensumme", "insgesamt",
100
+ # French
101
+ "sous-total", "somme",
102
+ # Spanish / Portuguese / Catalan
103
+ "suma", "subtotal",
104
+ # Portuguese
105
+ "soma",
106
+ # Italian
107
+ "totale", "subtotale", "somma",
108
+ # Polish
109
+ "razem", "podsuma", "ogółem", "łącznie",
110
+ # Dutch
111
+ "totaal", "subtotaal", "som",
112
+ # Russian
113
+ "итого", "всего", "сумма",
114
+ # Japanese
115
+ "合計", "小計", "総計",
116
+ # Chinese
117
+ "合计", "小计", "总计",
118
+ # Korean
119
+ "합계", "소계", "총계",
120
+ # Turkish
121
+ "toplam", "ara toplam",
122
+ # Czech
123
+ "celkem", "součet",
124
+ # Swedish / Finnish
125
+ "totalt", "yhteensä",
126
+ }
127
+
128
+ _KW_SUM_AVG = {
129
+ # English
130
+ "sum", "average", "avg", "mean", "count",
131
+ # German
132
+ "durchschnitt", "mittelwert", "anzahl",
133
+ # French
134
+ "moyenne", "nombre",
135
+ # Spanish
136
+ "promedio", "media", "cuenta",
137
+ # Polish
138
+ "średnia", "liczba",
139
+ # Russian
140
+ "среднее", "количество",
141
+ # Japanese / Chinese
142
+ "平均",
143
+ }
144
+
145
+ _KW_DATE_TIME = {
146
+ # English
147
+ "date", "year", "month", "quarter",
148
+ # German
149
+ "datum", "jahr", "monat", "quartal",
150
+ # French
151
+ "année", "mois", "trimestre",
152
+ # Spanish
153
+ "fecha", "año", "mes",
154
+ # Polish
155
+ "data", "rok", "miesiąc", "kwartał",
156
+ # Russian
157
+ "дата", "год", "месяц", "квартал",
158
+ # Japanese
159
+ "日付", "年", "月", "四半期",
160
+ # Chinese
161
+ "日期", "季度",
162
+ }
163
+
164
+ _KW_NOTE_SOURCE = {
165
+ # English
166
+ "note", "notes", "source", "*see", "footnote", "remark",
167
+ # German
168
+ "quelle", "anmerkung", "hinweis", "fußnote",
169
+ # French
170
+ "remarque",
171
+ # Spanish
172
+ "fuente", "nota", "observación",
173
+ # Polish
174
+ "źródło", "uwaga", "przypis",
175
+ # Russian
176
+ "источник", "примечание", "сноска",
177
+ }
178
+
179
+ _UNIT_INDICATORS_WORD = {
180
+ "usd", "eur", "pln", "jpy", "gbp", "chf", "cad", "aud", "cny", "krw",
181
+ "mln", "tys", "bln", "trn",
182
+ "millions", "thousands", "billions", "units",
183
+ "bps", "pp", "kg", "mt", "mwh", "gwh", "kwh",
184
+ }
185
+ _UNIT_INDICATORS_SUBSTR = {"p.p.", "000s"}
186
+
187
+ _FOOTNOTE_MARKERS = {"*", "**", "***", "†", "‡", "§", "¶", "||"}
188
 
189
 
190
  def _cell_features(cell: dict, max_row: int, max_col: int,
191
  median_font_size: float = 11.0) -> list[float]:
192
  """Build a fixed-size numeric feature vector for a cell node.
193
 
194
+ 71 dimensions total:
195
  [0-2] position (3)
196
  [3-11] content stats (9)
197
  [12-27] formatting (16)
198
  [28-31] formula (4)
199
  [32-43] content type (12)
200
+ [44-54] keywords (11, multilingual)
201
  [55] merge span (1)
202
+ [56-70] textual / punctuation features (15)
203
 
204
  Does NOT include table_id, is_sheet_metadata, or header_level — those
205
  are ground-truth labels and would cause leakage if used as input features.
 
250
  colspan = min(cell.get("colspan", 1), 20) / 20.0
251
  is_merge_origin = float(cell.get("is_merge_origin", False))
252
 
253
+ # --- Group C: keywords (now multilingual) ---
254
+ kw_total = float(any(w in val_lower for w in _KW_TOTAL))
255
+ kw_sum_avg = float(any(w in val_lower for w in _KW_SUM_AVG))
256
+ kw_date_time = float(any(w in val_lower for w in _KW_DATE_TIME))
257
+ kw_note_source = float(any(w in val_lower for w in _KW_NOTE_SOURCE))
258
  kw_unnamed = float("unnamed:" in val_lower)
259
  kw_ref_error = float(any(w in val_lower for w in ("#ref!", "#n/a", "#value!", "#div!")))
260
  kw_asterisk = float(val_lower.startswith("*"))
 
264
  "amount", "price", "cost", "revenue", "profit", "loss", "net", "gross")))
265
  kw_month = float(any(m in val_lower.split() for m in _MONTH_ABBREVS))
266
 
267
+ # --- Group D: textual / punctuation features ---
268
+ val_stripped = val.strip()
269
+
270
+ starts_footnote = float(val_stripped in _FOOTNOTE_MARKERS
271
+ or any(val_stripped.startswith(m) for m in _FOOTNOTE_MARKERS))
272
+
273
+ has_footnote_ref = float(bool(
274
+ re.search(r"\[\d+\]|\(\d+\)|\[[a-z]\]|[\u00B9\u00B2\u00B3\u2070-\u2079]", val)))
275
+
276
+ ends_colon = float(val_stripped.endswith(":"))
277
+
278
+ has_parenthetical = float(bool(
279
+ re.search(r"\([a-zA-Z%][a-zA-Z0-9% .,]*\)", val)))
280
+
281
+ letters_in_val = [ch for ch in val if ch.isalpha()]
282
+ is_all_caps = float(len(letters_in_val) > 0 and all(ch.isupper() for ch in letters_in_val))
283
+
284
+ has_numbering = float(bool(
285
+ re.match(r"^(\d{1,3}[\.\)]\s|[a-zA-Z][\.\)]\s|[ivxIVX]+[\.\)]\s)", val_stripped)))
286
+
287
+ val_lower_words = set(re.findall(r"[a-z0-9.]+", val_lower))
288
+ has_unit = float(bool(val_lower_words & _UNIT_INDICATORS_WORD)
289
+ or any(u in val_lower for u in _UNIT_INDICATORS_SUBSTR)
290
+ or any(sym in val for sym in ("$", "£", "€", "¥", "₩", "₽", "zł")))
291
+
292
+ n = len(val)
293
+ char_diversity = len(set(val)) / max(n, 1)
294
+ special_chars = sum(1 for ch in val if not ch.isalnum() and not ch.isspace())
295
+ special_char_ratio = special_chars / max(n, 1)
296
+ line_count = min(val.count("\n") + 1, 10) / 10.0
297
+
298
+ words = val.split()
299
+ if len(words) > 1:
300
+ wlens = [len(w) for w in words]
301
+ mean_wl = sum(wlens) / len(wlens)
302
+ token_len_var = sum((wl - mean_wl) ** 2 for wl in wlens) / len(wlens)
303
+ token_len_var = min(token_len_var, 100.0) / 100.0
304
+ else:
305
+ token_len_var = 0.0
306
+
307
+ leading_spaces = float(len(val) - len(val.lstrip(" "))) / 10.0
308
+
309
  return [
310
  # [0-2] position (3)
311
  r / max(max_row, 1),
 
351
  has_nf,
352
  nf_number, nf_date, nf_currency, nf_percent,
353
  is_merge_origin,
354
+ # [44-54] keywords (11, now multilingual)
355
  kw_total,
356
  kw_sum_avg,
357
  kw_date_time,
 
365
  kw_month,
366
  # [55] merge span (1)
367
  (rowspan + colspan) / 2.0,
368
+ # [56-70] textual / punctuation features (15)
369
+ starts_footnote,
370
+ has_footnote_ref,
371
+ ends_colon,
372
+ has_parenthetical,
373
+ is_all_caps,
374
+ has_numbering,
375
+ has_unit,
376
+ char_diversity,
377
+ special_char_ratio,
378
+ line_count,
379
+ token_len_var,
380
+ leading_spaces,
381
+ float(val_stripped.startswith("(")),
382
+ float(val_stripped.endswith(")")),
383
+ float(":" in val and not val_stripped.endswith(":")),
384
  ]
385
 
386
 
 
438
  return [src, dst], edge_types
439
 
440
 
441
+ def _build_row_col_edges(
442
+ cells: list[dict], idx_map: dict, table_groups: dict[int, set],
443
+ max_k: int = 32,
444
+ ) -> Tuple[list[list[int]], list[int]]:
445
+ """Connect cells sharing the same row or column within a table.
446
+
447
+ For groups with <=max_k cells, creates all pairwise edges.
448
+ For larger groups, connects each cell to max_k nearest neighbors
449
+ plus the first/last cell in the group.
450
+ """
451
+ table_rows: dict[int, dict[int, list]] = defaultdict(lambda: defaultdict(list))
452
+ table_cols: dict[int, dict[int, list]] = defaultdict(lambda: defaultdict(list))
453
+
454
+ for c in cells:
455
+ key = (c["row"], c["col"])
456
+ if key not in idx_map:
457
+ continue
458
+ tid = c.get("table", 0)
459
+ if tid == 0:
460
+ continue
461
+ table_rows[tid][c["row"]].append(idx_map[key])
462
+ table_cols[tid][c["col"]].append(idx_map[key])
463
+
464
+ src, dst, edge_types = [], [], []
465
+
466
+ def _add_edges(node_ids: list[int], etype: int):
467
+ n = len(node_ids)
468
+ if n <= 1:
469
+ return
470
+ if n <= max_k:
471
+ for i in range(n):
472
+ for j in range(i + 1, n):
473
+ src.append(node_ids[i]); dst.append(node_ids[j]); edge_types.append(etype)
474
+ src.append(node_ids[j]); dst.append(node_ids[i]); edge_types.append(etype)
475
+ else:
476
+ for i in range(n):
477
+ lo = max(0, i - max_k // 2)
478
+ hi = min(n, i + max_k // 2 + 1)
479
+ targets = set(range(lo, hi))
480
+ targets.add(0)
481
+ targets.add(n - 1)
482
+ targets.discard(i)
483
+ for j in targets:
484
+ src.append(node_ids[i]); dst.append(node_ids[j]); edge_types.append(etype)
485
+
486
+ for tid in table_rows:
487
+ for row_nodes in table_rows[tid].values():
488
+ _add_edges(row_nodes, EDGE_TYPE_ROW)
489
+ for tid in table_cols:
490
+ for col_nodes in table_cols[tid].values():
491
+ _add_edges(col_nodes, EDGE_TYPE_COL)
492
+
493
+ return [src, dst], edge_types
494
+
495
+
496
  def _build_hierarchical_ground_truth(
497
  cells: list[dict], idx_map: dict, table_groups: dict[int, set]
498
  ) -> Tuple[list[list[int]], list[int]]:
 
707
  # Spatial edges (within-table — used for GNN message passing)
708
  spatial_edges, spatial_types = _build_spatial_edges(all_cells, idx_map, table_groups)
709
 
710
+ # Row/column edges (within-table — long-range connectivity)
711
+ rc_edges, rc_types = _build_row_col_edges(all_cells, idx_map, table_groups)
712
+
713
  # Hierarchical edges (within-table)
714
  hier_edges, hier_types = _build_hierarchical_ground_truth(all_cells, idx_map, table_groups)
715
 
716
  # Sheet-to-table edges
717
  s2t_edges, s2t_types = _build_sheet_to_table_edges(all_cells, idx_map, table_groups)
718
 
719
+ # Combine all edges for GNN message passing:
720
+ # spatial + row/col + sheet-to-table + hierarchical (ground-truth during training)
721
+ all_src = spatial_edges[0] + rc_edges[0] + s2t_edges[0] + hier_edges[0]
722
+ all_dst = spatial_edges[1] + rc_edges[1] + s2t_edges[1] + hier_edges[1]
723
+ all_types = spatial_types + rc_types + s2t_types + hier_types
724
 
725
  if not all_src:
726
  return None
727
 
728
  edge_index = torch.tensor([all_src, all_dst], dtype=torch.long)
729
 
730
+ # Edge features: one-hot type + sinusoidal relative position encoding (vectorized)
731
+ num_edges = len(all_types)
732
+ edge_type_onehot = torch.zeros(num_edges, 5)
733
+ et_arr = torch.tensor(all_types, dtype=torch.long)
734
+ edge_type_onehot.scatter_(1, et_arr.unsqueeze(1), 1.0)
735
+
736
+ # Vectorized coordinate lookup for relative position encoding
737
+ node_rows = torch.zeros(len(all_cells), dtype=torch.float)
738
+ node_cols = torch.zeros(len(all_cells), dtype=torch.float)
739
+ for i, c in enumerate(all_cells):
740
+ node_rows[i] = c["row"]
741
+ node_cols[i] = c["col"]
742
+
743
+ src_t = torch.tensor(all_src, dtype=torch.long)
744
+ dst_t = torch.tensor(all_dst, dtype=torch.long)
745
+ dr = node_rows[dst_t] - node_rows[src_t]
746
+ dc = node_cols[dst_t] - node_cols[src_t]
747
+
748
+ REL_PE_DIM = 16
749
+ half = REL_PE_DIM // 4
750
+ div_term = torch.exp(
751
+ torch.arange(0, half, dtype=torch.float) * -(math.log(10000.0) / max(half, 1))
752
+ )
753
+ rel_pe = torch.cat([
754
+ torch.sin(dr.unsqueeze(-1) * div_term),
755
+ torch.cos(dr.unsqueeze(-1) * div_term),
756
+ torch.sin(dc.unsqueeze(-1) * div_term),
757
+ torch.cos(dc.unsqueeze(-1) * div_term),
758
+ ], dim=1)
759
+
760
+ edge_type_tensor = torch.cat([edge_type_onehot, rel_pe], dim=1)
761
 
762
+ # Hierarchical ground truth for edge prediction training (separate from conv edges)
763
  if hier_edges[0]:
764
  hier_edge_index = torch.tensor(hier_edges, dtype=torch.long)
765
  else:
 
797
  )
798
 
799
 
800
+ def load_all_graphs(max_nodes: int = 5000) -> list[Data]:
801
+ """Load all labeled JSONs as PyG Data objects.
802
+
803
+ Sheets with more than ``max_nodes`` cells are skipped to keep memory
804
+ bounded for attention-based models. Set max_nodes=0 to disable.
805
+ """
806
  graphs = []
807
+ skipped = 0
808
  for fp in sorted(config.LABELED_DIR.glob("*.json")):
809
  g = json_to_pyg(fp)
810
  if g is not None:
811
+ if max_nodes > 0 and g.num_nodes > max_nodes:
812
+ skipped += 1
813
+ continue
814
  graphs.append(g)
815
+ if skipped:
816
+ print(f" Skipped {skipped} graphs with >{max_nodes} nodes")
817
  return graphs
818
 
819
 
 
867
  return torch.tensor([neg_src, neg_dst], dtype=torch.long)
868
 
869
 
870
+ def train_epoch(model, graphs, optimizer, device, class_weights=None,
871
+ focal_loss_fn=None):
872
  model.train()
873
  total_node_loss = 0
874
  total_edge_loss = 0
 
882
  edge_attr = g.edge_attr if hasattr(g, "edge_attr") and g.edge_attr is not None else None
883
  h = model(g.x, g.edge_index, edge_attr)
884
 
 
885
  node_logits = model.classify_nodes(h)
886
  mask = g.train_mask
887
  if mask.sum() > 0:
888
+ if focal_loss_fn is not None:
889
+ node_loss = focal_loss_fn(node_logits[mask], g.y[mask])
890
+ else:
891
+ node_loss = F.cross_entropy(node_logits[mask], g.y[mask], weight=cw)
892
  else:
893
  node_loss = torch.tensor(0.0, device=device)
894
 
 
1002
 
1003
  print(f"Train graphs: {len(train_graphs)}, Val graphs: {len(val_graphs)}")
1004
 
1005
+ model = SpreadsheetGNN(in_dim=in_dim, hidden_dim=256).to(device)
1006
  optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4)
1007
  scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=num_epochs)
1008
+ focal_loss_fn = FocalLoss(alpha=class_weights, gamma=2.0).to(device)
1009
 
1010
  best_val_acc = 0
1011
  best_state = None
 
1014
  random.shuffle(train_graphs)
1015
  node_loss, edge_loss, table_loss = train_epoch(
1016
  model, train_graphs, optimizer, device, class_weights,
1017
+ focal_loss_fn=focal_loss_fn,
1018
  )
1019
  scheduler.step()
1020
 
 
1041
  version = _next_model_version()
1042
  checkpoint = {
1043
  "model_state_dict": model.state_dict(),
1044
+ "config": {"in_dim": in_dim, "hidden_dim": 256, "num_classes": NUM_CLASSES,
1045
+ "edge_type_dim": EDGE_TYPE_DIM},
1046
  "cell_labels": CELL_LABELS,
1047
  "version": version,
1048
  "num_training_sheets": len(graphs),