File size: 16,418 Bytes
3e363e2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 | """Configuration objects for reproducible GRM model construction."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any, Literal
@dataclass(frozen=True)
class PATConfig:
enabled: bool = True
hidden_dim: int = 128
num_layers: int = 2
num_heads: int = 4
max_path_length: int = 8
num_token_types: int = 10
dropout: float = 0.1
relation_aware_attention: bool = True
query_conditioning: bool = True
type_vocab_size: int = 1024
attribute_vocab_size: int = 2048
def __post_init__(self) -> None:
if self.hidden_dim <= 0 or self.num_layers <= 0 or self.num_heads <= 0:
raise ValueError("PAT dimensions and layer counts must be positive")
if (
self.max_path_length <= 0
or self.num_token_types <= 0
or self.type_vocab_size <= 0
or self.attribute_vocab_size <= 0
):
raise ValueError("PAT path length and token vocabulary must be positive")
if not 0.0 <= self.dropout < 1.0:
raise ValueError("PAT dropout must be in [0, 1)")
@dataclass(frozen=True)
class LossConfig:
graph: float = 0.1
link: float = 0.2
next_hop: float = 1.0
relation: float = 0.5
path: float = 1.0
pat: float = 1.0
answer: float = 1.0
verification: float = 0.5
contradiction: float = 0.5
calibration: float = 0.1
count: float = 0.3
#: Set-level selection objective. Weighted above the per-path term because
#: committing to the right number of paths is what exact match measures.
selection_set: float = 1.5
#: Listwise ranking objective over the candidate set. ``count_guided_selection``
#: commits the top-k candidates by score, so the ranking -- not the threshold --
#: is what inference consumes; optimising only the thresholded objective left a
#: train/inference mismatch of the same kind the on-policy experiment targeted.
selection_rank: float = 1.0
#: Weight on the *unsupported* class in the verification objective. About 85% of
#: curriculum examples are answerable, and with a per-example batch the unweighted
#: objective is minimised well by always predicting "supported": the shipped model
#: abstained on 25% of answerable examples and 12.5% of unanswerable ones, worse
#: than chance in the direction that matters. Set from the measured base rate.
unsupported_weight: float = 5.0
#: Penalty on the mean pairwise cosine of a graph's final node states. Long
#: runs collapsed every node to one direction (mean cosine 0.03 -> 0.98 over
#: ~100K steps, artifacts/retrain-20260928-full-epoch/freeze.json), which
#: leaves answer ranking and unseen-ontology transfer nothing to work with.
#: 0 disables it and keeps older configs' objective unchanged.
node_spread: float = 0.0
@dataclass(frozen=True)
class TrainingConfig:
precision: Literal["fp32", "fp16", "bf16"] = "fp32"
link_head: Literal["cosine", "mlp"] = "cosine"
gradient_checkpointing: bool = False
batch_size: int = 8
gradient_accumulation: int = 1
learning_rate: float = 3e-4
warmup_ratio: float = 0.03
weight_decay: float = 0.01
max_steps: int = 1000
seed: int = 7
distributed_backend: str = "nccl"
checkpoint_every: int = 0
evaluation_every: int = 0
use_fsdp: bool = False
deterministic: bool = True
grad_clip: float = 1.0
#: Examples backpropagated together in one autograd invocation. Larger is
#: faster but holds more activations; 1 reproduces per-example backward.
backward_chunk_size: int = 8
#: When > 0, each training example gets a freshly renamed ontology with this
#: probability. This forces the model to learn relational semantics from
#: type signatures and structure rather than memorizing relation names.
#: 0=no renaming, 1=always rename. Default 0.0 gives the original behavior.
rename_probability: float = 0.0
#: Run each backward chunk through ``GraphModel.forward_batch`` (one pass of
#: every network over the chunk) instead of one forward per example. The
#: objective is the same; per-example forwards left the GPU ~85% idle.
batched_forward: bool = True
#: Learning-rate multiplier for the query encoder alone. Its first
#: self-attention layer was the source of the gradient blow-up that stopped
#: the v3 full-epoch run at step 183,000 while the rest of the model was
#: stable. 1.0 keeps the optimizer exactly as before.
query_encoder_lr_scale: float = 1.0
#: Stop training the query encoder. At 0.1x learning rate its gradients
#: still grew (median 88 -> 234 over 15K steps after step 183,500), always
#: concentrated in its input projection and first attention layer.
freeze_query_encoder: bool = False
def __post_init__(self) -> None:
# Literal types are not enforced at runtime; a typo such as "BF16" or
# "gin" would otherwise be accepted and silently mapped to a default.
if self.precision not in ("fp32", "fp16", "bf16"):
raise ValueError(
f"precision must be fp32, fp16 or bf16: {self.precision!r}"
)
if self.distributed_backend not in ("nccl", "gloo"):
raise ValueError(
f"distributed_backend must be nccl or gloo: {self.distributed_backend!r}"
)
if self.use_fsdp:
# Refused here, not only when distributed: a single-process run would
# otherwise train without FSDP and say nothing.
raise ValueError("use_fsdp is not implemented; set it to false")
if (
self.batch_size <= 0
or self.gradient_accumulation <= 0
or self.max_steps <= 0
):
raise ValueError(
"batch_size, gradient_accumulation, and max_steps must be positive"
)
if (
self.learning_rate <= 0
or self.weight_decay < 0
or not 0.0 <= self.warmup_ratio <= 1.0
):
raise ValueError("invalid optimizer or warmup configuration")
if self.checkpoint_every < 0 or self.evaluation_every < 0:
raise ValueError("checkpoint/evaluation intervals cannot be negative")
if self.grad_clip <= 0:
raise ValueError("grad_clip must be positive")
if self.backward_chunk_size <= 0:
raise ValueError("backward_chunk_size must be positive")
@dataclass(frozen=True)
class GraphConfig:
model_version: str = "graph-smoke-v1"
target_parameters: int = 100_000_000
hidden_dim: int = 128
graph_input_dim: int = 8
edge_input_dim: int = 8
num_node_types: int = 64
num_relations: int = 128
num_relation_types: int = 128
vocab_size: int = 16_384
max_query_tokens: int = 128
graph_layers: int = 3
graph_encoder: Literal["sage", "gat"] = "sage"
use_gat: bool = False
#: Identity skip connection around each message-passing layer. Without it
#: eight layers of mean aggregation oversmooth: node states converged to a
#: shared direction on long runs. Off by default so existing checkpoints
#: keep computing what they were trained to compute.
graph_residual: bool = False
#: Mark the query's start entities in the graph encoder's input: a learned
#: start/other embedding plus the query state on start nodes, so node states
#: are computed relative to where the question begins. Synthetic entity
#: names are random identifiers, so without a marker nodes that play the same
#: structural role are indistinguishable and long runs collapsed them together.
start_marking: bool = False
#: Soft cap on attention scores in PAT and the reasoning core's graph
#: cross-attention (``graph.utils.soft_cap``); 0 disables it.
attention_logit_cap: float = 0.0
#: Normalise attention queries and keys in PAT and the graph
#: cross-attention (``graph.utils.qk_normalize``).
attention_qk_norm: bool = False
num_reasoning_layers: int = 2
num_reasoning_heads: int = 4
#: FFN expansion inside the reasoning core. Exposed because it is the main
#: lever for hitting the section 6 reasoning-transformer budget without
#: changing depth or width.
reasoning_ffn_multiplier: int = 4
dropout: float = 0.1
max_hops: int = 8
beam_width: int = 8
candidate_width: int = 32
relation_top_k: int = 16
answer_top_k: int = 8
#: Largest count the aggregate head can emit.
max_count: int = 32
#: Cap on supervised positive/negative paths per example, bounding the PAT
#: and selection batch built for one training step.
max_supervised_paths: int = 8
#: How many reachable paths to scan when topping up selection negatives from
#: the distribution the beam actually produces.
reachable_negative_pool: int = 256
#: Negatives per positive in the selection batch. This sets the *base rate*
#: the threshold is fitted against, and it has to match inference: the beam
#: surfaces roughly one gold path in six, so training on a balanced set
#: produced a threshold that kept ten candidates when the gold set held two.
selection_negative_ratio: int = 6
#: Train path selection on the candidates the beam actually produces.
#:
#: Measured WORSE and therefore off by default. At 200 steps on a 128-dim
#: probe it lost on every metric (F1 0.254 vs 0.310, selection precision 0.149
#: vs 0.195, renamed-ontology PAT 0.429 vs 0.741). The cause looks like cold
#: start: early in training the beam is near-random, so its candidate set holds
#: almost no gold path and the objective sees a degenerate, low-diversity
#: signal. Kept because it is the right idea once a competent policy exists to
#: bootstrap from -- worth revisiting as a late-training phase, not from step 0.
on_policy_selection: bool = False
#: Size the selected set from the count head's prediction instead of a
#: threshold. The threshold is uncalibrated against the inference candidate
#: distribution, so it kept ~9 paths where the gold set held ~2.
count_guided_selection: bool = True
#: Semantic text features for entity, relation and query strings. ``None`` keeps
#: the original behaviour: every string becomes a one-hot at a hashed index, so two
#: relations with the same meaning and different names get *orthogonal* features and
#: an unseen relation string carries no meaning at all. That is the measured cause
#: of the model's transfer failure -- unseen-ontology exact match 0.150 with PAT at
#: 0.557 against a 0.5 floor, and losing to a released 34M model on real typed KGs
#: whose one advantage is reading strings through a pretrained encoder.
#: Either "hashed-bag" (dependency-free, shares substrings) or
#: "sentence-transformer" (frozen pretrained, shares meaning).
#: Scalar evidence signals fed to the verification trunk. The abstention decision
#: was measured worse than chance -- abstaining on 25% of answerable examples and
#: 12.5% of unanswerable ones -- because the pooled evidence the support head reads
#: is built from gold positives during training and from the beam's own candidates
#: at inference. These signals (max and mean PAT query-compatibility, and the
#: candidate count) are computed identically in both regimes, and PAT compatibility
#: is already a well-trained discriminator (0.959). Set to 3 to enable; it adds
#: weights, so a checkpoint trained without it cannot be loaded with it.
verification_signals: int = 0
#: Soft bound on the verification and contradiction logits: cap * tanh(logit / cap).
#: 0 disables it. Unbounded logits let one confidently wrong example produce a
#: binary cross-entropy of 40 to 116, which destabilizes training late in the run.
verification_logit_cap: float = 0.0
#: Subtract a running mean of the verifier state before the heads read it. The
#: state shares a large common direction across examples (mean pairwise cosine
#: 0.69 at the collapse), which leaves the support head with no per-example signal.
#: Uses running statistics at inference, so it has no train/test leak.
verification_center: bool = False
#: Report the trained confidence head's probability as the result confidence
#: instead of sigmoid(best path score). The head is trained with a Brier loss
#: against answer correctness; path scores are trained for ranking and are not
#: probabilities, which is why path-score confidence calibrates poorly.
confidence_from_head: bool = False
#: Support probability below which the model abstains rather than answering.
#:
#: The evidence signals above make abstention work in-distribution, and they make it
#: *distribution-sensitive*: they are built from PAT query-compatibility, which
#: saturates low on relation vocabulary the model has never seen. On real typed KGs
#: the gate therefore fired on 100% of queries -- every real-KG metric went to
#: 0.000, even when the gold relation path was supplied. A caller working outside
#: the training distribution should lower this, or pass ``abstain=False`` to
#: ``reason`` and rely on the deterministic verifier alone.
abstention_threshold: float = 0.5
text_features: str | None = None
text_feature_dim: int = 384
text_feature_model: str | None = None
#: Depth and width of the shared neural verification trunk (PLAN section 20).
verifier_layers: int = 2
verifier_width_multiplier: int = 4
pat: PATConfig = field(default_factory=PATConfig)
loss: LossConfig = field(default_factory=LossConfig)
training: TrainingConfig = field(default_factory=TrainingConfig)
def __post_init__(self) -> None:
if self.graph_encoder not in ("sage", "gat"):
raise ValueError(
f"graph_encoder must be sage or gat: {self.graph_encoder!r}"
)
if (
self.hidden_dim <= 0
or self.graph_layers <= 0
or self.graph_input_dim <= 0
or self.edge_input_dim <= 0
):
raise ValueError("model dimensions and graph layer count must be positive")
if (
self.num_node_types <= 0
or self.num_relations <= 0
or self.num_relation_types <= 0
):
raise ValueError("node and relation vocabularies must be positive")
if self.vocab_size <= 0 or self.max_query_tokens <= 0:
raise ValueError("query vocabulary and token limit must be positive")
if self.pat.hidden_dim != self.hidden_dim:
raise ValueError(
"PAT hidden_dim must match GRM hidden_dim for residual fusion"
)
if self.pat.num_heads <= 0 or self.hidden_dim % self.pat.num_heads:
raise ValueError("hidden_dim must be divisible by PAT num_heads")
if self.num_reasoning_heads <= 0 or self.hidden_dim % self.num_reasoning_heads:
raise ValueError("hidden_dim must be divisible by reasoning heads")
if self.max_hops <= 0 or self.beam_width <= 0:
raise ValueError("max_hops and beam_width must be positive")
if (
self.candidate_width <= 0
or self.relation_top_k <= 0
or self.answer_top_k <= 0
):
raise ValueError("reasoning widths must be positive")
if self.reasoning_ffn_multiplier <= 0:
raise ValueError("reasoning_ffn_multiplier must be positive")
if self.max_count <= 0 or self.max_supervised_paths <= 0:
raise ValueError("max_count and max_supervised_paths must be positive")
if self.reachable_negative_pool <= 0:
raise ValueError("reachable_negative_pool must be positive")
if self.selection_negative_ratio <= 0:
raise ValueError("selection_negative_ratio must be positive")
if self.verifier_layers <= 0 or self.verifier_width_multiplier <= 0:
raise ValueError("verifier depth and width multiplier must be positive")
if not 0.0 <= self.dropout < 1.0:
raise ValueError("model dropout must be in [0, 1)")
def to_dict(self) -> dict[str, Any]:
return asdict(self)
|