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,74 @@
1
+ """
2
+ TaxonomyIndex: charge les fichiers YAML de taxonomies GCN et expose
3
+ l'appartenance par gcn_class_key = "taxonomy_name.class_name".
4
+
5
+ Pas de règles ici — uniquement le chargement des données.
6
+ """
7
+ from __future__ import annotations
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ import yaml
11
+
12
+
13
+ @dataclass
14
+ class TaxonomyIndex:
15
+ """
16
+ Mappe chaque gcn_class_key vers un frozenset de lemmes.
17
+ Ex: "verbes.etat" -> {"être", "avoir", "savoir", ...}
18
+
19
+ Pour une langue sans fichier → ensemble vide → feature = 0.
20
+ Le modèle apprend le poids de chaque dimension.
21
+ """
22
+ data: dict[str, frozenset[str]] = field(default_factory=dict)
23
+
24
+ @classmethod
25
+ def load(cls, taxonomies_dir: Path, lang_code: str = "fr") -> "TaxonomyIndex":
26
+ """
27
+ Charge les taxonomies depuis:
28
+ 1. taxonomies_dir/{lang_code}/ (spécifiques à la langue)
29
+ 2. taxonomies_dir/ (partagées, fallback)
30
+ """
31
+ index: dict[str, frozenset[str]] = {}
32
+
33
+ dirs_to_scan = []
34
+ lang_dir = taxonomies_dir / lang_code
35
+ if lang_dir.is_dir():
36
+ dirs_to_scan.append(lang_dir)
37
+ if taxonomies_dir.is_dir():
38
+ dirs_to_scan.append(taxonomies_dir)
39
+
40
+ for scan_dir in dirs_to_scan:
41
+ for yaml_path in sorted(scan_dir.glob("*.yaml")):
42
+ try:
43
+ doc = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
44
+ except Exception:
45
+ continue
46
+ if not isinstance(doc, dict) or "taxonomy" not in doc:
47
+ continue
48
+ tax_name = doc["taxonomy"]
49
+ classes = doc.get("classes") or {}
50
+ for class_name, class_data in classes.items():
51
+ key = f"{tax_name}.{class_name}"
52
+ if key in index:
53
+ continue # lang-specific already loaded
54
+ lemmas: set[str] = set()
55
+ examples = (class_data or {}).get("examples_fr") or []
56
+ for entry in examples:
57
+ if isinstance(entry, dict) and "lemma" in entry:
58
+ lemmas.add(entry["lemma"].lower().strip())
59
+ elif isinstance(entry, str):
60
+ lemmas.add(entry.lower().strip())
61
+ index[key] = frozenset(lemmas)
62
+
63
+ return cls(data=index)
64
+
65
+ def membership(self, lemma: str) -> dict[str, bool]:
66
+ """Retourne {gcn_class_key: True/False} pour un lemme."""
67
+ lower = lemma.lower().strip()
68
+ return {key: lower in lemmas for key, lemmas in self.data.items()}
69
+
70
+ def keys(self) -> list[str]:
71
+ return sorted(self.data.keys())
72
+
73
+ def __len__(self) -> int:
74
+ return len(self.data)
File without changes
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+
10
+ @click.command("gcn-bootstrap")
11
+ @click.option("--input", "input_file", required=True, type=click.Path(path_type=Path),
12
+ help="Fichier texte (.txt) — une phrase par ligne")
13
+ @click.option("--lang", default="fr", show_default=True)
14
+ @click.option("--out-dir", required=True, type=click.Path(path_type=Path),
15
+ help="Répertoire de sortie pour les fichiers JSON générés")
16
+ @click.option("--taxonomy-dir", default=None, type=click.Path(path_type=Path),
17
+ envvar="GCN_TAXONOMY_DIR",
18
+ help="Répertoire des taxonomies (ou env GCN_TAXONOMY_DIR)")
19
+ @click.option("--gcn-bin", default="gcn", show_default=True,
20
+ help="Chemin vers le binaire gcn-cli Rust")
21
+ def bootstrap_cmd(
22
+ input_file: Path, lang: str, out_dir: Path,
23
+ taxonomy_dir: Path | None, gcn_bin: str,
24
+ ) -> None:
25
+ """Génère des données d'entraînement JSON depuis des phrases brutes via gcn-cli Rust.
26
+
27
+ Appelle `gcn analyze <texte>` pour chaque ligne, convertit le CausalIR JSON
28
+ produit au format gcn-nl (document.sentences), et écrit les fichiers dans out-dir.
29
+ """
30
+ out_dir.mkdir(parents=True, exist_ok=True)
31
+ texts = [l.strip() for l in input_file.read_text("utf-8").splitlines() if l.strip()]
32
+
33
+ if not texts:
34
+ raise click.ClickException(f"Aucune phrase dans {input_file}")
35
+
36
+ click.echo(f"Génération de {len(texts)} exemples vers {out_dir} ...")
37
+ success = 0
38
+ errors = 0
39
+
40
+ for i, text in enumerate(texts):
41
+ try:
42
+ cmd_args = [gcn_bin, "analyze", text]
43
+ if taxonomy_dir:
44
+ cmd_args += ["--data-dir", str(taxonomy_dir)]
45
+ result = subprocess.run(
46
+ cmd_args,
47
+ capture_output=True, text=True, timeout=30,
48
+ )
49
+ if result.returncode != 0:
50
+ click.echo(f" [{i+1}] Erreur gcn-cli : {result.stderr.strip()}", err=True)
51
+ errors += 1
52
+ continue
53
+
54
+ cir = json.loads(result.stdout)
55
+ doc = _cir_to_doc(text, lang, cir)
56
+ out_path = out_dir / f"generated_{i+1:04d}.json"
57
+ out_path.write_text(
58
+ json.dumps(doc, ensure_ascii=False, indent=2),
59
+ encoding="utf-8",
60
+ )
61
+ success += 1
62
+ except subprocess.TimeoutExpired:
63
+ click.echo(f" [{i+1}] Timeout", err=True)
64
+ errors += 1
65
+ except (json.JSONDecodeError, KeyError) as exc:
66
+ click.echo(f" [{i+1}] Parse error: {exc}", err=True)
67
+ errors += 1
68
+
69
+ click.echo(f"Terminé : {success} succès, {errors} erreurs.")
70
+
71
+
72
+ def _cir_to_doc(text: str, lang: str, cir: dict) -> dict:
73
+ """Convertit un CausalIR dict (format Rust/JSON) en document JSON gcn-nl."""
74
+ nodes = cir.get("nodes", [])
75
+ edges = cir.get("edges", [])
76
+
77
+ doc_nodes = [
78
+ {
79
+ "id": n.get("id", f"n{i+1:03d}"),
80
+ "type": n.get("node_type", "action"),
81
+ "label": n.get("label", ""),
82
+ "token_span": list(n.get("token_span", [0, 0])),
83
+ "scope": n.get("scope", "specific"),
84
+ "temporal_index": n.get("temporal_index", 0),
85
+ "origin": n.get("origin", "explicit"),
86
+ }
87
+ for i, n in enumerate(nodes)
88
+ ]
89
+
90
+ doc_edges = [
91
+ {
92
+ "source": e.get("source", ""),
93
+ "target": e.get("target", ""),
94
+ "relation": e.get("relation_type", e.get("relation", "cause")),
95
+ "confidence": float(e.get("confidence", 1.0)),
96
+ "explicit": bool(e.get("explicit", True)),
97
+ "negated": bool(e.get("negated", False)),
98
+ }
99
+ for e in edges
100
+ ]
101
+
102
+ return {
103
+ "document": {
104
+ "lang": lang,
105
+ "sentences": [
106
+ {
107
+ "id": "s001",
108
+ "text": text,
109
+ "tokens": [],
110
+ "cir": {
111
+ "nodes": doc_nodes,
112
+ "edges": doc_edges,
113
+ },
114
+ }
115
+ ],
116
+ }
117
+ }
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+ from pathlib import Path
3
+ import numpy as np
4
+
5
+ from ..pipeline.cgnp import CGNPipeline
6
+ from ..layer1.features import FeatureVocabulary
7
+
8
+
9
+ def save_checkpoint(pipeline: CGNPipeline, path: Path) -> None:
10
+ """Sérialise tous les poids du pipeline dans un fichier .npz."""
11
+ arrays: dict[str, np.ndarray] = {}
12
+
13
+ encoder_params = pipeline.encoder.parameters()
14
+ for i, p in enumerate(encoder_params):
15
+ arrays[f"encoder_{i}"] = p
16
+
17
+ graph_params = pipeline.graph.parameters()
18
+ for i, p in enumerate(graph_params):
19
+ arrays[f"graph_{i}"] = p
20
+
21
+ vocab_json = pipeline.vocabulary.to_json()
22
+ arrays["_vocab_json"] = np.array([vocab_json], dtype=object)
23
+
24
+ if pipeline.decoder is not None and hasattr(pipeline.decoder, 'parameters'):
25
+ decoder_params = pipeline.decoder.parameters()
26
+ for i, p in enumerate(decoder_params):
27
+ arrays[f"decoder_{i}"] = p
28
+ if hasattr(pipeline.decoder, 'to_json'):
29
+ arrays["_decoder_meta_json"] = np.array([pipeline.decoder.to_json()], dtype=object)
30
+
31
+ np.savez(path, **arrays)
32
+
33
+
34
+ def load_checkpoint(pipeline: CGNPipeline, path: Path) -> None:
35
+ """Restaure les poids depuis un fichier .npz produit par save_checkpoint.
36
+
37
+ Atomique : toutes les shapes sont validées avant toute mutation du pipeline.
38
+ Un ValueError laisse le pipeline intact (vocabulary, encoder et graph inchangés).
39
+ """
40
+ data = np.load(path, allow_pickle=True)
41
+
42
+ new_vocab = None
43
+ if "_vocab_json" in data:
44
+ new_vocab = FeatureVocabulary.from_json(str(data["_vocab_json"][0]))
45
+
46
+ # Validation de toutes les formes avant toute mutation
47
+ encoder_params = pipeline.encoder.parameters()
48
+ for i, p in enumerate(encoder_params):
49
+ key = f"encoder_{i}"
50
+ if key in data and data[key].shape != p.shape:
51
+ raise ValueError(
52
+ f"Incompatibilité de dimension pour encoder_{i} : "
53
+ f"checkpoint={data[key].shape} ≠ pipeline={p.shape}. "
54
+ f"Reconstruisez le pipeline avec la même taxonomie que le checkpoint."
55
+ )
56
+
57
+ graph_params = pipeline.graph.parameters()
58
+ for i, p in enumerate(graph_params):
59
+ key = f"graph_{i}"
60
+ if key in data and data[key].shape != p.shape:
61
+ raise ValueError(
62
+ f"Incompatibilité de dimension pour graph_{i} : "
63
+ f"checkpoint={data[key].shape} ≠ pipeline={p.shape}."
64
+ )
65
+
66
+ # Toutes les formes validées — mutation sûre
67
+ if new_vocab is not None:
68
+ pipeline.vocabulary = new_vocab
69
+
70
+ for i, p in enumerate(encoder_params):
71
+ key = f"encoder_{i}"
72
+ if key in data:
73
+ p[:] = data[key]
74
+
75
+ for i, p in enumerate(graph_params):
76
+ key = f"graph_{i}"
77
+ if key in data:
78
+ p[:] = data[key]
79
+
80
+ if "_decoder_meta_json" in data:
81
+ from ..verbalizer.trainable import TrainableDecoder
82
+ decoder = TrainableDecoder.from_json(str(data["_decoder_meta_json"][0]))
83
+ decoder_params = decoder.parameters()
84
+ for i, p in enumerate(decoder_params):
85
+ key = f"decoder_{i}"
86
+ if key in data:
87
+ if data[key].shape != p.shape:
88
+ raise ValueError(
89
+ f"Incompatibilité de dimension pour decoder_{i} : "
90
+ f"checkpoint={data[key].shape} ≠ decoder={p.shape}."
91
+ )
92
+ p[:] = data[key]
93
+ pipeline.decoder = decoder
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+ import csv
3
+ import json
4
+ import warnings
5
+ from pathlib import Path
6
+
7
+ import click
8
+ import numpy as np
9
+
10
+ from ..layer1.features import FeatureVocabulary
11
+ from ..layer2.reference import MLPEncoder
12
+ from ..layer3.reference import RGCNLayer
13
+ from ..pipeline.cgnp import CGNPipeline
14
+ from ..data.loader import GCNDataLoader, reps_from_sentence
15
+ from ..constants import NODE_TYPES, RELATION_TYPES
16
+ from ..evaluation.metrics import node_accuracy, node_macro_f1, edge_accuracy, edge_macro_f1
17
+ from ..evaluation.recorder import TrainingRecorder
18
+ from .checkpoint import save_checkpoint
19
+
20
+
21
+ @click.command("gcn-train")
22
+ @click.option("--data-dir", required=True, type=click.Path(path_type=Path),
23
+ help="Répertoire contenant les fichiers JSON d'entraînement")
24
+ @click.option("--lang", default="fr", show_default=True)
25
+ @click.option("--epochs", default=50, show_default=True, type=int)
26
+ @click.option("--lr", default=0.001, show_default=True, type=float)
27
+ @click.option("--output", default="model.npz", show_default=True,
28
+ type=click.Path(path_type=Path), help="Chemin du checkpoint de sortie")
29
+ @click.option("--log-csv", default=None, type=click.Path(path_type=Path),
30
+ help="CSV des métriques par epoch (optionnel)")
31
+ @click.option("--verbalize-dir", default=None, type=click.Path(path_type=Path),
32
+ help="Répertoire contenant les paires verbalize JSON (optionnel, active l'entraînement conjoint)")
33
+ def train_cmd(
34
+ data_dir: Path,
35
+ lang: str,
36
+ epochs: int,
37
+ lr: float,
38
+ output: Path,
39
+ log_csv: Path | None,
40
+ verbalize_dir: Path | None,
41
+ ) -> None:
42
+ """Entraîne le pipeline CGNP (NumPy référence) par descente de gradient."""
43
+ from ..data.verbalize_loader import VerbalizerDataLoader
44
+ from ..verbalizer.trainable import TrainableDecoder
45
+
46
+ vocab = FeatureVocabulary()
47
+ encoder = MLPEncoder(d_clause=vocab.d_clause, d_edge=vocab.d_edge)
48
+ graph = RGCNLayer(d_in=vocab.d_clause, d_out=vocab.d_clause)
49
+
50
+ verb_loader: VerbalizerDataLoader | None = None
51
+ verb_source_map: dict[str, list] = {}
52
+ decoder: TrainableDecoder | None = None
53
+ if verbalize_dir is not None:
54
+ verb_loader = VerbalizerDataLoader(verbalize_dir)
55
+ if len(verb_loader) == 0:
56
+ raise click.ClickException(f"Aucune paire verbalize dans {verbalize_dir}")
57
+ decoder = TrainableDecoder(verb_loader.vocab)
58
+ verb_source_map = verb_loader.source_text_map()
59
+ click.echo(f"Verbalize : {len(verb_loader)} paires | vocab={len(verb_loader.vocab)} tokens")
60
+
61
+ pipeline = CGNPipeline(encoder=encoder, graph=graph, lang=lang, vocabulary=vocab,
62
+ decoder=decoder)
63
+
64
+ loader = GCNDataLoader(data_dir, lang=lang)
65
+ if len(loader) == 0:
66
+ raise click.ClickException(f"Aucune sentence dans {data_dir}")
67
+
68
+ click.echo(f"Données : {len(loader)} sentences | epochs={epochs} lr={lr}")
69
+
70
+ recorder = TrainingRecorder()
71
+ history: list[dict] = []
72
+ csv_writer = None
73
+ csv_file = None
74
+ if log_csv:
75
+ csv_file = open(log_csv, "w", newline="", encoding="utf-8")
76
+ csv_writer = csv.DictWriter(
77
+ csv_file,
78
+ fieldnames=["epoch", "loss", "node_accuracy", "node_macro_f1",
79
+ "edge_accuracy", "edge_macro_f1"],
80
+ )
81
+ csv_writer.writeheader()
82
+
83
+ try:
84
+ for epoch in range(1, epochs + 1):
85
+ epoch_loss = 0.0
86
+ n_samples = 0
87
+ epoch_node_preds: list[str] = []
88
+ epoch_node_gold: list[str] = []
89
+ epoch_edge_preds: list[str] = []
90
+ epoch_edge_gold: list[str] = []
91
+
92
+ for sample in loader:
93
+ if not sample.sentence.clauses:
94
+ continue
95
+
96
+ try:
97
+ reps, valid_clause_idxs, connector_reps = reps_from_sentence(sample.sentence)
98
+ if not reps:
99
+ continue
100
+ pipeline.forward(
101
+ reps, sample.sentence.text,
102
+ clause_positions=valid_clause_idxs,
103
+ n_total_clauses=len(sample.sentence.clauses),
104
+ connector_reps=connector_reps,
105
+ )
106
+ except ValueError:
107
+ raise # misconfiguration (d_out, clause_positions…) — non ignorable
108
+ except Exception as exc:
109
+ warnings.warn(
110
+ f"[{sample.sentence.id}] forward ignoré : "
111
+ f"{type(exc).__name__}: {exc}",
112
+ UserWarning, stacklevel=2,
113
+ )
114
+ continue
115
+
116
+ node_logits = pipeline._cached_node_logits
117
+ edge_logits = pipeline._cached_edge_logits
118
+ if node_logits is None or len(node_logits) == 0:
119
+ continue
120
+
121
+ # Aligner les gold labels sur les seules clauses converties en reps
122
+ if valid_clause_idxs:
123
+ gold_node = sample.gold_node_labels[
124
+ np.array(valid_clause_idxs, dtype=np.int64)
125
+ ]
126
+ else:
127
+ gold_node = sample.gold_node_labels
128
+ # Aligner les gold edges sur les paires consécutives prédites via edge_map
129
+ if (valid_clause_idxs and len(valid_clause_idxs) >= 2
130
+ and sample.edge_map
131
+ and edge_logits is not None and len(edge_logits) > 0):
132
+ pairs = [
133
+ (valid_clause_idxs[k], valid_clause_idxs[k + 1])
134
+ for k in range(len(valid_clause_idxs) - 1)
135
+ ]
136
+ gold_edge_full = np.array(
137
+ [sample.edge_map.get(p, -1) for p in pairs], dtype=np.int64
138
+ )
139
+ valid_edge_mask = gold_edge_full >= 0
140
+ if valid_edge_mask.any():
141
+ valid_edge_idxs = np.where(valid_edge_mask)[0]
142
+ gold_edge = gold_edge_full[valid_edge_idxs]
143
+ edge_logits_arg = edge_logits[valid_edge_idxs]
144
+ pipeline.filter_edge_cache(valid_edge_idxs)
145
+ else:
146
+ gold_edge = None
147
+ edge_logits_arg = None
148
+ else:
149
+ gold_edge = None
150
+ edge_logits_arg = None
151
+
152
+ # Joint training : chercher une surface gold pour ce sample
153
+ _gold_surface = None
154
+ if verb_source_map:
155
+ _surfaces = verb_source_map.get(sample.sentence.text, [])
156
+ if _surfaces:
157
+ _gold_surface = _surfaces[0]
158
+
159
+ loss_val, d_node, d_edge = pipeline.loss(
160
+ node_logits, edge_logits_arg, gold_node, gold_edge,
161
+ gold_surface=_gold_surface,
162
+ )
163
+
164
+ # Backward
165
+ pipeline.backward(d_node, d_edge, lr=lr)
166
+
167
+ # Accumuler les prédictions pour les métriques de l'époque
168
+ node_pred_idxs = np.argmax(node_logits, axis=1)
169
+ if valid_clause_idxs:
170
+ gold_node_aligned = sample.gold_node_labels[
171
+ np.array(valid_clause_idxs, dtype=np.int64)
172
+ ]
173
+ else:
174
+ gold_node_aligned = sample.gold_node_labels
175
+ epoch_node_preds.extend(NODE_TYPES[i] for i in node_pred_idxs)
176
+ epoch_node_gold.extend(NODE_TYPES[i] for i in gold_node_aligned)
177
+
178
+ if gold_edge is not None and edge_logits_arg is not None and len(edge_logits_arg) > 0:
179
+ edge_pred_idxs = np.argmax(edge_logits_arg, axis=1)
180
+ epoch_edge_preds.extend(RELATION_TYPES[i] for i in edge_pred_idxs)
181
+ epoch_edge_gold.extend(RELATION_TYPES[i] for i in gold_edge)
182
+
183
+ epoch_loss += loss_val
184
+ n_samples += 1
185
+
186
+ # Entraînement standalone du décodeur sur les paires verbalize
187
+ if verb_loader is not None and pipeline.decoder is not None:
188
+ for vsample in verb_loader:
189
+ if len(vsample.gold_tokens) == 0:
190
+ continue
191
+ dec_logits = pipeline.decoder.forward_decode(vsample.node_type_embeddings)
192
+ dec_loss, d_dec = pipeline.decoder.loss_decode(dec_logits, vsample.gold_tokens)
193
+ if not np.isfinite(dec_loss):
194
+ continue
195
+ _, dec_grads = pipeline.decoder.backward_decode(d_dec)
196
+ pipeline.decoder.update(dec_grads, lr)
197
+ epoch_loss += dec_loss
198
+ n_samples += 1
199
+
200
+ avg_loss = epoch_loss / max(n_samples, 1)
201
+ metrics = {
202
+ "node_accuracy": node_accuracy(epoch_node_preds, epoch_node_gold),
203
+ "node_macro_f1": node_macro_f1(epoch_node_preds, epoch_node_gold),
204
+ "edge_accuracy": edge_accuracy(epoch_edge_preds, epoch_edge_gold),
205
+ "edge_macro_f1": edge_macro_f1(epoch_edge_preds, epoch_edge_gold),
206
+ }
207
+ recorder.record(epoch, avg_loss, metrics)
208
+ history.append({"epoch": epoch, "loss": avg_loss, **metrics})
209
+
210
+ if csv_writer:
211
+ csv_writer.writerow({"epoch": epoch, "loss": avg_loss, **metrics})
212
+
213
+ if epoch % max(1, epochs // 10) == 0 or epoch == 1:
214
+ click.echo(
215
+ f"Epoch {epoch:4d}/{epochs} loss={avg_loss:.4f}"
216
+ f" node_acc={metrics['node_accuracy']:.3f}"
217
+ f" edge_acc={metrics['edge_accuracy']:.3f}"
218
+ )
219
+ finally:
220
+ if csv_file:
221
+ csv_file.close()
222
+
223
+ save_checkpoint(pipeline, output)
224
+ click.echo(f"Checkpoint sauvegardé : {output}")
225
+
226
+ if log_csv:
227
+ json_path = Path(log_csv).with_suffix(".json")
228
+ recorder.to_json(json_path)
229
+ click.echo(f"Courbe d'entraînement : {json_path}")
230
+
231
+ # Vérification : la loss doit décroître sur les 10 dernières epochs
232
+ if len(history) >= 10:
233
+ first = sum(r["loss"] for r in history[:5]) / 5
234
+ last = sum(r["loss"] for r in history[-5:]) / 5
235
+ if last < first:
236
+ click.echo(f"Loss décroissante : {first:.4f} → {last:.4f} ✓")
237
+ else:
238
+ click.echo(f"Avertissement : loss non décroissante ({first:.4f} → {last:.4f})")
@@ -0,0 +1,4 @@
1
+ from .decoder import ReferenceDecoder
2
+ from .interface import VerbalizerDecoder
3
+
4
+ __all__ = ["ReferenceDecoder", "VerbalizerDecoder"]
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+ import sys
3
+ import click
4
+ from .decoder import ReferenceDecoder
5
+
6
+
7
+ @click.command("gcn-verbalize")
8
+ @click.argument("ir", type=click.File("r"), default="-")
9
+ def verbalize_cmd(ir: click.File) -> None:
10
+ """
11
+ Decode a CausalIR JSON to a surface form.
12
+
13
+ IR: path to a CausalIR JSON file, or '-' to read from stdin.
14
+
15
+ What the decoder produces depends on its training data.
16
+ No explicit parameter needed.
17
+ """
18
+ ir_json = ir.read()
19
+ decoder = ReferenceDecoder()
20
+ try:
21
+ result = decoder.decode(ir_json)
22
+ except Exception as exc:
23
+ click.echo(f"error: {exc}", err=True)
24
+ sys.exit(1)
25
+ click.echo(result)
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+ import json
3
+
4
+
5
+ class ReferenceDecoder:
6
+ """
7
+ Reference verbalizer decoder — format-agnostic linearization placeholder.
8
+
9
+ Implements the VerbalizerDecoder protocol.
10
+ Replace with a trained model: the trained model learns what to produce
11
+ from its training data — no output format is presupposed here.
12
+
13
+ This reference produces a structural linearization of the CausalIR graph:
14
+ one fragment per edge, joined by newlines. It makes no assumption about
15
+ whether the output should be natural language, code, or any other form.
16
+ """
17
+
18
+ def decode(self, ir_json: str) -> str:
19
+ ir = json.loads(ir_json)
20
+ nodes: list[dict] = ir.get("nodes", [])
21
+ edges: list[list] = ir.get("edges", [])
22
+
23
+ node_map: dict[int, str] = {n["id"]: n["label"] for n in nodes}
24
+
25
+ fragments: list[str] = []
26
+ for edge_tuple in edges:
27
+ src_id, dst_id, edge = edge_tuple
28
+ src = node_map.get(src_id, str(src_id))
29
+ dst = node_map.get(dst_id, str(dst_id))
30
+ relation: str = edge.get("relation", "?")
31
+ negated: bool = edge.get("negated", False)
32
+ neg = "¬" if negated else ""
33
+ fragments.append(f"{src} -{neg}[{relation}]-> {dst}")
34
+
35
+ if not fragments:
36
+ return " | ".join(n["label"] for n in nodes)
37
+
38
+ return "\n".join(fragments)
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+ from typing import Protocol, runtime_checkable
3
+
4
+
5
+ @runtime_checkable
6
+ class VerbalizerDecoder(Protocol):
7
+ """
8
+ Protocol for verbalizer decoders: CausalIR JSON → surface string.
9
+
10
+ The reference implementation is ReferenceDecoder (linearization placeholder).
11
+ Replace with a trained model: what the model produces depends on its training data.
12
+ """
13
+
14
+ def decode(self, ir_json: str) -> str:
15
+ """
16
+ Decode a CausalIR JSON string into a surface form.
17
+
18
+ What the decoder produces depends entirely on its training data.
19
+ No output format is presupposed by the architecture.
20
+ """
21
+ ...