gcn-python 1.0.0__py3-none-any.whl
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.
- gcn_python/__init__.py +2 -0
- gcn_python/constants.py +28 -0
- gcn_python/data/__init__.py +0 -0
- gcn_python/data/json_reader.py +124 -0
- gcn_python/data/loader.py +212 -0
- gcn_python/data/schema.py +50 -0
- gcn_python/data/verbalize_loader.py +96 -0
- gcn_python/evaluation/__init__.py +0 -0
- gcn_python/evaluation/eval_runner.py +123 -0
- gcn_python/evaluation/metrics.py +257 -0
- gcn_python/evaluation/recorder.py +158 -0
- gcn_python/layer1/__init__.py +0 -0
- gcn_python/layer1/features.py +126 -0
- gcn_python/layer1/representation.py +37 -0
- gcn_python/layer2/__init__.py +0 -0
- gcn_python/layer2/interface.py +52 -0
- gcn_python/layer2/reference.py +159 -0
- gcn_python/layer3/__init__.py +0 -0
- gcn_python/layer3/interface.py +32 -0
- gcn_python/layer3/pytorch_rgcn.py +190 -0
- gcn_python/layer3/reference.py +102 -0
- gcn_python/pipeline/__init__.py +0 -0
- gcn_python/pipeline/cgnp.py +419 -0
- gcn_python/pipeline/cli.py +77 -0
- gcn_python/pipeline/ir_emitter.py +71 -0
- gcn_python/pipeline/label_builder.py +82 -0
- gcn_python/taxonomy/__init__.py +0 -0
- gcn_python/taxonomy/loader.py +74 -0
- gcn_python/training/__init__.py +0 -0
- gcn_python/training/bootstrap.py +117 -0
- gcn_python/training/checkpoint.py +93 -0
- gcn_python/training/train.py +238 -0
- gcn_python/verbalizer/__init__.py +4 -0
- gcn_python/verbalizer/cli.py +25 -0
- gcn_python/verbalizer/decoder.py +38 -0
- gcn_python/verbalizer/interface.py +21 -0
- gcn_python/verbalizer/trainable.py +204 -0
- gcn_python-1.0.0.dist-info/METADATA +12 -0
- gcn_python-1.0.0.dist-info/RECORD +41 -0
- gcn_python-1.0.0.dist-info/WHEEL +4 -0
- gcn_python-1.0.0.dist-info/entry_points.txt +6 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
from ..layer1.features import FeatureVocabulary, vectorize_clause, vectorize_edge
|
|
5
|
+
from ..layer2.interface import CausalEncoder
|
|
6
|
+
from ..layer3.interface import CausalGraph
|
|
7
|
+
from ..constants import NODE_TYPES, RELATION_TYPES
|
|
8
|
+
from .label_builder import build_label
|
|
9
|
+
from .ir_emitter import emit
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CGNPipeline:
|
|
13
|
+
"""
|
|
14
|
+
Pipeline complet des couches 1-3 de l'architecture CGNP.
|
|
15
|
+
|
|
16
|
+
Le data scientist instancie ce pipeline avec ses implémentations de
|
|
17
|
+
CausalEncoder (Couche 2) et CausalGraph (Couche 3).
|
|
18
|
+
|
|
19
|
+
- forward(reps, text, ...) → CausalIR dict (prêt pour serde_json Rust)
|
|
20
|
+
- loss(node_logits, edge_logits, ...) → (float, d_node, d_edge)
|
|
21
|
+
- backward(d_node, d_edge, lr) → rétropropagation + mise à jour SGD
|
|
22
|
+
|
|
23
|
+
Précondition à la construction : si graph expose d_out, il doit être égal à
|
|
24
|
+
vocabulary.d_clause — vérifié immédiatement, ValueError sinon.
|
|
25
|
+
|
|
26
|
+
decoder (optionnel) : TrainableDecoder ou tout objet implémentant
|
|
27
|
+
forward_decode / loss_decode / backward_decode / update. Si None, le pipeline
|
|
28
|
+
se comporte exactement comme avant (rétro-compatible).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
encoder: CausalEncoder,
|
|
34
|
+
graph: CausalGraph,
|
|
35
|
+
lang: str,
|
|
36
|
+
vocabulary: FeatureVocabulary,
|
|
37
|
+
*,
|
|
38
|
+
decoder=None,
|
|
39
|
+
):
|
|
40
|
+
if hasattr(graph, 'd_out') and graph.d_out != vocabulary.d_clause:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"RGCNLayer.d_out={graph.d_out} ≠ vocabulary.d_clause="
|
|
43
|
+
f"{vocabulary.d_clause} : instanciez RGCNLayer avec "
|
|
44
|
+
f"d_out=vocabulary.d_clause pour alimenter le MLP nœud "
|
|
45
|
+
f"depuis les représentations enrichies."
|
|
46
|
+
)
|
|
47
|
+
self.encoder = encoder
|
|
48
|
+
self.graph = graph
|
|
49
|
+
self.lang = lang
|
|
50
|
+
self.vocabulary = vocabulary
|
|
51
|
+
self.decoder = decoder
|
|
52
|
+
|
|
53
|
+
# Cache rempli par forward() — utilisé par loss() et backward()
|
|
54
|
+
self._cached_clause_vecs: np.ndarray | None = None
|
|
55
|
+
self._cached_enriched_vecs: np.ndarray | None = None
|
|
56
|
+
self._cached_edge_vecs: np.ndarray | None = None
|
|
57
|
+
self._cached_node_logits: np.ndarray | None = None
|
|
58
|
+
self._cached_edge_logits: np.ndarray | None = None
|
|
59
|
+
self._cached_edge_index: np.ndarray | None = None
|
|
60
|
+
self._cached_edge_type_idxs: np.ndarray | None = None
|
|
61
|
+
# Snapshots des activations MLP par nœud/arête — évitent de re-exécuter
|
|
62
|
+
# forward_node au backward (pas de re-run, pas de fragilitié de cache)
|
|
63
|
+
self._cached_node_snapshots: list | None = None
|
|
64
|
+
self._cached_edge_snapshots: list | None = None
|
|
65
|
+
# Cache décodeur — rempli par forward() si decoder présent
|
|
66
|
+
self._cached_decode_logits: np.ndarray | None = None
|
|
67
|
+
self._cached_decode_gradient: np.ndarray | None = None
|
|
68
|
+
|
|
69
|
+
def forward(
|
|
70
|
+
self,
|
|
71
|
+
reps: list,
|
|
72
|
+
text: str = "",
|
|
73
|
+
clause_positions: list[int] | None = None,
|
|
74
|
+
n_total_clauses: int | None = None,
|
|
75
|
+
connector_reps: list | None = None,
|
|
76
|
+
) -> dict:
|
|
77
|
+
"""UDRepresentation list → CausalIR dict (JSON-serializable, conforme schéma serde Rust).
|
|
78
|
+
|
|
79
|
+
clause_positions : indices originaux des reps dans la phrase complète — utilisés
|
|
80
|
+
pour calculer les features de position dans vectorize_edge.
|
|
81
|
+
Doit avoir exactement len(reps) éléments si fourni, ValueError sinon.
|
|
82
|
+
n_total_clauses : nombre total de clauses dans la phrase (dénominateur de la distance).
|
|
83
|
+
Comparé via `is not None` — la valeur 0 est traitée comme zéro clause, pas comme absent.
|
|
84
|
+
connector_reps : UDRepresentation|None par paire consécutive (len = len(reps)-1).
|
|
85
|
+
"""
|
|
86
|
+
return self._forward_from_reps(reps, text, clause_positions, n_total_clauses, connector_reps)
|
|
87
|
+
|
|
88
|
+
def _forward_from_reps(
|
|
89
|
+
self,
|
|
90
|
+
reps: list,
|
|
91
|
+
text: str,
|
|
92
|
+
clause_positions: list[int] | None = None,
|
|
93
|
+
n_total_clauses: int | None = None,
|
|
94
|
+
connector_reps: list | None = None,
|
|
95
|
+
) -> dict:
|
|
96
|
+
if clause_positions is not None and len(clause_positions) != len(reps):
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"clause_positions a {len(clause_positions)} éléments pour {len(reps)} reps."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Réinitialiser le cache
|
|
102
|
+
self._cached_clause_vecs = None
|
|
103
|
+
self._cached_enriched_vecs = None
|
|
104
|
+
self._cached_edge_vecs = None
|
|
105
|
+
self._cached_node_logits = None
|
|
106
|
+
self._cached_edge_logits = None
|
|
107
|
+
self._cached_edge_index = None
|
|
108
|
+
self._cached_edge_type_idxs = None
|
|
109
|
+
self._cached_node_snapshots = None
|
|
110
|
+
self._cached_edge_snapshots = None
|
|
111
|
+
self._cached_decode_logits = None
|
|
112
|
+
self._cached_decode_gradient = None
|
|
113
|
+
_snap = hasattr(self.encoder, 'snapshot_node_cache')
|
|
114
|
+
|
|
115
|
+
if not reps:
|
|
116
|
+
return emit(text, self.lang, [], [], [], [], [])
|
|
117
|
+
|
|
118
|
+
# Couche 1 — vectorisation
|
|
119
|
+
clause_vecs = np.stack([
|
|
120
|
+
vectorize_clause(r, self.vocabulary) for r in reps
|
|
121
|
+
]) # (N, D_clause)
|
|
122
|
+
self._cached_clause_vecs = clause_vecs
|
|
123
|
+
|
|
124
|
+
# Couche 2 — prédiction des types de nœuds (snapshot par nœud pour backward)
|
|
125
|
+
node_logits_list: list[np.ndarray] = []
|
|
126
|
+
node_snapshots: list = []
|
|
127
|
+
for v in clause_vecs:
|
|
128
|
+
node_logits_list.append(self.encoder.forward_node(v))
|
|
129
|
+
if _snap:
|
|
130
|
+
node_snapshots.append(self.encoder.snapshot_node_cache())
|
|
131
|
+
node_logits = np.stack(node_logits_list)
|
|
132
|
+
node_type_idxs = np.argmax(node_logits, axis=1)
|
|
133
|
+
node_types = [NODE_TYPES[i] for i in node_type_idxs]
|
|
134
|
+
|
|
135
|
+
# Prédiction des arêtes entre clauses adjacentes
|
|
136
|
+
edge_triples: list[tuple[int, int, str, float, bool, int | None]] = []
|
|
137
|
+
edge_vecs: list[np.ndarray] = []
|
|
138
|
+
all_edge_logits: list[np.ndarray] = []
|
|
139
|
+
edge_snapshots: list = []
|
|
140
|
+
if len(reps) >= 2:
|
|
141
|
+
real_n = n_total_clauses if n_total_clauses is not None else len(reps)
|
|
142
|
+
for src_i in range(len(reps) - 1):
|
|
143
|
+
dst_i = src_i + 1
|
|
144
|
+
real_src = clause_positions[src_i] if clause_positions else src_i
|
|
145
|
+
real_dst = clause_positions[dst_i] if clause_positions else dst_i
|
|
146
|
+
connector = (connector_reps[src_i]
|
|
147
|
+
if connector_reps and src_i < len(connector_reps) else None)
|
|
148
|
+
edge_vec = vectorize_edge(
|
|
149
|
+
reps[src_i], reps[dst_i], connector,
|
|
150
|
+
real_src, real_dst, real_n,
|
|
151
|
+
self.vocabulary,
|
|
152
|
+
)
|
|
153
|
+
edge_vecs.append(edge_vec)
|
|
154
|
+
edge_logit = self.encoder.forward_edge(edge_vec)
|
|
155
|
+
all_edge_logits.append(edge_logit)
|
|
156
|
+
if _snap:
|
|
157
|
+
edge_snapshots.append(self.encoder.snapshot_edge_cache())
|
|
158
|
+
rel_idx = int(np.argmax(edge_logit))
|
|
159
|
+
rel_conf = float(_softmax(edge_logit.reshape(1, -1))[0, rel_idx])
|
|
160
|
+
edge_triples.append((src_i, dst_i, RELATION_TYPES[rel_idx], rel_conf, False, None))
|
|
161
|
+
|
|
162
|
+
if edge_vecs:
|
|
163
|
+
self._cached_edge_vecs = np.stack(edge_vecs)
|
|
164
|
+
self._cached_edge_logits = np.stack(all_edge_logits)
|
|
165
|
+
if _snap:
|
|
166
|
+
self._cached_edge_snapshots = edge_snapshots
|
|
167
|
+
|
|
168
|
+
# Couche 3 — R-GCN message passing
|
|
169
|
+
if len(reps) > 1 and edge_triples:
|
|
170
|
+
edge_index = np.array(
|
|
171
|
+
[[e[0] for e in edge_triples], [e[1] for e in edge_triples]], dtype=np.int64
|
|
172
|
+
)
|
|
173
|
+
edge_type_idxs = np.array(
|
|
174
|
+
[RELATION_TYPES.index(e[2]) if e[2] in RELATION_TYPES else 0
|
|
175
|
+
for e in edge_triples],
|
|
176
|
+
dtype=np.int64,
|
|
177
|
+
)
|
|
178
|
+
self._cached_edge_index = edge_index
|
|
179
|
+
self._cached_edge_type_idxs = edge_type_idxs
|
|
180
|
+
enriched = self.graph.message_pass(clause_vecs, edge_index, edge_type_idxs)
|
|
181
|
+
node_logits2_list: list[np.ndarray] = []
|
|
182
|
+
node_snapshots = []
|
|
183
|
+
for v in enriched:
|
|
184
|
+
node_logits2_list.append(self.encoder.forward_node(v))
|
|
185
|
+
if _snap:
|
|
186
|
+
node_snapshots.append(self.encoder.snapshot_node_cache())
|
|
187
|
+
node_logits2 = np.stack(node_logits2_list)
|
|
188
|
+
node_type_idxs = np.argmax(node_logits2, axis=1)
|
|
189
|
+
node_types = [NODE_TYPES[i] for i in node_type_idxs]
|
|
190
|
+
node_logits = node_logits2
|
|
191
|
+
self._cached_enriched_vecs = enriched
|
|
192
|
+
|
|
193
|
+
self._cached_node_logits = node_logits
|
|
194
|
+
if _snap:
|
|
195
|
+
self._cached_node_snapshots = node_snapshots
|
|
196
|
+
|
|
197
|
+
node_labels = [
|
|
198
|
+
build_label(r, nt)
|
|
199
|
+
for r, nt in zip(reps, node_types)
|
|
200
|
+
]
|
|
201
|
+
token_spans = [r.token_span for r in reps]
|
|
202
|
+
scopes = ["specific"] * len(reps)
|
|
203
|
+
|
|
204
|
+
# Décodeur (optionnel) — utilise les embeddings R-GCN enrichis si disponibles
|
|
205
|
+
if self.decoder is not None:
|
|
206
|
+
_vecs = (self._cached_enriched_vecs
|
|
207
|
+
if self._cached_enriched_vecs is not None
|
|
208
|
+
else self._cached_clause_vecs)
|
|
209
|
+
if _vecs is not None and len(_vecs) > 0:
|
|
210
|
+
self._cached_decode_logits = self.decoder.forward_decode(_vecs)
|
|
211
|
+
|
|
212
|
+
return emit(text, self.lang, node_types, node_labels, token_spans,
|
|
213
|
+
scopes, edge_triples)
|
|
214
|
+
|
|
215
|
+
def filter_edge_cache(self, valid_idxs: np.ndarray) -> None:
|
|
216
|
+
"""Filtre les caches MLP d'arêtes aux seuls indices valides.
|
|
217
|
+
|
|
218
|
+
Appelé après forward() pour aligner edge_logits ↔ gold_edge avant loss/backward.
|
|
219
|
+
_cached_edge_index/_cached_edge_type_idxs ne sont PAS filtrés : le R-GCN
|
|
220
|
+
a utilisé toutes les arêtes dans son forward et a besoin de toutes pour backward.
|
|
221
|
+
"""
|
|
222
|
+
if self._cached_edge_vecs is not None:
|
|
223
|
+
self._cached_edge_vecs = self._cached_edge_vecs[valid_idxs]
|
|
224
|
+
if self._cached_edge_logits is not None:
|
|
225
|
+
self._cached_edge_logits = self._cached_edge_logits[valid_idxs]
|
|
226
|
+
if self._cached_edge_snapshots is not None:
|
|
227
|
+
self._cached_edge_snapshots = [self._cached_edge_snapshots[i] for i in valid_idxs]
|
|
228
|
+
|
|
229
|
+
def loss(
|
|
230
|
+
self,
|
|
231
|
+
node_logits: np.ndarray, # (N, 7) — logits nœuds du forward
|
|
232
|
+
edge_logits: np.ndarray | None, # (E, 11) — logits arêtes du forward, ou None
|
|
233
|
+
gold_node: np.ndarray, # (N,) int — indices dans NODE_TYPES
|
|
234
|
+
gold_edge: np.ndarray | None = None, # (E,) int — indices dans RELATION_TYPES
|
|
235
|
+
edge_loss_weight: float = 1.0, # pondération relative edge_loss / node_loss
|
|
236
|
+
gold_surface: np.ndarray | None = None, # (T,) int — tokens gold pour le décodeur
|
|
237
|
+
) -> tuple[float, np.ndarray, np.ndarray]:
|
|
238
|
+
"""
|
|
239
|
+
Cross-entropie NumPy sur nœuds + arêtes + décodeur (optionnel).
|
|
240
|
+
|
|
241
|
+
Retourne (total_loss, d_node_logits, d_edge_logits).
|
|
242
|
+
Gradients normalisés par le nombre d'exemples.
|
|
243
|
+
edge_loss_weight permet d'équilibrer la contribution des arêtes dans la loss totale.
|
|
244
|
+
Si gold_surface est fourni et que le décodeur a produit des logits (forward()),
|
|
245
|
+
la loss décodeur est ajoutée au total et son gradient est caché pour backward().
|
|
246
|
+
"""
|
|
247
|
+
if len(node_logits) != len(gold_node):
|
|
248
|
+
raise ValueError(
|
|
249
|
+
f"Désalignement node_logits/gold_node : {len(node_logits)} logits vs {len(gold_node)} labels"
|
|
250
|
+
)
|
|
251
|
+
node_loss, d_node = _cross_entropy(node_logits, gold_node)
|
|
252
|
+
|
|
253
|
+
if edge_logits is not None and gold_edge is not None and len(edge_logits) > 0:
|
|
254
|
+
if len(edge_logits) != len(gold_edge):
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"Désalignement edge_logits/gold_edge : {len(edge_logits)} logits vs {len(gold_edge)} labels"
|
|
257
|
+
)
|
|
258
|
+
edge_loss, d_edge = _cross_entropy(edge_logits, gold_edge)
|
|
259
|
+
else:
|
|
260
|
+
edge_loss = 0.0
|
|
261
|
+
d_edge = np.zeros((0, len(RELATION_TYPES)), dtype=np.float32)
|
|
262
|
+
|
|
263
|
+
total_loss = node_loss + edge_loss_weight * edge_loss
|
|
264
|
+
|
|
265
|
+
# Decoder loss (optionnel — uniquement si gold_surface fourni et decoder actif)
|
|
266
|
+
self._cached_decode_gradient = None
|
|
267
|
+
if (self.decoder is not None
|
|
268
|
+
and gold_surface is not None
|
|
269
|
+
and self._cached_decode_logits is not None
|
|
270
|
+
and len(gold_surface) > 0):
|
|
271
|
+
dec_loss, d_dec = self.decoder.loss_decode(self._cached_decode_logits, gold_surface)
|
|
272
|
+
total_loss += dec_loss
|
|
273
|
+
self._cached_decode_gradient = d_dec
|
|
274
|
+
|
|
275
|
+
return total_loss, d_node, d_edge
|
|
276
|
+
|
|
277
|
+
def backward(
|
|
278
|
+
self,
|
|
279
|
+
d_node_logits: np.ndarray, # (N, 7)
|
|
280
|
+
d_edge_logits: np.ndarray, # (E, 11)
|
|
281
|
+
lr: float = 0.01,
|
|
282
|
+
) -> None:
|
|
283
|
+
"""
|
|
284
|
+
Rétropropagation + SGD sur l'implémentation de référence NumPy.
|
|
285
|
+
|
|
286
|
+
Opère sur MLPEncoder (backward_node_dx / backward_edge) et
|
|
287
|
+
RGCNLayer (backward_message_pass). Le DS PyTorch override cette méthode.
|
|
288
|
+
Les appels à update_node/update_edge sont gardés par hasattr — un encodeur
|
|
289
|
+
tiers sans ces méthodes est silencieusement ignoré (ses poids ne sont pas
|
|
290
|
+
mis à jour par ce backward).
|
|
291
|
+
"""
|
|
292
|
+
if not hasattr(self.encoder, 'backward_node_dx'):
|
|
293
|
+
return # implémentation non-référence, le DS gère son propre backward
|
|
294
|
+
|
|
295
|
+
vecs = (self._cached_enriched_vecs
|
|
296
|
+
if self._cached_enriched_vecs is not None
|
|
297
|
+
else self._cached_clause_vecs)
|
|
298
|
+
if vecs is None or len(vecs) == 0:
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
n = min(len(d_node_logits), len(vecs))
|
|
302
|
+
_has_node_snap = (
|
|
303
|
+
hasattr(self.encoder, 'restore_node_cache')
|
|
304
|
+
and self._cached_node_snapshots is not None
|
|
305
|
+
and len(self._cached_node_snapshots) >= n
|
|
306
|
+
)
|
|
307
|
+
_has_edge_snap = (
|
|
308
|
+
hasattr(self.encoder, 'restore_edge_cache')
|
|
309
|
+
and self._cached_edge_snapshots is not None
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# --- Rétropropagation nœuds ---
|
|
313
|
+
all_node_grads: list[tuple[np.ndarray, np.ndarray]] | None = None
|
|
314
|
+
d_enriched = np.zeros((n, vecs.shape[1]), dtype=np.float32)
|
|
315
|
+
|
|
316
|
+
for i in range(n):
|
|
317
|
+
if _has_node_snap:
|
|
318
|
+
self.encoder.restore_node_cache(self._cached_node_snapshots[i])
|
|
319
|
+
else:
|
|
320
|
+
self.encoder.forward_node(vecs[i]) # fallback sans snapshot
|
|
321
|
+
grads_i, dx_i = self.encoder.backward_node_dx(d_node_logits[i])
|
|
322
|
+
d_enriched[i] = dx_i
|
|
323
|
+
if all_node_grads is None:
|
|
324
|
+
all_node_grads = [(dW.copy(), db.copy()) for dW, db in grads_i]
|
|
325
|
+
else:
|
|
326
|
+
for j, (dW_i, db_i) in enumerate(grads_i):
|
|
327
|
+
all_node_grads[j] = (
|
|
328
|
+
all_node_grads[j][0] + dW_i,
|
|
329
|
+
all_node_grads[j][1] + db_i,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# _cross_entropy normalise déjà par N — pas de renormalisation ici
|
|
333
|
+
|
|
334
|
+
# --- Rétropropagation arêtes ---
|
|
335
|
+
all_edge_grads: list[tuple[np.ndarray, np.ndarray]] | None = None
|
|
336
|
+
if (d_edge_logits is not None and len(d_edge_logits) > 0
|
|
337
|
+
and self._cached_edge_vecs is not None):
|
|
338
|
+
e = min(len(d_edge_logits), len(self._cached_edge_vecs))
|
|
339
|
+
for i in range(e):
|
|
340
|
+
if _has_edge_snap and i < len(self._cached_edge_snapshots):
|
|
341
|
+
self.encoder.restore_edge_cache(self._cached_edge_snapshots[i])
|
|
342
|
+
else:
|
|
343
|
+
self.encoder.forward_edge(self._cached_edge_vecs[i])
|
|
344
|
+
grads_i = self.encoder.backward_edge(d_edge_logits[i])
|
|
345
|
+
if all_edge_grads is None:
|
|
346
|
+
all_edge_grads = [(dW.copy(), db.copy()) for dW, db in grads_i]
|
|
347
|
+
else:
|
|
348
|
+
for j, (dW_i, db_i) in enumerate(grads_i):
|
|
349
|
+
all_edge_grads[j] = (
|
|
350
|
+
all_edge_grads[j][0] + dW_i,
|
|
351
|
+
all_edge_grads[j][1] + db_i,
|
|
352
|
+
)
|
|
353
|
+
# _cross_entropy normalise déjà par E — pas de renormalisation ici
|
|
354
|
+
|
|
355
|
+
# --- Mise à jour encodeur ---
|
|
356
|
+
if all_node_grads is not None and hasattr(self.encoder, 'update_node'):
|
|
357
|
+
self.encoder.update_node(all_node_grads, lr)
|
|
358
|
+
if all_edge_grads is not None and hasattr(self.encoder, 'update_edge'):
|
|
359
|
+
self.encoder.update_edge(all_edge_grads, lr)
|
|
360
|
+
|
|
361
|
+
# --- Gradient décodeur → R-GCN (joint training) ---
|
|
362
|
+
if (self.decoder is not None
|
|
363
|
+
and self._cached_decode_gradient is not None
|
|
364
|
+
and hasattr(self.decoder, 'backward_decode')):
|
|
365
|
+
d_mean, dec_grads = self.decoder.backward_decode(self._cached_decode_gradient)
|
|
366
|
+
self.decoder.update(dec_grads, lr)
|
|
367
|
+
# Propager d_mean vers d_enriched si les dimensions correspondent
|
|
368
|
+
if (d_mean.shape[0] == d_enriched.shape[1]
|
|
369
|
+
and self._cached_enriched_vecs is not None):
|
|
370
|
+
N_dec = len(self._cached_enriched_vecs)
|
|
371
|
+
d_enriched[:min(n, N_dec)] += d_mean[np.newaxis, :] / max(N_dec, 1)
|
|
372
|
+
|
|
373
|
+
# --- Rétropropagation R-GCN ---
|
|
374
|
+
if (self._cached_edge_index is not None
|
|
375
|
+
and self._cached_enriched_vecs is not None
|
|
376
|
+
and hasattr(self.graph, 'backward_message_pass')):
|
|
377
|
+
N_full = len(self._cached_enriched_vecs)
|
|
378
|
+
if d_enriched.shape[0] < N_full:
|
|
379
|
+
pad = np.zeros((N_full - d_enriched.shape[0], d_enriched.shape[1]), dtype=np.float32)
|
|
380
|
+
d_enriched_full = np.concatenate([d_enriched, pad], axis=0)
|
|
381
|
+
else:
|
|
382
|
+
d_enriched_full = d_enriched
|
|
383
|
+
_, graph_grads = self.graph.backward_message_pass(d_enriched_full)
|
|
384
|
+
self.graph.update(graph_grads, lr)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _softmax(x: np.ndarray) -> np.ndarray:
|
|
388
|
+
e = np.exp(x - x.max(axis=-1, keepdims=True))
|
|
389
|
+
return e / e.sum(axis=-1, keepdims=True)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _cross_entropy(
|
|
393
|
+
logits: np.ndarray, # (N, C)
|
|
394
|
+
labels: np.ndarray, # (N,) int
|
|
395
|
+
) -> tuple[float, np.ndarray]:
|
|
396
|
+
"""Cross-entropie NumPy. Retourne (loss, d_logits) normalisés par N.
|
|
397
|
+
|
|
398
|
+
Lève ValueError si labels contient des valeurs négatives (sentinelle -1 non filtrée)
|
|
399
|
+
ou hors-bornes (>= n_classes).
|
|
400
|
+
"""
|
|
401
|
+
if len(logits) == 0:
|
|
402
|
+
return 0.0, np.zeros_like(logits)
|
|
403
|
+
if len(labels) > 0 and int(labels.min()) < 0:
|
|
404
|
+
raise ValueError(
|
|
405
|
+
f"Label négatif dans _cross_entropy : min={labels.min()} "
|
|
406
|
+
f"(sentinelle -1 non filtrée ?)"
|
|
407
|
+
)
|
|
408
|
+
if len(labels) > 0 and int(labels.max()) >= logits.shape[1]:
|
|
409
|
+
raise ValueError(
|
|
410
|
+
f"Label hors-bornes dans _cross_entropy : "
|
|
411
|
+
f"max={labels.max()} >= n_classes={logits.shape[1]}"
|
|
412
|
+
)
|
|
413
|
+
N = len(logits)
|
|
414
|
+
probs = _softmax(logits) # (N, C)
|
|
415
|
+
loss = float(-np.log(probs[np.arange(N), labels] + 1e-9).mean())
|
|
416
|
+
d_logits = probs.copy()
|
|
417
|
+
d_logits[np.arange(N), labels] -= 1.0
|
|
418
|
+
d_logits /= N
|
|
419
|
+
return loss, d_logits
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from ..layer1.features import FeatureVocabulary
|
|
8
|
+
from ..layer2.reference import MLPEncoder
|
|
9
|
+
from ..layer3.reference import RGCNLayer
|
|
10
|
+
from .cgnp import CGNPipeline
|
|
11
|
+
from ..data.json_reader import load_sentences
|
|
12
|
+
from ..data.loader import reps_from_sentence
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command("gcn-forward")
|
|
16
|
+
@click.argument("dataset_path", type=click.Path(path_type=Path, exists=True))
|
|
17
|
+
@click.option("--sentence-id", default=None,
|
|
18
|
+
help="ID de la sentence dans le fichier (défaut : première)")
|
|
19
|
+
@click.option("--lang", default="fr", show_default=True, help="Code langue (fr, en, …)")
|
|
20
|
+
@click.option("--pretty/--compact", default=True, help="JSON indenté ou compact")
|
|
21
|
+
@click.option(
|
|
22
|
+
"--model-path", type=click.Path(path_type=Path), default=None,
|
|
23
|
+
help="Checkpoint .npz (produit par gcn-train). Sans ce flag : poids aléatoires.",
|
|
24
|
+
)
|
|
25
|
+
def forward_cmd(
|
|
26
|
+
dataset_path: Path,
|
|
27
|
+
sentence_id: str | None,
|
|
28
|
+
lang: str,
|
|
29
|
+
pretty: bool,
|
|
30
|
+
model_path: Path | None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Run the CGNP forward pass: dataset JSON annoté → CausalIR JSON → stdout.
|
|
34
|
+
|
|
35
|
+
DATASET_PATH doit être un fichier au format dataset GCN-NL (tokens + cir).
|
|
36
|
+
"""
|
|
37
|
+
records = load_sentences(dataset_path, lang)
|
|
38
|
+
if not records:
|
|
39
|
+
raise click.ClickException(f"Aucune sentence chargée depuis {dataset_path}")
|
|
40
|
+
|
|
41
|
+
if sentence_id:
|
|
42
|
+
rec = next((r for r in records if r.id == sentence_id), None)
|
|
43
|
+
if rec is None:
|
|
44
|
+
raise click.ClickException(
|
|
45
|
+
f"Sentence {sentence_id!r} introuvable dans {dataset_path}. "
|
|
46
|
+
f"IDs disponibles : {[r.id for r in records]}"
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
rec = records[0]
|
|
50
|
+
|
|
51
|
+
reps, valid_clause_idxs, connector_reps = reps_from_sentence(rec)
|
|
52
|
+
if not reps:
|
|
53
|
+
raise click.ClickException(
|
|
54
|
+
f"La sentence {rec.id!r} ne contient pas de tokens annotés "
|
|
55
|
+
f"(format paper_examples non supporté ici — utiliser un fichier dataset avec tokens)."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
vocab = FeatureVocabulary()
|
|
59
|
+
encoder = MLPEncoder(d_clause=vocab.d_clause, d_edge=vocab.d_edge)
|
|
60
|
+
graph = RGCNLayer(d_in=vocab.d_clause, d_out=vocab.d_clause)
|
|
61
|
+
pipeline = CGNPipeline(encoder=encoder, graph=graph, lang=lang, vocabulary=vocab)
|
|
62
|
+
|
|
63
|
+
if model_path is not None:
|
|
64
|
+
from ..training.checkpoint import load_checkpoint
|
|
65
|
+
if not model_path.exists():
|
|
66
|
+
raise click.ClickException(f"Checkpoint introuvable : {model_path}")
|
|
67
|
+
load_checkpoint(pipeline, model_path)
|
|
68
|
+
else:
|
|
69
|
+
click.echo("Avertissement : poids aléatoires (pas de --model-path)", file=sys.stderr)
|
|
70
|
+
|
|
71
|
+
result = pipeline.forward(
|
|
72
|
+
reps, rec.text,
|
|
73
|
+
clause_positions=valid_clause_idxs,
|
|
74
|
+
n_total_clauses=len(rec.clauses),
|
|
75
|
+
connector_reps=connector_reps,
|
|
76
|
+
)
|
|
77
|
+
click.echo(json.dumps(result, ensure_ascii=False, indent=2 if pretty else None))
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def emit(
|
|
5
|
+
text: str,
|
|
6
|
+
lang: str,
|
|
7
|
+
node_types: list[str],
|
|
8
|
+
node_labels: list[str],
|
|
9
|
+
token_spans: list[tuple[int, int]],
|
|
10
|
+
scopes: list[str],
|
|
11
|
+
edge_triples: list[tuple[int, int, str, float, bool, int | None]],
|
|
12
|
+
node_origins: list[str] | None = None,
|
|
13
|
+
) -> dict:
|
|
14
|
+
"""
|
|
15
|
+
Produit un dict CausalIR conforme au schéma serde Rust de gcn-ir.
|
|
16
|
+
JSON-serializable. Tous les noms en snake_case.
|
|
17
|
+
|
|
18
|
+
edge_triples : (src_idx, dst_idx, relation, confidence, negated, marker_token)
|
|
19
|
+
"""
|
|
20
|
+
if node_origins is None:
|
|
21
|
+
node_origins = ["explicit"] * len(node_types)
|
|
22
|
+
|
|
23
|
+
nodes = []
|
|
24
|
+
for i, (nt, label, span, scope, origin) in enumerate(
|
|
25
|
+
zip(node_types, node_labels, token_spans, scopes, node_origins)
|
|
26
|
+
):
|
|
27
|
+
nodes.append({
|
|
28
|
+
"id": i,
|
|
29
|
+
"node_type": nt,
|
|
30
|
+
"label": label,
|
|
31
|
+
"source_span": {"token_span": {"start": span[0], "end": span[1]}},
|
|
32
|
+
"scope": scope,
|
|
33
|
+
"modifiers": [],
|
|
34
|
+
"temporal_ref": "unresolved",
|
|
35
|
+
"temporal_index": i,
|
|
36
|
+
"origin": origin,
|
|
37
|
+
"attributes": {
|
|
38
|
+
"entity": None,
|
|
39
|
+
"quality": None,
|
|
40
|
+
"agent": None,
|
|
41
|
+
"patient": None,
|
|
42
|
+
"agent_type": None,
|
|
43
|
+
"reversible": None,
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
edges = []
|
|
48
|
+
for src, dst, relation, confidence, negated, marker_token in edge_triples:
|
|
49
|
+
edges.append([src, dst, {
|
|
50
|
+
"relation": relation,
|
|
51
|
+
"confidence": float(confidence),
|
|
52
|
+
"temporal_gap": None,
|
|
53
|
+
"explicit": marker_token is not None,
|
|
54
|
+
"negated": negated,
|
|
55
|
+
"marker_token": marker_token,
|
|
56
|
+
"in_cycle": None,
|
|
57
|
+
}])
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
"source_lang": {"natural": {"lang": lang}},
|
|
61
|
+
"source_text": text,
|
|
62
|
+
"nodes": nodes,
|
|
63
|
+
"edges": edges,
|
|
64
|
+
"cycles": [],
|
|
65
|
+
"unresolved": [],
|
|
66
|
+
"metadata": {
|
|
67
|
+
"schema_version": "1.0",
|
|
68
|
+
"pipeline": ["cgnp-layer1", "cgnp-layer2", "cgnp-layer3"],
|
|
69
|
+
"created_at": None,
|
|
70
|
+
},
|
|
71
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import yaml
|
|
4
|
+
|
|
5
|
+
from ..layer1.representation import UDRepresentation
|
|
6
|
+
|
|
7
|
+
_nom_cache: dict[tuple[str, str], dict[str, str]] = {}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_label(
|
|
11
|
+
rep: UDRepresentation,
|
|
12
|
+
node_type: str,
|
|
13
|
+
taxonomies_dir: Path | None = None,
|
|
14
|
+
) -> str:
|
|
15
|
+
"""
|
|
16
|
+
(UDRepresentation, node_type, TaxonomyIndex) → str label CIR.
|
|
17
|
+
|
|
18
|
+
Format selon node_type :
|
|
19
|
+
action → "{verb_lemma}({subject_lemma})"
|
|
20
|
+
etat/transition/ → "{nominalization}({entity})" ou "{verb}({subject})"
|
|
21
|
+
processus
|
|
22
|
+
entite/etat_system. → "{entity_lemma}"
|
|
23
|
+
condition → "cause_cachée(?)"
|
|
24
|
+
"""
|
|
25
|
+
subject = _find_subject_lemma(rep)
|
|
26
|
+
entity = _find_entity_lemma(rep)
|
|
27
|
+
nom = _nominalize(rep.root_lemma, rep.lang, taxonomies_dir)
|
|
28
|
+
|
|
29
|
+
if node_type == "condition":
|
|
30
|
+
return "hidden_cause(?)" if rep.lang == "en" else "cause_cachée(?)"
|
|
31
|
+
if node_type in ("entite", "etat_systemique"):
|
|
32
|
+
return entity or rep.root_lemma
|
|
33
|
+
if node_type == "action":
|
|
34
|
+
return f"{rep.root_lemma}({subject})" if subject else rep.root_lemma
|
|
35
|
+
# etat, transition, processus
|
|
36
|
+
if entity:
|
|
37
|
+
return f"{nom}({entity})"
|
|
38
|
+
if subject:
|
|
39
|
+
return f"{rep.root_lemma}({subject})"
|
|
40
|
+
return nom
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _find_subject_lemma(rep: UDRepresentation) -> str | None:
|
|
44
|
+
for t in rep.tokens:
|
|
45
|
+
if t.get("dep_rel") in {"nsubj", "nsubj:pass"}:
|
|
46
|
+
return t["lemma"]
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _find_entity_lemma(rep: UDRepresentation) -> str | None:
|
|
51
|
+
for t in rep.tokens:
|
|
52
|
+
if t.get("dep_rel") in {"nsubj", "nsubj:pass"} and t.get("pos") in {"NOUN", "PROPN"}:
|
|
53
|
+
return t["lemma"]
|
|
54
|
+
for t in rep.tokens:
|
|
55
|
+
if t.get("pos") in {"NOUN", "PROPN"} and t.get("dep_rel") != "punct":
|
|
56
|
+
return t["lemma"]
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _nominalize(lemma: str, lang: str, taxonomies_dir: Path | None) -> str:
|
|
61
|
+
if taxonomies_dir is None:
|
|
62
|
+
return lemma
|
|
63
|
+
cache_key = (str(taxonomies_dir), lang)
|
|
64
|
+
if cache_key not in _nom_cache:
|
|
65
|
+
_nom_cache[cache_key] = _load_nominalizations(taxonomies_dir, lang)
|
|
66
|
+
return _nom_cache[cache_key].get(lemma, lemma)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _load_nominalizations(taxonomies_dir: Path, lang: str) -> dict[str, str]:
|
|
70
|
+
table: dict[str, str] = {}
|
|
71
|
+
for search_dir in [taxonomies_dir / lang, taxonomies_dir]:
|
|
72
|
+
nom_path = search_dir / "nominalizations.yaml"
|
|
73
|
+
if nom_path.exists():
|
|
74
|
+
doc = yaml.safe_load(nom_path.read_text(encoding="utf-8"))
|
|
75
|
+
if isinstance(doc, dict):
|
|
76
|
+
examples_key = "examples_fr" if lang == "fr" else "examples"
|
|
77
|
+
for _cls, cls_data in (doc.get("classes") or {}).items():
|
|
78
|
+
for entry in (cls_data or {}).get(examples_key) or []:
|
|
79
|
+
if isinstance(entry, dict) and "lemma" in entry and "note" in entry:
|
|
80
|
+
table[entry["lemma"].lower()] = entry["note"]
|
|
81
|
+
break
|
|
82
|
+
return table
|
|
File without changes
|