gcn-python 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. gcn_python-1.0.0/.gitignore +20 -0
  2. gcn_python-1.0.0/PKG-INFO +12 -0
  3. gcn_python-1.0.0/pyproject.toml +31 -0
  4. gcn_python-1.0.0/src/gcn_python/__init__.py +2 -0
  5. gcn_python-1.0.0/src/gcn_python/constants.py +28 -0
  6. gcn_python-1.0.0/src/gcn_python/data/__init__.py +0 -0
  7. gcn_python-1.0.0/src/gcn_python/data/json_reader.py +124 -0
  8. gcn_python-1.0.0/src/gcn_python/data/loader.py +212 -0
  9. gcn_python-1.0.0/src/gcn_python/data/schema.py +50 -0
  10. gcn_python-1.0.0/src/gcn_python/data/verbalize_loader.py +96 -0
  11. gcn_python-1.0.0/src/gcn_python/evaluation/__init__.py +0 -0
  12. gcn_python-1.0.0/src/gcn_python/evaluation/eval_runner.py +123 -0
  13. gcn_python-1.0.0/src/gcn_python/evaluation/metrics.py +257 -0
  14. gcn_python-1.0.0/src/gcn_python/evaluation/recorder.py +158 -0
  15. gcn_python-1.0.0/src/gcn_python/layer1/__init__.py +0 -0
  16. gcn_python-1.0.0/src/gcn_python/layer1/features.py +126 -0
  17. gcn_python-1.0.0/src/gcn_python/layer1/representation.py +37 -0
  18. gcn_python-1.0.0/src/gcn_python/layer2/__init__.py +0 -0
  19. gcn_python-1.0.0/src/gcn_python/layer2/interface.py +52 -0
  20. gcn_python-1.0.0/src/gcn_python/layer2/reference.py +159 -0
  21. gcn_python-1.0.0/src/gcn_python/layer3/__init__.py +0 -0
  22. gcn_python-1.0.0/src/gcn_python/layer3/interface.py +32 -0
  23. gcn_python-1.0.0/src/gcn_python/layer3/pytorch_rgcn.py +190 -0
  24. gcn_python-1.0.0/src/gcn_python/layer3/reference.py +102 -0
  25. gcn_python-1.0.0/src/gcn_python/pipeline/__init__.py +0 -0
  26. gcn_python-1.0.0/src/gcn_python/pipeline/cgnp.py +419 -0
  27. gcn_python-1.0.0/src/gcn_python/pipeline/cli.py +77 -0
  28. gcn_python-1.0.0/src/gcn_python/pipeline/ir_emitter.py +71 -0
  29. gcn_python-1.0.0/src/gcn_python/pipeline/label_builder.py +82 -0
  30. gcn_python-1.0.0/src/gcn_python/taxonomy/__init__.py +0 -0
  31. gcn_python-1.0.0/src/gcn_python/taxonomy/loader.py +74 -0
  32. gcn_python-1.0.0/src/gcn_python/training/__init__.py +0 -0
  33. gcn_python-1.0.0/src/gcn_python/training/bootstrap.py +117 -0
  34. gcn_python-1.0.0/src/gcn_python/training/checkpoint.py +93 -0
  35. gcn_python-1.0.0/src/gcn_python/training/train.py +238 -0
  36. gcn_python-1.0.0/src/gcn_python/verbalizer/__init__.py +4 -0
  37. gcn_python-1.0.0/src/gcn_python/verbalizer/cli.py +25 -0
  38. gcn_python-1.0.0/src/gcn_python/verbalizer/decoder.py +38 -0
  39. gcn_python-1.0.0/src/gcn_python/verbalizer/interface.py +21 -0
  40. gcn_python-1.0.0/src/gcn_python/verbalizer/trainable.py +204 -0
  41. gcn_python-1.0.0/tests/conftest.py +30 -0
  42. gcn_python-1.0.0/tests/test_evaluation.py +246 -0
  43. gcn_python-1.0.0/tests/test_ir_emitter.py +37 -0
  44. gcn_python-1.0.0/tests/test_json_reader.py +14 -0
  45. gcn_python-1.0.0/tests/test_layer1.py +42 -0
  46. gcn_python-1.0.0/tests/test_layer2.py +27 -0
  47. gcn_python-1.0.0/tests/test_layer3.py +78 -0
  48. gcn_python-1.0.0/tests/test_pipeline.py +187 -0
  49. gcn_python-1.0.0/tests/test_pytorch_rgcn.py +132 -0
  50. gcn_python-1.0.0/tests/test_taxonomy.py +21 -0
  51. gcn_python-1.0.0/tests/test_trainable_decoder.py +229 -0
  52. gcn_python-1.0.0/tests/test_training.py +390 -0
  53. gcn_python-1.0.0/tests/test_verbalize_loader.py +93 -0
  54. gcn_python-1.0.0/tests/test_verbalizer.py +96 -0
@@ -0,0 +1,20 @@
1
+ # Rust
2
+ gcn-core/target/
3
+
4
+ # Python
5
+ gcn-python/__pycache__/
6
+ gcn-python/src/**/__pycache__/
7
+ gcn-python/tests/__pycache__/
8
+ gcn-python/*.egg-info/
9
+ gcn-python/src/*.egg-info/
10
+ gcn-python/.pytest_cache/
11
+ gcn-python/dist/
12
+ gcn-python/build/
13
+ gcn-python/models/
14
+
15
+ # General
16
+ .env
17
+ *.pyc
18
+ *.pyo
19
+ *.so
20
+ *.DS_Store
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.5
2
+ Name: gcn-python
3
+ Version: 1.0.0
4
+ Summary: CGNP ML layers — Causal Graph Neural Parser architecture
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: click>=8.1
7
+ Requires-Dist: numpy<3.0,>=1.24
8
+ Requires-Dist: pyyaml>=6.0
9
+ Requires-Dist: spacy<4.0,>=3.7
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=7.4; extra == 'dev'
12
+ Requires-Dist: ruff>=0.1; extra == 'dev'
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "gcn-python"
7
+ version = "1.0.0"
8
+ description = "CGNP ML layers — Causal Graph Neural Parser architecture"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "spacy>=3.7,<4.0",
12
+ "numpy>=1.24,<3.0",
13
+ "pyyaml>=6.0",
14
+ "click>=8.1",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = ["pytest>=7.4", "ruff>=0.1"]
19
+
20
+ [project.scripts]
21
+ gcn-forward = "gcn_python.pipeline.cli:forward_cmd"
22
+ gcn-verbalize = "gcn_python.verbalizer.cli:verbalize_cmd"
23
+ gcn-train = "gcn_python.training.train:train_cmd"
24
+ gcn-bootstrap = "gcn_python.training.bootstrap:bootstrap_cmd"
25
+ gcn-eval = "gcn_python.evaluation.eval_runner:eval_cmd"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/gcn_python"]
29
+
30
+ [tool.pytest.ini_options]
31
+ testpaths = ["tests"]
@@ -0,0 +1,2 @@
1
+ """gcn-python — CGNP ML layers (Causal Graph Neural Parser)."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,28 @@
1
+ # Sync avec gcn-core/crates/gcn-ir/src/{ir,node,edge}.rs
2
+ NODE_TYPES = ["etat", "action", "transition", "processus", "condition", "entite", "etat_systemique"]
3
+ RELATION_TYPES = ["cause", "enable", "prevent", "condition", "concession", "sequence",
4
+ "motivation", "filter", "opposition", "data_dependency", "control_dependency"]
5
+ SCOPE_VALUES = ["universal", "existential", "partial", "null", "specific", "unknown"]
6
+ NODE_ORIGIN_VALUES = ["explicit", "inferred", "hypothetical"]
7
+ AGENT_TYPE_VALUES = ["human", "collective", "institutional", "natural"]
8
+
9
+ # Universal Dependencies constants
10
+ UPOS_TAGS = ["ADJ", "ADP", "ADV", "AUX", "CCONJ", "DET", "INTJ", "NOUN", "NUM",
11
+ "PART", "PRON", "PROPN", "PUNCT", "SCONJ", "SYM", "VERB", "X", "_unk"] # 18 + _unk = 19
12
+
13
+ UD_DEP_RELS = ["acl", "advcl", "advmod", "amod", "appos", "aux", "case", "cc", "ccomp", "clf",
14
+ "compound", "conj", "cop", "csubj", "dep", "det", "discourse", "dislocated",
15
+ "expl", "fixed", "flat", "goeswith", "iobj", "list", "mark", "nmod", "nsubj",
16
+ "nummod", "obj", "obl", "orphan", "parataxis", "punct", "reparandum", "root",
17
+ "vocative", "xcomp", "_unk"] # 37 + _unk = 38
18
+
19
+ UD_TENSE_VALUES = ["Pres", "Past", "Fut", "Imp", "_absent"] # 5
20
+ UD_ASPECT_VALUES = ["Perf", "Imp", "Prog", "_absent"] # 4
21
+ UD_MOOD_VALUES = ["Ind", "Sub", "Cond", "Imp", "_absent"] # 5
22
+ UD_POLARITY_VALUES = ["Neg"] # 1 binaire
23
+
24
+ # D_clause = 19 + 38 + 5(subject_pos) + 5 + 4 + 5 + 1 + 3(flags) + N_taxonomy
25
+ # D_conn = 19(marker_upos) + N_taxonomy + 2(position)
26
+
27
+ # Subject POS categories
28
+ SUBJECT_POS_CATS = ["PRON", "NOUN", "PROPN", "_other", "_absent"] # 5
File without changes
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+ from pathlib import Path
3
+ import json
4
+ import warnings
5
+ from .schema import SentenceRecord, TokenRecord, ClauseRecord, EdgeRecord
6
+
7
+
8
+ def load_sentences(path: Path, lang: str = "fr") -> list[SentenceRecord]:
9
+ """Charge un fichier JSON GCN-NL → List[SentenceRecord]."""
10
+ doc = json.loads(path.read_text(encoding="utf-8"))
11
+ if not isinstance(doc, dict):
12
+ return []
13
+
14
+ # Format paper_examples.json
15
+ if "examples" in doc:
16
+ return [_parse_paper_example(ex, lang) for ex in doc["examples"] if "expected_cir" in ex]
17
+
18
+ # Format dataset (document.sentences)
19
+ if "document" in doc:
20
+ doc_lang = doc["document"].get("lang", lang)
21
+ sentences = doc["document"].get("sentences") or []
22
+ return [_parse_dataset_sentence(s, doc_lang) for s in sentences if "cir" in s]
23
+
24
+ return []
25
+
26
+
27
+ def load_all_sentences(data_dir: Path, lang: str = "fr") -> list[SentenceRecord]:
28
+ """Charge tous les fichiers JSON d'un répertoire."""
29
+ records = []
30
+ for p in sorted(data_dir.glob("*.json")):
31
+ records.extend(load_sentences(p, lang))
32
+ return records
33
+
34
+
35
+ def _parse_paper_example(ex: dict, lang: str) -> SentenceRecord:
36
+ cir = ex.get("expected_cir", {})
37
+ clauses = [_parse_clause_node(n) for n in cir.get("nodes", [])]
38
+ edges = [_parse_edge(e) for e in cir.get("edges", [])]
39
+ return SentenceRecord(
40
+ id=ex.get("id", ""),
41
+ text=ex.get("text", ""),
42
+ lang=lang,
43
+ tokens=[],
44
+ clauses=clauses,
45
+ edges=edges,
46
+ )
47
+
48
+
49
+ def _parse_dataset_sentence(s: dict, lang: str) -> SentenceRecord:
50
+ tokens = [_parse_token(t) for t in s.get("tokens", [])]
51
+ cir = s.get("cir", {})
52
+ clauses = [_parse_clause_node(n) for n in cir.get("nodes", [])]
53
+ edges = [_parse_edge(e) for e in cir.get("edges", [])]
54
+ return SentenceRecord(
55
+ id=s.get("id", ""),
56
+ text=s.get("text", ""),
57
+ lang=lang,
58
+ tokens=tokens,
59
+ clauses=clauses,
60
+ edges=edges,
61
+ )
62
+
63
+
64
+ def _parse_token(t: dict) -> TokenRecord:
65
+ gcn = t.get("gcn", {}) or {}
66
+ morph_raw = t.get("morph") or {}
67
+ return TokenRecord(
68
+ id=int(t.get("id", 0)),
69
+ form=t.get("form", ""),
70
+ lemma=t.get("lemma", ""),
71
+ pos=t.get("pos", ""),
72
+ dep_rel=t.get("dep_rel", ""),
73
+ dep_head=int(t.get("dep_head", 0)),
74
+ morph=morph_raw if isinstance(morph_raw, dict) else {},
75
+ gcn_causal_type=gcn.get("causal_type"),
76
+ gcn_causal_class=gcn.get("causal_class"),
77
+ )
78
+
79
+
80
+ def _parse_clause_node(n: dict) -> ClauseRecord:
81
+ span = n.get("token_span", [0, 0])
82
+ attrs = dict(n.get("attributes", {}) or {})
83
+ for f in ("entity", "quality", "agent", "patient", "agent_type", "temporal_index", "scope"):
84
+ if f in n and f not in attrs:
85
+ attrs[f] = n[f]
86
+ node_type = n.get("type") or ""
87
+ if not node_type:
88
+ warnings.warn(
89
+ f"Nœud {n.get('id', '?')} sans champ 'type' — défaut 'action' appliqué.",
90
+ UserWarning, stacklevel=3,
91
+ )
92
+ node_type = "action"
93
+ return ClauseRecord(
94
+ node_id=n.get("id", ""),
95
+ node_type=node_type,
96
+ label=n.get("label", ""),
97
+ token_span=(span[0], span[1]) if len(span) >= 2 else (0, 0),
98
+ scope=n.get("scope", attrs.get("scope", "specific")),
99
+ temporal_index=int(n.get("temporal_index", attrs.get("temporal_index", 0))),
100
+ origin=n.get("origin", "explicit"),
101
+ attributes=attrs,
102
+ modifiers=n.get("modifiers", []) or [],
103
+ )
104
+
105
+
106
+ def _parse_edge(e: dict) -> EdgeRecord:
107
+ attrs = e.get("attributes") or {}
108
+ relation = e.get("relation") or ""
109
+ if not relation:
110
+ warnings.warn(
111
+ f"Arête {e.get('source', '?')}→{e.get('target', '?')} sans champ 'relation' "
112
+ f"— défaut 'cause' appliqué.",
113
+ UserWarning, stacklevel=3,
114
+ )
115
+ relation = "cause"
116
+ return EdgeRecord(
117
+ source=e.get("source", ""),
118
+ target=e.get("target", ""),
119
+ relation=relation,
120
+ confidence=float(attrs.get("confidence", e.get("confidence", 1.0))),
121
+ explicit=bool(attrs.get("explicit", e.get("explicit", True))),
122
+ negated=bool(attrs.get("negated", e.get("negated", False))),
123
+ marker_token=attrs.get("marker_token", e.get("marker_token")),
124
+ )
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ import warnings
5
+ import numpy as np
6
+
7
+ from .json_reader import load_all_sentences
8
+ from .schema import SentenceRecord, TokenRecord, ClauseRecord
9
+ from ..constants import NODE_TYPES, RELATION_TYPES
10
+ from ..layer1.representation import UDRepresentation
11
+
12
+
13
+ def _node_type_idx(node_type: str, sentence_id: str) -> int:
14
+ if node_type not in NODE_TYPES:
15
+ raise ValueError(
16
+ f"[sentence {sentence_id}] node_type inconnu : {node_type!r}. "
17
+ f"Valeurs autorisées : {NODE_TYPES}"
18
+ )
19
+ return NODE_TYPES.index(node_type)
20
+
21
+
22
+ def _relation_idx(relation: str, sentence_id: str) -> int:
23
+ if relation not in RELATION_TYPES:
24
+ raise ValueError(
25
+ f"[sentence {sentence_id}] relation inconnue : {relation!r}. "
26
+ f"Valeurs autorisées : {RELATION_TYPES}"
27
+ )
28
+ return RELATION_TYPES.index(relation)
29
+
30
+
31
+ @dataclass
32
+ class TrainingSample:
33
+ sentence: SentenceRecord
34
+ gold_node_labels: np.ndarray # (N,) int — indices dans NODE_TYPES
35
+ edge_map: dict # {(src_clause_idx, tgt_clause_idx): rel_idx} — seule source de vérité pour les arêtes
36
+
37
+
38
+ class GCNDataLoader:
39
+ """Itère sur les sentences YAML d'un répertoire et produit des TrainingSample."""
40
+
41
+ def __init__(self, data_dir: Path, lang: str = "fr", repeat: bool = False):
42
+ self.data_dir = data_dir
43
+ self.lang = lang
44
+ self.repeat = repeat
45
+ self._records = load_all_sentences(data_dir, lang)
46
+
47
+ def __len__(self) -> int:
48
+ return len(self._records)
49
+
50
+ def __iter__(self):
51
+ while True:
52
+ for rec in self._records:
53
+ try:
54
+ yield self._to_sample(rec)
55
+ except ValueError as exc:
56
+ warnings.warn(f"[{rec.id}] sample ignoré : {exc}", UserWarning, stacklevel=2)
57
+ if not self.repeat:
58
+ break
59
+
60
+ def _to_sample(self, rec: SentenceRecord) -> TrainingSample:
61
+ # _relation_idx est appelé après les guards gap>1 et backward (src>tgt)
62
+ # pour éviter de rejeter toute la phrase sur une arête non-supervisable
63
+ # dont la relation serait inconnue.
64
+ node_id_to_idx = {c.node_id: i for i, c in enumerate(rec.clauses)}
65
+ node_labels = np.array(
66
+ [_node_type_idx(c.node_type, rec.id) for c in rec.clauses],
67
+ dtype=np.int64,
68
+ )
69
+ edge_map: dict[tuple[int, int], int] = {}
70
+ n_backward = 0
71
+ for e in rec.edges:
72
+ src_idx = node_id_to_idx.get(e.source)
73
+ tgt_idx = node_id_to_idx.get(e.target)
74
+ if src_idx is None or tgt_idx is None:
75
+ warnings.warn(
76
+ f"[{rec.id}] arête {e.source}→{e.target} : node_id inconnu — arête ignorée.",
77
+ UserWarning, stacklevel=2,
78
+ )
79
+ continue
80
+ gap = abs(tgt_idx - src_idx)
81
+ if gap > 1:
82
+ warnings.warn(
83
+ f"[{rec.id}] arête longue distance {e.source}→{e.target} "
84
+ f"(gap={gap}) : aucune supervision d'arête possible (forward prédit "
85
+ f"uniquement les paires consécutives).",
86
+ UserWarning, stacklevel=2,
87
+ )
88
+ continue # arête non-supervisable, ne pas insérer dans edge_map
89
+ if src_idx > tgt_idx:
90
+ n_backward += 1
91
+ continue # arête backward non-supervisable, ne pas insérer dans edge_map
92
+ rel_idx = _relation_idx(e.relation, rec.id)
93
+ edge_map[(src_idx, tgt_idx)] = rel_idx
94
+ if n_backward:
95
+ warnings.warn(
96
+ f"[{rec.id}] {n_backward} arête(s) gold en direction inverse "
97
+ f"(src > tgt) — non supervisées (le forward prédit uniquement la "
98
+ f"direction consécutive croissante).",
99
+ UserWarning, stacklevel=2,
100
+ )
101
+ return TrainingSample(rec, node_labels, edge_map)
102
+
103
+
104
+ def reps_from_sentence(
105
+ rec: SentenceRecord,
106
+ ) -> tuple[list[UDRepresentation], list[int], list[UDRepresentation | None]]:
107
+ """Une UDRepresentation par ClauseRecord non-vide, construite depuis les tokens YAML.
108
+
109
+ Bypass spaCy : garantit l'alignement exact features ↔ gold labels.
110
+ Retourne ([], [], []) si le SentenceRecord n'a pas de tokens annotés.
111
+
112
+ Retourne un triplet :
113
+ - reps : UDRepresentation par clause valide
114
+ - valid_indices : indices des clauses converties dans rec.clauses
115
+ - connector_reps : UDRepresentation du connecteur entre reps[k] et reps[k+1],
116
+ ou None si aucun connecteur trouvé (longueur = len(reps) - 1)
117
+ """
118
+ if not rec.tokens or not rec.clauses:
119
+ return [], [], []
120
+ result: list[UDRepresentation] = []
121
+ valid_indices: list[int] = []
122
+ for i, clause in enumerate(rec.clauses):
123
+ rep = _rep_from_clause(clause, rec.tokens, rec.lang)
124
+ if rep is not None:
125
+ result.append(rep)
126
+ valid_indices.append(i)
127
+ connector_reps: list[UDRepresentation | None] = [
128
+ _connector_between(
129
+ rec.clauses[valid_indices[k]],
130
+ rec.clauses[valid_indices[k + 1]],
131
+ rec.tokens,
132
+ rec.lang,
133
+ )
134
+ for k in range(len(result) - 1)
135
+ ]
136
+ return result, valid_indices, connector_reps
137
+
138
+
139
+ def _connector_between(
140
+ clause_a: ClauseRecord,
141
+ clause_b: ClauseRecord,
142
+ all_tokens: list[TokenRecord],
143
+ lang: str,
144
+ ) -> UDRepresentation | None:
145
+ """Retourne une UDRepresentation pour le token connecteur entre deux spans consécutives."""
146
+ end_a = clause_a.token_span[1]
147
+ start_b = clause_b.token_span[0]
148
+ gap_toks = [t for t in all_tokens if end_a < t.id < start_b]
149
+ if not gap_toks:
150
+ return None
151
+ tok = (
152
+ next((t for t in gap_toks if t.gcn_causal_type == "conjonction"), None)
153
+ or next((t for t in gap_toks if t.pos in {"SCONJ", "CCONJ", "ADP"}), None)
154
+ )
155
+ if tok is None:
156
+ return None
157
+ return UDRepresentation(
158
+ tokens=[{"lemma": tok.lemma, "pos": tok.pos, "dep_rel": tok.dep_rel, "morph": tok.morph}],
159
+ root_lemma=tok.lemma,
160
+ root_pos=tok.pos,
161
+ root_dep_rel=tok.dep_rel,
162
+ root_morph=tok.morph,
163
+ subject_pos=None,
164
+ has_object=False,
165
+ has_advcl=False,
166
+ has_temporal_obl=False,
167
+ token_span=(tok.id, tok.id),
168
+ lang=lang,
169
+ )
170
+
171
+
172
+ def _rep_from_clause(
173
+ clause: ClauseRecord,
174
+ all_tokens: list[TokenRecord],
175
+ lang: str,
176
+ ) -> UDRepresentation | None:
177
+ span_start, span_end = clause.token_span
178
+ if span_start > span_end:
179
+ warnings.warn(
180
+ f"Span inversée dans {clause.node_id} : ({span_start}, {span_end}) — clause ignorée.",
181
+ UserWarning, stacklevel=3,
182
+ )
183
+ return None
184
+ span_toks = [t for t in all_tokens if span_start <= t.id <= span_end]
185
+ if not span_toks:
186
+ return None
187
+
188
+ # Priorité : VERB annoté gcn_causal_type="verbe", sinon premier VERB/AUX, sinon premier token
189
+ root_tok = (
190
+ next((t for t in span_toks
191
+ if t.gcn_causal_type == "verbe" and t.pos in {"VERB", "AUX"}), None)
192
+ or next((t for t in span_toks if t.pos in {"VERB", "AUX"}), span_toks[0])
193
+ )
194
+
195
+ subject = next((t for t in span_toks if t.dep_rel in {"nsubj", "nsubj:pass"}), None)
196
+
197
+ return UDRepresentation(
198
+ tokens=[
199
+ {"lemma": t.lemma, "pos": t.pos, "dep_rel": t.dep_rel, "morph": t.morph}
200
+ for t in span_toks
201
+ ],
202
+ root_lemma=root_tok.lemma,
203
+ root_pos=root_tok.pos,
204
+ root_dep_rel=root_tok.dep_rel,
205
+ root_morph=root_tok.morph,
206
+ subject_pos=subject.pos if subject else None,
207
+ has_object=any(t.dep_rel in {"obj", "iobj", "nobj"} for t in span_toks),
208
+ has_advcl=any(t.dep_rel == "advcl" for t in span_toks),
209
+ has_temporal_obl=any(t.dep_rel in {"obl", "obl:tmod"} for t in span_toks),
210
+ token_span=clause.token_span,
211
+ lang=lang,
212
+ )
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from typing import Optional
4
+
5
+
6
+ @dataclass
7
+ class TokenRecord:
8
+ id: int # 1-based
9
+ form: str
10
+ lemma: str
11
+ pos: str # UPOS
12
+ dep_rel: str # UD dep relation
13
+ dep_head: int # 0 = root
14
+ morph: dict[str, str] = field(default_factory=dict)
15
+ gcn_causal_type: Optional[str] = None
16
+ gcn_causal_class: Optional[str] = None
17
+
18
+
19
+ @dataclass
20
+ class ClauseRecord:
21
+ node_id: str # "n001"
22
+ node_type: str # NodeType snake_case
23
+ label: str
24
+ token_span: tuple[int, int]
25
+ scope: str
26
+ temporal_index: int
27
+ origin: str
28
+ attributes: dict[str, object] = field(default_factory=dict)
29
+ modifiers: list[dict] = field(default_factory=list)
30
+
31
+
32
+ @dataclass
33
+ class EdgeRecord:
34
+ source: str # "n001"
35
+ target: str # "n002"
36
+ relation: str # RelationType snake_case
37
+ confidence: float
38
+ explicit: bool
39
+ negated: bool
40
+ marker_token: Optional[int]
41
+
42
+
43
+ @dataclass
44
+ class SentenceRecord:
45
+ id: str
46
+ text: str
47
+ lang: str
48
+ tokens: list[TokenRecord]
49
+ clauses: list[ClauseRecord]
50
+ edges: list[EdgeRecord]
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+ import json
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+
8
+ from ..verbalizer.trainable import SurfaceVocabulary
9
+ from ..constants import NODE_TYPES
10
+
11
+
12
+ def _node_type_embeddings(nodes: list[dict]) -> np.ndarray:
13
+ """Build one-hot node_type embeddings from a list of CausalIR node dicts."""
14
+ if not nodes:
15
+ return np.zeros((1, len(NODE_TYPES)), dtype=np.float32)
16
+ embs = []
17
+ for node in nodes:
18
+ nt = node.get("node_type", "")
19
+ idx = NODE_TYPES.index(nt) if nt in NODE_TYPES else 0
20
+ onehot = np.zeros(len(NODE_TYPES), dtype=np.float32)
21
+ onehot[idx] = 1.0
22
+ embs.append(onehot)
23
+ return np.stack(embs) # (N, 7)
24
+
25
+
26
+ @dataclass
27
+ class VerbalizeSample:
28
+ ir_json: str # CausalIR JSON (for inference)
29
+ node_type_embeddings: np.ndarray # (N, 7) one-hot — fallback when R-GCN not available
30
+ gold_tokens: np.ndarray # (T,) int indices in SurfaceVocabulary
31
+ source_text: str # used to match encoding dataset samples
32
+
33
+
34
+ class VerbalizerDataLoader:
35
+ """Loads verbalize JSON files and produces VerbalizeSample per (CausalIR, surface) pair.
36
+
37
+ Reads files matching verbalize_*.json in data_dir.
38
+ Each cross-modal example with N surfaces produces N VerbalizeSamples.
39
+ Only gold and silver quality surfaces are used for training.
40
+ gcn-verbalize.schema.yaml is documentation for annotators — never read here.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ data_dir: Path,
46
+ vocab: SurfaceVocabulary | None = None,
47
+ ) -> None:
48
+ raw = self._load_raw(data_dir)
49
+
50
+ if vocab is None:
51
+ vocab = SurfaceVocabulary()
52
+ surfaces = [
53
+ surf["text"]
54
+ for ex in raw
55
+ for surf in ex.get("surfaces", [])
56
+ if surf.get("quality") in ("gold", "silver")
57
+ ]
58
+ vocab.build(surfaces)
59
+ self.vocab = vocab
60
+
61
+ self._samples: list[VerbalizeSample] = []
62
+ for ex in raw:
63
+ ir = ex["causal_ir"]
64
+ ir_json = json.dumps(ir)
65
+ source_text: str = ir.get("source_text", "")
66
+ nodes: list[dict] = ir.get("nodes", [])
67
+ node_embs = _node_type_embeddings(nodes)
68
+ for surf in ex.get("surfaces", []):
69
+ if surf.get("quality") not in ("gold", "silver"):
70
+ continue
71
+ gold_tokens = np.array(vocab.encode(surf["text"]), dtype=np.int64)
72
+ self._samples.append(
73
+ VerbalizeSample(ir_json, node_embs, gold_tokens, source_text)
74
+ )
75
+
76
+ @staticmethod
77
+ def _load_raw(data_dir: Path) -> list[dict]:
78
+ examples: list[dict] = []
79
+ for p in sorted(data_dir.glob("verbalize_*.json")):
80
+ with p.open(encoding="utf-8") as f:
81
+ data = json.load(f)
82
+ examples.extend(data.get("examples", []))
83
+ return examples
84
+
85
+ def source_text_map(self) -> dict[str, list[np.ndarray]]:
86
+ """Returns {source_text: [gold_tokens, ...]} for joint training lookup."""
87
+ result: dict[str, list[np.ndarray]] = {}
88
+ for s in self._samples:
89
+ result.setdefault(s.source_text, []).append(s.gold_tokens)
90
+ return result
91
+
92
+ def __len__(self) -> int:
93
+ return len(self._samples)
94
+
95
+ def __iter__(self):
96
+ yield from self._samples
File without changes