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,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,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
|
+
...
|