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,257 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Métriques d'évaluation CGNP — NumPy pur, framework-agnostique.
|
|
3
|
+
|
|
4
|
+
Le data scientist appelle ces fonctions après chaque epoch pour
|
|
5
|
+
mesurer la qualité des prédictions. Aucune dépendance à PyTorch/sklearn.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import numpy as np
|
|
9
|
+
from ..constants import NODE_TYPES, RELATION_TYPES
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Types de base
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
Predictions = list[str] # valeurs snake_case ex: ["action", "processus"]
|
|
17
|
+
GoldLabels = list[str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Métriques nœuds
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
def node_accuracy(pred: Predictions, gold: GoldLabels) -> float:
|
|
25
|
+
"""Exactitude globale sur la prédiction des NodeType."""
|
|
26
|
+
if not gold:
|
|
27
|
+
return 0.0
|
|
28
|
+
correct = sum(p == g for p, g in zip(pred, gold))
|
|
29
|
+
return correct / len(gold)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def node_f1_per_class(
|
|
33
|
+
pred: Predictions, gold: GoldLabels
|
|
34
|
+
) -> dict[str, dict[str, float]]:
|
|
35
|
+
"""
|
|
36
|
+
F1, précision, rappel par NodeType.
|
|
37
|
+
|
|
38
|
+
Retourne :
|
|
39
|
+
{"action": {"precision": 0.9, "recall": 0.8, "f1": 0.85, "support": 12}, …}
|
|
40
|
+
"""
|
|
41
|
+
return _f1_per_class(pred, gold, NODE_TYPES)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def node_macro_f1(pred: Predictions, gold: GoldLabels) -> float:
|
|
45
|
+
"""F1 macro-moyenné sur tous les NodeType présents dans gold."""
|
|
46
|
+
per_class = node_f1_per_class(pred, gold)
|
|
47
|
+
scores = [v["f1"] for v in per_class.values() if v["support"] > 0]
|
|
48
|
+
return float(np.mean(scores)) if scores else 0.0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Métriques arêtes
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def edge_accuracy(pred: Predictions, gold: GoldLabels) -> float:
|
|
56
|
+
"""Exactitude globale sur la prédiction des RelationType."""
|
|
57
|
+
if not gold:
|
|
58
|
+
return 0.0
|
|
59
|
+
correct = sum(p == g for p, g in zip(pred, gold))
|
|
60
|
+
return correct / len(gold)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def edge_f1_per_class(
|
|
64
|
+
pred: Predictions, gold: GoldLabels
|
|
65
|
+
) -> dict[str, dict[str, float]]:
|
|
66
|
+
"""F1, précision, rappel par RelationType."""
|
|
67
|
+
return _f1_per_class(pred, gold, RELATION_TYPES)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def edge_macro_f1(pred: Predictions, gold: GoldLabels) -> float:
|
|
71
|
+
per_class = edge_f1_per_class(pred, gold)
|
|
72
|
+
scores = [v["f1"] for v in per_class.values() if v["support"] > 0]
|
|
73
|
+
return float(np.mean(scores)) if scores else 0.0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Similarité de graphe causal
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def causal_graph_similarity(pred_ir: dict, gold_ir: dict) -> dict[str, float]:
|
|
81
|
+
"""
|
|
82
|
+
Mesure la similarité entre deux CausalIR (dicts JSON).
|
|
83
|
+
|
|
84
|
+
Retourne :
|
|
85
|
+
{
|
|
86
|
+
"node_count_ratio": float, # |pred_nodes| / |gold_nodes|
|
|
87
|
+
"node_type_accuracy": float, # % de nœuds avec le bon type (par position)
|
|
88
|
+
"edge_count_ratio": float,
|
|
89
|
+
"edge_relation_accuracy": float,
|
|
90
|
+
"overall": float, # moyenne des 4 mesures
|
|
91
|
+
}
|
|
92
|
+
"""
|
|
93
|
+
pred_nodes = pred_ir.get("nodes", [])
|
|
94
|
+
gold_nodes = gold_ir.get("nodes", [])
|
|
95
|
+
pred_edges = pred_ir.get("edges", [])
|
|
96
|
+
gold_edges = gold_ir.get("edges", [])
|
|
97
|
+
|
|
98
|
+
# Node count ratio (capped at 1.0)
|
|
99
|
+
node_count_ratio = (
|
|
100
|
+
min(len(pred_nodes), len(gold_nodes)) / max(len(gold_nodes), 1)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Node type accuracy (align by position)
|
|
104
|
+
n = min(len(pred_nodes), len(gold_nodes))
|
|
105
|
+
node_type_acc = (
|
|
106
|
+
sum(pred_nodes[i]["node_type"] == gold_nodes[i]["node_type"] for i in range(n)) / max(n, 1)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Edge count ratio
|
|
110
|
+
edge_count_ratio = (
|
|
111
|
+
min(len(pred_edges), len(gold_edges)) / max(len(gold_edges), 1)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Edge relation accuracy (align by position)
|
|
115
|
+
e = min(len(pred_edges), len(gold_edges))
|
|
116
|
+
edge_rel_acc = (
|
|
117
|
+
sum(pred_edges[i][2]["relation"] == gold_edges[i][2]["relation"] for i in range(e))
|
|
118
|
+
/ max(e, 1)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
overall = float(np.mean([node_count_ratio, node_type_acc,
|
|
122
|
+
edge_count_ratio, edge_rel_acc]))
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"node_count_ratio": round(node_count_ratio, 4),
|
|
126
|
+
"node_type_accuracy": round(node_type_acc, 4),
|
|
127
|
+
"edge_count_ratio": round(edge_count_ratio, 4),
|
|
128
|
+
"edge_relation_accuracy": round(edge_rel_acc, 4),
|
|
129
|
+
"overall": round(overall, 4),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# Métriques décodeur (CausalIR → texte)
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def decoder_causal_fidelity(decoded_ir: dict, gold_ir: dict) -> dict[str, float]:
|
|
138
|
+
"""
|
|
139
|
+
Fidélité causale du décodeur : compare le CausalIR obtenu en re-parsant
|
|
140
|
+
la sortie du décodeur avec le CausalIR gold d'origine.
|
|
141
|
+
|
|
142
|
+
Le data scientist appelle cette fonction après avoir re-parsé la surface
|
|
143
|
+
générée par le décodeur. Utilise causal_graph_similarity en interne.
|
|
144
|
+
|
|
145
|
+
Retourne les mêmes clés que causal_graph_similarity + "causal_fidelity"
|
|
146
|
+
(alias de "overall" pour clarté sémantique).
|
|
147
|
+
"""
|
|
148
|
+
sim = causal_graph_similarity(decoded_ir, gold_ir)
|
|
149
|
+
sim["causal_fidelity"] = sim["overall"]
|
|
150
|
+
return sim
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cross_modal_consistency(ir_a: dict, ir_b: dict) -> dict[str, float]:
|
|
154
|
+
"""
|
|
155
|
+
Cohérence cross-modale : mesure si deux CausalIR issus de surfaces
|
|
156
|
+
différentes (ex: fr + python) encodent la même structure causale.
|
|
157
|
+
|
|
158
|
+
Les deux IR doivent avoir été produits depuis le même graphe causal.
|
|
159
|
+
Un score "consistency" proche de 1.0 indique que les deux surfaces
|
|
160
|
+
encodent fidèlement la même structure.
|
|
161
|
+
|
|
162
|
+
Retourne les mêmes clés que causal_graph_similarity + "consistency".
|
|
163
|
+
"""
|
|
164
|
+
sim = causal_graph_similarity(ir_a, ir_b)
|
|
165
|
+
sim["consistency"] = sim["overall"]
|
|
166
|
+
return sim
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def roundtrip_similarity(source_ir: dict, decoded_ir: dict) -> dict[str, float]:
|
|
170
|
+
"""
|
|
171
|
+
Similarité round-trip : mesure la fidélité du cycle complet
|
|
172
|
+
texte → CausalIR → texte → CausalIR.
|
|
173
|
+
|
|
174
|
+
source_ir : CausalIR produit par l'encodeur depuis le texte original
|
|
175
|
+
decoded_ir : CausalIR produit par l'encodeur depuis le texte généré
|
|
176
|
+
par le décodeur
|
|
177
|
+
|
|
178
|
+
Un score "roundtrip" proche de 1.0 indique que l'architecture
|
|
179
|
+
encodeur-décodeur préserve la structure causale.
|
|
180
|
+
"""
|
|
181
|
+
sim = causal_graph_similarity(decoded_ir, source_ir)
|
|
182
|
+
sim["roundtrip"] = sim["overall"]
|
|
183
|
+
return sim
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def generation_bleu(hypothesis: str, references: list[str], max_n: int = 4) -> float:
|
|
187
|
+
"""
|
|
188
|
+
BLEU score simplifié — NumPy pur, sans dépendance externe.
|
|
189
|
+
|
|
190
|
+
Mesure la qualité de surface de la sortie du décodeur par rapport
|
|
191
|
+
aux surfaces gold du dataset. Utile pour les surfaces en langage naturel.
|
|
192
|
+
Pour le code source, préférer decoder_causal_fidelity.
|
|
193
|
+
"""
|
|
194
|
+
import math
|
|
195
|
+
from collections import Counter
|
|
196
|
+
|
|
197
|
+
hyp = hypothesis.split()
|
|
198
|
+
refs = [r.split() for r in references]
|
|
199
|
+
|
|
200
|
+
if not hyp or not refs:
|
|
201
|
+
return 0.0
|
|
202
|
+
|
|
203
|
+
hyp_len = len(hyp)
|
|
204
|
+
ref_len = min((len(r) for r in refs), key=lambda rl: (abs(rl - hyp_len), rl))
|
|
205
|
+
bp = 1.0 if hyp_len >= ref_len else math.exp(1 - ref_len / hyp_len)
|
|
206
|
+
|
|
207
|
+
# Note : on itère jusqu'à min(max_n, len(hyp)) — pas jusqu'à max_n.
|
|
208
|
+
# Comportement intentionnel : BLEU tronqué aux n-grammes disponibles pour
|
|
209
|
+
# les courtes hypothèses NLP (labels causaux, clauses). Standard BLEU-4
|
|
210
|
+
# retournerait 0 pour toute hypothèse < 4 tokens ; ce n'est pas utile ici.
|
|
211
|
+
precisions: list[float] = []
|
|
212
|
+
for n in range(1, min(max_n, len(hyp)) + 1):
|
|
213
|
+
hyp_ng = Counter(_ngrams(hyp, n))
|
|
214
|
+
clipped_total = 0
|
|
215
|
+
for ng, cnt in hyp_ng.items():
|
|
216
|
+
max_ref = max(Counter(_ngrams(r, n))[ng] for r in refs)
|
|
217
|
+
clipped_total += min(cnt, max_ref)
|
|
218
|
+
total = sum(hyp_ng.values())
|
|
219
|
+
precisions.append(clipped_total / total if total > 0 else 0.0)
|
|
220
|
+
|
|
221
|
+
if not precisions or min(precisions) == 0.0:
|
|
222
|
+
return 0.0
|
|
223
|
+
|
|
224
|
+
log_avg = sum(math.log(p) for p in precisions) / len(precisions)
|
|
225
|
+
return round(bp * math.exp(log_avg), 4)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _ngrams(tokens: list[str], n: int) -> list[tuple]:
|
|
229
|
+
return [tuple(tokens[i: i + n]) for i in range(len(tokens) - n + 1)]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# Helpers internes
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def _f1_per_class(
|
|
237
|
+
pred: Predictions, gold: GoldLabels, classes: list[str]
|
|
238
|
+
) -> dict[str, dict[str, float]]:
|
|
239
|
+
result = {}
|
|
240
|
+
for cls in classes:
|
|
241
|
+
tp = sum(p == cls and g == cls for p, g in zip(pred, gold))
|
|
242
|
+
fp = sum(p == cls and g != cls for p, g in zip(pred, gold))
|
|
243
|
+
fn = sum(p != cls and g == cls for p, g in zip(pred, gold))
|
|
244
|
+
support = sum(g == cls for g in gold)
|
|
245
|
+
|
|
246
|
+
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
|
|
247
|
+
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
|
248
|
+
f1 = (2 * precision * recall / (precision + recall)
|
|
249
|
+
if (precision + recall) > 0 else 0.0)
|
|
250
|
+
|
|
251
|
+
result[cls] = {
|
|
252
|
+
"precision": round(precision, 4),
|
|
253
|
+
"recall": round(recall, 4),
|
|
254
|
+
"f1": round(f1, 4),
|
|
255
|
+
"support": support,
|
|
256
|
+
}
|
|
257
|
+
return result
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TrainingRecorder — suivi des métriques et courbes d'apprentissage par epoch.
|
|
3
|
+
|
|
4
|
+
Framework-agnostique (NumPy pur). Le data scientist appelle .record() dans
|
|
5
|
+
sa boucle d'entraînement. Le recorder stocke l'historique et permet
|
|
6
|
+
l'export CSV pour visualisation externe (matplotlib, Excel, etc.).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import csv
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class EpochRecord:
|
|
17
|
+
epoch: int
|
|
18
|
+
loss: float
|
|
19
|
+
metrics: dict[str, float] # {"node_accuracy": 0.87, "edge_macro_f1": 0.72, …}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TrainingRecorder:
|
|
23
|
+
"""
|
|
24
|
+
Enregistre loss et métriques CGNP par epoch.
|
|
25
|
+
|
|
26
|
+
Usage typique dans la boucle du DS :
|
|
27
|
+
|
|
28
|
+
recorder = TrainingRecorder()
|
|
29
|
+
for epoch in range(n_epochs):
|
|
30
|
+
pipeline.forward(reps, text)
|
|
31
|
+
node_logits = pipeline._cached_node_logits
|
|
32
|
+
edge_logits = pipeline._cached_edge_logits
|
|
33
|
+
loss_val, d_node, d_edge = pipeline.loss(
|
|
34
|
+
node_logits, edge_logits, gold_node, gold_edge
|
|
35
|
+
)
|
|
36
|
+
pipeline.backward(d_node, d_edge, lr=0.001)
|
|
37
|
+
metrics = {
|
|
38
|
+
"node_accuracy": node_accuracy(pred_types, gold_types),
|
|
39
|
+
"edge_macro_f1": edge_macro_f1(pred_rels, gold_rels),
|
|
40
|
+
}
|
|
41
|
+
recorder.record(epoch, loss_val, metrics)
|
|
42
|
+
|
|
43
|
+
recorder.to_csv("training_history.csv")
|
|
44
|
+
curve = recorder.learning_curve()
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self._history: list[EpochRecord] = []
|
|
49
|
+
|
|
50
|
+
def record(self, epoch: int, loss: float, metrics: dict[str, float]) -> None:
|
|
51
|
+
"""Enregistre une epoch."""
|
|
52
|
+
self._history.append(EpochRecord(epoch=epoch, loss=loss, metrics=dict(metrics)))
|
|
53
|
+
|
|
54
|
+
def learning_curve(self) -> dict[str, list]:
|
|
55
|
+
"""
|
|
56
|
+
Retourne les séries temporelles pour tracer les courbes d'apprentissage.
|
|
57
|
+
|
|
58
|
+
Exemple de retour :
|
|
59
|
+
{
|
|
60
|
+
"epoch": [0, 1, 2, …],
|
|
61
|
+
"loss": [2.1, 1.8, 1.5, …],
|
|
62
|
+
"node_accuracy": [0.3, 0.55, 0.72, …],
|
|
63
|
+
"node_macro_f1": […],
|
|
64
|
+
"edge_accuracy": […],
|
|
65
|
+
"edge_macro_f1": […],
|
|
66
|
+
}
|
|
67
|
+
"""
|
|
68
|
+
if not self._history:
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
curve: dict[str, list] = {
|
|
72
|
+
"epoch": [r.epoch for r in self._history],
|
|
73
|
+
"loss": [r.loss for r in self._history],
|
|
74
|
+
}
|
|
75
|
+
# Collect all metric keys that appear across epochs
|
|
76
|
+
all_keys: set[str] = set()
|
|
77
|
+
for r in self._history:
|
|
78
|
+
all_keys.update(r.metrics.keys())
|
|
79
|
+
|
|
80
|
+
for key in sorted(all_keys):
|
|
81
|
+
curve[key] = [r.metrics.get(key, float("nan")) for r in self._history]
|
|
82
|
+
|
|
83
|
+
return curve
|
|
84
|
+
|
|
85
|
+
def best_epoch(self, metric: str = "loss", mode: str = "min") -> EpochRecord | None:
|
|
86
|
+
"""
|
|
87
|
+
Retourne l'epoch avec la meilleure valeur d'une métrique.
|
|
88
|
+
|
|
89
|
+
mode="min" → minimize (pour loss)
|
|
90
|
+
mode="max" → maximize (pour accuracy, f1)
|
|
91
|
+
"""
|
|
92
|
+
if not self._history:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def get_val(r: EpochRecord) -> float:
|
|
96
|
+
if metric == "loss":
|
|
97
|
+
return r.loss
|
|
98
|
+
return r.metrics.get(metric, float("nan"))
|
|
99
|
+
|
|
100
|
+
valid = [r for r in self._history if not _is_nan(get_val(r))]
|
|
101
|
+
if not valid:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
if mode == "min":
|
|
105
|
+
return min(valid, key=get_val)
|
|
106
|
+
return max(valid, key=get_val)
|
|
107
|
+
|
|
108
|
+
def summary(self) -> dict:
|
|
109
|
+
"""Résumé : première epoch, dernière epoch, meilleure loss."""
|
|
110
|
+
if not self._history:
|
|
111
|
+
return {}
|
|
112
|
+
first = self._history[0]
|
|
113
|
+
last = self._history[-1]
|
|
114
|
+
best = self.best_epoch("loss", "min")
|
|
115
|
+
return {
|
|
116
|
+
"n_epochs": len(self._history),
|
|
117
|
+
"first_loss": first.loss,
|
|
118
|
+
"last_loss": last.loss,
|
|
119
|
+
"best_loss": best.loss if best else None,
|
|
120
|
+
"best_epoch": best.epoch if best else None,
|
|
121
|
+
"last_metrics": last.metrics,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
def to_csv(self, path: Path | str) -> None:
|
|
125
|
+
"""Export CSV — une ligne par epoch, toutes les métriques en colonnes."""
|
|
126
|
+
curve = self.learning_curve()
|
|
127
|
+
if not curve:
|
|
128
|
+
return
|
|
129
|
+
path = Path(path)
|
|
130
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
keys = list(curve.keys())
|
|
132
|
+
with path.open("w", newline="", encoding="utf-8") as f:
|
|
133
|
+
writer = csv.writer(f)
|
|
134
|
+
writer.writerow(keys)
|
|
135
|
+
n = len(curve["epoch"])
|
|
136
|
+
for i in range(n):
|
|
137
|
+
writer.writerow([curve[k][i] for k in keys])
|
|
138
|
+
|
|
139
|
+
def to_json(self, path: Path | str) -> None:
|
|
140
|
+
"""Export JSON — liste d'objets {epoch, loss, …metrics}."""
|
|
141
|
+
path = Path(path)
|
|
142
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
records = []
|
|
144
|
+
for r in self._history:
|
|
145
|
+
row = {"epoch": r.epoch, "loss": r.loss}
|
|
146
|
+
row.update(r.metrics)
|
|
147
|
+
records.append(row)
|
|
148
|
+
path.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
149
|
+
|
|
150
|
+
def __len__(self) -> int:
|
|
151
|
+
return len(self._history)
|
|
152
|
+
|
|
153
|
+
def __repr__(self) -> str:
|
|
154
|
+
return f"TrainingRecorder(epochs={len(self._history)})"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _is_nan(v: float) -> bool:
|
|
158
|
+
return v != v # NaN check without math import
|
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
import json
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from ..constants import (
|
|
7
|
+
UPOS_TAGS, UD_DEP_RELS, UD_TENSE_VALUES, UD_ASPECT_VALUES,
|
|
8
|
+
UD_MOOD_VALUES, SUBJECT_POS_CATS,
|
|
9
|
+
)
|
|
10
|
+
from .representation import UDRepresentation
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class FeatureVocabulary:
|
|
15
|
+
"""
|
|
16
|
+
Schéma ordonné des features de la Couche 1.
|
|
17
|
+
Sérialisable JSON — partagé entre entraînement et inférence.
|
|
18
|
+
Le data scientist sérialise cette instance avec son checkpoint.
|
|
19
|
+
Features purement syntaxiques : UPOS, DEP_REL, tense/aspect/mood, polarity, flags structurels.
|
|
20
|
+
"""
|
|
21
|
+
upos_tags: list[str] = field(default_factory=lambda: list(UPOS_TAGS))
|
|
22
|
+
dep_rels: list[str] = field(default_factory=lambda: list(UD_DEP_RELS))
|
|
23
|
+
tense_values: list[str] = field(default_factory=lambda: list(UD_TENSE_VALUES))
|
|
24
|
+
aspect_values: list[str] = field(default_factory=lambda: list(UD_ASPECT_VALUES))
|
|
25
|
+
mood_values: list[str] = field(default_factory=lambda: list(UD_MOOD_VALUES))
|
|
26
|
+
subject_pos_cats: list[str] = field(default_factory=lambda: list(SUBJECT_POS_CATS))
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def d_clause(self) -> int:
|
|
30
|
+
return (
|
|
31
|
+
len(self.upos_tags)
|
|
32
|
+
+ len(self.dep_rels)
|
|
33
|
+
+ len(self.subject_pos_cats)
|
|
34
|
+
+ len(self.tense_values)
|
|
35
|
+
+ len(self.aspect_values)
|
|
36
|
+
+ len(self.mood_values)
|
|
37
|
+
+ 1 # Polarity
|
|
38
|
+
+ 3 # structural flags
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def d_conn(self) -> int:
|
|
43
|
+
return len(self.upos_tags) + 2
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def d_edge(self) -> int:
|
|
47
|
+
return 2 * self.d_clause + self.d_conn
|
|
48
|
+
|
|
49
|
+
def to_json(self) -> str:
|
|
50
|
+
return json.dumps({
|
|
51
|
+
"upos_tags": self.upos_tags,
|
|
52
|
+
"dep_rels": self.dep_rels,
|
|
53
|
+
"tense_values": self.tense_values,
|
|
54
|
+
"aspect_values": self.aspect_values,
|
|
55
|
+
"mood_values": self.mood_values,
|
|
56
|
+
"subject_pos_cats": self.subject_pos_cats,
|
|
57
|
+
}, ensure_ascii=False)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_json(cls, s: str) -> "FeatureVocabulary":
|
|
61
|
+
return cls(**json.loads(s))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _one_hot(value: str, vocab: list[str]) -> np.ndarray:
|
|
65
|
+
v = np.zeros(len(vocab), dtype=np.float32)
|
|
66
|
+
if value in vocab:
|
|
67
|
+
v[vocab.index(value)] = 1.0
|
|
68
|
+
elif "_unk" in vocab:
|
|
69
|
+
v[vocab.index("_unk")] = 1.0
|
|
70
|
+
return v
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def vectorize_clause(
|
|
74
|
+
rep: UDRepresentation,
|
|
75
|
+
vocab: FeatureVocabulary,
|
|
76
|
+
) -> np.ndarray:
|
|
77
|
+
"""UDRepresentation → np.ndarray[d_clause]"""
|
|
78
|
+
parts = [
|
|
79
|
+
_one_hot(rep.root_pos, vocab.upos_tags),
|
|
80
|
+
_one_hot(rep.root_dep_rel, vocab.dep_rels),
|
|
81
|
+
_one_hot(rep.subject_pos or "_absent", vocab.subject_pos_cats),
|
|
82
|
+
_one_hot(rep.tense, vocab.tense_values),
|
|
83
|
+
_one_hot(rep.aspect, vocab.aspect_values),
|
|
84
|
+
_one_hot(rep.mood, vocab.mood_values),
|
|
85
|
+
np.array([1.0 if rep.is_negative else 0.0], dtype=np.float32),
|
|
86
|
+
np.array([float(rep.has_object), float(rep.has_advcl), float(rep.has_temporal_obl)],
|
|
87
|
+
dtype=np.float32),
|
|
88
|
+
]
|
|
89
|
+
return np.concatenate(parts)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def vectorize_connector(
|
|
93
|
+
marker_rep: UDRepresentation | None,
|
|
94
|
+
src_idx: int,
|
|
95
|
+
dst_idx: int,
|
|
96
|
+
n_clauses: int,
|
|
97
|
+
vocab: FeatureVocabulary,
|
|
98
|
+
) -> np.ndarray:
|
|
99
|
+
"""Connector features between two clauses → np.ndarray[d_conn]"""
|
|
100
|
+
if marker_rep is not None:
|
|
101
|
+
upos_vec = _one_hot(marker_rep.root_pos, vocab.upos_tags)
|
|
102
|
+
else:
|
|
103
|
+
upos_vec = np.zeros(len(vocab.upos_tags), dtype=np.float32)
|
|
104
|
+
|
|
105
|
+
pos_vec = np.array(
|
|
106
|
+
[float(src_idx < dst_idx), abs(dst_idx - src_idx) / max(n_clauses, 1)],
|
|
107
|
+
dtype=np.float32,
|
|
108
|
+
)
|
|
109
|
+
return np.concatenate([upos_vec, pos_vec])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def vectorize_edge(
|
|
113
|
+
src: UDRepresentation,
|
|
114
|
+
dst: UDRepresentation,
|
|
115
|
+
connector: UDRepresentation | None,
|
|
116
|
+
src_idx: int,
|
|
117
|
+
dst_idx: int,
|
|
118
|
+
n_clauses: int,
|
|
119
|
+
vocab: FeatureVocabulary,
|
|
120
|
+
) -> np.ndarray:
|
|
121
|
+
"""Two clauses + connector → np.ndarray[d_edge]"""
|
|
122
|
+
return np.concatenate([
|
|
123
|
+
vectorize_clause(src, vocab),
|
|
124
|
+
vectorize_clause(dst, vocab),
|
|
125
|
+
vectorize_connector(connector, src_idx, dst_idx, n_clauses, vocab),
|
|
126
|
+
])
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class UDRepresentation:
|
|
7
|
+
"""
|
|
8
|
+
Abstraction UD-agnostique d'une clause construite depuis des tokens YAML annotés.
|
|
9
|
+
Aucune heuristique linguistique — uniquement des features UD universelles.
|
|
10
|
+
"""
|
|
11
|
+
tokens: list[dict] # [{lemma, pos(UPOS), dep_rel(UD), morph: dict}]
|
|
12
|
+
root_lemma: str
|
|
13
|
+
root_pos: str # UPOS
|
|
14
|
+
root_dep_rel: str # UD dep_rel
|
|
15
|
+
root_morph: dict[str, str] # {Tense: "Past", Aspect: "Imp", Mood: "Ind", …}
|
|
16
|
+
subject_pos: str | None # UPOS du nsubj, ou None
|
|
17
|
+
has_object: bool # dep obj/iobj existe
|
|
18
|
+
has_advcl: bool # dep advcl existe
|
|
19
|
+
has_temporal_obl: bool # dep obl avec morph temporel
|
|
20
|
+
token_span: tuple[int, int]
|
|
21
|
+
lang: str
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def tense(self) -> str:
|
|
25
|
+
return self.root_morph.get("Tense", "_absent")
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def aspect(self) -> str:
|
|
29
|
+
return self.root_morph.get("Aspect", "_absent")
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def mood(self) -> str:
|
|
33
|
+
return self.root_morph.get("Mood", "_absent")
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_negative(self) -> bool:
|
|
37
|
+
return self.root_morph.get("Polarity", "") == "Neg"
|
|
File without changes
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Protocol, runtime_checkable
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@runtime_checkable
|
|
7
|
+
class CausalEncoder(Protocol):
|
|
8
|
+
"""
|
|
9
|
+
Contrat d'interface de la Couche 2 — Encodage causal.
|
|
10
|
+
|
|
11
|
+
Le data scientist implémente ce protocol avec le framework de son choix
|
|
12
|
+
(NumPy, PyTorch, JAX, sklearn, …).
|
|
13
|
+
|
|
14
|
+
Entrée : vecteur de features Couche 1 (UDRepresentation vectorisé)
|
|
15
|
+
Sortie : logits sur les types causaux GCN
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def forward_node(self, x: np.ndarray) -> np.ndarray:
|
|
19
|
+
"""
|
|
20
|
+
x : shape (D_clause,) — features d'une clause
|
|
21
|
+
retour : shape (7,) — logits sur NODE_TYPES (non normalisés)
|
|
22
|
+
"""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def forward_edge(self, x: np.ndarray) -> np.ndarray:
|
|
26
|
+
"""
|
|
27
|
+
x : shape (D_edge,) — features d'une paire de clauses
|
|
28
|
+
retour : shape (11,) — logits sur RELATION_TYPES (non normalisés)
|
|
29
|
+
"""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def parameters(self) -> list[np.ndarray]:
|
|
33
|
+
"""Retourne tous les paramètres apprenables (utilisé par le checkpoint)."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
def update_node(self, grads: list[tuple[np.ndarray, np.ndarray]], lr: float) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Applique les gradients du MLP nœud.
|
|
39
|
+
grads : liste de (dW, db) par couche, dans l'ordre des couches du node MLP.
|
|
40
|
+
"""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
def update_edge(self, grads: list[tuple[np.ndarray, np.ndarray]], lr: float) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Applique les gradients du MLP arête.
|
|
46
|
+
grads : liste de (dW, db) par couche, dans l'ordre des couches du edge MLP.
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def update(self, grads: list[np.ndarray], lr: float) -> None:
|
|
51
|
+
"""Applique une liste plate de gradients (conservé pour compatibilité externe)."""
|
|
52
|
+
...
|