Initial app: Gradio missing-field suggester (v6.1 model)
Browse files- README.md +33 -1
- app.py +213 -0
- output_v6.1_lever1_only_seed42/checkpoints/yaml_bert_v4_epoch_30.pt +3 -0
- output_v6.1_lever1_only_seed42/vocab.json +0 -0
- requirements.txt +2 -0
- yaml_bert/__init__.py +26 -0
- yaml_bert/annotator.py +39 -0
- yaml_bert/attention_pooling.py +24 -0
- yaml_bert/cache.py +104 -0
- yaml_bert/config.py +43 -0
- yaml_bert/dataset.py +367 -0
- yaml_bert/embedding.py +80 -0
- yaml_bert/evaluate.py +240 -0
- yaml_bert/linearizer.py +99 -0
- yaml_bert/model.py +77 -0
- yaml_bert/pooling.py +57 -0
- yaml_bert/similarity.py +86 -0
- yaml_bert/suggest.py +236 -0
- yaml_bert/trainer.py +127 -0
- yaml_bert/types.py +29 -0
- yaml_bert/visualize.py +145 -0
- yaml_bert/vocab.py +345 -0
README.md
CHANGED
|
@@ -12,4 +12,36 @@ license: mit
|
|
| 12 |
short_description: Tree-aware transformer for structured (YAML) data
|
| 13 |
---
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
short_description: Tree-aware transformer for structured (YAML) data
|
| 13 |
---
|
| 14 |
|
| 15 |
+
# YAML-BERT — Kubernetes missing-field suggester
|
| 16 |
+
|
| 17 |
+
A small (7.8M-param) BERT-style encoder trained on 276K Kubernetes YAML
|
| 18 |
+
manifests with **tree-aware positional encoding** and **hybrid bigram/
|
| 19 |
+
trigram prediction targets**. Paste a YAML manifest below; the model
|
| 20 |
+
identifies fields it expects to see but that are absent, ranked by
|
| 21 |
+
confidence.
|
| 22 |
+
|
| 23 |
+
This Space runs the **v6.1 checkpoint** — same architecture as v5, with
|
| 24 |
+
a 5-line bug-fix to selective masking. See the project repo for full
|
| 25 |
+
details.
|
| 26 |
+
|
| 27 |
+
## What the model does
|
| 28 |
+
|
| 29 |
+
- Predicts standard Kubernetes structural fields (`spec`, `replicas`,
|
| 30 |
+
`containers.image`, etc.)
|
| 31 |
+
- Distinguishes kind-specific fields (`Deployment.replicas`,
|
| 32 |
+
`Service.ports`)
|
| 33 |
+
- Calibrated confidence: strong on common patterns, weaker on ambiguous
|
| 34 |
+
positions
|
| 35 |
+
|
| 36 |
+
## Known limitations
|
| 37 |
+
|
| 38 |
+
- Status-side fields not yet predicted (addressed in v6.2)
|
| 39 |
+
- Novel CRD instances and very rare annotation keys may collapse to
|
| 40 |
+
`[UNK]`
|
| 41 |
+
- Trained on `substratusai/the-stack-yaml-k8s`
|
| 42 |
+
|
| 43 |
+
## Links
|
| 44 |
+
|
| 45 |
+
- Code & docs: <https://github.com/vimalk78/yaml-bert>
|
| 46 |
+
- Evaluation results: <https://github.com/vimalk78/yaml-bert/blob/main/docs/evaluation-results.md>
|
| 47 |
+
- v6 plan: <https://github.com/vimalk78/yaml-bert/blob/main/docs/v6-plan.md>
|
app.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""YAML-BERT missing-field suggester — Gradio demo.
|
| 2 |
+
|
| 3 |
+
Paste a Kubernetes YAML manifest; the model identifies fields it expects
|
| 4 |
+
to see but that are absent. Runs the v6.1 checkpoint (Lever 1 — selective
|
| 5 |
+
masking applied during training).
|
| 6 |
+
|
| 7 |
+
Run locally:
|
| 8 |
+
pip install gradio
|
| 9 |
+
PYTHONPATH=. python app.py
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
|
| 16 |
+
import gradio as gr
|
| 17 |
+
import torch
|
| 18 |
+
|
| 19 |
+
from yaml_bert.config import YamlBertConfig
|
| 20 |
+
from yaml_bert.embedding import YamlBertEmbedding
|
| 21 |
+
from yaml_bert.model import YamlBertModel
|
| 22 |
+
from yaml_bert.suggest import suggest_missing_fields
|
| 23 |
+
from yaml_bert.vocab import Vocabulary
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ----- Model loading (once at startup) -----
|
| 27 |
+
|
| 28 |
+
DEFAULT_CHECKPOINT = "output_v6.1_lever1_only_seed42/checkpoints/yaml_bert_v4_epoch_30.pt"
|
| 29 |
+
DEFAULT_VOCAB = "output_v6.1_lever1_only_seed42/vocab.json"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def load_model(checkpoint_path: str, vocab_path: str) -> tuple[YamlBertModel, Vocabulary]:
|
| 33 |
+
vocab = Vocabulary.load(vocab_path)
|
| 34 |
+
config = YamlBertConfig()
|
| 35 |
+
emb = YamlBertEmbedding(
|
| 36 |
+
config=config,
|
| 37 |
+
key_vocab_size=vocab.key_vocab_size,
|
| 38 |
+
value_vocab_size=vocab.value_vocab_size,
|
| 39 |
+
)
|
| 40 |
+
model = YamlBertModel(
|
| 41 |
+
config=config,
|
| 42 |
+
embedding=emb,
|
| 43 |
+
simple_vocab_size=vocab.simple_target_vocab_size,
|
| 44 |
+
kind_vocab_size=vocab.kind_target_vocab_size,
|
| 45 |
+
)
|
| 46 |
+
cp = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
| 47 |
+
model.load_state_dict(cp["model_state_dict"])
|
| 48 |
+
model.eval()
|
| 49 |
+
return model, vocab
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
checkpoint_path = os.environ.get("YAML_BERT_CHECKPOINT", DEFAULT_CHECKPOINT)
|
| 53 |
+
vocab_path = os.environ.get("YAML_BERT_VOCAB", DEFAULT_VOCAB)
|
| 54 |
+
|
| 55 |
+
print(f"Loading model from {checkpoint_path} (vocab: {vocab_path})", file=sys.stderr)
|
| 56 |
+
MODEL, VOCAB = load_model(checkpoint_path, vocab_path)
|
| 57 |
+
n_params = sum(p.numel() for p in MODEL.parameters())
|
| 58 |
+
print(f"Loaded {n_params:,} parameters", file=sys.stderr)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ----- Inference -----
|
| 62 |
+
|
| 63 |
+
def suggest(yaml_text: str, threshold: float, top_k: int) -> str:
|
| 64 |
+
yaml_text = (yaml_text or "").strip()
|
| 65 |
+
if not yaml_text:
|
| 66 |
+
return "_Paste a YAML manifest above to see missing-field suggestions._"
|
| 67 |
+
|
| 68 |
+
try:
|
| 69 |
+
suggestions = suggest_missing_fields(
|
| 70 |
+
MODEL, VOCAB, yaml_text,
|
| 71 |
+
threshold=threshold, top_k=top_k,
|
| 72 |
+
)
|
| 73 |
+
except Exception as e:
|
| 74 |
+
return f"**Error parsing YAML:**\n```\n{e}\n```"
|
| 75 |
+
|
| 76 |
+
if not suggestions:
|
| 77 |
+
return ("_No suggestions above threshold. Model thinks this YAML is "
|
| 78 |
+
"either complete, or it doesn't have strong opinions at this confidence level._")
|
| 79 |
+
|
| 80 |
+
# Group by parent_path for readability
|
| 81 |
+
by_parent: dict[str, list[dict]] = {}
|
| 82 |
+
for s in suggestions:
|
| 83 |
+
by_parent.setdefault(s.get("parent_path") or "(root)", []).append(s)
|
| 84 |
+
|
| 85 |
+
blocks: list[str] = []
|
| 86 |
+
for parent in sorted(by_parent.keys(), key=lambda p: -max(s["confidence"] for s in by_parent[p])):
|
| 87 |
+
rows = ["| Missing key | Confidence | Strength |", "|---|---:|---|"]
|
| 88 |
+
for s in sorted(by_parent[parent], key=lambda x: -x["confidence"]):
|
| 89 |
+
conf = s["confidence"]
|
| 90 |
+
strength = "**STRONG**" if conf >= 0.7 else ("MODERATE" if conf >= 0.5 else "weak")
|
| 91 |
+
rows.append(f"| `{s['missing_key']}` | {conf:.1%} | {strength} |")
|
| 92 |
+
blocks.append(f"### `{parent}`\n" + "\n".join(rows))
|
| 93 |
+
|
| 94 |
+
return "\n\n".join(blocks)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ----- UI -----
|
| 98 |
+
|
| 99 |
+
EXAMPLE_NGINX = """apiVersion: apps/v1
|
| 100 |
+
kind: Deployment
|
| 101 |
+
metadata:
|
| 102 |
+
name: nginx-deployment
|
| 103 |
+
spec:
|
| 104 |
+
selector:
|
| 105 |
+
matchLabels:
|
| 106 |
+
app: nginx
|
| 107 |
+
replicas: 2
|
| 108 |
+
template:
|
| 109 |
+
metadata:
|
| 110 |
+
labels:
|
| 111 |
+
app: nginx
|
| 112 |
+
spec:
|
| 113 |
+
containers:
|
| 114 |
+
- name: nginx
|
| 115 |
+
image: nginx:1.8
|
| 116 |
+
resources:
|
| 117 |
+
limits:
|
| 118 |
+
memory: "128Mi"
|
| 119 |
+
cpu: "250m"
|
| 120 |
+
ports:
|
| 121 |
+
- containerPort: 80
|
| 122 |
+
"""
|
| 123 |
+
|
| 124 |
+
EXAMPLE_INCOMPLETE_SERVICE = """apiVersion: v1
|
| 125 |
+
kind: Service
|
| 126 |
+
metadata:
|
| 127 |
+
name: my-svc
|
| 128 |
+
spec:
|
| 129 |
+
selector:
|
| 130 |
+
app: web
|
| 131 |
+
"""
|
| 132 |
+
|
| 133 |
+
EXAMPLE_CONFIGMAP = """apiVersion: v1
|
| 134 |
+
kind: ConfigMap
|
| 135 |
+
metadata:
|
| 136 |
+
name: app-config
|
| 137 |
+
data:
|
| 138 |
+
key1: value1
|
| 139 |
+
"""
|
| 140 |
+
|
| 141 |
+
with gr.Blocks(title="YAML-BERT — missing-field suggester") as demo:
|
| 142 |
+
gr.Markdown(
|
| 143 |
+
f"""
|
| 144 |
+
# YAML-BERT — Kubernetes missing-field suggester
|
| 145 |
+
|
| 146 |
+
A small (7.8M-param) BERT-style encoder trained on 276K Kubernetes YAML manifests with
|
| 147 |
+
**tree-aware positional encoding** and **hybrid bigram/trigram prediction targets**.
|
| 148 |
+
This page runs the **v6.1 checkpoint** — same architecture as v5, with a 5-line bug
|
| 149 |
+
fix to selective masking ([details](https://github.com/vimalk78/yaml-bert/blob/main/docs/evaluation-results.md#7-v61--lever-1-selective-masking)).
|
| 150 |
+
|
| 151 |
+
**How it works:** the model walks each parent level in your YAML, inserts a fake
|
| 152 |
+
`[MASK]` node, and reports the keys it expects to see there but that aren't present.
|
| 153 |
+
Confidence reflects how strongly the model "expects" each missing field.
|
| 154 |
+
|
| 155 |
+
Code: [github.com/vimalk78/yaml-bert](https://github.com/vimalk78/yaml-bert) ·
|
| 156 |
+
{n_params:,} params
|
| 157 |
+
"""
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
with gr.Row():
|
| 161 |
+
with gr.Column(scale=1):
|
| 162 |
+
yaml_input = gr.Code(
|
| 163 |
+
language="yaml",
|
| 164 |
+
lines=22,
|
| 165 |
+
label="YAML input",
|
| 166 |
+
value=EXAMPLE_NGINX,
|
| 167 |
+
)
|
| 168 |
+
with gr.Row():
|
| 169 |
+
threshold = gr.Slider(
|
| 170 |
+
minimum=0.1, maximum=0.95, value=0.3, step=0.05,
|
| 171 |
+
label="Confidence threshold",
|
| 172 |
+
)
|
| 173 |
+
top_k = gr.Slider(
|
| 174 |
+
minimum=3, maximum=20, value=10, step=1,
|
| 175 |
+
label="Top-K predictions per position",
|
| 176 |
+
)
|
| 177 |
+
submit = gr.Button("Suggest missing fields", variant="primary")
|
| 178 |
+
|
| 179 |
+
with gr.Column(scale=1):
|
| 180 |
+
output = gr.Markdown(label="Suggestions", value="")
|
| 181 |
+
|
| 182 |
+
submit.click(fn=suggest, inputs=[yaml_input, threshold, top_k], outputs=output)
|
| 183 |
+
yaml_input.change(fn=suggest, inputs=[yaml_input, threshold, top_k], outputs=output)
|
| 184 |
+
|
| 185 |
+
gr.Examples(
|
| 186 |
+
examples=[
|
| 187 |
+
[EXAMPLE_NGINX, 0.3, 10],
|
| 188 |
+
[EXAMPLE_INCOMPLETE_SERVICE, 0.3, 10],
|
| 189 |
+
[EXAMPLE_CONFIGMAP, 0.3, 10],
|
| 190 |
+
],
|
| 191 |
+
inputs=[yaml_input, threshold, top_k],
|
| 192 |
+
label="Example YAMLs",
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
gr.Markdown(
|
| 196 |
+
"""
|
| 197 |
+
---
|
| 198 |
+
|
| 199 |
+
### What this model does well
|
| 200 |
+
- Predicts standard Kubernetes structural fields (`spec`, `replicas`, `containers.image`, etc.)
|
| 201 |
+
- Distinguishes kind-specific fields (`Deployment.replicas`, `Service.ports`)
|
| 202 |
+
- Calibrated confidence: strong on common patterns, weaker on ambiguous positions
|
| 203 |
+
|
| 204 |
+
### Known limitations
|
| 205 |
+
- **Status-side fields not yet predicted** (still being addressed in v6.2)
|
| 206 |
+
- Novel CRD instances and very rare annotation keys may collapse to `[UNK]`
|
| 207 |
+
- Trained on `substratusai/the-stack-yaml-k8s` (276K manifests from HF)
|
| 208 |
+
"""
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
if __name__ == "__main__":
|
| 213 |
+
demo.launch()
|
output_v6.1_lever1_only_seed42/checkpoints/yaml_bert_v4_epoch_30.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:dc057c85c31db30efafca5d89486f629ce0831efc9b9560dfd0d7dfca483b198
|
| 3 |
+
size 93942115
|
output_v6.1_lever1_only_seed42/vocab.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.0
|
| 2 |
+
pyyaml>=6.0
|
yaml_bert/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""YAML-BERT: Attention on Kubernetes Structured Data."""
|
| 2 |
+
|
| 3 |
+
from yaml_bert.config import YamlBertConfig
|
| 4 |
+
from yaml_bert.types import NodeType, YamlNode
|
| 5 |
+
from yaml_bert.linearizer import YamlLinearizer
|
| 6 |
+
from yaml_bert.vocab import Vocabulary, VocabBuilder
|
| 7 |
+
from yaml_bert.annotator import DomainAnnotator
|
| 8 |
+
from yaml_bert.embedding import YamlBertEmbedding
|
| 9 |
+
from yaml_bert.model import YamlBertModel
|
| 10 |
+
from yaml_bert.dataset import YamlDataset, collate_fn
|
| 11 |
+
from yaml_bert.trainer import YamlBertTrainer
|
| 12 |
+
|
| 13 |
+
__all__ = [
|
| 14 |
+
"YamlBertConfig",
|
| 15 |
+
"NodeType",
|
| 16 |
+
"YamlNode",
|
| 17 |
+
"YamlLinearizer",
|
| 18 |
+
"Vocabulary",
|
| 19 |
+
"VocabBuilder",
|
| 20 |
+
"DomainAnnotator",
|
| 21 |
+
"YamlBertEmbedding",
|
| 22 |
+
"YamlBertModel",
|
| 23 |
+
"YamlDataset",
|
| 24 |
+
"collate_fn",
|
| 25 |
+
"YamlBertTrainer",
|
| 26 |
+
]
|
yaml_bert/annotator.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from yaml_bert.types import NodeType, YamlNode
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class DomainAnnotator:
|
| 7 |
+
ORDERED_LISTS = {"initContainers"}
|
| 8 |
+
|
| 9 |
+
def annotate(self, nodes: list[YamlNode]) -> list[YamlNode]:
|
| 10 |
+
list_parent_ids = self._find_list_parent_ids(nodes)
|
| 11 |
+
for node in nodes:
|
| 12 |
+
if id(node) in list_parent_ids:
|
| 13 |
+
node.annotations["list_ordered"] = (
|
| 14 |
+
node.token in self.ORDERED_LISTS
|
| 15 |
+
)
|
| 16 |
+
return nodes
|
| 17 |
+
|
| 18 |
+
def _find_list_parent_ids(self, nodes: list[YamlNode]) -> set[int]:
|
| 19 |
+
list_parent_ids: set[int] = set()
|
| 20 |
+
parent_paths_with_list_items: set[str] = set()
|
| 21 |
+
|
| 22 |
+
for node in nodes:
|
| 23 |
+
if node.node_type in (NodeType.LIST_KEY, NodeType.LIST_VALUE):
|
| 24 |
+
parts = node.parent_path.split(".")
|
| 25 |
+
for i, part in enumerate(parts):
|
| 26 |
+
if part.isdigit():
|
| 27 |
+
list_parent_path = ".".join(parts[:i])
|
| 28 |
+
parent_paths_with_list_items.add(list_parent_path)
|
| 29 |
+
break
|
| 30 |
+
|
| 31 |
+
for node in nodes:
|
| 32 |
+
if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
|
| 33 |
+
node_full_path = (
|
| 34 |
+
f"{node.parent_path}.{node.token}" if node.parent_path else node.token
|
| 35 |
+
)
|
| 36 |
+
if node_full_path in parent_paths_with_list_items:
|
| 37 |
+
list_parent_ids.add(id(node))
|
| 38 |
+
|
| 39 |
+
return list_parent_ids
|
yaml_bert/attention_pooling.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class AttentionPooling(nn.Module):
|
| 8 |
+
def __init__(self, d_model: int) -> None:
|
| 9 |
+
super().__init__()
|
| 10 |
+
self.d_model = d_model
|
| 11 |
+
self.W_doc = nn.Linear(in_features=d_model, out_features=1) # (d_model,1)
|
| 12 |
+
|
| 13 |
+
def forward(
|
| 14 |
+
self,
|
| 15 |
+
h: torch.Tensor, # Shape(Batch, Seq_Len, d_model)
|
| 16 |
+
):
|
| 17 |
+
scores: torch.Tensor = self.W_doc(h) # (Batch, Seq_Len, 1)
|
| 18 |
+
scores = scores / math.sqrt(self.d_model)
|
| 19 |
+
scores = scores.view(scores.shape[0], 1, scores.shape[1]) # (Batch, 1, Seq_Len)
|
| 20 |
+
weights: torch.Tensor = scores.softmax(dim=-1) # (Batch,1, Seq_Len)
|
| 21 |
+
return (
|
| 22 |
+
(weights @ h).squeeze(1),
|
| 23 |
+
weights,
|
| 24 |
+
) # returning weights for testing/visualization (Batch,d_model), (Batch,1,Seq_Len)
|
yaml_bert/cache.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cache linearized YAML documents to disk.
|
| 2 |
+
|
| 3 |
+
Linearizes all documents once (with multiprocessing), saves to pickle.
|
| 4 |
+
Both VocabBuilder and YamlDataset load from cache — no re-parsing.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
from yaml_bert.cache import build_or_load_cache
|
| 8 |
+
|
| 9 |
+
docs = build_or_load_cache(
|
| 10 |
+
dataset_name="substratusai/the-stack-yaml-k8s",
|
| 11 |
+
cache_path="output_v3/doc_cache.pkl",
|
| 12 |
+
max_docs=276520,
|
| 13 |
+
)
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import pickle
|
| 19 |
+
import time
|
| 20 |
+
from multiprocessing import Pool, cpu_count
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
from yaml_bert.annotator import DomainAnnotator
|
| 24 |
+
from yaml_bert.linearizer import YamlLinearizer
|
| 25 |
+
from yaml_bert.types import YamlNode
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _linearize_one(yaml_content: str) -> list[YamlNode] | None:
|
| 29 |
+
"""Linearize a single YAML string. Used by multiprocessing pool."""
|
| 30 |
+
try:
|
| 31 |
+
linearizer = YamlLinearizer()
|
| 32 |
+
annotator = DomainAnnotator()
|
| 33 |
+
nodes: list[YamlNode] = linearizer.linearize(yaml_content)
|
| 34 |
+
if nodes:
|
| 35 |
+
annotator.annotate(nodes)
|
| 36 |
+
return nodes
|
| 37 |
+
except Exception:
|
| 38 |
+
pass
|
| 39 |
+
return None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def build_or_load_cache(
|
| 43 |
+
dataset_name: str,
|
| 44 |
+
cache_path: str,
|
| 45 |
+
max_docs: int | None = None,
|
| 46 |
+
num_workers: int | None = None,
|
| 47 |
+
) -> list[list[YamlNode]]:
|
| 48 |
+
"""Build or load cached linearized documents.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
dataset_name: HuggingFace dataset ID
|
| 52 |
+
cache_path: Path to save/load the cache pickle
|
| 53 |
+
max_docs: Max documents to process (None = all)
|
| 54 |
+
num_workers: Number of parallel workers (None = cpu_count)
|
| 55 |
+
|
| 56 |
+
Returns:
|
| 57 |
+
List of linearized documents (each is a list of YamlNode)
|
| 58 |
+
"""
|
| 59 |
+
if os.path.exists(cache_path):
|
| 60 |
+
print(f"Loading cached documents from {cache_path}")
|
| 61 |
+
start: float = time.time()
|
| 62 |
+
with open(cache_path, "rb") as f:
|
| 63 |
+
documents: list[list[YamlNode]] = pickle.load(f)
|
| 64 |
+
print(f"Loaded {len(documents):,} documents in {time.time() - start:.1f}s")
|
| 65 |
+
return documents
|
| 66 |
+
|
| 67 |
+
from datasets import load_dataset
|
| 68 |
+
|
| 69 |
+
print(f"Building document cache from {dataset_name}")
|
| 70 |
+
ds = load_dataset(dataset_name, split="train")
|
| 71 |
+
|
| 72 |
+
total: int = len(ds) if max_docs is None else min(max_docs, len(ds))
|
| 73 |
+
print(f"Linearizing {total:,} documents with {num_workers or cpu_count()} workers...")
|
| 74 |
+
|
| 75 |
+
# Extract YAML content strings
|
| 76 |
+
yaml_contents: list[str] = [ds[i]["content"] for i in range(total)]
|
| 77 |
+
|
| 78 |
+
# Parallel linearization with progress bar
|
| 79 |
+
from tqdm import tqdm
|
| 80 |
+
|
| 81 |
+
start = time.time()
|
| 82 |
+
workers: int = num_workers or cpu_count()
|
| 83 |
+
with Pool(workers) as pool:
|
| 84 |
+
results: list[list[YamlNode] | None] = list(tqdm(
|
| 85 |
+
pool.imap(_linearize_one, yaml_contents, chunksize=500),
|
| 86 |
+
total=len(yaml_contents),
|
| 87 |
+
desc="Linearizing",
|
| 88 |
+
))
|
| 89 |
+
|
| 90 |
+
documents = [doc for doc in results if doc is not None]
|
| 91 |
+
skipped: int = sum(1 for doc in results if doc is None)
|
| 92 |
+
elapsed: float = time.time() - start
|
| 93 |
+
|
| 94 |
+
print(f"Linearized {len(documents):,} documents in {elapsed:.1f}s ({skipped} skipped)")
|
| 95 |
+
|
| 96 |
+
# Save cache
|
| 97 |
+
os.makedirs(os.path.dirname(cache_path) or ".", exist_ok=True)
|
| 98 |
+
with open(cache_path, "wb") as f:
|
| 99 |
+
pickle.dump(documents, f)
|
| 100 |
+
|
| 101 |
+
cache_size: float = os.path.getsize(cache_path) / 1024 / 1024
|
| 102 |
+
print(f"Cache saved: {cache_path} ({cache_size:.1f} MB)")
|
| 103 |
+
|
| 104 |
+
return documents
|
yaml_bert/config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from enum import Enum
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TreePosVariant(str, Enum):
|
| 8 |
+
"""Tree positional encoding ablation variants."""
|
| 9 |
+
|
| 10 |
+
FULL = "full" # depth + sibling + node_type (default)
|
| 11 |
+
NO_DEPTH = "no_depth" # sibling + node_type
|
| 12 |
+
NO_SIBLING = "no_sibling" # depth + node_type
|
| 13 |
+
SEQUENTIAL = "sequential" # learned pos[seq_idx] + node_type
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class YamlBertConfig:
|
| 18 |
+
"""Central configuration for YAML-BERT hyperparameters."""
|
| 19 |
+
|
| 20 |
+
# Model architecture
|
| 21 |
+
d_model: int = 256
|
| 22 |
+
num_layers: int = 6
|
| 23 |
+
num_heads: int = 8
|
| 24 |
+
d_ff: int = 0 # 0 means "auto: 4 * d_model"
|
| 25 |
+
max_depth: int = 16
|
| 26 |
+
max_sibling: int = 32
|
| 27 |
+
tree_pos_variant: TreePosVariant = TreePosVariant.FULL
|
| 28 |
+
|
| 29 |
+
# Training
|
| 30 |
+
mask_prob: float = 0.15
|
| 31 |
+
lr: float = 1e-4
|
| 32 |
+
batch_size: int = 32
|
| 33 |
+
num_epochs: int = 30
|
| 34 |
+
max_seq_len: int = 512
|
| 35 |
+
skip_unk_targets: bool = True # v6: don't supervise on positions whose target is [UNK]
|
| 36 |
+
|
| 37 |
+
# Auxiliary loss weights
|
| 38 |
+
aux_kind_weight: float = 0.1 # α: kind classification loss weight
|
| 39 |
+
aux_parent_weight: float = 0.1 # β: parent_key classification loss weight
|
| 40 |
+
|
| 41 |
+
def __post_init__(self) -> None:
|
| 42 |
+
if self.d_ff == 0:
|
| 43 |
+
self.d_ff = 4 * self.d_model
|
yaml_bert/dataset.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import glob
|
| 4 |
+
import os
|
| 5 |
+
import random
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
from torch.utils.data import Dataset
|
| 9 |
+
|
| 10 |
+
from yaml_bert.annotator import DomainAnnotator
|
| 11 |
+
from yaml_bert.config import YamlBertConfig
|
| 12 |
+
from yaml_bert.linearizer import YamlLinearizer
|
| 13 |
+
from yaml_bert.types import NodeType, YamlNode
|
| 14 |
+
from yaml_bert.vocab import Vocabulary, compute_target
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _extract_kind(nodes: list[YamlNode]) -> str:
|
| 18 |
+
"""Extract the kind value from a document's node list, normalized to canonical casing."""
|
| 19 |
+
from yaml_bert.vocab import normalize_kind
|
| 20 |
+
for i, node in enumerate(nodes):
|
| 21 |
+
if (node.token == "kind"
|
| 22 |
+
and node.depth == 0
|
| 23 |
+
and node.node_type == NodeType.KEY
|
| 24 |
+
and i + 1 < len(nodes)
|
| 25 |
+
and nodes[i + 1].node_type == NodeType.VALUE):
|
| 26 |
+
return normalize_kind(nodes[i + 1].token)
|
| 27 |
+
return ""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# NodeType to integer index mapping
|
| 31 |
+
_NODE_TYPE_INDEX: dict[NodeType, int] = {
|
| 32 |
+
NodeType.KEY: 0,
|
| 33 |
+
NodeType.VALUE: 1,
|
| 34 |
+
NodeType.LIST_KEY: 2,
|
| 35 |
+
NodeType.LIST_VALUE: 3,
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
# Node types eligible for masking
|
| 39 |
+
_MASKABLE_TYPES: set[NodeType] = {NodeType.KEY, NodeType.LIST_KEY}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class YamlDataset(Dataset):
|
| 43 |
+
"""Dataset of linearized, masked YAML documents for YAML-BERT training."""
|
| 44 |
+
|
| 45 |
+
def __init__(
|
| 46 |
+
self,
|
| 47 |
+
yaml_dir: str,
|
| 48 |
+
vocab: Vocabulary,
|
| 49 |
+
linearizer: YamlLinearizer,
|
| 50 |
+
annotator: DomainAnnotator,
|
| 51 |
+
config: YamlBertConfig | None = None,
|
| 52 |
+
) -> None:
|
| 53 |
+
config = config or YamlBertConfig()
|
| 54 |
+
self.vocab: Vocabulary = vocab
|
| 55 |
+
self.linearizer: YamlLinearizer = linearizer
|
| 56 |
+
self.annotator: DomainAnnotator = annotator
|
| 57 |
+
self.mask_prob: float = config.mask_prob
|
| 58 |
+
self.max_seq_len: int = config.max_seq_len
|
| 59 |
+
|
| 60 |
+
# Load and linearize all YAML files. Skip any files the linearizer
|
| 61 |
+
# can't handle (e.g., uncommon tags like !!value) rather than failing
|
| 62 |
+
# the whole load.
|
| 63 |
+
yaml_files: list[str] = sorted(
|
| 64 |
+
glob.glob(os.path.join(yaml_dir, "**", "*.yaml"), recursive=True)
|
| 65 |
+
)
|
| 66 |
+
self.documents: list[list[YamlNode]] = []
|
| 67 |
+
self.document_kinds: list[str] = []
|
| 68 |
+
skipped: int = 0
|
| 69 |
+
for path in yaml_files:
|
| 70 |
+
try:
|
| 71 |
+
nodes: list[YamlNode] = linearizer.linearize_file(path)
|
| 72 |
+
except Exception:
|
| 73 |
+
skipped += 1
|
| 74 |
+
continue
|
| 75 |
+
if nodes:
|
| 76 |
+
annotator.annotate(nodes)
|
| 77 |
+
self.documents.append(nodes)
|
| 78 |
+
self.document_kinds.append(_extract_kind(nodes))
|
| 79 |
+
if skipped:
|
| 80 |
+
print(f"Skipped {skipped} unparseable YAML files")
|
| 81 |
+
|
| 82 |
+
@classmethod
|
| 83 |
+
def from_huggingface(
|
| 84 |
+
cls,
|
| 85 |
+
dataset_name: str,
|
| 86 |
+
vocab: Vocabulary,
|
| 87 |
+
linearizer: YamlLinearizer,
|
| 88 |
+
annotator: DomainAnnotator,
|
| 89 |
+
config: YamlBertConfig | None = None,
|
| 90 |
+
max_docs: int | None = None,
|
| 91 |
+
) -> "YamlDataset":
|
| 92 |
+
"""Load dataset from HuggingFace Hub.
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
dataset_name: HuggingFace dataset ID, e.g. "substratusai/the-stack-yaml-k8s"
|
| 96 |
+
vocab: Pre-built vocabulary
|
| 97 |
+
linearizer: YamlLinearizer instance
|
| 98 |
+
annotator: DomainAnnotator instance
|
| 99 |
+
config: Optional config (uses defaults if None)
|
| 100 |
+
max_docs: Load at most this many documents (None = all)
|
| 101 |
+
"""
|
| 102 |
+
from datasets import load_dataset
|
| 103 |
+
|
| 104 |
+
config = config or YamlBertConfig()
|
| 105 |
+
instance: YamlDataset = cls.__new__(cls)
|
| 106 |
+
instance.vocab = vocab
|
| 107 |
+
instance.linearizer = linearizer
|
| 108 |
+
instance.annotator = annotator
|
| 109 |
+
instance.mask_prob = config.mask_prob
|
| 110 |
+
instance.max_seq_len = config.max_seq_len
|
| 111 |
+
instance.documents = []
|
| 112 |
+
instance.document_kinds = []
|
| 113 |
+
|
| 114 |
+
print(f"Loading dataset: {dataset_name}")
|
| 115 |
+
ds = load_dataset(dataset_name, split="train")
|
| 116 |
+
|
| 117 |
+
total: int = len(ds) if max_docs is None else min(max_docs, len(ds))
|
| 118 |
+
print(f"Linearizing {total:,} / {len(ds):,} documents...")
|
| 119 |
+
|
| 120 |
+
skipped: int = 0
|
| 121 |
+
for i in range(total):
|
| 122 |
+
yaml_content: str = ds[i]["content"]
|
| 123 |
+
try:
|
| 124 |
+
nodes: list[YamlNode] = linearizer.linearize(yaml_content)
|
| 125 |
+
except Exception:
|
| 126 |
+
skipped += 1
|
| 127 |
+
continue
|
| 128 |
+
if nodes:
|
| 129 |
+
annotator.annotate(nodes)
|
| 130 |
+
instance.documents.append(nodes)
|
| 131 |
+
instance.document_kinds.append(_extract_kind(nodes))
|
| 132 |
+
|
| 133 |
+
if (i + 1) % 10000 == 0:
|
| 134 |
+
print(f" {i + 1:,} / {total:,} processed ({skipped} skipped)")
|
| 135 |
+
|
| 136 |
+
print(f"Loaded {len(instance.documents):,} documents ({skipped} skipped)")
|
| 137 |
+
return instance
|
| 138 |
+
|
| 139 |
+
@classmethod
|
| 140 |
+
def from_cached_docs(
|
| 141 |
+
cls,
|
| 142 |
+
documents: list[list[YamlNode]],
|
| 143 |
+
vocab: Vocabulary,
|
| 144 |
+
config: YamlBertConfig | None = None,
|
| 145 |
+
) -> "YamlDataset":
|
| 146 |
+
"""Build dataset from pre-linearized cached documents. No parsing needed."""
|
| 147 |
+
config = config or YamlBertConfig()
|
| 148 |
+
instance: YamlDataset = cls.__new__(cls)
|
| 149 |
+
instance.vocab = vocab
|
| 150 |
+
instance.linearizer = None
|
| 151 |
+
instance.annotator = None
|
| 152 |
+
instance.mask_prob = config.mask_prob
|
| 153 |
+
instance.max_seq_len = config.max_seq_len
|
| 154 |
+
instance.documents = documents
|
| 155 |
+
instance.document_kinds = [_extract_kind(doc) for doc in documents]
|
| 156 |
+
return instance
|
| 157 |
+
|
| 158 |
+
@classmethod
|
| 159 |
+
def from_cached_docs_v4(
|
| 160 |
+
cls,
|
| 161 |
+
documents: list[list[YamlNode]],
|
| 162 |
+
vocab: Vocabulary,
|
| 163 |
+
config: YamlBertConfig | None = None,
|
| 164 |
+
) -> "YamlDataset":
|
| 165 |
+
"""Build v4 dataset from pre-linearized cached documents."""
|
| 166 |
+
config = config or YamlBertConfig()
|
| 167 |
+
instance = cls.__new__(cls)
|
| 168 |
+
instance.vocab = vocab
|
| 169 |
+
instance.linearizer = None
|
| 170 |
+
instance.annotator = None
|
| 171 |
+
instance.mask_prob = config.mask_prob
|
| 172 |
+
instance.max_seq_len = config.max_seq_len
|
| 173 |
+
instance.skip_unk_targets = config.skip_unk_targets
|
| 174 |
+
instance.documents = documents
|
| 175 |
+
instance.document_kinds = [_extract_kind(doc) for doc in documents]
|
| 176 |
+
instance._v4_mode = True # Flag for __getitem__
|
| 177 |
+
return instance
|
| 178 |
+
|
| 179 |
+
def __len__(self) -> int:
|
| 180 |
+
return len(self.documents)
|
| 181 |
+
|
| 182 |
+
def _getitem_v4(self, idx: int) -> dict[str, torch.Tensor]:
|
| 183 |
+
"""v4 item encoding: hybrid simple/kind labels, no parent_key_ids or kind_ids."""
|
| 184 |
+
nodes: list[YamlNode] = self.documents[idx]
|
| 185 |
+
|
| 186 |
+
# Truncate if needed
|
| 187 |
+
if len(nodes) > self.max_seq_len:
|
| 188 |
+
nodes = nodes[: self.max_seq_len]
|
| 189 |
+
|
| 190 |
+
seq_len: int = len(nodes)
|
| 191 |
+
kind: str = self.document_kinds[idx]
|
| 192 |
+
|
| 193 |
+
token_ids: list[int] = []
|
| 194 |
+
node_types: list[int] = []
|
| 195 |
+
depths: list[int] = []
|
| 196 |
+
sibling_indices: list[int] = []
|
| 197 |
+
|
| 198 |
+
for node in nodes:
|
| 199 |
+
if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
|
| 200 |
+
token_ids.append(self.vocab.encode_key(node.token))
|
| 201 |
+
else:
|
| 202 |
+
token_ids.append(self.vocab.encode_value(node.token))
|
| 203 |
+
|
| 204 |
+
node_types.append(_NODE_TYPE_INDEX[node.node_type])
|
| 205 |
+
depths.append(min(node.depth, 15))
|
| 206 |
+
sibling_indices.append(min(node.sibling_index, 31))
|
| 207 |
+
|
| 208 |
+
# Apply masking
|
| 209 |
+
simple_labels: list[int] = [-100] * seq_len
|
| 210 |
+
kind_labels: list[int] = [-100] * seq_len
|
| 211 |
+
mask_token_id: int = self.vocab.special_tokens["[MASK]"]
|
| 212 |
+
unk_id: int = self.vocab.special_tokens["[UNK]"]
|
| 213 |
+
skip_unk: bool = getattr(self, "skip_unk_targets", True)
|
| 214 |
+
|
| 215 |
+
for i in range(seq_len):
|
| 216 |
+
if nodes[i].node_type not in _MASKABLE_TYPES:
|
| 217 |
+
continue
|
| 218 |
+
if random.random() >= self.mask_prob:
|
| 219 |
+
continue
|
| 220 |
+
|
| 221 |
+
# Compute hybrid target FIRST so we can skip positions whose
|
| 222 |
+
# target is [UNK] (Lever 1, v6.1 — don't train the model to
|
| 223 |
+
# confidently predict [UNK])
|
| 224 |
+
target, head_type = compute_target(nodes[i], kind)
|
| 225 |
+
if head_type == "simple":
|
| 226 |
+
target_id = self.vocab.encode_simple_target(target)
|
| 227 |
+
else:
|
| 228 |
+
target_id = self.vocab.encode_kind_target(target)
|
| 229 |
+
|
| 230 |
+
if skip_unk and target_id == unk_id:
|
| 231 |
+
continue
|
| 232 |
+
|
| 233 |
+
if head_type == "simple":
|
| 234 |
+
simple_labels[i] = target_id
|
| 235 |
+
kind_labels[i] = -100
|
| 236 |
+
else:
|
| 237 |
+
kind_labels[i] = target_id
|
| 238 |
+
simple_labels[i] = -100
|
| 239 |
+
|
| 240 |
+
# Apply token replacement (80/10/10 rule)
|
| 241 |
+
rand: float = random.random()
|
| 242 |
+
if rand < 0.8:
|
| 243 |
+
token_ids[i] = mask_token_id
|
| 244 |
+
elif rand < 0.9:
|
| 245 |
+
random_key_id: int = random.randint(
|
| 246 |
+
len(self.vocab.special_tokens),
|
| 247 |
+
len(self.vocab.key_vocab) + len(self.vocab.special_tokens) - 1,
|
| 248 |
+
)
|
| 249 |
+
token_ids[i] = random_key_id
|
| 250 |
+
# else 10%: keep unchanged
|
| 251 |
+
|
| 252 |
+
return {
|
| 253 |
+
"token_ids": torch.tensor(token_ids, dtype=torch.long),
|
| 254 |
+
"node_types": torch.tensor(node_types, dtype=torch.long),
|
| 255 |
+
"depths": torch.tensor(depths, dtype=torch.long),
|
| 256 |
+
"sibling_indices": torch.tensor(sibling_indices, dtype=torch.long),
|
| 257 |
+
"simple_labels": torch.tensor(simple_labels, dtype=torch.long),
|
| 258 |
+
"kind_labels": torch.tensor(kind_labels, dtype=torch.long),
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
def __getitem__(self, idx: int) -> dict[str, torch.Tensor]:
|
| 262 |
+
if getattr(self, "_v4_mode", False):
|
| 263 |
+
return self._getitem_v4(idx)
|
| 264 |
+
|
| 265 |
+
nodes: list[YamlNode] = self.documents[idx]
|
| 266 |
+
|
| 267 |
+
# Truncate if needed
|
| 268 |
+
if len(nodes) > self.max_seq_len:
|
| 269 |
+
nodes = nodes[: self.max_seq_len]
|
| 270 |
+
|
| 271 |
+
seq_len: int = len(nodes)
|
| 272 |
+
|
| 273 |
+
# Encode nodes to integer arrays
|
| 274 |
+
token_ids: list[int] = []
|
| 275 |
+
node_types: list[int] = []
|
| 276 |
+
depths: list[int] = []
|
| 277 |
+
sibling_indices: list[int] = []
|
| 278 |
+
parent_key_ids: list[int] = []
|
| 279 |
+
|
| 280 |
+
for node in nodes:
|
| 281 |
+
# Token ID — route to correct vocab
|
| 282 |
+
if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
|
| 283 |
+
token_ids.append(self.vocab.encode_key(node.token))
|
| 284 |
+
else:
|
| 285 |
+
token_ids.append(self.vocab.encode_value(node.token))
|
| 286 |
+
|
| 287 |
+
node_types.append(_NODE_TYPE_INDEX[node.node_type])
|
| 288 |
+
depths.append(min(node.depth, 15)) # clamp to max_depth - 1
|
| 289 |
+
sibling_indices.append(min(node.sibling_index, 31)) # clamp to max_sibling - 1
|
| 290 |
+
|
| 291 |
+
parent_key: str = Vocabulary.extract_parent_key(node.parent_path)
|
| 292 |
+
parent_key_ids.append(self.vocab.encode_key(parent_key))
|
| 293 |
+
|
| 294 |
+
# Apply masking (only to KEY and LIST_KEY nodes)
|
| 295 |
+
labels: list[int] = [-100] * seq_len
|
| 296 |
+
mask_token_id: int = self.vocab.special_tokens["[MASK]"]
|
| 297 |
+
|
| 298 |
+
for i in range(seq_len):
|
| 299 |
+
if nodes[i].node_type not in _MASKABLE_TYPES:
|
| 300 |
+
continue
|
| 301 |
+
if random.random() >= self.mask_prob:
|
| 302 |
+
continue
|
| 303 |
+
|
| 304 |
+
# This position is selected for masking
|
| 305 |
+
labels[i] = token_ids[i] # save original token ID as label
|
| 306 |
+
|
| 307 |
+
rand: float = random.random()
|
| 308 |
+
if rand < 0.8:
|
| 309 |
+
# 80%: replace with [MASK]
|
| 310 |
+
token_ids[i] = mask_token_id
|
| 311 |
+
elif rand < 0.9:
|
| 312 |
+
# 10%: replace with random key
|
| 313 |
+
random_key_id: int = random.randint(
|
| 314 |
+
len(self.vocab.special_tokens),
|
| 315 |
+
len(self.vocab.key_vocab) + len(self.vocab.special_tokens) - 1,
|
| 316 |
+
)
|
| 317 |
+
token_ids[i] = random_key_id
|
| 318 |
+
# else 10%: keep unchanged
|
| 319 |
+
|
| 320 |
+
kind: str = self.document_kinds[idx]
|
| 321 |
+
kind_id: int = self.vocab.encode_kind(kind)
|
| 322 |
+
kind_ids: list[int] = [kind_id] * seq_len
|
| 323 |
+
|
| 324 |
+
return {
|
| 325 |
+
"token_ids": torch.tensor(token_ids, dtype=torch.long),
|
| 326 |
+
"node_types": torch.tensor(node_types, dtype=torch.long),
|
| 327 |
+
"depths": torch.tensor(depths, dtype=torch.long),
|
| 328 |
+
"sibling_indices": torch.tensor(sibling_indices, dtype=torch.long),
|
| 329 |
+
"parent_key_ids": torch.tensor(parent_key_ids, dtype=torch.long),
|
| 330 |
+
"labels": torch.tensor(labels, dtype=torch.long),
|
| 331 |
+
"kind_ids": torch.tensor(kind_ids, dtype=torch.long),
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def collate_fn(batch: list[dict[str, torch.Tensor]]) -> dict[str, torch.Tensor]:
|
| 336 |
+
"""Pad a batch of variable-length sequences and create padding mask."""
|
| 337 |
+
max_len: int = max(item["token_ids"].size(0) for item in batch)
|
| 338 |
+
|
| 339 |
+
padded: dict[str, list[torch.Tensor]] = {key: [] for key in batch[0].keys()}
|
| 340 |
+
padding_masks: list[torch.Tensor] = []
|
| 341 |
+
|
| 342 |
+
for item in batch:
|
| 343 |
+
seq_len: int = item["token_ids"].size(0)
|
| 344 |
+
pad_len: int = max_len - seq_len
|
| 345 |
+
|
| 346 |
+
for key in item:
|
| 347 |
+
if pad_len > 0:
|
| 348 |
+
pad_value: int = -100 if (key == "labels" or key.endswith("_labels")) else 0
|
| 349 |
+
padding: torch.Tensor = torch.full(
|
| 350 |
+
(pad_len,), pad_value, dtype=torch.long
|
| 351 |
+
)
|
| 352 |
+
padded[key].append(torch.cat([item[key], padding]))
|
| 353 |
+
else:
|
| 354 |
+
padded[key].append(item[key])
|
| 355 |
+
|
| 356 |
+
mask: torch.Tensor = torch.cat([
|
| 357 |
+
torch.zeros(seq_len, dtype=torch.bool),
|
| 358 |
+
torch.ones(pad_len, dtype=torch.bool),
|
| 359 |
+
]) if pad_len > 0 else torch.zeros(seq_len, dtype=torch.bool)
|
| 360 |
+
padding_masks.append(mask)
|
| 361 |
+
|
| 362 |
+
result: dict[str, torch.Tensor] = {
|
| 363 |
+
key: torch.stack(tensors) for key, tensors in padded.items()
|
| 364 |
+
}
|
| 365 |
+
result["padding_mask"] = torch.stack(padding_masks)
|
| 366 |
+
|
| 367 |
+
return result
|
yaml_bert/embedding.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
|
| 6 |
+
from yaml_bert.config import TreePosVariant, YamlBertConfig
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class YamlBertEmbedding(nn.Module):
|
| 10 |
+
"""Embedding layer with tree positional encoding for YAML-BERT.
|
| 11 |
+
|
| 12 |
+
Produces input vectors by summing:
|
| 13 |
+
- Token embedding (key_embedding or value_embedding, routed by node_type)
|
| 14 |
+
- Tree positional encoding (composition depends on config.tree_pos_variant)
|
| 15 |
+
|
| 16 |
+
Kind and parent awareness come from the hybrid prediction targets, not input encoding.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
def __init__(
|
| 20 |
+
self,
|
| 21 |
+
config: YamlBertConfig,
|
| 22 |
+
key_vocab_size: int,
|
| 23 |
+
value_vocab_size: int,
|
| 24 |
+
) -> None:
|
| 25 |
+
super().__init__()
|
| 26 |
+
d: int = config.d_model
|
| 27 |
+
variant: TreePosVariant = config.tree_pos_variant
|
| 28 |
+
self.variant: TreePosVariant = variant
|
| 29 |
+
|
| 30 |
+
self.key_embedding: nn.Embedding = nn.Embedding(key_vocab_size, d)
|
| 31 |
+
self.value_embedding: nn.Embedding = nn.Embedding(value_vocab_size, d)
|
| 32 |
+
self.node_type_embedding: nn.Embedding = nn.Embedding(4, d)
|
| 33 |
+
|
| 34 |
+
use_depth: bool = variant in (TreePosVariant.FULL, TreePosVariant.NO_SIBLING)
|
| 35 |
+
use_sibling: bool = variant in (TreePosVariant.FULL, TreePosVariant.NO_DEPTH)
|
| 36 |
+
use_seq_pos: bool = variant == TreePosVariant.SEQUENTIAL
|
| 37 |
+
|
| 38 |
+
self.depth_embedding: nn.Embedding | None = (
|
| 39 |
+
nn.Embedding(config.max_depth, d) if use_depth else None
|
| 40 |
+
)
|
| 41 |
+
self.sibling_embedding: nn.Embedding | None = (
|
| 42 |
+
nn.Embedding(config.max_sibling, d) if use_sibling else None
|
| 43 |
+
)
|
| 44 |
+
self.pos_embedding: nn.Embedding | None = (
|
| 45 |
+
nn.Embedding(config.max_seq_len, d) if use_seq_pos else None
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
self.layer_norm: nn.LayerNorm = nn.LayerNorm(d)
|
| 49 |
+
|
| 50 |
+
def forward(
|
| 51 |
+
self,
|
| 52 |
+
token_ids: torch.Tensor,
|
| 53 |
+
node_types: torch.Tensor,
|
| 54 |
+
depths: torch.Tensor,
|
| 55 |
+
sibling_indices: torch.Tensor,
|
| 56 |
+
) -> torch.Tensor:
|
| 57 |
+
is_key: torch.Tensor = (node_types == 0) | (node_types == 2)
|
| 58 |
+
key_vocab_size: int = self.key_embedding.num_embeddings
|
| 59 |
+
val_vocab_size: int = self.value_embedding.num_embeddings
|
| 60 |
+
key_emb: torch.Tensor = self.key_embedding(token_ids.clamp(0, key_vocab_size - 1))
|
| 61 |
+
val_emb: torch.Tensor = self.value_embedding(token_ids.clamp(0, val_vocab_size - 1))
|
| 62 |
+
token_emb: torch.Tensor = torch.where(is_key.unsqueeze(-1), key_emb, val_emb)
|
| 63 |
+
|
| 64 |
+
tree_pos: torch.Tensor = self.node_type_embedding(node_types)
|
| 65 |
+
if self.depth_embedding is not None:
|
| 66 |
+
tree_pos = tree_pos + self.depth_embedding(depths)
|
| 67 |
+
if self.sibling_embedding is not None:
|
| 68 |
+
tree_pos = tree_pos + self.sibling_embedding(sibling_indices)
|
| 69 |
+
if self.pos_embedding is not None:
|
| 70 |
+
seq_len: int = token_ids.size(1)
|
| 71 |
+
max_pos: int = self.pos_embedding.num_embeddings
|
| 72 |
+
positions: torch.Tensor = (
|
| 73 |
+
torch.arange(seq_len, device=token_ids.device)
|
| 74 |
+
.clamp(max=max_pos - 1)
|
| 75 |
+
.unsqueeze(0)
|
| 76 |
+
.expand(token_ids.size(0), seq_len)
|
| 77 |
+
)
|
| 78 |
+
tree_pos = tree_pos + self.pos_embedding(positions)
|
| 79 |
+
|
| 80 |
+
return self.layer_norm(token_emb + tree_pos)
|
yaml_bert/evaluate.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
|
| 8 |
+
from yaml_bert.dataset import YamlDataset
|
| 9 |
+
from yaml_bert.model import YamlBertModel
|
| 10 |
+
from yaml_bert.vocab import Vocabulary
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class YamlBertEvaluator:
|
| 14 |
+
"""Post-training evaluation for YAML-BERT."""
|
| 15 |
+
|
| 16 |
+
def __init__(
|
| 17 |
+
self,
|
| 18 |
+
model: YamlBertModel,
|
| 19 |
+
dataset: YamlDataset,
|
| 20 |
+
vocab: Vocabulary,
|
| 21 |
+
) -> None:
|
| 22 |
+
self.model: YamlBertModel = model
|
| 23 |
+
self.dataset: YamlDataset = dataset
|
| 24 |
+
self.vocab: Vocabulary = vocab
|
| 25 |
+
self.device: torch.device = next(model.parameters()).device
|
| 26 |
+
self._id_to_simple: dict[int, str] = {
|
| 27 |
+
v: k for k, v in vocab.simple_target_vocab.items()
|
| 28 |
+
}
|
| 29 |
+
self._id_to_kind: dict[int, str] = {
|
| 30 |
+
v: k for k, v in vocab.kind_target_vocab.items()
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
def _decode_simple(self, id: int) -> str:
|
| 34 |
+
if id in self.vocab._id_to_special:
|
| 35 |
+
return self.vocab._id_to_special[id]
|
| 36 |
+
return self._id_to_simple.get(id, "[UNK]")
|
| 37 |
+
|
| 38 |
+
def _decode_kind(self, id: int) -> str:
|
| 39 |
+
if id in self.vocab._id_to_special:
|
| 40 |
+
return self.vocab._id_to_special[id]
|
| 41 |
+
return self._id_to_kind.get(id, "[UNK]")
|
| 42 |
+
|
| 43 |
+
@torch.no_grad()
|
| 44 |
+
def evaluate_prediction_accuracy(self) -> dict[str, float]:
|
| 45 |
+
"""Compute top-1 and top-5 masked key prediction accuracy over the dataset.
|
| 46 |
+
|
| 47 |
+
Evaluates both simple and kind-specific heads independently.
|
| 48 |
+
"""
|
| 49 |
+
self.model.eval()
|
| 50 |
+
|
| 51 |
+
simple_total: int = 0
|
| 52 |
+
simple_top1: int = 0
|
| 53 |
+
simple_top5: int = 0
|
| 54 |
+
kind_total: int = 0
|
| 55 |
+
kind_top1: int = 0
|
| 56 |
+
kind_top5: int = 0
|
| 57 |
+
|
| 58 |
+
for idx in range(len(self.dataset)):
|
| 59 |
+
item: dict[str, torch.Tensor] = self.dataset[idx]
|
| 60 |
+
simple_labels: torch.Tensor = item["simple_labels"]
|
| 61 |
+
kind_labels: torch.Tensor = item["kind_labels"]
|
| 62 |
+
|
| 63 |
+
simple_masked: torch.Tensor = simple_labels != -100
|
| 64 |
+
kind_masked: torch.Tensor = kind_labels != -100
|
| 65 |
+
if not simple_masked.any() and not kind_masked.any():
|
| 66 |
+
continue
|
| 67 |
+
|
| 68 |
+
simple_logits, kind_logits = self.model(
|
| 69 |
+
token_ids=item["token_ids"].unsqueeze(0).to(self.device),
|
| 70 |
+
node_types=item["node_types"].unsqueeze(0).to(self.device),
|
| 71 |
+
depths=item["depths"].unsqueeze(0).to(self.device),
|
| 72 |
+
sibling_indices=item["sibling_indices"].unsqueeze(0).to(self.device),
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
s_logits: torch.Tensor = simple_logits[0]
|
| 76 |
+
for pos in simple_masked.nonzero(as_tuple=True)[0]:
|
| 77 |
+
true_id: int = simple_labels[pos].item()
|
| 78 |
+
pos_logits: torch.Tensor = s_logits[pos]
|
| 79 |
+
top5_ids: torch.Tensor = pos_logits.topk(5).indices
|
| 80 |
+
|
| 81 |
+
if top5_ids[0].item() == true_id:
|
| 82 |
+
simple_top1 += 1
|
| 83 |
+
if true_id in top5_ids.tolist():
|
| 84 |
+
simple_top5 += 1
|
| 85 |
+
simple_total += 1
|
| 86 |
+
|
| 87 |
+
k_logits: torch.Tensor = kind_logits[0]
|
| 88 |
+
for pos in kind_masked.nonzero(as_tuple=True)[0]:
|
| 89 |
+
true_id = kind_labels[pos].item()
|
| 90 |
+
pos_logits = k_logits[pos]
|
| 91 |
+
top5_ids = pos_logits.topk(5).indices
|
| 92 |
+
|
| 93 |
+
if top5_ids[0].item() == true_id:
|
| 94 |
+
kind_top1 += 1
|
| 95 |
+
if true_id in top5_ids.tolist():
|
| 96 |
+
kind_top5 += 1
|
| 97 |
+
kind_total += 1
|
| 98 |
+
|
| 99 |
+
total_masked: int = simple_total + kind_total
|
| 100 |
+
total_top1: int = simple_top1 + kind_top1
|
| 101 |
+
total_top5: int = simple_top5 + kind_top5
|
| 102 |
+
|
| 103 |
+
return {
|
| 104 |
+
"top1_accuracy": total_top1 / max(total_masked, 1),
|
| 105 |
+
"top5_accuracy": total_top5 / max(total_masked, 1),
|
| 106 |
+
"total_masked": total_masked,
|
| 107 |
+
"simple_top1_accuracy": simple_top1 / max(simple_total, 1),
|
| 108 |
+
"kind_top1_accuracy": kind_top1 / max(kind_total, 1),
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
@torch.no_grad()
|
| 112 |
+
def analyze_embeddings(self) -> list[dict[str, Any]]:
|
| 113 |
+
"""Compare embeddings of the same key at different tree positions."""
|
| 114 |
+
self.model.eval()
|
| 115 |
+
results: list[dict[str, Any]] = []
|
| 116 |
+
|
| 117 |
+
test_pairs: list[dict[str, Any]] = [
|
| 118 |
+
{
|
| 119 |
+
"key": "spec",
|
| 120 |
+
"position_a": {"depth": 0},
|
| 121 |
+
"position_b": {"depth": 2},
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"key": "name",
|
| 125 |
+
"position_a": {"depth": 1},
|
| 126 |
+
"position_b": {"depth": 1},
|
| 127 |
+
},
|
| 128 |
+
]
|
| 129 |
+
|
| 130 |
+
for pair in test_pairs:
|
| 131 |
+
key_id: int = self.vocab.encode_key(pair["key"])
|
| 132 |
+
|
| 133 |
+
token_ids: torch.Tensor = torch.tensor(
|
| 134 |
+
[[key_id, key_id]], device=self.device
|
| 135 |
+
)
|
| 136 |
+
node_types: torch.Tensor = torch.tensor(
|
| 137 |
+
[[0, 0]], device=self.device
|
| 138 |
+
)
|
| 139 |
+
depths: torch.Tensor = torch.tensor(
|
| 140 |
+
[[pair["position_a"]["depth"], pair["position_b"]["depth"]]],
|
| 141 |
+
device=self.device,
|
| 142 |
+
)
|
| 143 |
+
siblings: torch.Tensor = torch.tensor(
|
| 144 |
+
[[0, 0]], device=self.device
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
embeddings: torch.Tensor = self.model.embedding(
|
| 148 |
+
token_ids, node_types, depths, siblings
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
cosine_sim: float = F.cosine_similarity(
|
| 152 |
+
embeddings[0, 0].unsqueeze(0),
|
| 153 |
+
embeddings[0, 1].unsqueeze(0),
|
| 154 |
+
).item()
|
| 155 |
+
|
| 156 |
+
results.append({
|
| 157 |
+
"key": pair["key"],
|
| 158 |
+
"position_a": pair["position_a"],
|
| 159 |
+
"position_b": pair["position_b"],
|
| 160 |
+
"cosine_similarity": cosine_sim,
|
| 161 |
+
})
|
| 162 |
+
|
| 163 |
+
return results
|
| 164 |
+
|
| 165 |
+
@torch.no_grad()
|
| 166 |
+
def top_k_predictions(
|
| 167 |
+
self, doc_idx: int, k: int = 5
|
| 168 |
+
) -> list[dict[str, Any]]:
|
| 169 |
+
"""Show top-k predicted keys for each masked position in a document.
|
| 170 |
+
|
| 171 |
+
Reports predictions from both the simple and kind-specific heads.
|
| 172 |
+
"""
|
| 173 |
+
self.model.eval()
|
| 174 |
+
|
| 175 |
+
item: dict[str, torch.Tensor] = self.dataset[doc_idx]
|
| 176 |
+
simple_labels: torch.Tensor = item["simple_labels"]
|
| 177 |
+
kind_labels: torch.Tensor = item["kind_labels"]
|
| 178 |
+
|
| 179 |
+
simple_masked: torch.Tensor = simple_labels != -100
|
| 180 |
+
kind_masked: torch.Tensor = kind_labels != -100
|
| 181 |
+
if not simple_masked.any() and not kind_masked.any():
|
| 182 |
+
return []
|
| 183 |
+
|
| 184 |
+
simple_logits, kind_logits = self.model(
|
| 185 |
+
token_ids=item["token_ids"].unsqueeze(0).to(self.device),
|
| 186 |
+
node_types=item["node_types"].unsqueeze(0).to(self.device),
|
| 187 |
+
depths=item["depths"].unsqueeze(0).to(self.device),
|
| 188 |
+
sibling_indices=item["sibling_indices"].unsqueeze(0).to(self.device),
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
predictions: list[dict[str, Any]] = []
|
| 192 |
+
|
| 193 |
+
# Simple head predictions
|
| 194 |
+
s_logits: torch.Tensor = simple_logits[0]
|
| 195 |
+
for pos in simple_masked.nonzero(as_tuple=True)[0]:
|
| 196 |
+
true_id: int = simple_labels[pos].item()
|
| 197 |
+
pos_logits: torch.Tensor = s_logits[pos]
|
| 198 |
+
probs: torch.Tensor = F.softmax(pos_logits, dim=-1)
|
| 199 |
+
topk: torch.return_types.topk = probs.topk(k)
|
| 200 |
+
|
| 201 |
+
predicted_keys: list[dict[str, Any]] = [
|
| 202 |
+
{
|
| 203 |
+
"key": self._decode_simple(topk.indices[i].item()),
|
| 204 |
+
"probability": topk.values[i].item(),
|
| 205 |
+
}
|
| 206 |
+
for i in range(k)
|
| 207 |
+
]
|
| 208 |
+
|
| 209 |
+
predictions.append({
|
| 210 |
+
"position": pos.item(),
|
| 211 |
+
"head": "simple",
|
| 212 |
+
"true_key": self._decode_simple(true_id),
|
| 213 |
+
"predicted_keys": predicted_keys,
|
| 214 |
+
})
|
| 215 |
+
|
| 216 |
+
# Kind-specific head predictions
|
| 217 |
+
k_logits: torch.Tensor = kind_logits[0]
|
| 218 |
+
for pos in kind_masked.nonzero(as_tuple=True)[0]:
|
| 219 |
+
true_id = kind_labels[pos].item()
|
| 220 |
+
pos_logits = k_logits[pos]
|
| 221 |
+
probs = F.softmax(pos_logits, dim=-1)
|
| 222 |
+
topk = probs.topk(k)
|
| 223 |
+
|
| 224 |
+
predicted_keys = [
|
| 225 |
+
{
|
| 226 |
+
"key": self._decode_kind(topk.indices[i].item()),
|
| 227 |
+
"probability": topk.values[i].item(),
|
| 228 |
+
}
|
| 229 |
+
for i in range(k)
|
| 230 |
+
]
|
| 231 |
+
|
| 232 |
+
predictions.append({
|
| 233 |
+
"position": pos.item(),
|
| 234 |
+
"head": "kind",
|
| 235 |
+
"true_key": self._decode_kind(true_id),
|
| 236 |
+
"predicted_keys": predicted_keys,
|
| 237 |
+
})
|
| 238 |
+
|
| 239 |
+
predictions.sort(key=lambda p: p["position"])
|
| 240 |
+
return predictions
|
yaml_bert/linearizer.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import yaml
|
| 4 |
+
|
| 5 |
+
from yaml_bert.types import NodeType, YamlNode
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class YamlLinearizer:
|
| 9 |
+
def linearize(self, yaml_string: str) -> list[YamlNode]:
|
| 10 |
+
data = yaml.safe_load(yaml_string)
|
| 11 |
+
if data is None:
|
| 12 |
+
return []
|
| 13 |
+
nodes: list[YamlNode] = []
|
| 14 |
+
self._walk(data, depth=0, parent_path="", nodes=nodes, in_list=False)
|
| 15 |
+
return nodes
|
| 16 |
+
|
| 17 |
+
def _walk(
|
| 18 |
+
self,
|
| 19 |
+
data,
|
| 20 |
+
depth: int,
|
| 21 |
+
parent_path: str,
|
| 22 |
+
nodes: list[YamlNode],
|
| 23 |
+
in_list: bool,
|
| 24 |
+
) -> None:
|
| 25 |
+
if isinstance(data, dict):
|
| 26 |
+
for sibling_index, (key, value) in enumerate(data.items()):
|
| 27 |
+
key_str = str(key)
|
| 28 |
+
key_type = NodeType.LIST_KEY if in_list else NodeType.KEY
|
| 29 |
+
nodes.append(
|
| 30 |
+
YamlNode(
|
| 31 |
+
token=key_str,
|
| 32 |
+
node_type=key_type,
|
| 33 |
+
depth=depth,
|
| 34 |
+
sibling_index=sibling_index,
|
| 35 |
+
parent_path=parent_path,
|
| 36 |
+
)
|
| 37 |
+
)
|
| 38 |
+
if isinstance(value, dict):
|
| 39 |
+
child_path = f"{parent_path}.{key_str}" if parent_path else key_str
|
| 40 |
+
self._walk(value, depth + 1, child_path, nodes, in_list=False)
|
| 41 |
+
elif isinstance(value, list):
|
| 42 |
+
child_path = f"{parent_path}.{key_str}" if parent_path else key_str
|
| 43 |
+
self._walk_list(value, depth + 1, child_path, nodes)
|
| 44 |
+
else:
|
| 45 |
+
value_path = f"{parent_path}.{key_str}" if parent_path else key_str
|
| 46 |
+
value_type = NodeType.LIST_VALUE if in_list else NodeType.VALUE
|
| 47 |
+
nodes.append(
|
| 48 |
+
YamlNode(
|
| 49 |
+
token=str(value),
|
| 50 |
+
node_type=value_type,
|
| 51 |
+
depth=depth,
|
| 52 |
+
sibling_index=sibling_index,
|
| 53 |
+
parent_path=value_path,
|
| 54 |
+
)
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
def linearize_file(self, path: str) -> list[YamlNode]:
|
| 58 |
+
with open(path) as f:
|
| 59 |
+
content = f.read()
|
| 60 |
+
nodes: list[YamlNode] = []
|
| 61 |
+
for doc in yaml.safe_load_all(content):
|
| 62 |
+
if doc is None:
|
| 63 |
+
continue
|
| 64 |
+
self._walk(doc, depth=0, parent_path="", nodes=nodes, in_list=False)
|
| 65 |
+
return nodes
|
| 66 |
+
|
| 67 |
+
def linearize_multi_doc(self, yaml_string: str) -> list[list[YamlNode]]:
|
| 68 |
+
result = []
|
| 69 |
+
for doc in yaml.safe_load_all(yaml_string):
|
| 70 |
+
if doc is None:
|
| 71 |
+
continue
|
| 72 |
+
nodes: list[YamlNode] = []
|
| 73 |
+
self._walk(doc, depth=0, parent_path="", nodes=nodes, in_list=False)
|
| 74 |
+
result.append(nodes)
|
| 75 |
+
return result
|
| 76 |
+
|
| 77 |
+
def _walk_list(
|
| 78 |
+
self,
|
| 79 |
+
data: list,
|
| 80 |
+
depth: int,
|
| 81 |
+
parent_path: str,
|
| 82 |
+
nodes: list[YamlNode],
|
| 83 |
+
) -> None:
|
| 84 |
+
for item_index, item in enumerate(data):
|
| 85 |
+
item_path = f"{parent_path}.{item_index}"
|
| 86 |
+
if isinstance(item, dict):
|
| 87 |
+
self._walk(item, depth, item_path, nodes, in_list=True)
|
| 88 |
+
elif isinstance(item, list):
|
| 89 |
+
self._walk_list(item, depth, item_path, nodes)
|
| 90 |
+
else:
|
| 91 |
+
nodes.append(
|
| 92 |
+
YamlNode(
|
| 93 |
+
token=str(item),
|
| 94 |
+
node_type=NodeType.LIST_VALUE,
|
| 95 |
+
depth=depth,
|
| 96 |
+
sibling_index=item_index,
|
| 97 |
+
parent_path=item_path,
|
| 98 |
+
)
|
| 99 |
+
)
|
yaml_bert/model.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
|
| 6 |
+
from yaml_bert.config import YamlBertConfig
|
| 7 |
+
from yaml_bert.embedding import YamlBertEmbedding
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class YamlBertModel(nn.Module):
|
| 11 |
+
"""YAML-BERT: Transformer encoder with tree positional encoding.
|
| 12 |
+
|
| 13 |
+
Two prediction heads: simple (bigram) + kind-specific (trigram).
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
def __init__(
|
| 17 |
+
self,
|
| 18 |
+
config: YamlBertConfig,
|
| 19 |
+
embedding: YamlBertEmbedding,
|
| 20 |
+
simple_vocab_size: int,
|
| 21 |
+
kind_vocab_size: int,
|
| 22 |
+
) -> None:
|
| 23 |
+
super().__init__()
|
| 24 |
+
self.embedding: YamlBertEmbedding = embedding
|
| 25 |
+
|
| 26 |
+
encoder_layer: nn.TransformerEncoderLayer = nn.TransformerEncoderLayer(
|
| 27 |
+
d_model=config.d_model,
|
| 28 |
+
nhead=config.num_heads,
|
| 29 |
+
dim_feedforward=config.d_ff,
|
| 30 |
+
batch_first=True,
|
| 31 |
+
)
|
| 32 |
+
self.encoder: nn.TransformerEncoder = nn.TransformerEncoder(
|
| 33 |
+
encoder_layer, num_layers=config.num_layers
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
self.simple_head: nn.Linear = nn.Linear(config.d_model, simple_vocab_size)
|
| 37 |
+
self.kind_head: nn.Linear = nn.Linear(config.d_model, kind_vocab_size)
|
| 38 |
+
self.loss_fn: nn.CrossEntropyLoss = nn.CrossEntropyLoss(ignore_index=-100)
|
| 39 |
+
|
| 40 |
+
def forward(
|
| 41 |
+
self,
|
| 42 |
+
token_ids: torch.Tensor,
|
| 43 |
+
node_types: torch.Tensor,
|
| 44 |
+
depths: torch.Tensor,
|
| 45 |
+
sibling_indices: torch.Tensor,
|
| 46 |
+
padding_mask: torch.Tensor | None = None,
|
| 47 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 48 |
+
x: torch.Tensor = self.embedding(token_ids, node_types, depths, sibling_indices)
|
| 49 |
+
x = self.encoder(x, src_key_padding_mask=padding_mask)
|
| 50 |
+
simple_logits: torch.Tensor = self.simple_head(x)
|
| 51 |
+
kind_logits: torch.Tensor = self.kind_head(x)
|
| 52 |
+
return simple_logits, kind_logits
|
| 53 |
+
|
| 54 |
+
def compute_loss(
|
| 55 |
+
self,
|
| 56 |
+
simple_logits: torch.Tensor,
|
| 57 |
+
simple_labels: torch.Tensor,
|
| 58 |
+
kind_logits: torch.Tensor,
|
| 59 |
+
kind_labels: torch.Tensor,
|
| 60 |
+
) -> tuple[torch.Tensor, dict[str, float]]:
|
| 61 |
+
simple_loss: torch.Tensor = self.loss_fn(
|
| 62 |
+
simple_logits.view(-1, simple_logits.size(-1)),
|
| 63 |
+
simple_labels.view(-1),
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Kind loss: only compute if there are kind-specific labels in this batch
|
| 67 |
+
has_kind: bool = (kind_labels != -100).any().item()
|
| 68 |
+
if has_kind:
|
| 69 |
+
kind_loss: torch.Tensor = self.loss_fn(
|
| 70 |
+
kind_logits.view(-1, kind_logits.size(-1)),
|
| 71 |
+
kind_labels.view(-1),
|
| 72 |
+
)
|
| 73 |
+
else:
|
| 74 |
+
kind_loss = torch.tensor(0.0, device=simple_logits.device)
|
| 75 |
+
|
| 76 |
+
total: torch.Tensor = simple_loss + kind_loss
|
| 77 |
+
return total, {"simple": simple_loss.item(), "kind": kind_loss.item()}
|
yaml_bert/pooling.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
import torch.nn.functional as F
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class DocumentPooling(nn.Module):
|
| 9 |
+
"""Pooling by Multi-head Attention.
|
| 10 |
+
|
| 11 |
+
The kind node queries all other nodes via cross-attention
|
| 12 |
+
to produce a single document embedding.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
def __init__(self, d_model: int, num_heads: int = 4) -> None:
|
| 16 |
+
super().__init__()
|
| 17 |
+
self.query_proj: nn.Linear = nn.Linear(d_model, d_model)
|
| 18 |
+
self.cross_attn: nn.MultiheadAttention = nn.MultiheadAttention(
|
| 19 |
+
d_model, num_heads, batch_first=True,
|
| 20 |
+
)
|
| 21 |
+
self.layer_norm: nn.LayerNorm = nn.LayerNorm(d_model)
|
| 22 |
+
|
| 23 |
+
def forward(
|
| 24 |
+
self,
|
| 25 |
+
kind_hidden: torch.Tensor,
|
| 26 |
+
all_hidden: torch.Tensor,
|
| 27 |
+
) -> torch.Tensor:
|
| 28 |
+
query: torch.Tensor = self.query_proj(kind_hidden)
|
| 29 |
+
doc_emb, _ = self.cross_attn(query, all_hidden, all_hidden)
|
| 30 |
+
doc_emb = self.layer_norm(doc_emb)
|
| 31 |
+
return doc_emb.squeeze(1)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def supervised_contrastive_loss(
|
| 35 |
+
embeddings: torch.Tensor,
|
| 36 |
+
labels: torch.Tensor,
|
| 37 |
+
temperature: float = 0.1,
|
| 38 |
+
) -> torch.Tensor:
|
| 39 |
+
embeddings = F.normalize(embeddings, dim=1)
|
| 40 |
+
batch_size: int = embeddings.shape[0]
|
| 41 |
+
|
| 42 |
+
sim: torch.Tensor = embeddings @ embeddings.T / temperature
|
| 43 |
+
|
| 44 |
+
label_mask: torch.Tensor = labels.unsqueeze(0) == labels.unsqueeze(1)
|
| 45 |
+
self_mask: torch.Tensor = torch.eye(batch_size, dtype=torch.bool, device=embeddings.device)
|
| 46 |
+
label_mask = label_mask & ~self_mask
|
| 47 |
+
|
| 48 |
+
sim_max, _ = sim.max(dim=1, keepdim=True)
|
| 49 |
+
sim = sim - sim_max.detach()
|
| 50 |
+
|
| 51 |
+
exp_sim: torch.Tensor = torch.exp(sim) * (~self_mask).float()
|
| 52 |
+
log_prob: torch.Tensor = sim - torch.log(exp_sim.sum(dim=1, keepdim=True) + 1e-8)
|
| 53 |
+
|
| 54 |
+
pos_count: torch.Tensor = label_mask.float().sum(dim=1).clamp(min=1)
|
| 55 |
+
loss: torch.Tensor = -(label_mask.float() * log_prob).sum(dim=1) / pos_count
|
| 56 |
+
|
| 57 |
+
return loss.mean()
|
yaml_bert/similarity.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
|
| 8 |
+
from yaml_bert.annotator import DomainAnnotator
|
| 9 |
+
from yaml_bert.linearizer import YamlLinearizer
|
| 10 |
+
from yaml_bert.model import YamlBertModel
|
| 11 |
+
from yaml_bert.pooling import DocumentPooling
|
| 12 |
+
from yaml_bert.types import NodeType, YamlNode
|
| 13 |
+
from yaml_bert.vocab import Vocabulary
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
_NODE_TYPE_INDEX: dict[NodeType, int] = {
|
| 17 |
+
NodeType.KEY: 0, NodeType.VALUE: 1,
|
| 18 |
+
NodeType.LIST_KEY: 2, NodeType.LIST_VALUE: 3,
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@torch.no_grad()
|
| 23 |
+
def extract_hidden_states(
|
| 24 |
+
model: YamlBertModel,
|
| 25 |
+
vocab: Vocabulary,
|
| 26 |
+
yaml_text: str,
|
| 27 |
+
) -> tuple[torch.Tensor, int]:
|
| 28 |
+
linearizer: YamlLinearizer = YamlLinearizer()
|
| 29 |
+
annotator: DomainAnnotator = DomainAnnotator()
|
| 30 |
+
# Use linearize_multi_doc to handle --- separators, take the first doc
|
| 31 |
+
docs: list[list[YamlNode]] = linearizer.linearize_multi_doc(yaml_text)
|
| 32 |
+
nodes: list[YamlNode] = docs[0] if docs else []
|
| 33 |
+
if not nodes:
|
| 34 |
+
return torch.empty(0), -1
|
| 35 |
+
annotator.annotate(nodes)
|
| 36 |
+
|
| 37 |
+
token_ids: list[int] = []
|
| 38 |
+
node_types: list[int] = []
|
| 39 |
+
depths: list[int] = []
|
| 40 |
+
siblings: list[int] = []
|
| 41 |
+
kind_pos: int = -1
|
| 42 |
+
|
| 43 |
+
for i, node in enumerate(nodes):
|
| 44 |
+
if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
|
| 45 |
+
token_ids.append(vocab.encode_key(node.token))
|
| 46 |
+
else:
|
| 47 |
+
token_ids.append(vocab.encode_value(node.token))
|
| 48 |
+
node_types.append(_NODE_TYPE_INDEX[node.node_type])
|
| 49 |
+
depths.append(min(node.depth, 15))
|
| 50 |
+
siblings.append(min(node.sibling_index, 31))
|
| 51 |
+
|
| 52 |
+
if node.token == "kind" and node.depth == 0 and node.node_type == NodeType.KEY and kind_pos == -1:
|
| 53 |
+
kind_pos = i
|
| 54 |
+
|
| 55 |
+
t = lambda x: torch.tensor([x])
|
| 56 |
+
model.eval()
|
| 57 |
+
|
| 58 |
+
x: torch.Tensor = model.embedding(
|
| 59 |
+
t(token_ids), t(node_types), t(depths), t(siblings),
|
| 60 |
+
)
|
| 61 |
+
for layer in model.encoder.layers:
|
| 62 |
+
x = layer(x)
|
| 63 |
+
|
| 64 |
+
return x.squeeze(0), kind_pos
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@torch.no_grad()
|
| 68 |
+
def get_document_embedding(
|
| 69 |
+
model: YamlBertModel,
|
| 70 |
+
pooling: DocumentPooling,
|
| 71 |
+
vocab: Vocabulary,
|
| 72 |
+
yaml_text: str,
|
| 73 |
+
) -> torch.Tensor:
|
| 74 |
+
hidden, kind_pos = extract_hidden_states(model, vocab, yaml_text)
|
| 75 |
+
if hidden.shape[0] == 0 or kind_pos < 0:
|
| 76 |
+
return torch.zeros(hidden.shape[1] if hidden.dim() > 1 else 1)
|
| 77 |
+
|
| 78 |
+
pooling.eval()
|
| 79 |
+
kind_hidden: torch.Tensor = hidden[kind_pos].unsqueeze(0).unsqueeze(0)
|
| 80 |
+
all_hidden: torch.Tensor = hidden.unsqueeze(0)
|
| 81 |
+
return pooling(kind_hidden, all_hidden).squeeze(0)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def cosine_similarity_matrix(embeddings: torch.Tensor) -> torch.Tensor:
|
| 85 |
+
normed: torch.Tensor = F.normalize(embeddings, dim=1)
|
| 86 |
+
return normed @ normed.T
|
yaml_bert/suggest.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
|
| 8 |
+
from yaml_bert.annotator import DomainAnnotator
|
| 9 |
+
from yaml_bert.dataset import _extract_kind
|
| 10 |
+
from yaml_bert.linearizer import YamlLinearizer
|
| 11 |
+
from yaml_bert.model import YamlBertModel
|
| 12 |
+
from yaml_bert.types import NodeType, YamlNode
|
| 13 |
+
from yaml_bert.vocab import Vocabulary, compute_target
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# Keys managed by the cluster, not written by users
|
| 17 |
+
_CLUSTER_MANAGED_KEYS: set[str] = {
|
| 18 |
+
"status", "creationTimestamp", "generation", "resourceVersion",
|
| 19 |
+
"selfLink", "uid", "managedFields",
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
_NODE_TYPE_INDEX: dict[NodeType, int] = {
|
| 23 |
+
NodeType.KEY: 0,
|
| 24 |
+
NodeType.VALUE: 1,
|
| 25 |
+
NodeType.LIST_KEY: 2,
|
| 26 |
+
NodeType.LIST_VALUE: 3,
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def suggest_missing_fields(
|
| 31 |
+
model: YamlBertModel,
|
| 32 |
+
vocab: Vocabulary,
|
| 33 |
+
yaml_text: str,
|
| 34 |
+
threshold: float = 0.3,
|
| 35 |
+
top_k: int = 10,
|
| 36 |
+
) -> list[dict[str, Any]]:
|
| 37 |
+
"""Suggest missing fields in a YAML document based on model conventions.
|
| 38 |
+
|
| 39 |
+
Uses the dual-head (simple + kind-specific) prediction approach.
|
| 40 |
+
For each parent level, a fake [MASK] node is appended and both heads
|
| 41 |
+
are consulted based on which head would apply at that tree position.
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
model: Trained YAML-BERT model
|
| 45 |
+
vocab: Vocabulary
|
| 46 |
+
yaml_text: Raw YAML text
|
| 47 |
+
threshold: Minimum confidence to report a missing field
|
| 48 |
+
top_k: Number of predictions per masked position
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
List of suggestions sorted by confidence (highest first).
|
| 52 |
+
Each suggestion: {"parent_path": str, "missing_key": str, "confidence": float}
|
| 53 |
+
"""
|
| 54 |
+
linearizer: YamlLinearizer = YamlLinearizer()
|
| 55 |
+
annotator: DomainAnnotator = DomainAnnotator()
|
| 56 |
+
nodes: list[YamlNode] = linearizer.linearize(yaml_text)
|
| 57 |
+
if not nodes:
|
| 58 |
+
return []
|
| 59 |
+
annotator.annotate(nodes)
|
| 60 |
+
|
| 61 |
+
token_ids, node_types, depths, siblings = _encode_nodes(nodes, vocab)
|
| 62 |
+
|
| 63 |
+
kind: str = _extract_kind(nodes)
|
| 64 |
+
mask_id: int = vocab.special_tokens["[MASK]"]
|
| 65 |
+
|
| 66 |
+
# Build reverse lookups for decoding predictions
|
| 67 |
+
id_to_simple: dict[int, str] = {v: k for k, v in vocab.simple_target_vocab.items()}
|
| 68 |
+
id_to_kind: dict[int, str] = {v: k for k, v in vocab.kind_target_vocab.items()}
|
| 69 |
+
id_to_special: dict[int, str] = {v: k for k, v in vocab.special_tokens.items()}
|
| 70 |
+
|
| 71 |
+
# Group key nodes by parent_path
|
| 72 |
+
keys_by_parent: dict[str, set[str]] = {}
|
| 73 |
+
key_positions_by_parent: dict[str, list[int]] = {}
|
| 74 |
+
|
| 75 |
+
for i, node in enumerate(nodes):
|
| 76 |
+
if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
|
| 77 |
+
keys_by_parent.setdefault(node.parent_path, set()).add(node.token)
|
| 78 |
+
key_positions_by_parent.setdefault(node.parent_path, []).append(i)
|
| 79 |
+
|
| 80 |
+
# For each parent level, append a fake masked node as the "next sibling"
|
| 81 |
+
# to discover missing keys. Filter out noise: self-references, keys that
|
| 82 |
+
# already exist at root level, and special tokens.
|
| 83 |
+
model.eval()
|
| 84 |
+
predicted_keys_by_parent: dict[str, dict[str, float]] = {}
|
| 85 |
+
skipped_by_parent: dict[str, list[dict[str, Any]]] = {}
|
| 86 |
+
|
| 87 |
+
# Collect all existing keys across the entire document for cross-reference filtering
|
| 88 |
+
all_root_keys: set[str] = {
|
| 89 |
+
n.token for n in nodes
|
| 90 |
+
if n.node_type in (NodeType.KEY, NodeType.LIST_KEY) and n.depth == 0
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
t = lambda x: torch.tensor([x])
|
| 94 |
+
|
| 95 |
+
for parent_path, positions in key_positions_by_parent.items():
|
| 96 |
+
predicted: dict[str, float] = {}
|
| 97 |
+
# Extract parent key name, skipping numeric list indices
|
| 98 |
+
# e.g., "spec.containers.0" → "containers", not "0"
|
| 99 |
+
parent_key_name: str = ""
|
| 100 |
+
if parent_path:
|
| 101 |
+
for part in reversed(parent_path.split(".")):
|
| 102 |
+
if not part.isdigit():
|
| 103 |
+
parent_key_name = part
|
| 104 |
+
break
|
| 105 |
+
|
| 106 |
+
# Use the last key node at this level as a template for the fake node
|
| 107 |
+
last_pos: int = positions[-1]
|
| 108 |
+
last_node: YamlNode = nodes[last_pos]
|
| 109 |
+
next_sibling: int = min(last_node.sibling_index + 1, 31)
|
| 110 |
+
|
| 111 |
+
# Find the insertion point: right after the last sibling's subtree.
|
| 112 |
+
# Walk forward from last_pos until we find a node at the same or
|
| 113 |
+
# shallower depth (i.e., the subtree under last_pos has ended).
|
| 114 |
+
insert_pos: int = last_pos + 1
|
| 115 |
+
while insert_pos < len(token_ids):
|
| 116 |
+
if nodes[insert_pos].depth <= last_node.depth:
|
| 117 |
+
break
|
| 118 |
+
insert_pos += 1
|
| 119 |
+
|
| 120 |
+
# Insert a fake [MASK] node at the correct position in the sequence
|
| 121 |
+
fake_token_ids: list[int] = token_ids[:insert_pos] + [mask_id] + token_ids[insert_pos:]
|
| 122 |
+
fake_node_types: list[int] = node_types[:insert_pos] + [_NODE_TYPE_INDEX[last_node.node_type]] + node_types[insert_pos:]
|
| 123 |
+
fake_depths: list[int] = depths[:insert_pos] + [last_node.depth] + depths[insert_pos:]
|
| 124 |
+
fake_siblings: list[int] = siblings[:insert_pos] + [next_sibling] + siblings[insert_pos:]
|
| 125 |
+
fake_pos: int = insert_pos
|
| 126 |
+
|
| 127 |
+
# Determine which head applies for this position using compute_target logic.
|
| 128 |
+
# Create a fake YamlNode to probe which head to use.
|
| 129 |
+
fake_node = YamlNode(
|
| 130 |
+
token="__probe__",
|
| 131 |
+
node_type=last_node.node_type,
|
| 132 |
+
depth=last_node.depth,
|
| 133 |
+
sibling_index=next_sibling,
|
| 134 |
+
parent_path=last_node.parent_path,
|
| 135 |
+
)
|
| 136 |
+
_, head_type = compute_target(fake_node, kind)
|
| 137 |
+
|
| 138 |
+
with torch.no_grad():
|
| 139 |
+
simple_logits, kind_logits = model(
|
| 140 |
+
t(fake_token_ids), t(fake_node_types), t(fake_depths),
|
| 141 |
+
t(fake_siblings),
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
if head_type == "simple":
|
| 145 |
+
probs: torch.Tensor = F.softmax(simple_logits[0, fake_pos], dim=-1)
|
| 146 |
+
id_to_target = id_to_simple
|
| 147 |
+
else:
|
| 148 |
+
probs = F.softmax(kind_logits[0, fake_pos], dim=-1)
|
| 149 |
+
id_to_target = id_to_kind
|
| 150 |
+
|
| 151 |
+
topk = probs.topk(top_k + 5)
|
| 152 |
+
|
| 153 |
+
for j in range(topk.indices.shape[0]):
|
| 154 |
+
target_id: int = topk.indices[j].item()
|
| 155 |
+
prob: float = topk.values[j].item()
|
| 156 |
+
|
| 157 |
+
# Decode target string
|
| 158 |
+
if target_id in id_to_special:
|
| 159 |
+
target_name: str = id_to_special[target_id]
|
| 160 |
+
else:
|
| 161 |
+
target_name = id_to_target.get(target_id, "[UNK]")
|
| 162 |
+
|
| 163 |
+
# Extract the raw key name and validate parent from composite targets.
|
| 164 |
+
# Simple targets: "parent::key" or just "key" (root level)
|
| 165 |
+
# Kind targets: "Kind::parent::key"
|
| 166 |
+
parts: list[str] = target_name.split("::")
|
| 167 |
+
key_name: str = parts[-1]
|
| 168 |
+
|
| 169 |
+
# Validate that the predicted target's parent matches where we're probing.
|
| 170 |
+
# E.g., "spec::imagePullSecrets" should only be accepted when probing under spec,
|
| 171 |
+
# not under matchLabels or metadata.
|
| 172 |
+
if len(parts) >= 2:
|
| 173 |
+
predicted_parent: str = parts[-2]
|
| 174 |
+
if predicted_parent != parent_key_name:
|
| 175 |
+
skipped_by_parent.setdefault(parent_path, []).append({
|
| 176 |
+
"target": target_name,
|
| 177 |
+
"key": key_name,
|
| 178 |
+
"predicted_parent": predicted_parent,
|
| 179 |
+
"actual_parent": parent_key_name,
|
| 180 |
+
"confidence": prob,
|
| 181 |
+
})
|
| 182 |
+
continue
|
| 183 |
+
|
| 184 |
+
# Skip special tokens
|
| 185 |
+
if key_name in ("[PAD]", "[UNK]", "[MASK]"):
|
| 186 |
+
continue
|
| 187 |
+
# Skip self-referencing (key same as parent key name)
|
| 188 |
+
if key_name == parent_key_name:
|
| 189 |
+
continue
|
| 190 |
+
# Skip root-level keys suggested as children (e.g., roleRef under subjects)
|
| 191 |
+
if parent_path and key_name in all_root_keys and last_node.depth > 0:
|
| 192 |
+
continue
|
| 193 |
+
|
| 194 |
+
predicted[key_name] = prob
|
| 195 |
+
|
| 196 |
+
predicted_keys_by_parent[parent_path] = predicted
|
| 197 |
+
|
| 198 |
+
# Find missing keys
|
| 199 |
+
suggestions: list[dict[str, Any]] = []
|
| 200 |
+
|
| 201 |
+
for parent_path, predicted in predicted_keys_by_parent.items():
|
| 202 |
+
existing: set[str] = keys_by_parent.get(parent_path, set())
|
| 203 |
+
for key_name, confidence in predicted.items():
|
| 204 |
+
if (key_name not in existing
|
| 205 |
+
and key_name not in _CLUSTER_MANAGED_KEYS
|
| 206 |
+
and confidence >= threshold):
|
| 207 |
+
suggestions.append({
|
| 208 |
+
"parent_path": parent_path,
|
| 209 |
+
"missing_key": key_name,
|
| 210 |
+
"confidence": confidence,
|
| 211 |
+
})
|
| 212 |
+
|
| 213 |
+
suggestions.sort(key=lambda s: -s["confidence"])
|
| 214 |
+
return suggestions, skipped_by_parent
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _encode_nodes(
|
| 218 |
+
nodes: list[YamlNode],
|
| 219 |
+
vocab: Vocabulary,
|
| 220 |
+
) -> tuple[list[int], list[int], list[int], list[int]]:
|
| 221 |
+
"""Encode nodes to integer lists for model input."""
|
| 222 |
+
token_ids: list[int] = []
|
| 223 |
+
node_types: list[int] = []
|
| 224 |
+
depths: list[int] = []
|
| 225 |
+
siblings: list[int] = []
|
| 226 |
+
|
| 227 |
+
for node in nodes:
|
| 228 |
+
if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
|
| 229 |
+
token_ids.append(vocab.encode_key(node.token))
|
| 230 |
+
else:
|
| 231 |
+
token_ids.append(vocab.encode_value(node.token))
|
| 232 |
+
node_types.append(_NODE_TYPE_INDEX[node.node_type])
|
| 233 |
+
depths.append(min(node.depth, 15))
|
| 234 |
+
siblings.append(min(node.sibling_index, 31))
|
| 235 |
+
|
| 236 |
+
return token_ids, node_types, depths, siblings
|
yaml_bert/trainer.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
from torch.optim import AdamW
|
| 7 |
+
from torch.utils.data import DataLoader
|
| 8 |
+
from tqdm import tqdm
|
| 9 |
+
|
| 10 |
+
from yaml_bert.config import YamlBertConfig
|
| 11 |
+
from yaml_bert.dataset import YamlDataset, collate_fn
|
| 12 |
+
from yaml_bert.model import YamlBertModel
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class YamlBertTrainer:
|
| 16 |
+
"""Training loop with hybrid loss from two prediction heads."""
|
| 17 |
+
|
| 18 |
+
def __init__(
|
| 19 |
+
self,
|
| 20 |
+
config: YamlBertConfig,
|
| 21 |
+
model: YamlBertModel,
|
| 22 |
+
dataset: YamlDataset,
|
| 23 |
+
checkpoint_dir: str | None = None,
|
| 24 |
+
checkpoint_every: int = 1,
|
| 25 |
+
resume_from: str | None = None,
|
| 26 |
+
) -> None:
|
| 27 |
+
self.config = config
|
| 28 |
+
self.model = model
|
| 29 |
+
self.dataset = dataset
|
| 30 |
+
self.checkpoint_dir = checkpoint_dir
|
| 31 |
+
self.checkpoint_every = checkpoint_every
|
| 32 |
+
self.resume_from = resume_from
|
| 33 |
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 34 |
+
|
| 35 |
+
def train(self) -> list[float]:
|
| 36 |
+
from datetime import datetime
|
| 37 |
+
self.model.to(self.device)
|
| 38 |
+
self.model.train()
|
| 39 |
+
|
| 40 |
+
print(f"Training started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
| 41 |
+
num_params = sum(p.numel() for p in self.model.parameters())
|
| 42 |
+
print(f"Model parameters: {num_params:,}")
|
| 43 |
+
print(f"Config: d_model={self.config.d_model}, layers={self.config.num_layers}, heads={self.config.num_heads}")
|
| 44 |
+
print(f"Device: {self.device}")
|
| 45 |
+
|
| 46 |
+
optimizer = AdamW(self.model.parameters(), lr=self.config.lr, weight_decay=0.01)
|
| 47 |
+
start_epoch = 0
|
| 48 |
+
|
| 49 |
+
if self.resume_from:
|
| 50 |
+
checkpoint = torch.load(self.resume_from, map_location=self.device)
|
| 51 |
+
self.model.load_state_dict(checkpoint["model_state_dict"])
|
| 52 |
+
optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
|
| 53 |
+
start_epoch = checkpoint["epoch"]
|
| 54 |
+
print(f"Resumed from epoch {start_epoch}")
|
| 55 |
+
|
| 56 |
+
dataloader = DataLoader(
|
| 57 |
+
self.dataset, batch_size=self.config.batch_size,
|
| 58 |
+
shuffle=True, collate_fn=collate_fn,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
epoch_losses: list[float] = []
|
| 62 |
+
for epoch in range(start_epoch, self.config.num_epochs):
|
| 63 |
+
total_loss: float = 0.0
|
| 64 |
+
num_batches: int = 0
|
| 65 |
+
running_breakdown: dict[str, float] = {}
|
| 66 |
+
|
| 67 |
+
pbar = tqdm(dataloader, desc=f"Epoch {epoch+1}/{self.config.num_epochs}", leave=True)
|
| 68 |
+
for batch in pbar:
|
| 69 |
+
batch = {k: v.to(self.device) for k, v in batch.items()}
|
| 70 |
+
optimizer.zero_grad()
|
| 71 |
+
|
| 72 |
+
simple_logits, kind_logits = self.model(
|
| 73 |
+
token_ids=batch["token_ids"],
|
| 74 |
+
node_types=batch["node_types"],
|
| 75 |
+
depths=batch["depths"],
|
| 76 |
+
sibling_indices=batch["sibling_indices"],
|
| 77 |
+
padding_mask=batch["padding_mask"],
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
loss, breakdown = self.model.compute_loss(
|
| 81 |
+
simple_logits, batch["simple_labels"],
|
| 82 |
+
kind_logits, batch["kind_labels"],
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
if torch.isnan(loss):
|
| 86 |
+
continue # skip bad batches
|
| 87 |
+
|
| 88 |
+
loss.backward()
|
| 89 |
+
torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)
|
| 90 |
+
optimizer.step()
|
| 91 |
+
|
| 92 |
+
total_loss += loss.item()
|
| 93 |
+
for k, v in breakdown.items():
|
| 94 |
+
running_breakdown[k] = running_breakdown.get(k, 0.0) + v
|
| 95 |
+
num_batches += 1
|
| 96 |
+
|
| 97 |
+
postfix = {"loss": f"{total_loss/num_batches:.4f}"}
|
| 98 |
+
for k in ["simple", "kind"]:
|
| 99 |
+
if k in running_breakdown:
|
| 100 |
+
postfix[k] = f"{running_breakdown[k]/num_batches:.4f}"
|
| 101 |
+
pbar.set_postfix(**postfix)
|
| 102 |
+
|
| 103 |
+
avg_loss: float = total_loss / max(num_batches, 1)
|
| 104 |
+
epoch_losses.append(avg_loss)
|
| 105 |
+
breakdown_str: str = " | ".join(
|
| 106 |
+
f"{k}: {v/max(num_batches,1):.4f}" for k, v in sorted(running_breakdown.items())
|
| 107 |
+
)
|
| 108 |
+
print(f"Epoch {epoch+1}/{self.config.num_epochs} — loss: {avg_loss:.4f} ({breakdown_str})")
|
| 109 |
+
|
| 110 |
+
if self.checkpoint_dir and (epoch+1) % self.checkpoint_every == 0:
|
| 111 |
+
self._save_checkpoint(epoch+1, optimizer)
|
| 112 |
+
|
| 113 |
+
if self.checkpoint_dir:
|
| 114 |
+
self._save_checkpoint(self.config.num_epochs, optimizer)
|
| 115 |
+
|
| 116 |
+
return epoch_losses
|
| 117 |
+
|
| 118 |
+
def _save_checkpoint(self, epoch: int, optimizer: AdamW) -> None:
|
| 119 |
+
os.makedirs(self.checkpoint_dir, exist_ok=True)
|
| 120 |
+
path = os.path.join(self.checkpoint_dir, f"yaml_bert_v4_epoch_{epoch}.pt")
|
| 121 |
+
torch.save({
|
| 122 |
+
"epoch": epoch,
|
| 123 |
+
"model_state_dict": self.model.state_dict(),
|
| 124 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 125 |
+
"tree_pos_variant": self.config.tree_pos_variant.value,
|
| 126 |
+
}, path)
|
| 127 |
+
print(f"Checkpoint saved: {path}")
|
yaml_bert/types.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from enum import Enum
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class NodeType(Enum):
|
| 9 |
+
KEY = "KEY"
|
| 10 |
+
VALUE = "VALUE"
|
| 11 |
+
LIST_KEY = "LIST_KEY"
|
| 12 |
+
LIST_VALUE = "LIST_VALUE"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class YamlNode:
|
| 17 |
+
token: str
|
| 18 |
+
node_type: NodeType
|
| 19 |
+
depth: int
|
| 20 |
+
sibling_index: int
|
| 21 |
+
parent_path: str
|
| 22 |
+
annotations: dict[str, Any] = field(default_factory=dict)
|
| 23 |
+
|
| 24 |
+
def __repr__(self) -> str:
|
| 25 |
+
return (
|
| 26 |
+
f"YamlNode({self.token!r}, {self.node_type.value}, "
|
| 27 |
+
f"depth={self.depth}, sibling={self.sibling_index}, "
|
| 28 |
+
f"path={self.parent_path!r})"
|
| 29 |
+
)
|
yaml_bert/visualize.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
import matplotlib
|
| 6 |
+
matplotlib.use("Agg") # non-interactive backend
|
| 7 |
+
import matplotlib.pyplot as plt
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def plot_training_loss(
|
| 12 |
+
losses: list[float],
|
| 13 |
+
output_path: str = "training_loss.png",
|
| 14 |
+
title: str = "YAML-BERT Training Loss",
|
| 15 |
+
) -> None:
|
| 16 |
+
"""Plot training loss curve over epochs."""
|
| 17 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
| 18 |
+
ax.plot(range(1, len(losses) + 1), losses, marker="o", linewidth=2)
|
| 19 |
+
ax.set_xlabel("Epoch")
|
| 20 |
+
ax.set_ylabel("Loss")
|
| 21 |
+
ax.set_title(title)
|
| 22 |
+
ax.grid(True, alpha=0.3)
|
| 23 |
+
fig.tight_layout()
|
| 24 |
+
fig.savefig(output_path, dpi=150)
|
| 25 |
+
plt.close(fig)
|
| 26 |
+
print(f"Training loss plot saved: {output_path}")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def plot_accuracy(
|
| 30 |
+
results: dict[str, float],
|
| 31 |
+
output_path: str = "accuracy.png",
|
| 32 |
+
title: str = "YAML-BERT Key Prediction Accuracy",
|
| 33 |
+
) -> None:
|
| 34 |
+
"""Plot top-1 and top-5 prediction accuracy as a bar chart."""
|
| 35 |
+
labels: list[str] = ["Top-1", "Top-5"]
|
| 36 |
+
values: list[float] = [results["top1_accuracy"], results["top5_accuracy"]]
|
| 37 |
+
|
| 38 |
+
fig, ax = plt.subplots(figsize=(6, 5))
|
| 39 |
+
bars = ax.bar(labels, values, color=["steelblue", "coral"], width=0.5)
|
| 40 |
+
ax.set_ylabel("Accuracy")
|
| 41 |
+
ax.set_title(title)
|
| 42 |
+
ax.set_ylim(0, 1)
|
| 43 |
+
|
| 44 |
+
for bar, val in zip(bars, values):
|
| 45 |
+
ax.text(
|
| 46 |
+
bar.get_x() + bar.get_width() / 2,
|
| 47 |
+
bar.get_height() + 0.02,
|
| 48 |
+
f"{val:.1%}",
|
| 49 |
+
ha="center",
|
| 50 |
+
fontsize=14,
|
| 51 |
+
fontweight="bold",
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
total: float = results.get("total_masked", 0)
|
| 55 |
+
ax.text(
|
| 56 |
+
0.5, -0.1,
|
| 57 |
+
f"Total masked positions: {int(total)}",
|
| 58 |
+
ha="center",
|
| 59 |
+
transform=ax.transAxes,
|
| 60 |
+
fontsize=10,
|
| 61 |
+
color="gray",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
fig.tight_layout()
|
| 65 |
+
fig.savefig(output_path, dpi=150)
|
| 66 |
+
plt.close(fig)
|
| 67 |
+
print(f"Accuracy plot saved: {output_path}")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def plot_embedding_similarity(
|
| 71 |
+
results: list[dict[str, Any]],
|
| 72 |
+
output_path: str = "embedding_similarity.png",
|
| 73 |
+
title: str = "Tree Position Embedding Similarity",
|
| 74 |
+
) -> None:
|
| 75 |
+
"""Plot cosine similarity between same-key embeddings at different tree positions."""
|
| 76 |
+
labels: list[str] = []
|
| 77 |
+
similarities: list[float] = []
|
| 78 |
+
|
| 79 |
+
for r in results:
|
| 80 |
+
pa: dict[str, Any] = r["position_a"]
|
| 81 |
+
pb: dict[str, Any] = r["position_b"]
|
| 82 |
+
label: str = (
|
| 83 |
+
f"{r['key']}\n"
|
| 84 |
+
f"d={pa['depth']},p={pa['parent_key']}\n"
|
| 85 |
+
f"vs d={pb['depth']},p={pb['parent_key']}"
|
| 86 |
+
)
|
| 87 |
+
labels.append(label)
|
| 88 |
+
similarities.append(r["cosine_similarity"])
|
| 89 |
+
|
| 90 |
+
fig, ax = plt.subplots(figsize=(max(8, len(results) * 3), 6))
|
| 91 |
+
bars = ax.bar(range(len(results)), similarities, color="steelblue")
|
| 92 |
+
ax.set_xticks(range(len(results)))
|
| 93 |
+
ax.set_xticklabels(labels, fontsize=9)
|
| 94 |
+
ax.set_ylabel("Cosine Similarity")
|
| 95 |
+
ax.set_title(title)
|
| 96 |
+
ax.set_ylim(-1, 1)
|
| 97 |
+
ax.axhline(y=0, color="gray", linestyle="--", alpha=0.5)
|
| 98 |
+
|
| 99 |
+
for bar, sim in zip(bars, similarities):
|
| 100 |
+
ax.text(
|
| 101 |
+
bar.get_x() + bar.get_width() / 2,
|
| 102 |
+
bar.get_height() + 0.02,
|
| 103 |
+
f"{sim:.3f}",
|
| 104 |
+
ha="center",
|
| 105 |
+
fontsize=10,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
fig.tight_layout()
|
| 109 |
+
fig.savefig(output_path, dpi=150)
|
| 110 |
+
plt.close(fig)
|
| 111 |
+
print(f"Embedding similarity plot saved: {output_path}")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def plot_attention_patterns(
|
| 115 |
+
attention_weights: torch.Tensor,
|
| 116 |
+
token_labels: list[str],
|
| 117 |
+
output_path: str = "attention_patterns.png",
|
| 118 |
+
title: str = "Attention Patterns",
|
| 119 |
+
) -> None:
|
| 120 |
+
"""Plot attention heatmaps for each head.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
attention_weights: (num_heads, seq_len, seq_len)
|
| 124 |
+
token_labels: labels for each position in the sequence
|
| 125 |
+
"""
|
| 126 |
+
num_heads: int = attention_weights.shape[0]
|
| 127 |
+
fig, axes = plt.subplots(1, num_heads, figsize=(6 * num_heads, 5))
|
| 128 |
+
|
| 129 |
+
if num_heads == 1:
|
| 130 |
+
axes = [axes]
|
| 131 |
+
|
| 132 |
+
for head_idx, ax in enumerate(axes):
|
| 133 |
+
weights: torch.Tensor = attention_weights[head_idx].cpu()
|
| 134 |
+
im = ax.imshow(weights.numpy(), cmap="Blues", vmin=0, vmax=1)
|
| 135 |
+
ax.set_title(f"Head {head_idx}")
|
| 136 |
+
ax.set_xticks(range(len(token_labels)))
|
| 137 |
+
ax.set_yticks(range(len(token_labels)))
|
| 138 |
+
ax.set_xticklabels(token_labels, rotation=45, ha="right", fontsize=7)
|
| 139 |
+
ax.set_yticklabels(token_labels, fontsize=7)
|
| 140 |
+
|
| 141 |
+
fig.suptitle(title)
|
| 142 |
+
fig.tight_layout()
|
| 143 |
+
fig.savefig(output_path, dpi=150)
|
| 144 |
+
plt.close(fig)
|
| 145 |
+
print(f"Attention pattern plot saved: {output_path}")
|
yaml_bert/vocab.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from yaml_bert.types import NodeType, YamlNode
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
SPECIAL_TOKENS = ["[PAD]", "[UNK]", "[MASK]"]
|
| 8 |
+
|
| 9 |
+
UNIVERSAL_ROOT_KEYS: set[str] = {"apiVersion", "kind", "metadata"}
|
| 10 |
+
|
| 11 |
+
# Canonical casing for known Kubernetes kinds.
|
| 12 |
+
# Maps lowercase → canonical form.
|
| 13 |
+
_KIND_CANONICAL: dict[str, str] = {}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def normalize_kind(kind: str) -> str:
|
| 17 |
+
"""Normalize kind value to canonical casing.
|
| 18 |
+
|
| 19 |
+
'configmap' → 'ConfigMap', 'pod' → 'Pod', etc.
|
| 20 |
+
Unknown kinds are returned as-is.
|
| 21 |
+
"""
|
| 22 |
+
return _KIND_CANONICAL.get(kind.lower(), kind)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def compute_target(node: YamlNode, kind: str) -> tuple[str, str]:
|
| 26 |
+
"""Compute hybrid prediction target.
|
| 27 |
+
|
| 28 |
+
Returns (target_string, head_type) where head_type is "simple" or "kind_specific".
|
| 29 |
+
"""
|
| 30 |
+
if node.depth == 0:
|
| 31 |
+
return node.token, "simple"
|
| 32 |
+
|
| 33 |
+
parent_key: str = Vocabulary.extract_parent_key(node.parent_path)
|
| 34 |
+
|
| 35 |
+
if node.depth == 1 and parent_key not in UNIVERSAL_ROOT_KEYS and parent_key != "":
|
| 36 |
+
return f"{kind}::{parent_key}::{node.token}", "kind_specific"
|
| 37 |
+
|
| 38 |
+
return (f"{parent_key}::{node.token}" if parent_key else node.token), "simple"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class Vocabulary:
|
| 42 |
+
def __init__(
|
| 43 |
+
self,
|
| 44 |
+
key_vocab: dict[str, int],
|
| 45 |
+
value_vocab: dict[str, int],
|
| 46 |
+
special_tokens: dict[str, int],
|
| 47 |
+
kind_vocab: dict[str, int] | None = None,
|
| 48 |
+
simple_target_vocab: dict[str, int] | None = None,
|
| 49 |
+
kind_target_vocab: dict[str, int] | None = None,
|
| 50 |
+
) -> None:
|
| 51 |
+
self.key_vocab = key_vocab
|
| 52 |
+
self.value_vocab = value_vocab
|
| 53 |
+
self.special_tokens = special_tokens
|
| 54 |
+
self.kind_vocab = kind_vocab or {"[NO_KIND]": 0}
|
| 55 |
+
self.simple_target_vocab = simple_target_vocab or {}
|
| 56 |
+
self.kind_target_vocab = kind_target_vocab or {}
|
| 57 |
+
self._id_to_key = {v: k for k, v in key_vocab.items()}
|
| 58 |
+
self._id_to_value = {v: k for k, v in value_vocab.items()}
|
| 59 |
+
self._id_to_special = {v: k for k, v in special_tokens.items()}
|
| 60 |
+
|
| 61 |
+
def encode_key(self, token: str) -> int:
|
| 62 |
+
return self.key_vocab.get(token, self.special_tokens["[UNK]"])
|
| 63 |
+
|
| 64 |
+
def encode_value(self, token: str) -> int:
|
| 65 |
+
return self.value_vocab.get(token, self.special_tokens["[UNK]"])
|
| 66 |
+
|
| 67 |
+
def encode_kind(self, kind: str) -> int:
|
| 68 |
+
if not kind:
|
| 69 |
+
return self.kind_vocab["[NO_KIND]"]
|
| 70 |
+
return self.kind_vocab.get(kind, self.kind_vocab["[NO_KIND]"])
|
| 71 |
+
|
| 72 |
+
def decode_key(self, id: int) -> str:
|
| 73 |
+
if id in self._id_to_special:
|
| 74 |
+
return self._id_to_special[id]
|
| 75 |
+
return self._id_to_key.get(id, "[UNK]")
|
| 76 |
+
|
| 77 |
+
def decode_value(self, id: int) -> str:
|
| 78 |
+
if id in self._id_to_special:
|
| 79 |
+
return self._id_to_special[id]
|
| 80 |
+
return self._id_to_value.get(id, "[UNK]")
|
| 81 |
+
|
| 82 |
+
def encode_simple_target(self, target: str) -> int:
|
| 83 |
+
return self.simple_target_vocab.get(target, self.special_tokens["[UNK]"])
|
| 84 |
+
|
| 85 |
+
def encode_kind_target(self, target: str) -> int:
|
| 86 |
+
return self.kind_target_vocab.get(target, self.special_tokens["[UNK]"])
|
| 87 |
+
|
| 88 |
+
@staticmethod
|
| 89 |
+
def extract_parent_key(parent_path: str) -> str:
|
| 90 |
+
"""Extract the last non-numeric component from a parent_path.
|
| 91 |
+
|
| 92 |
+
Examples:
|
| 93 |
+
"spec.template.spec.containers.0" -> "containers"
|
| 94 |
+
"metadata" -> "metadata"
|
| 95 |
+
"" -> ""
|
| 96 |
+
"""
|
| 97 |
+
if not parent_path:
|
| 98 |
+
return ""
|
| 99 |
+
parts = parent_path.split(".")
|
| 100 |
+
for part in reversed(parts):
|
| 101 |
+
if not part.isdigit():
|
| 102 |
+
return part
|
| 103 |
+
return ""
|
| 104 |
+
|
| 105 |
+
@property
|
| 106 |
+
def key_vocab_size(self) -> int:
|
| 107 |
+
return len(self.key_vocab) + len(self.special_tokens)
|
| 108 |
+
|
| 109 |
+
@property
|
| 110 |
+
def value_vocab_size(self) -> int:
|
| 111 |
+
return len(self.value_vocab) + len(self.special_tokens)
|
| 112 |
+
|
| 113 |
+
@property
|
| 114 |
+
def kind_vocab_size(self) -> int:
|
| 115 |
+
return len(self.kind_vocab)
|
| 116 |
+
|
| 117 |
+
@property
|
| 118 |
+
def simple_target_vocab_size(self) -> int:
|
| 119 |
+
return len(self.simple_target_vocab) + len(self.special_tokens)
|
| 120 |
+
|
| 121 |
+
@property
|
| 122 |
+
def kind_target_vocab_size(self) -> int:
|
| 123 |
+
return len(self.kind_target_vocab) + len(self.special_tokens)
|
| 124 |
+
|
| 125 |
+
def save(self, path: str) -> None:
|
| 126 |
+
data = {
|
| 127 |
+
"key_vocab": self.key_vocab,
|
| 128 |
+
"value_vocab": self.value_vocab,
|
| 129 |
+
"special_tokens": self.special_tokens,
|
| 130 |
+
"kind_vocab": self.kind_vocab,
|
| 131 |
+
"simple_target_vocab": self.simple_target_vocab,
|
| 132 |
+
"kind_target_vocab": self.kind_target_vocab,
|
| 133 |
+
}
|
| 134 |
+
with open(path, "w") as f:
|
| 135 |
+
json.dump(data, f, indent=2)
|
| 136 |
+
|
| 137 |
+
@classmethod
|
| 138 |
+
def load(cls, path: str) -> Vocabulary:
|
| 139 |
+
with open(path) as f:
|
| 140 |
+
data = json.load(f)
|
| 141 |
+
return cls(
|
| 142 |
+
key_vocab=data["key_vocab"],
|
| 143 |
+
value_vocab=data["value_vocab"],
|
| 144 |
+
special_tokens=data["special_tokens"],
|
| 145 |
+
kind_vocab=data.get("kind_vocab"),
|
| 146 |
+
simple_target_vocab=data.get("simple_target_vocab", {}),
|
| 147 |
+
kind_target_vocab=data.get("kind_target_vocab", {}),
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class VocabBuilder:
|
| 152 |
+
def build(self, nodes: list[YamlNode], min_freq: int = 1) -> Vocabulary:
|
| 153 |
+
key_counts: dict[str, int] = {}
|
| 154 |
+
value_counts: dict[str, int] = {}
|
| 155 |
+
kind_set: set[str] = set()
|
| 156 |
+
simple_target_counts: dict[str, int] = {}
|
| 157 |
+
kind_target_counts: dict[str, int] = {}
|
| 158 |
+
|
| 159 |
+
# First pass: collect all kind values to build canonical casing map
|
| 160 |
+
raw_kinds: dict[str, dict[str, int]] = {} # lowercase → {variant: count}
|
| 161 |
+
prev_was_kind_key = False
|
| 162 |
+
for node in nodes:
|
| 163 |
+
if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
|
| 164 |
+
prev_was_kind_key = (node.token == "kind" and node.depth == 0)
|
| 165 |
+
elif node.node_type in (NodeType.VALUE, NodeType.LIST_VALUE):
|
| 166 |
+
if prev_was_kind_key:
|
| 167 |
+
lower = node.token.lower()
|
| 168 |
+
raw_kinds.setdefault(lower, {})
|
| 169 |
+
raw_kinds[lower][node.token] = raw_kinds[lower].get(node.token, 0) + 1
|
| 170 |
+
prev_was_kind_key = False
|
| 171 |
+
|
| 172 |
+
# Build canonical map: most frequent casing wins
|
| 173 |
+
_KIND_CANONICAL.clear()
|
| 174 |
+
for lower, variants in raw_kinds.items():
|
| 175 |
+
canonical = max(variants, key=variants.get)
|
| 176 |
+
_KIND_CANONICAL[lower] = canonical
|
| 177 |
+
|
| 178 |
+
# Second pass: count tokens with normalized kinds
|
| 179 |
+
current_kind: str = ""
|
| 180 |
+
prev_was_kind_key = False
|
| 181 |
+
for node in nodes:
|
| 182 |
+
if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
|
| 183 |
+
key_counts[node.token] = key_counts.get(node.token, 0) + 1
|
| 184 |
+
prev_was_kind_key = (node.token == "kind" and node.depth == 0)
|
| 185 |
+
target, head_type = compute_target(node, current_kind)
|
| 186 |
+
if head_type == "kind_specific":
|
| 187 |
+
kind_target_counts[target] = kind_target_counts.get(target, 0) + 1
|
| 188 |
+
else:
|
| 189 |
+
simple_target_counts[target] = simple_target_counts.get(target, 0) + 1
|
| 190 |
+
elif node.node_type in (NodeType.VALUE, NodeType.LIST_VALUE):
|
| 191 |
+
value_counts[node.token] = value_counts.get(node.token, 0) + 1
|
| 192 |
+
if prev_was_kind_key:
|
| 193 |
+
normalized = normalize_kind(node.token)
|
| 194 |
+
kind_set.add(normalized)
|
| 195 |
+
current_kind = normalized
|
| 196 |
+
prev_was_kind_key = False
|
| 197 |
+
|
| 198 |
+
# Filter target vocabs by min_freq
|
| 199 |
+
simple_target_set: set[str] = {t for t, c in simple_target_counts.items() if c >= min_freq}
|
| 200 |
+
kind_target_set: set[str] = {t for t, c in kind_target_counts.items() if c >= min_freq}
|
| 201 |
+
|
| 202 |
+
return self.build_from_counts(
|
| 203 |
+
key_counts, value_counts, min_freq, kind_set,
|
| 204 |
+
simple_target_set, kind_target_set,
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
@staticmethod
|
| 208 |
+
def build_from_counts(
|
| 209 |
+
key_counts: dict[str, int],
|
| 210 |
+
value_counts: dict[str, int],
|
| 211 |
+
min_freq: int = 1,
|
| 212 |
+
kind_set: set[str] | None = None,
|
| 213 |
+
simple_target_set: set[str] | None = None,
|
| 214 |
+
kind_target_set: set[str] | None = None,
|
| 215 |
+
) -> Vocabulary:
|
| 216 |
+
"""Build vocabulary from pre-computed token counts."""
|
| 217 |
+
special_tokens = {tok: i for i, tok in enumerate(SPECIAL_TOKENS)}
|
| 218 |
+
offset = len(special_tokens)
|
| 219 |
+
|
| 220 |
+
key_vocab = {
|
| 221 |
+
token: i + offset
|
| 222 |
+
for i, token in enumerate(
|
| 223 |
+
sorted(t for t, c in key_counts.items() if c >= min_freq)
|
| 224 |
+
)
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
# Build value vocab: min_freq filtered, but always include kind values
|
| 228 |
+
value_tokens = set(t for t, c in value_counts.items() if c >= min_freq)
|
| 229 |
+
if kind_set:
|
| 230 |
+
value_tokens.update(kind_set) # kinds always in value vocab
|
| 231 |
+
value_vocab = {
|
| 232 |
+
token: i + offset
|
| 233 |
+
for i, token in enumerate(sorted(value_tokens))
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
kind_vocab: dict[str, int] = {"[NO_KIND]": 0}
|
| 237 |
+
for i, kind in enumerate(sorted(kind_set or [])):
|
| 238 |
+
kind_vocab[kind] = i + 1
|
| 239 |
+
|
| 240 |
+
simple_target_vocab = {
|
| 241 |
+
target: i + offset
|
| 242 |
+
for i, target in enumerate(sorted(simple_target_set or []))
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
kind_target_vocab = {
|
| 246 |
+
target: i + offset
|
| 247 |
+
for i, target in enumerate(sorted(kind_target_set or []))
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
return Vocabulary(key_vocab, value_vocab, special_tokens, kind_vocab,
|
| 251 |
+
simple_target_vocab, kind_target_vocab)
|
| 252 |
+
|
| 253 |
+
@staticmethod
|
| 254 |
+
def save_counts(
|
| 255 |
+
key_counts: dict[str, int],
|
| 256 |
+
value_counts: dict[str, int],
|
| 257 |
+
path: str,
|
| 258 |
+
) -> None:
|
| 259 |
+
"""Save raw token counts to a JSON file for reuse."""
|
| 260 |
+
with open(path, "w") as f:
|
| 261 |
+
json.dump({"key_counts": key_counts, "value_counts": value_counts}, f)
|
| 262 |
+
print(f"Token counts saved: {path} ({len(key_counts)} keys, {len(value_counts)} values)")
|
| 263 |
+
|
| 264 |
+
@staticmethod
|
| 265 |
+
def load_counts(path: str) -> tuple[dict[str, int], dict[str, int]]:
|
| 266 |
+
"""Load raw token counts from a JSON file."""
|
| 267 |
+
with open(path) as f:
|
| 268 |
+
data = json.load(f)
|
| 269 |
+
key_counts: dict[str, int] = data["key_counts"]
|
| 270 |
+
value_counts: dict[str, int] = data["value_counts"]
|
| 271 |
+
print(f"Token counts loaded: {path} ({len(key_counts)} keys, {len(value_counts)} values)")
|
| 272 |
+
return key_counts, value_counts
|
| 273 |
+
|
| 274 |
+
def build_from_huggingface(
|
| 275 |
+
self,
|
| 276 |
+
dataset_name: str,
|
| 277 |
+
linearizer: "YamlLinearizer",
|
| 278 |
+
annotator: "DomainAnnotator",
|
| 279 |
+
max_docs: int | None = None,
|
| 280 |
+
min_freq: int = 1,
|
| 281 |
+
counts_path: str | None = None,
|
| 282 |
+
) -> Vocabulary:
|
| 283 |
+
"""Build vocabulary by scanning a HuggingFace dataset.
|
| 284 |
+
|
| 285 |
+
Args:
|
| 286 |
+
dataset_name: HuggingFace dataset ID
|
| 287 |
+
linearizer: YamlLinearizer instance
|
| 288 |
+
annotator: DomainAnnotator instance
|
| 289 |
+
max_docs: Scan at most this many docs for vocab (None = all)
|
| 290 |
+
min_freq: Minimum frequency to include a token
|
| 291 |
+
counts_path: If set, save raw counts here (and load if file exists)
|
| 292 |
+
"""
|
| 293 |
+
import os
|
| 294 |
+
|
| 295 |
+
# Reuse saved counts if available
|
| 296 |
+
if counts_path and os.path.exists(counts_path):
|
| 297 |
+
key_counts, value_counts = self.load_counts(counts_path)
|
| 298 |
+
return self.build_from_counts(key_counts, value_counts, min_freq, None)
|
| 299 |
+
|
| 300 |
+
from datasets import load_dataset
|
| 301 |
+
from yaml_bert.types import YamlNode
|
| 302 |
+
|
| 303 |
+
print(f"Building vocabulary from: {dataset_name}")
|
| 304 |
+
ds = load_dataset(dataset_name, split="train")
|
| 305 |
+
|
| 306 |
+
total: int = len(ds) if max_docs is None else min(max_docs, len(ds))
|
| 307 |
+
print(f"Scanning {total:,} / {len(ds):,} documents for vocabulary...")
|
| 308 |
+
|
| 309 |
+
all_nodes: list[YamlNode] = []
|
| 310 |
+
skipped: int = 0
|
| 311 |
+
for i in range(total):
|
| 312 |
+
try:
|
| 313 |
+
nodes = linearizer.linearize(ds[i]["content"])
|
| 314 |
+
except Exception:
|
| 315 |
+
skipped += 1
|
| 316 |
+
continue
|
| 317 |
+
if nodes:
|
| 318 |
+
annotator.annotate(nodes)
|
| 319 |
+
all_nodes.extend(nodes)
|
| 320 |
+
|
| 321 |
+
if (i + 1) % 10000 == 0:
|
| 322 |
+
print(f" {i + 1:,} / {total:,} scanned ({len(all_nodes):,} nodes)")
|
| 323 |
+
|
| 324 |
+
print(f"Scanned {total - skipped:,} docs, {len(all_nodes):,} nodes ({skipped} skipped)")
|
| 325 |
+
|
| 326 |
+
# Compute counts
|
| 327 |
+
key_counts: dict[str, int] = {}
|
| 328 |
+
value_counts: dict[str, int] = {}
|
| 329 |
+
kind_set: set[str] = set()
|
| 330 |
+
prev_was_kind_key: bool = False
|
| 331 |
+
for node in all_nodes:
|
| 332 |
+
if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
|
| 333 |
+
key_counts[node.token] = key_counts.get(node.token, 0) + 1
|
| 334 |
+
prev_was_kind_key = (node.token == "kind" and node.depth == 0)
|
| 335 |
+
elif node.node_type in (NodeType.VALUE, NodeType.LIST_VALUE):
|
| 336 |
+
value_counts[node.token] = value_counts.get(node.token, 0) + 1
|
| 337 |
+
if prev_was_kind_key:
|
| 338 |
+
kind_set.add(node.token)
|
| 339 |
+
prev_was_kind_key = False
|
| 340 |
+
|
| 341 |
+
# Save counts for reuse
|
| 342 |
+
if counts_path:
|
| 343 |
+
self.save_counts(key_counts, value_counts, counts_path)
|
| 344 |
+
|
| 345 |
+
return self.build_from_counts(key_counts, value_counts, min_freq, kind_set)
|