Spaces:
Sleeping
Sleeping
zofiasmolenasana commited on
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
- LABELING_GUIDE.md +10 -10
- chunk_builder.py +31 -8
- features.py +71 -1
- models.py +1 -1
- notes.md +129 -2
- predict.py +10 -1
- static/index.html +68 -42
- train_baseline.py +6 -0
- train_compare.py +23 -14
- 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
|
| 49 |
|
| 50 |
-
To add more tables, click the **[+]** button.
|
| 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
|
| 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
|
| 443 |
-
| **L** then
|
| 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
|
| 500 |
|
| 501 |
-
Aby dodać więcej tabel, kliknij **[+]**.
|
| 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
|
| 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
|
| 894 |
-
| **L** potem
|
| 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).
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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
|
| 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 |
-
|
|
|
|
| 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>+
|
| 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>+
|
| 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
|
| 428 |
-
const darken =
|
| 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
|
| 435 |
-
const darken =
|
| 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 <
|
| 601 |
-
const c =
|
| 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 |
-
|
| 630 |
-
|
| 631 |
-
|
| 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 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 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 +
|
| 1064 |
if (waitingForTableNum) {
|
| 1065 |
-
|
| 1066 |
-
const n = parseInt(e.key);
|
| 1067 |
-
if (!isNaN(n) && n >= 0 && n <= 9) {
|
| 1068 |
e.preventDefault();
|
| 1069 |
-
|
|
|
|
|
|
|
| 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 +
|
| 1081 |
if (waitingForLevelNum) {
|
| 1082 |
-
|
| 1083 |
-
const n = parseInt(e.key);
|
| 1084 |
-
if (!isNaN(n) && n >= 1 && n <= 9) {
|
| 1085 |
e.preventDefault();
|
| 1086 |
-
|
|
|
|
|
|
|
| 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, …</strong> = individual tables.</li>
|
| 1582 |
-
<li>Set active table: click in the <strong>Table</strong> bar or <kbd>T</kbd> +
|
| 1583 |
-
<li>Click <strong>[+]</strong> to add more tables (
|
| 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, …</strong> = even broader.</li>
|
| 1593 |
-
<li>Set level: click in the <strong>Level</strong> bar or <kbd>L</kbd> +
|
| 1594 |
</ul>
|
| 1595 |
|
| 1596 |
<h4>Row headers (R) — 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> + <
|
| 1714 |
-
<tr><td><kbd>L</kbd> + <
|
| 1715 |
<tr><td><kbd>Esc</kbd></td><td>Cancel comment linking</td></tr>
|
| 1716 |
<tr><td><kbd>Enter</kbd></td><td>Save & 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, …</strong> = poszczególne tabele.</li>
|
| 1759 |
-
<li>Ustaw aktywną tabelę: kliknij w pasku <strong>Table</strong> lub <kbd>T</kbd> +
|
| 1760 |
-
<li>Kliknij <strong>[+]</strong>, aby dodać więcej (
|
| 1761 |
</ul>
|
| 1762 |
<p class="warn">Komórki T0 NIE generują chunkó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ółowy (najbliższy danych). Zacznij tutaj.</li>
|
| 1768 |
<li><strong>L2</strong> = szersze grupowanie obejmujące L1.</li>
|
| 1769 |
<li><strong>L3, L4, …</strong> = jeszcze szersze grupowania.</li>
|
| 1770 |
-
<li>Ustaw poziom: kliknij w pasku <strong>Level</strong> lub <kbd>L</kbd> +
|
| 1771 |
</ul>
|
| 1772 |
|
| 1773 |
<h4>Nagłówki wierszy (R) — 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> + <
|
| 1891 |
-
<tr><td><kbd>L</kbd> + <
|
| 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, …</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, …</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) — 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 & 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, …</strong> = poszczegó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órki T0 NIE generują chunków. Tylko T1+ generuje dane. Nigdy nie umieszczaj value/attribute/header w T0.</p>
|
| 1789 |
|
|
|
|
| 1793 |
<li><strong>L1</strong> = najbardziej szczegółowy (najbliższy danych). Zacznij tutaj.</li>
|
| 1794 |
<li><strong>L2</strong> = szersze grupowanie obejmujące L1.</li>
|
| 1795 |
<li><strong>L3, L4, …</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łówki wierszy (R) — 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 =
|
| 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 |
-
|
| 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 |
-
|
| 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 =
|
| 287 |
|
| 288 |
-
# Feature index ranges within the
|
| 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 |
-
|
|
|
|
|
|
|
| 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":
|
| 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 =
|
| 35 |
-
EDGE_TYPE_DIM =
|
| 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 = {
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
[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
|
| 115 |
-
kw_sum_avg = float(any(w in val_lower for w in
|
| 116 |
-
kw_date_time = float(any(w in val_lower for w in
|
| 117 |
-
kw_note_source = float(any(w in val_lower for w in
|
| 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 |
-
|
| 465 |
-
|
| 466 |
-
|
|
|
|
| 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
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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":
|
|
|
|
| 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),
|