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.
Files changed (41) hide show
  1. gcn_python/__init__.py +2 -0
  2. gcn_python/constants.py +28 -0
  3. gcn_python/data/__init__.py +0 -0
  4. gcn_python/data/json_reader.py +124 -0
  5. gcn_python/data/loader.py +212 -0
  6. gcn_python/data/schema.py +50 -0
  7. gcn_python/data/verbalize_loader.py +96 -0
  8. gcn_python/evaluation/__init__.py +0 -0
  9. gcn_python/evaluation/eval_runner.py +123 -0
  10. gcn_python/evaluation/metrics.py +257 -0
  11. gcn_python/evaluation/recorder.py +158 -0
  12. gcn_python/layer1/__init__.py +0 -0
  13. gcn_python/layer1/features.py +126 -0
  14. gcn_python/layer1/representation.py +37 -0
  15. gcn_python/layer2/__init__.py +0 -0
  16. gcn_python/layer2/interface.py +52 -0
  17. gcn_python/layer2/reference.py +159 -0
  18. gcn_python/layer3/__init__.py +0 -0
  19. gcn_python/layer3/interface.py +32 -0
  20. gcn_python/layer3/pytorch_rgcn.py +190 -0
  21. gcn_python/layer3/reference.py +102 -0
  22. gcn_python/pipeline/__init__.py +0 -0
  23. gcn_python/pipeline/cgnp.py +419 -0
  24. gcn_python/pipeline/cli.py +77 -0
  25. gcn_python/pipeline/ir_emitter.py +71 -0
  26. gcn_python/pipeline/label_builder.py +82 -0
  27. gcn_python/taxonomy/__init__.py +0 -0
  28. gcn_python/taxonomy/loader.py +74 -0
  29. gcn_python/training/__init__.py +0 -0
  30. gcn_python/training/bootstrap.py +117 -0
  31. gcn_python/training/checkpoint.py +93 -0
  32. gcn_python/training/train.py +238 -0
  33. gcn_python/verbalizer/__init__.py +4 -0
  34. gcn_python/verbalizer/cli.py +25 -0
  35. gcn_python/verbalizer/decoder.py +38 -0
  36. gcn_python/verbalizer/interface.py +21 -0
  37. gcn_python/verbalizer/trainable.py +204 -0
  38. gcn_python-1.0.0.dist-info/METADATA +12 -0
  39. gcn_python-1.0.0.dist-info/RECORD +41 -0
  40. gcn_python-1.0.0.dist-info/WHEEL +4 -0
  41. 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