feat(v9): sub-tokenization — [UNK] collisions fixed + namespace probe passes + apiVersion probe added
Browse files- __pycache__/app.cpython-314.pyc +0 -0
- app.py +171 -24
- galaxy_data.json +0 -0
- model/vocab.json +0 -0
- model/yaml_bert.pt +2 -2
- tokenizers/v9_unified_bpe_8k.json +0 -0
- yaml_bert/aggregator.py +68 -27
- yaml_bert/config.py +1 -1
- yaml_bert/dataset.py +149 -139
- yaml_bert/embedding.py +8 -16
- yaml_bert/model.py +47 -29
- yaml_bert/suggest.py +16 -6
- yaml_bert/tokenizer.py +70 -0
- yaml_bert/vocab.py +90 -268
__pycache__/app.cpython-314.pyc
CHANGED
|
Binary files a/__pycache__/app.cpython-314.pyc and b/__pycache__/app.cpython-314.pyc differ
|
|
|
app.py
CHANGED
|
@@ -51,22 +51,20 @@ DEFAULT_VOCAB = "model/vocab.json"
|
|
| 51 |
def load_model(checkpoint_path: str, vocab_path: str) -> tuple[YamlBertModel, Vocabulary]:
|
| 52 |
_log(f"Loading vocab from {vocab_path}")
|
| 53 |
vocab = Vocabulary.load(vocab_path)
|
| 54 |
-
_log(f"Vocab loaded: {vocab.
|
| 55 |
-
f"{vocab.
|
| 56 |
-
f"{vocab.atomic_target_vocab_size} atomic targets")
|
| 57 |
|
| 58 |
_log(f"Reading checkpoint file {checkpoint_path}")
|
| 59 |
cp = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
| 60 |
|
| 61 |
-
_log("Building YamlBertModel architecture")
|
| 62 |
-
# recon_enabled=True
|
| 63 |
-
#
|
| 64 |
-
#
|
| 65 |
config = YamlBertConfig(recon_enabled=True)
|
| 66 |
emb = YamlBertEmbedding(
|
| 67 |
config=config,
|
| 68 |
-
|
| 69 |
-
value_vocab_size=vocab.value_vocab_size,
|
| 70 |
)
|
| 71 |
model = YamlBertModel(
|
| 72 |
config=config,
|
|
@@ -504,6 +502,80 @@ spec:
|
|
| 504 |
- containerPort: 80
|
| 505 |
"""
|
| 506 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
|
| 508 |
def _verdict_init(cos):
|
| 509 |
cd = float(cos[2][3])
|
|
@@ -513,10 +585,17 @@ def _verdict_init(cos):
|
|
| 513 |
f"`cos(C, D)` = **{cd:.3f}** (both have init) · "
|
| 514 |
f"`max(cos(A,C), cos(B,D))` = **{mx:.3f}** (mixed). "
|
| 515 |
+ ("Init-pairs cluster tighter than mixed pairs — the model treats "
|
| 516 |
-
"`initContainers` as a real structural feature
|
|
|
|
| 517 |
if passed else
|
| 518 |
-
"Mixed pairs are at least as close as init-pairs
|
| 519 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
)
|
| 521 |
return passed, msg
|
| 522 |
|
|
@@ -547,15 +626,48 @@ def _verdict_namespace(cos):
|
|
| 547 |
msg = (
|
| 548 |
f"`min(same-ns cos)` = **{same_ns:.3f}** · "
|
| 549 |
f"`max(cross-ns cos)` = **{cross_ns:.3f}**. "
|
| 550 |
-
+ ("Same-namespace pairs cluster tighter —
|
| 551 |
-
"
|
| 552 |
-
"
|
|
|
|
|
|
|
|
|
|
| 553 |
if passed else
|
| 554 |
"Same-namespace and cross-namespace pairs have indistinguishable "
|
| 555 |
-
"cosines
|
| 556 |
-
"
|
| 557 |
-
"
|
| 558 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
)
|
| 560 |
return passed, msg
|
| 561 |
|
|
@@ -620,10 +732,14 @@ PRESETS = [
|
|
| 620 |
"If the model encodes `metadata.namespace` as a feature, two "
|
| 621 |
"Pods in the same namespace should be closer than two Pods in "
|
| 622 |
"different namespaces (controlling for structure). "
|
| 623 |
-
"
|
| 624 |
-
"
|
| 625 |
-
"
|
| 626 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 627 |
),
|
| 628 |
"manifests": [
|
| 629 |
{"name": "production / web-1", "yaml": _POD_NS_PROD_1},
|
|
@@ -633,6 +749,32 @@ PRESETS = [
|
|
| 633 |
],
|
| 634 |
"verdict_fn": _verdict_namespace,
|
| 635 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
{
|
| 637 |
"id": "cross-kind",
|
| 638 |
"title": "Pod vs Deployment wrapping the same Pod",
|
|
@@ -698,6 +840,8 @@ def _encode_doc_vec(yaml_text: str) -> torch.Tensor:
|
|
| 698 |
sibling_indices=batch["sibling_indices"],
|
| 699 |
batch_info=batch["batch_info"],
|
| 700 |
padding_mask=batch["padding_mask"],
|
|
|
|
|
|
|
| 701 |
parent_of_tensor=batch["parent_of_tensor"],
|
| 702 |
top_level_key_mask=batch["top_level_key_mask"],
|
| 703 |
edges_by_depth=batch["edges_by_depth"],
|
|
@@ -722,6 +866,9 @@ def _layout_2d(vecs):
|
|
| 722 |
dist = 1.0 - cos
|
| 723 |
np.fill_diagonal(dist, 0.0)
|
| 724 |
dist = np.clip(dist, 0.0, None)
|
|
|
|
|
|
|
|
|
|
| 725 |
|
| 726 |
from sklearn.manifold import MDS
|
| 727 |
mds = MDS(
|
|
@@ -1198,7 +1345,7 @@ Trained with MLM + reconstruction on 276K K8s manifests ·
|
|
| 1198 |
value=EXAMPLE_NGINX,
|
| 1199 |
)
|
| 1200 |
threshold = gr.Slider(
|
| 1201 |
-
minimum=0.05, maximum=0.95, value=0.
|
| 1202 |
label="Confidence threshold",
|
| 1203 |
)
|
| 1204 |
submit = gr.Button("Suggest missing fields", variant="primary")
|
|
|
|
| 51 |
def load_model(checkpoint_path: str, vocab_path: str) -> tuple[YamlBertModel, Vocabulary]:
|
| 52 |
_log(f"Loading vocab from {vocab_path}")
|
| 53 |
vocab = Vocabulary.load(vocab_path)
|
| 54 |
+
_log(f"Vocab loaded: subword={vocab.subword_vocab_size}, "
|
| 55 |
+
f"atomic targets={vocab.atomic_target_vocab_size}")
|
|
|
|
| 56 |
|
| 57 |
_log(f"Reading checkpoint file {checkpoint_path}")
|
| 58 |
cp = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
| 59 |
|
| 60 |
+
_log("Building YamlBertModel architecture (v9: subword embedding)")
|
| 61 |
+
# recon_enabled=True keeps the checkpoint's recon_head weights loadable.
|
| 62 |
+
# The recon head exists but is never invoked at inference time
|
| 63 |
+
# (no subtree_roots_flat passed in forward).
|
| 64 |
config = YamlBertConfig(recon_enabled=True)
|
| 65 |
emb = YamlBertEmbedding(
|
| 66 |
config=config,
|
| 67 |
+
subword_vocab_size=vocab.subword_vocab_size,
|
|
|
|
| 68 |
)
|
| 69 |
model = YamlBertModel(
|
| 70 |
config=config,
|
|
|
|
| 502 |
- containerPort: 80
|
| 503 |
"""
|
| 504 |
|
| 505 |
+
_DEPLOY_APPS_V1 = """apiVersion: apps/v1
|
| 506 |
+
kind: Deployment
|
| 507 |
+
metadata:
|
| 508 |
+
name: web
|
| 509 |
+
spec:
|
| 510 |
+
replicas: 3
|
| 511 |
+
selector:
|
| 512 |
+
matchLabels:
|
| 513 |
+
app: web
|
| 514 |
+
template:
|
| 515 |
+
metadata:
|
| 516 |
+
labels:
|
| 517 |
+
app: web
|
| 518 |
+
spec:
|
| 519 |
+
containers:
|
| 520 |
+
- name: app
|
| 521 |
+
image: nginx
|
| 522 |
+
ports:
|
| 523 |
+
- containerPort: 80
|
| 524 |
+
"""
|
| 525 |
+
|
| 526 |
+
_DEPLOY_EXT_V1BETA1 = """apiVersion: extensions/v1beta1
|
| 527 |
+
kind: Deployment
|
| 528 |
+
metadata:
|
| 529 |
+
name: web
|
| 530 |
+
spec:
|
| 531 |
+
replicas: 3
|
| 532 |
+
selector:
|
| 533 |
+
matchLabels:
|
| 534 |
+
app: web
|
| 535 |
+
template:
|
| 536 |
+
metadata:
|
| 537 |
+
labels:
|
| 538 |
+
app: web
|
| 539 |
+
spec:
|
| 540 |
+
containers:
|
| 541 |
+
- name: app
|
| 542 |
+
image: nginx
|
| 543 |
+
ports:
|
| 544 |
+
- containerPort: 80
|
| 545 |
+
"""
|
| 546 |
+
|
| 547 |
+
_REPLICASET_APPS_V1 = """apiVersion: apps/v1
|
| 548 |
+
kind: ReplicaSet
|
| 549 |
+
metadata:
|
| 550 |
+
name: web
|
| 551 |
+
spec:
|
| 552 |
+
replicas: 3
|
| 553 |
+
selector:
|
| 554 |
+
matchLabels:
|
| 555 |
+
app: web
|
| 556 |
+
template:
|
| 557 |
+
metadata:
|
| 558 |
+
labels:
|
| 559 |
+
app: web
|
| 560 |
+
spec:
|
| 561 |
+
containers:
|
| 562 |
+
- name: app
|
| 563 |
+
image: nginx
|
| 564 |
+
ports:
|
| 565 |
+
- containerPort: 80
|
| 566 |
+
"""
|
| 567 |
+
|
| 568 |
+
_CONFIGMAP_PLAIN = """apiVersion: v1
|
| 569 |
+
kind: ConfigMap
|
| 570 |
+
metadata:
|
| 571 |
+
name: web
|
| 572 |
+
data:
|
| 573 |
+
config.yaml: |
|
| 574 |
+
debug: true
|
| 575 |
+
app.properties: |
|
| 576 |
+
key=value
|
| 577 |
+
"""
|
| 578 |
+
|
| 579 |
|
| 580 |
def _verdict_init(cos):
|
| 581 |
cd = float(cos[2][3])
|
|
|
|
| 585 |
f"`cos(C, D)` = **{cd:.3f}** (both have init) · "
|
| 586 |
f"`max(cos(A,C), cos(B,D))` = **{mx:.3f}** (mixed). "
|
| 587 |
+ ("Init-pairs cluster tighter than mixed pairs — the model treats "
|
| 588 |
+
"`initContainers` as a real structural feature, stronger than the "
|
| 589 |
+
"value-content similarity (shared `image: nginx` etc.)."
|
| 590 |
if passed else
|
| 591 |
+
"Mixed pairs are at least as close as init-pairs. This is not a "
|
| 592 |
+
"regression — it reveals a re-balance: BPE makes `image` values "
|
| 593 |
+
"compositionally visible to attention, so pods sharing `nginx` "
|
| 594 |
+
"cluster together regardless of init presence. v8 with atomic "
|
| 595 |
+
"`[UNK]` values had no choice but to lean on structure; v9 has "
|
| 596 |
+
"both signals and now weights content more heavily here. "
|
| 597 |
+
"Whether that's good depends on use case (good for content "
|
| 598 |
+
"retrieval, less ideal for structure-only similarity).")
|
| 599 |
)
|
| 600 |
return passed, msg
|
| 601 |
|
|
|
|
| 626 |
msg = (
|
| 627 |
f"`min(same-ns cos)` = **{same_ns:.3f}** · "
|
| 628 |
f"`max(cross-ns cos)` = **{cross_ns:.3f}**. "
|
| 629 |
+
+ ("Same-namespace pairs cluster tighter — value content reaches "
|
| 630 |
+
"`doc_vec` even though the aggregator only sums KEY subtrees. "
|
| 631 |
+
"BPE makes namespace values compositional (e.g., `prod | uction`), "
|
| 632 |
+
"and self-attention spreads that signal into neighboring KEY "
|
| 633 |
+
"hidden states, which then flow into `doc_vec`. This was the "
|
| 634 |
+
"first failure of `[UNK]`-vocab v8 that v9 fixed."
|
| 635 |
if passed else
|
| 636 |
"Same-namespace and cross-namespace pairs have indistinguishable "
|
| 637 |
+
"cosines. v8 saw this because both `production` and `staging` "
|
| 638 |
+
"often hit `[UNK]`. v9 was expected to pass — if it does not, "
|
| 639 |
+
"investigate.")
|
| 640 |
+
)
|
| 641 |
+
return passed, msg
|
| 642 |
+
|
| 643 |
+
|
| 644 |
+
def _verdict_apiversion(cos):
|
| 645 |
+
# A = apps/v1 Deployment
|
| 646 |
+
# B = extensions/v1beta1 Deployment (same kind, deprecated apiVersion)
|
| 647 |
+
# C = apps/v1 ReplicaSet (different kind, same group, similar structure)
|
| 648 |
+
# D = v1 ConfigMap (unrelated)
|
| 649 |
+
same_kind = float(cos[0][1])
|
| 650 |
+
same_group_diff_kind = float(cos[0][2])
|
| 651 |
+
unrelated = float(cos[0][3])
|
| 652 |
+
primary = same_kind > same_group_diff_kind
|
| 653 |
+
secondary = same_group_diff_kind > unrelated
|
| 654 |
+
passed = primary and secondary
|
| 655 |
+
msg = (
|
| 656 |
+
f"`cos(apps/v1 Dep, ext/v1beta1 Dep)` = **{same_kind:.3f}** · "
|
| 657 |
+
f"`cos(Dep, ReplicaSet)` = **{same_group_diff_kind:.3f}** · "
|
| 658 |
+
f"`cos(Dep, ConfigMap)` = **{unrelated:.3f}**. "
|
| 659 |
+
+ ("Same-kind-different-apiVersion pairs cluster tightest, then "
|
| 660 |
+
"same-group-different-kind, then unrelated. The model treats "
|
| 661 |
+
"apiVersion as a soft label, not a hard discriminator — it "
|
| 662 |
+
"recognizes `apps/v1` and `extensions/v1beta1` Deployments as "
|
| 663 |
+
"the same thing despite the apiVersion text differing."
|
| 664 |
+
if passed else
|
| 665 |
+
f"Expected ordering broken: same-kind={same_kind:.3f}, "
|
| 666 |
+
f"same-group={same_group_diff_kind:.3f}, unrelated={unrelated:.3f}. "
|
| 667 |
+
"If same-kind is NOT the tightest, the model is treating "
|
| 668 |
+
"apiVersion as a hard discriminator and separating same-kind "
|
| 669 |
+
"manifests by their api label — possibly an over-correction "
|
| 670 |
+
"from the eval-probe accuracy.")
|
| 671 |
)
|
| 672 |
return passed, msg
|
| 673 |
|
|
|
|
| 732 |
"If the model encodes `metadata.namespace` as a feature, two "
|
| 733 |
"Pods in the same namespace should be closer than two Pods in "
|
| 734 |
"different namespaces (controlling for structure). "
|
| 735 |
+
"_v8 with atomic vocab failed this probe — `production` and "
|
| 736 |
+
"`staging` often mapped to `[UNK]`, leaving attention with no "
|
| 737 |
+
"compositional content to work with. v9's byte-level BPE "
|
| 738 |
+
"decomposes namespace values into subwords, and self-attention "
|
| 739 |
+
"now spreads value content into surrounding KEY hidden states. "
|
| 740 |
+
"Those KEYs are what the aggregator pools into `doc_vec` — so "
|
| 741 |
+
"namespace effectively reaches `doc_vec` through the attention "
|
| 742 |
+
"channel, even though the aggregator stays KEY-only by design._"
|
| 743 |
),
|
| 744 |
"manifests": [
|
| 745 |
{"name": "production / web-1", "yaml": _POD_NS_PROD_1},
|
|
|
|
| 749 |
],
|
| 750 |
"verdict_fn": _verdict_namespace,
|
| 751 |
},
|
| 752 |
+
{
|
| 753 |
+
"id": "apiversion",
|
| 754 |
+
"title": "apiVersion sensitivity (same kind, different apiVersion)",
|
| 755 |
+
"hypothesis": (
|
| 756 |
+
"K8s often supports multiple `apiVersion`s for the same kind "
|
| 757 |
+
"(e.g., `apps/v1` Deployment and the deprecated "
|
| 758 |
+
"`extensions/v1beta1` Deployment have nearly identical structure). "
|
| 759 |
+
"If the model understands kind as the structural identity "
|
| 760 |
+
"and apiVersion as a soft label, two same-kind manifests "
|
| 761 |
+
"with different apiVersions should still cluster tighter than "
|
| 762 |
+
"a same-group different-kind manifest (`apps/v1` ReplicaSet), "
|
| 763 |
+
"which in turn should be tighter than an unrelated kind "
|
| 764 |
+
"(`v1` ConfigMap). _The eval probes already show apiVersion "
|
| 765 |
+
"classification at 99.8% — but classification accuracy is "
|
| 766 |
+
"compatible with either understanding the relationship or "
|
| 767 |
+
"treating apiVersion as a hard discriminator. This probe "
|
| 768 |
+
"tests which._"
|
| 769 |
+
),
|
| 770 |
+
"manifests": [
|
| 771 |
+
{"name": "apps/v1 Deployment", "yaml": _DEPLOY_APPS_V1},
|
| 772 |
+
{"name": "extensions/v1beta1 Deploy", "yaml": _DEPLOY_EXT_V1BETA1},
|
| 773 |
+
{"name": "apps/v1 ReplicaSet", "yaml": _REPLICASET_APPS_V1},
|
| 774 |
+
{"name": "v1 ConfigMap (unrelated)", "yaml": _CONFIGMAP_PLAIN},
|
| 775 |
+
],
|
| 776 |
+
"verdict_fn": _verdict_apiversion,
|
| 777 |
+
},
|
| 778 |
{
|
| 779 |
"id": "cross-kind",
|
| 780 |
"title": "Pod vs Deployment wrapping the same Pod",
|
|
|
|
| 840 |
sibling_indices=batch["sibling_indices"],
|
| 841 |
batch_info=batch["batch_info"],
|
| 842 |
padding_mask=batch["padding_mask"],
|
| 843 |
+
logical_ids=batch["logical_ids"],
|
| 844 |
+
n_logical_per_doc=batch["n_logical_per_doc"],
|
| 845 |
parent_of_tensor=batch["parent_of_tensor"],
|
| 846 |
top_level_key_mask=batch["top_level_key_mask"],
|
| 847 |
edges_by_depth=batch["edges_by_depth"],
|
|
|
|
| 866 |
dist = 1.0 - cos
|
| 867 |
np.fill_diagonal(dist, 0.0)
|
| 868 |
dist = np.clip(dist, 0.0, None)
|
| 869 |
+
# sklearn MDS(metric="precomputed") requires strict symmetry; floating-point
|
| 870 |
+
# matmul can leave ~1e-7 asymmetries. Symmetrize explicitly.
|
| 871 |
+
dist = (dist + dist.T) / 2
|
| 872 |
|
| 873 |
from sklearn.manifold import MDS
|
| 874 |
mds = MDS(
|
|
|
|
| 1345 |
value=EXAMPLE_NGINX,
|
| 1346 |
)
|
| 1347 |
threshold = gr.Slider(
|
| 1348 |
+
minimum=0.05, maximum=0.95, value=0.7, step=0.05,
|
| 1349 |
label="Confidence threshold",
|
| 1350 |
)
|
| 1351 |
submit = gr.Button("Suggest missing fields", variant="primary")
|
galaxy_data.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
model/vocab.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
model/yaml_bert.pt
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5663d31145fad1b0f3d842624f5ed08b24c2bff5bfb42e77682c0877741b2c9c
|
| 3 |
+
size 74032259
|
tokenizers/v9_unified_bpe_8k.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
yaml_bert/aggregator.py
CHANGED
|
@@ -1,11 +1,5 @@
|
|
| 1 |
-
"""Tree aggregator:
|
| 2 |
-
vectors + a document vector
|
| 3 |
-
|
| 4 |
-
Mean combine (no learnable params). Two execution paths share the same
|
| 5 |
-
output: a per-document reference loop, and a batched scatter-based
|
| 6 |
-
vectorized path activated when the caller passes precomputed tensors.
|
| 7 |
-
Numerical equivalence between the two paths is locked by
|
| 8 |
-
tests/test_aggregator_vectorized.py.
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
|
@@ -13,19 +7,59 @@ import torch
|
|
| 13 |
import torch.nn as nn
|
| 14 |
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
class TreeAggregator(nn.Module):
|
| 17 |
-
"""
|
| 18 |
|
| 19 |
Two execution paths:
|
| 20 |
- Reference path (default): per-doc Python loop. Used when the batch
|
| 21 |
-
doesn't provide vectorized precompute tensors. Kept for tests
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
Both paths produce numerically equivalent output on the same inputs
|
| 28 |
-
(guaranteed by tests/test_aggregator_vectorized.py).
|
| 29 |
"""
|
| 30 |
|
| 31 |
def __init__(self, d_model: int) -> None:
|
|
@@ -37,6 +71,8 @@ class TreeAggregator(nn.Module):
|
|
| 37 |
hidden_states: torch.Tensor,
|
| 38 |
batch_info: list[dict],
|
| 39 |
*,
|
|
|
|
|
|
|
| 40 |
parent_of_tensor: torch.Tensor | None = None,
|
| 41 |
top_level_key_mask: torch.Tensor | None = None,
|
| 42 |
edges_by_depth: dict[int, torch.Tensor] | None = None,
|
|
@@ -45,19 +81,21 @@ class TreeAggregator(nn.Module):
|
|
| 45 |
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 46 |
"""
|
| 47 |
Args:
|
| 48 |
-
hidden_states: (B,
|
| 49 |
-
|
| 50 |
-
|
|
|
|
| 51 |
parent_of_tensor / top_level_key_mask / edges_by_depth /
|
| 52 |
parents_by_depth: when ALL provided, use vectorized path.
|
| 53 |
-
subtree_mask: (B,
|
| 54 |
-
|
| 55 |
-
and from contributing to ancestor subtree_vecs. Used for the
|
| 56 |
-
v8 reconstruction objective's leak-prevention.
|
| 57 |
|
| 58 |
Returns:
|
| 59 |
-
(subtree_vecs, doc_vec)
|
|
|
|
| 60 |
"""
|
|
|
|
|
|
|
| 61 |
provided = (
|
| 62 |
parent_of_tensor is not None,
|
| 63 |
top_level_key_mask is not None,
|
|
@@ -75,16 +113,19 @@ class TreeAggregator(nn.Module):
|
|
| 75 |
f"parents_by_depth={'set' if provided[3] else 'None'}"
|
| 76 |
)
|
| 77 |
return self._forward_vectorized(
|
| 78 |
-
|
| 79 |
top_level_key_mask=top_level_key_mask,
|
| 80 |
edges_by_depth=edges_by_depth,
|
| 81 |
parents_by_depth=parents_by_depth,
|
| 82 |
subtree_mask=subtree_mask,
|
| 83 |
)
|
| 84 |
return self._forward_reference(
|
| 85 |
-
|
| 86 |
)
|
| 87 |
|
|
|
|
|
|
|
|
|
|
| 88 |
def _forward_reference(
|
| 89 |
self,
|
| 90 |
hidden_states: torch.Tensor,
|
|
|
|
| 1 |
+
"""Tree aggregator v9: pool subwords per logical node, then bottom-up
|
| 2 |
+
combine of logical KEY nodes into subtree vectors + a document vector.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
from __future__ import annotations
|
| 5 |
|
|
|
|
| 7 |
import torch.nn as nn
|
| 8 |
|
| 9 |
|
| 10 |
+
def _pool_subwords(
|
| 11 |
+
hidden_states: torch.Tensor,
|
| 12 |
+
logical_ids: torch.Tensor,
|
| 13 |
+
n_logical_per_doc: torch.Tensor,
|
| 14 |
+
) -> torch.Tensor:
|
| 15 |
+
"""Mean-pool subword hidden states into per-logical-node vectors.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
hidden_states: (B, N_sub, d) per-subword hidden states from the encoder.
|
| 19 |
+
logical_ids: (B, N_sub) int tensor; -1 marks padding (ignored).
|
| 20 |
+
n_logical_per_doc: (B,) number of logical nodes per doc; pooled output
|
| 21 |
+
shape is (B, max(n_logical_per_doc), d).
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
(B, L_max, d) where L_max = int(n_logical_per_doc.max()).
|
| 25 |
+
"""
|
| 26 |
+
B, N_sub, d = hidden_states.shape
|
| 27 |
+
L_max = int(n_logical_per_doc.max().item())
|
| 28 |
+
out = torch.zeros(B, L_max, d, device=hidden_states.device, dtype=hidden_states.dtype)
|
| 29 |
+
count = torch.zeros(B, L_max, device=hidden_states.device, dtype=torch.float32)
|
| 30 |
+
|
| 31 |
+
valid = logical_ids >= 0 # (B, N_sub)
|
| 32 |
+
safe_lids = logical_ids.clamp(min=0) # (B, N_sub)
|
| 33 |
+
|
| 34 |
+
# Doc index broadcast over N_sub
|
| 35 |
+
doc_idx = torch.arange(B, device=hidden_states.device).unsqueeze(1).expand(B, N_sub)
|
| 36 |
+
|
| 37 |
+
# Linear (doc, logical) → flat slot
|
| 38 |
+
flat = doc_idx * L_max + safe_lids # (B, N_sub)
|
| 39 |
+
flat_valid = flat[valid]
|
| 40 |
+
h_valid = hidden_states[valid]
|
| 41 |
+
|
| 42 |
+
out_flat = out.view(B * L_max, d)
|
| 43 |
+
count_flat = count.view(B * L_max)
|
| 44 |
+
out_flat.index_add_(0, flat_valid, h_valid)
|
| 45 |
+
count_flat.index_add_(
|
| 46 |
+
0, flat_valid, torch.ones_like(flat_valid, dtype=torch.float32),
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
pooled = out_flat / count_flat.clamp(min=1.0).unsqueeze(-1).to(out_flat.dtype)
|
| 50 |
+
return pooled.view(B, L_max, d)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
class TreeAggregator(nn.Module):
|
| 54 |
+
"""v9: pool subwords first, then run v8 logical-level aggregator.
|
| 55 |
|
| 56 |
Two execution paths:
|
| 57 |
- Reference path (default): per-doc Python loop. Used when the batch
|
| 58 |
+
doesn't provide vectorized precompute tensors. Kept for tests.
|
| 59 |
+
- Vectorized path: batched scatter ops, processed depth-by-depth.
|
| 60 |
+
|
| 61 |
+
Both paths produce numerically equivalent output (guaranteed by
|
| 62 |
+
tests/test_aggregator_vectorized.py).
|
|
|
|
|
|
|
|
|
|
| 63 |
"""
|
| 64 |
|
| 65 |
def __init__(self, d_model: int) -> None:
|
|
|
|
| 71 |
hidden_states: torch.Tensor,
|
| 72 |
batch_info: list[dict],
|
| 73 |
*,
|
| 74 |
+
logical_ids: torch.Tensor,
|
| 75 |
+
n_logical_per_doc: torch.Tensor,
|
| 76 |
parent_of_tensor: torch.Tensor | None = None,
|
| 77 |
top_level_key_mask: torch.Tensor | None = None,
|
| 78 |
edges_by_depth: dict[int, torch.Tensor] | None = None,
|
|
|
|
| 81 |
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 82 |
"""
|
| 83 |
Args:
|
| 84 |
+
hidden_states: (B, N_sub, d) per-subword hidden states from encoder.
|
| 85 |
+
logical_ids: (B, N_sub) per-subword logical-node id (-1 for pad).
|
| 86 |
+
n_logical_per_doc: (B,) number of logical nodes per doc.
|
| 87 |
+
batch_info: list of B dicts (legacy path; required for reference path).
|
| 88 |
parent_of_tensor / top_level_key_mask / edges_by_depth /
|
| 89 |
parents_by_depth: when ALL provided, use vectorized path.
|
| 90 |
+
subtree_mask: (B, L_max) bool; positions excluded from doc_vec and
|
| 91 |
+
ancestor subtree_vecs (used by v8 reconstruction objective).
|
|
|
|
|
|
|
| 92 |
|
| 93 |
Returns:
|
| 94 |
+
(subtree_vecs, doc_vec) where subtree_vecs is (B, L_max, d) —
|
| 95 |
+
indexed by LOGICAL position, not subword.
|
| 96 |
"""
|
| 97 |
+
pooled = _pool_subwords(hidden_states, logical_ids, n_logical_per_doc)
|
| 98 |
+
|
| 99 |
provided = (
|
| 100 |
parent_of_tensor is not None,
|
| 101 |
top_level_key_mask is not None,
|
|
|
|
| 113 |
f"parents_by_depth={'set' if provided[3] else 'None'}"
|
| 114 |
)
|
| 115 |
return self._forward_vectorized(
|
| 116 |
+
pooled,
|
| 117 |
top_level_key_mask=top_level_key_mask,
|
| 118 |
edges_by_depth=edges_by_depth,
|
| 119 |
parents_by_depth=parents_by_depth,
|
| 120 |
subtree_mask=subtree_mask,
|
| 121 |
)
|
| 122 |
return self._forward_reference(
|
| 123 |
+
pooled, batch_info, subtree_mask=subtree_mask,
|
| 124 |
)
|
| 125 |
|
| 126 |
+
# === _forward_reference and _forward_vectorized are verbatim from v8 ===
|
| 127 |
+
# They operate on per-position hidden states (now logical positions).
|
| 128 |
+
|
| 129 |
def _forward_reference(
|
| 130 |
self,
|
| 131 |
hidden_states: torch.Tensor,
|
yaml_bert/config.py
CHANGED
|
@@ -31,7 +31,7 @@ class YamlBertConfig:
|
|
| 31 |
lr: float = 1e-4
|
| 32 |
batch_size: int = 32
|
| 33 |
num_epochs: int = 30
|
| 34 |
-
max_seq_len: int = 512
|
| 35 |
|
| 36 |
# Reconstruction objective (subtree masking + bag-of-keys prediction)
|
| 37 |
recon_enabled: bool = False
|
|
|
|
| 31 |
lr: float = 1e-4
|
| 32 |
batch_size: int = 32
|
| 33 |
num_epochs: int = 30
|
| 34 |
+
max_seq_len: int = 768 # was 512 in v8; v9 BPE-expands sequences ~2.3x
|
| 35 |
|
| 36 |
# Reconstruction objective (subtree masking + bag-of-keys prediction)
|
| 37 |
recon_enabled: bool = False
|
yaml_bert/dataset.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
-
"""YAML-BERT dataset:
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import random
|
|
@@ -17,37 +18,11 @@ _LIST_INDEX_RE = re.compile(r"\.\d+$")
|
|
| 17 |
|
| 18 |
|
| 19 |
def _strip_trailing_list_index(path: str) -> str:
|
| 20 |
-
"""Strip a trailing numeric segment from a parent_path.
|
| 21 |
-
|
| 22 |
-
E.g., 'spec.containers.0' -> 'spec.containers'.
|
| 23 |
-
Returns the path unchanged if no trailing numeric segment exists.
|
| 24 |
-
"""
|
| 25 |
return _LIST_INDEX_RE.sub("", path)
|
| 26 |
|
| 27 |
|
| 28 |
def compute_children_info(nodes: list[YamlNode]) -> dict:
|
| 29 |
-
"""
|
| 30 |
-
|
| 31 |
-
For each KEY/LIST_KEY position, its children are KEY/LIST_KEY positions
|
| 32 |
-
whose parent_path equals this key's full_path. (VALUE nodes are leaves —
|
| 33 |
-
their hidden states are used in the aggregator without being treated as
|
| 34 |
-
"children" of any KEY for subtree purposes.)
|
| 35 |
-
|
| 36 |
-
For KEYs inside list items, parent_path ends in a numeric list index
|
| 37 |
-
(e.g., 'spec.containers.0'), but the linearizer never emits a synthetic
|
| 38 |
-
list-item node, so a direct lookup misses. We strip the trailing numeric
|
| 39 |
-
segment and link to the list-key itself. This flattens all list items
|
| 40 |
-
into their list parent — per-item grouping is lost, but the aggregator
|
| 41 |
-
still produces a meaningful doc vector. Phase 1 may add synthetic
|
| 42 |
-
list-item nodes if per-item grouping matters.
|
| 43 |
-
|
| 44 |
-
Returns a dict with:
|
| 45 |
-
children_of: list[list[int]] — children's positions per node
|
| 46 |
-
parent_of: list[int] — parent position (or -1)
|
| 47 |
-
key_positions: list[int] — positions that are KEY/LIST_KEY
|
| 48 |
-
depth_of: list[int] — depth per position
|
| 49 |
-
full_path_of: list[str] — full path per position
|
| 50 |
-
"""
|
| 51 |
n = len(nodes)
|
| 52 |
full_path_of: list[str] = []
|
| 53 |
for node in nodes:
|
|
@@ -56,7 +31,6 @@ def compute_children_info(nodes: list[YamlNode]) -> dict:
|
|
| 56 |
else:
|
| 57 |
full_path_of.append(node.token)
|
| 58 |
|
| 59 |
-
# Index KEY positions by their full_path
|
| 60 |
key_positions: list[int] = [
|
| 61 |
i for i, node in enumerate(nodes)
|
| 62 |
if node.node_type in (NodeType.KEY, NodeType.LIST_KEY)
|
|
@@ -73,11 +47,8 @@ def compute_children_info(nodes: list[YamlNode]) -> dict:
|
|
| 73 |
parent_path = nodes[p].parent_path
|
| 74 |
if not parent_path:
|
| 75 |
continue
|
| 76 |
-
# Try direct lookup first
|
| 77 |
parent_pos = path_to_key_pos.get(parent_path)
|
| 78 |
if parent_pos is None:
|
| 79 |
-
# Try with trailing list index stripped
|
| 80 |
-
# (e.g., "spec.containers.0" -> "spec.containers")
|
| 81 |
stripped = _strip_trailing_list_index(parent_path)
|
| 82 |
if stripped != parent_path:
|
| 83 |
parent_pos = path_to_key_pos.get(stripped)
|
|
@@ -104,7 +75,7 @@ _MASKABLE_TYPES = (NodeType.KEY, NodeType.LIST_KEY)
|
|
| 104 |
|
| 105 |
|
| 106 |
class YamlBertDataset(Dataset):
|
| 107 |
-
"""
|
| 108 |
|
| 109 |
def __init__(
|
| 110 |
self,
|
|
@@ -118,18 +89,12 @@ class YamlBertDataset(Dataset):
|
|
| 118 |
self.max_seq_len = config.max_seq_len
|
| 119 |
self.recon_enabled = config.recon_enabled
|
| 120 |
|
| 121 |
-
# Precompute per-doc children_info AND descendant cache (subtree picker
|
| 122 |
-
# uses descendants on every __getitem__ call; cache once at init).
|
| 123 |
self._cached_children_info: list[dict] = []
|
| 124 |
self._cached_descendants: list[dict[int, set[int]] | None] = []
|
| 125 |
-
if self.recon_enabled and len(documents) > 100:
|
| 126 |
-
import logging
|
| 127 |
-
logging.getLogger(__name__).info(
|
| 128 |
-
"YamlBertDataset: precomputing descendants for %d docs (recon enabled)",
|
| 129 |
-
len(documents),
|
| 130 |
-
)
|
| 131 |
for doc in documents:
|
| 132 |
-
|
|
|
|
|
|
|
| 133 |
self._cached_children_info.append(ci)
|
| 134 |
if self.recon_enabled:
|
| 135 |
desc_cache: dict[int, set[int]] = {}
|
|
@@ -145,80 +110,116 @@ class YamlBertDataset(Dataset):
|
|
| 145 |
|
| 146 |
def __getitem__(self, idx: int) -> dict:
|
| 147 |
nodes = self.documents[idx]
|
| 148 |
-
if len(nodes) > self.max_seq_len:
|
| 149 |
-
nodes = nodes[: self.max_seq_len]
|
| 150 |
-
|
| 151 |
-
token_ids: list[int] = []
|
| 152 |
-
node_types: list[int] = []
|
| 153 |
-
depths: list[int] = []
|
| 154 |
-
sibling_indices: list[int] = []
|
| 155 |
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
| 168 |
|
| 169 |
mlm_masked_positions: set[int] = set()
|
| 170 |
-
for
|
| 171 |
-
if node.node_type not in _MASKABLE_TYPES:
|
| 172 |
-
continue
|
| 173 |
if random.random() >= self.mask_prob:
|
| 174 |
continue
|
| 175 |
-
|
|
|
|
| 176 |
if atomic_id == unk_id:
|
| 177 |
-
continue # skip [UNK] targets (Lever 1)
|
| 178 |
-
atomic_labels[
|
| 179 |
-
mlm_masked_positions.add(
|
| 180 |
r = random.random()
|
|
|
|
| 181 |
if r < 0.8:
|
| 182 |
-
|
|
|
|
| 183 |
elif r < 0.9:
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
len(self.vocab.key_vocab) + len(self.vocab.special_tokens) - 1,
|
| 187 |
-
)
|
| 188 |
|
| 189 |
result = {
|
| 190 |
-
"token_ids": torch.tensor(
|
| 191 |
-
"node_types": torch.tensor(
|
| 192 |
-
"depths": torch.tensor(
|
| 193 |
-
"sibling_indices": torch.tensor(
|
|
|
|
| 194 |
"atomic_labels": torch.tensor(atomic_labels, dtype=torch.long),
|
| 195 |
-
"children_info":
|
|
|
|
| 196 |
}
|
| 197 |
|
| 198 |
if self.recon_enabled:
|
| 199 |
from yaml_bert.subtree_masking import pick_subtrees, bag_of_keys_target
|
| 200 |
-
ci = self._cached_children_info[idx]
|
| 201 |
picked_roots = pick_subtrees(
|
| 202 |
-
N=
|
| 203 |
-
key_positions=
|
| 204 |
-
depth_of=
|
| 205 |
-
children_of=
|
| 206 |
mlm_masked_positions=mlm_masked_positions,
|
| 207 |
rng=random,
|
| 208 |
-
descendants_cache=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
)
|
| 210 |
-
|
| 211 |
-
subtree_mask = torch.zeros(len(nodes), dtype=torch.bool)
|
| 212 |
picked_positions_all: set[int] = set()
|
| 213 |
bag_targets: list[torch.Tensor] = []
|
| 214 |
-
# position → key string lookup for bag-of-keys building
|
| 215 |
position_to_key_str = {
|
| 216 |
-
i: nodes[i].token
|
| 217 |
-
for i in range(len(nodes))
|
| 218 |
-
if nodes[i].node_type in (NodeType.KEY, NodeType.LIST_KEY)
|
| 219 |
}
|
| 220 |
for root_pos in picked_roots:
|
| 221 |
-
descs =
|
|
|
|
|
|
|
|
|
|
| 222 |
picked_positions_all |= descs
|
| 223 |
bag_targets.append(bag_of_keys_target(
|
| 224 |
subtree_positions=descs,
|
|
@@ -226,14 +227,16 @@ class YamlBertDataset(Dataset):
|
|
| 226 |
atomic_vocab=self.vocab.atomic_target_vocab,
|
| 227 |
vocab_size=self.vocab.atomic_target_vocab_size,
|
| 228 |
))
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
| 234 |
result["subtree_mask"] = subtree_mask
|
| 235 |
-
result["subtree_roots"] = picked_roots
|
| 236 |
-
result["bag_of_keys_targets"] = bag_targets
|
| 237 |
result["_atomic_vocab_size"] = self.vocab.atomic_target_vocab_size
|
| 238 |
|
| 239 |
return result
|
|
@@ -243,63 +246,79 @@ _COLLATE_NON_TENSOR_KEYS = frozenset({
|
|
| 243 |
"children_info",
|
| 244 |
"subtree_roots",
|
| 245 |
"bag_of_keys_targets",
|
| 246 |
-
"subtree_mask",
|
| 247 |
-
"_atomic_vocab_size",
|
|
|
|
| 248 |
})
|
| 249 |
|
| 250 |
|
| 251 |
def collate_fn(batch: list[dict]) -> dict:
|
| 252 |
-
"""Pad
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
padding_masks: list[torch.Tensor] = []
|
| 258 |
batch_info: list[dict] = []
|
|
|
|
| 259 |
|
| 260 |
for item in batch:
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
for
|
| 264 |
-
pad_value = -
|
| 265 |
-
if
|
| 266 |
-
padding = torch.full((
|
| 267 |
-
|
| 268 |
else:
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
mask = torch.cat([
|
| 271 |
-
torch.zeros(
|
| 272 |
-
torch.ones(
|
| 273 |
-
]) if
|
| 274 |
padding_masks.append(mask)
|
| 275 |
batch_info.append(item["children_info"])
|
|
|
|
| 276 |
|
| 277 |
-
result = {k: torch.stack(v) for k, v in
|
|
|
|
| 278 |
result["padding_mask"] = torch.stack(padding_masks)
|
| 279 |
result["batch_info"] = batch_info
|
|
|
|
| 280 |
|
| 281 |
-
#
|
| 282 |
B = len(batch)
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
parent_of_tensor = torch.full((B, N), -1, dtype=torch.long)
|
| 287 |
for b_idx, info in enumerate(batch_info):
|
| 288 |
-
parent_of = info["parent_of"]
|
| 289 |
n_b = len(parent_of)
|
| 290 |
if n_b > 0:
|
| 291 |
parent_of_tensor[b_idx, :n_b] = torch.tensor(parent_of, dtype=torch.long)
|
| 292 |
-
|
| 293 |
-
# top_level_key_mask: (B, N) bool. True where depth==0 AND position is a KEY.
|
| 294 |
-
top_level_key_mask = torch.zeros((B, N), dtype=torch.bool)
|
| 295 |
-
for b_idx, info in enumerate(batch_info):
|
| 296 |
depth_of = info["depth_of"]
|
| 297 |
depth_zero_kps = [kp for kp in info["key_positions"] if depth_of[kp] == 0]
|
| 298 |
if depth_zero_kps:
|
| 299 |
top_level_key_mask[b_idx, depth_zero_kps] = True
|
| 300 |
|
| 301 |
-
# edges_by_depth: dict[depth, (E, 3) long] of [doc_idx, child_pos, parent_pos] across batch.
|
| 302 |
-
# parents_by_depth: dict[depth, (P, 2) long] of unique [doc_idx, parent_pos] with at-least-one-child.
|
| 303 |
edges_by_depth: dict[int, list[tuple[int, int, int]]] = {}
|
| 304 |
parents_set_by_depth: dict[int, set[tuple[int, int]]] = {}
|
| 305 |
for b_idx, info in enumerate(batch_info):
|
|
@@ -328,13 +347,11 @@ def collate_fn(batch: list[dict]) -> dict:
|
|
| 328 |
for d, parents_set in parents_set_by_depth.items()
|
| 329 |
}
|
| 330 |
|
| 331 |
-
# Subtree-mask batching (only present when recon is enabled per-item).
|
| 332 |
if "subtree_mask" in batch[0]:
|
| 333 |
-
# subtree_mask: (B, N) bool, pad with False
|
| 334 |
subtree_masks: list[torch.Tensor] = []
|
| 335 |
for item in batch:
|
| 336 |
sm = item["subtree_mask"]
|
| 337 |
-
pad_len =
|
| 338 |
if pad_len > 0:
|
| 339 |
subtree_masks.append(torch.cat([
|
| 340 |
sm, torch.zeros(pad_len, dtype=torch.bool),
|
|
@@ -343,8 +360,6 @@ def collate_fn(batch: list[dict]) -> dict:
|
|
| 343 |
subtree_masks.append(sm)
|
| 344 |
result["subtree_mask"] = torch.stack(subtree_masks)
|
| 345 |
|
| 346 |
-
# subtree_roots_flat: (M, 2) of [batch_idx, root_pos]; M = total roots
|
| 347 |
-
# bag_of_keys_targets_flat: (M, V_atomic)
|
| 348 |
flat_roots: list[tuple[int, int]] = []
|
| 349 |
flat_targets: list[torch.Tensor] = []
|
| 350 |
for b_idx, item in enumerate(batch):
|
|
@@ -359,12 +374,7 @@ def collate_fn(batch: list[dict]) -> dict:
|
|
| 359 |
)
|
| 360 |
result["bag_of_keys_targets_flat"] = torch.stack(flat_targets)
|
| 361 |
else:
|
| 362 |
-
|
| 363 |
-
# picks (small docs, no candidates). Emit empty tensors so the
|
| 364 |
-
# consumer can detect "no subtrees this batch."
|
| 365 |
-
result["subtree_roots_flat"] = torch.zeros(
|
| 366 |
-
(0, 2), dtype=torch.long,
|
| 367 |
-
)
|
| 368 |
v = batch[0].get("_atomic_vocab_size", 0)
|
| 369 |
result["bag_of_keys_targets_flat"] = torch.zeros(
|
| 370 |
(0, v), dtype=torch.float,
|
|
|
|
| 1 |
+
"""v9 YAML-BERT dataset: BPE-expand each linearizer node into subword
|
| 2 |
+
positions, mask whole logical KEYs, emit per-logical-node atomic labels."""
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import random
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
def _strip_trailing_list_index(path: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
return _LIST_INDEX_RE.sub("", path)
|
| 22 |
|
| 23 |
|
| 24 |
def compute_children_info(nodes: list[YamlNode]) -> dict:
|
| 25 |
+
"""Same as v8 — operates on LOGICAL nodes (not subwords)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
n = len(nodes)
|
| 27 |
full_path_of: list[str] = []
|
| 28 |
for node in nodes:
|
|
|
|
| 31 |
else:
|
| 32 |
full_path_of.append(node.token)
|
| 33 |
|
|
|
|
| 34 |
key_positions: list[int] = [
|
| 35 |
i for i, node in enumerate(nodes)
|
| 36 |
if node.node_type in (NodeType.KEY, NodeType.LIST_KEY)
|
|
|
|
| 47 |
parent_path = nodes[p].parent_path
|
| 48 |
if not parent_path:
|
| 49 |
continue
|
|
|
|
| 50 |
parent_pos = path_to_key_pos.get(parent_path)
|
| 51 |
if parent_pos is None:
|
|
|
|
|
|
|
| 52 |
stripped = _strip_trailing_list_index(parent_path)
|
| 53 |
if stripped != parent_path:
|
| 54 |
parent_pos = path_to_key_pos.get(stripped)
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
class YamlBertDataset(Dataset):
|
| 78 |
+
"""v9 dataset: subword expansion + whole-key MLM masking + recon."""
|
| 79 |
|
| 80 |
def __init__(
|
| 81 |
self,
|
|
|
|
| 89 |
self.max_seq_len = config.max_seq_len
|
| 90 |
self.recon_enabled = config.recon_enabled
|
| 91 |
|
|
|
|
|
|
|
| 92 |
self._cached_children_info: list[dict] = []
|
| 93 |
self._cached_descendants: list[dict[int, set[int]] | None] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
for doc in documents:
|
| 95 |
+
# Cap LOGICAL nodes here; BPE expansion may still exceed max_seq_len
|
| 96 |
+
# at the subword level (handled below by truncation).
|
| 97 |
+
ci = compute_children_info(doc)
|
| 98 |
self._cached_children_info.append(ci)
|
| 99 |
if self.recon_enabled:
|
| 100 |
desc_cache: dict[int, set[int]] = {}
|
|
|
|
| 110 |
|
| 111 |
def __getitem__(self, idx: int) -> dict:
|
| 112 |
nodes = self.documents[idx]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
+
# Pass 1: BPE-expand each logical node, building per-subword tensors.
|
| 115 |
+
sub_token_ids: list[int] = []
|
| 116 |
+
sub_node_types: list[int] = []
|
| 117 |
+
sub_depths: list[int] = []
|
| 118 |
+
sub_sibling: list[int] = []
|
| 119 |
+
sub_logical_ids: list[int] = []
|
| 120 |
+
per_logical_subword_spans: list[tuple[int, int]] = []
|
| 121 |
+
# We may need to drop trailing logical nodes if subword expansion
|
| 122 |
+
# blows the max_seq_len cap.
|
| 123 |
+
kept_logical: int = 0
|
| 124 |
+
for logical_idx, node in enumerate(nodes):
|
| 125 |
+
is_value = node.node_type in (NodeType.VALUE, NodeType.LIST_VALUE)
|
| 126 |
+
ids = self.vocab.encode_token(node.token, is_value=is_value)
|
| 127 |
+
if len(sub_token_ids) + len(ids) > self.max_seq_len:
|
| 128 |
+
break
|
| 129 |
+
start = len(sub_token_ids)
|
| 130 |
+
sub_token_ids.extend(ids)
|
| 131 |
+
sub_node_types.extend([_NODE_TYPE_INDEX[node.node_type]] * len(ids))
|
| 132 |
+
sub_depths.extend([min(node.depth, 15)] * len(ids))
|
| 133 |
+
sub_sibling.extend([min(node.sibling_index, 31)] * len(ids))
|
| 134 |
+
sub_logical_ids.extend([kept_logical] * len(ids))
|
| 135 |
+
per_logical_subword_spans.append((start, start + len(ids)))
|
| 136 |
+
kept_logical += 1
|
| 137 |
+
|
| 138 |
+
n_logical = kept_logical
|
| 139 |
+
# Truncate cached children_info to kept logicals
|
| 140 |
+
ci = self._cached_children_info[idx]
|
| 141 |
+
kept_set = set(range(n_logical))
|
| 142 |
+
children_of_t = [
|
| 143 |
+
[c for c in ci["children_of"][p] if c in kept_set]
|
| 144 |
+
for p in range(n_logical)
|
| 145 |
+
]
|
| 146 |
+
parent_of_t = [
|
| 147 |
+
ci["parent_of"][p] if (ci["parent_of"][p] in kept_set or ci["parent_of"][p] == -1) else -1
|
| 148 |
+
for p in range(n_logical)
|
| 149 |
+
]
|
| 150 |
+
key_positions_t = [p for p in ci["key_positions"] if p < n_logical]
|
| 151 |
+
depth_of_t = ci["depth_of"][:n_logical]
|
| 152 |
+
full_path_of_t = ci["full_path_of"][:n_logical]
|
| 153 |
+
ci_t = {
|
| 154 |
+
"children_of": children_of_t,
|
| 155 |
+
"parent_of": parent_of_t,
|
| 156 |
+
"key_positions": key_positions_t,
|
| 157 |
+
"depth_of": depth_of_t,
|
| 158 |
+
"full_path_of": full_path_of_t,
|
| 159 |
+
}
|
| 160 |
|
| 161 |
+
# Pass 2: whole-key MLM masking, one decision per LOGICAL KEY.
|
| 162 |
+
atomic_labels: list[int] = [-100] * n_logical
|
| 163 |
+
mask_id = self.vocab.mask_id
|
| 164 |
+
unk_id = self.vocab.unk_id
|
| 165 |
+
subword_vocab_size = self.vocab.subword_vocab_size
|
| 166 |
|
| 167 |
mlm_masked_positions: set[int] = set()
|
| 168 |
+
for logical_idx in key_positions_t:
|
|
|
|
|
|
|
| 169 |
if random.random() >= self.mask_prob:
|
| 170 |
continue
|
| 171 |
+
tok = nodes[logical_idx].token
|
| 172 |
+
atomic_id = self.vocab.encode_atomic_target(tok)
|
| 173 |
if atomic_id == unk_id:
|
| 174 |
+
continue # skip [UNK] targets (Lever 1, carried from v8)
|
| 175 |
+
atomic_labels[logical_idx] = atomic_id
|
| 176 |
+
mlm_masked_positions.add(logical_idx)
|
| 177 |
r = random.random()
|
| 178 |
+
start, end = per_logical_subword_spans[logical_idx]
|
| 179 |
if r < 0.8:
|
| 180 |
+
for p in range(start, end):
|
| 181 |
+
sub_token_ids[p] = mask_id
|
| 182 |
elif r < 0.9:
|
| 183 |
+
for p in range(start, end):
|
| 184 |
+
sub_token_ids[p] = random.randint(4, subword_vocab_size - 1)
|
|
|
|
|
|
|
| 185 |
|
| 186 |
result = {
|
| 187 |
+
"token_ids": torch.tensor(sub_token_ids, dtype=torch.long),
|
| 188 |
+
"node_types": torch.tensor(sub_node_types, dtype=torch.long),
|
| 189 |
+
"depths": torch.tensor(sub_depths, dtype=torch.long),
|
| 190 |
+
"sibling_indices": torch.tensor(sub_sibling, dtype=torch.long),
|
| 191 |
+
"logical_ids": torch.tensor(sub_logical_ids, dtype=torch.long),
|
| 192 |
"atomic_labels": torch.tensor(atomic_labels, dtype=torch.long),
|
| 193 |
+
"children_info": ci_t,
|
| 194 |
+
"n_logical": n_logical,
|
| 195 |
}
|
| 196 |
|
| 197 |
if self.recon_enabled:
|
| 198 |
from yaml_bert.subtree_masking import pick_subtrees, bag_of_keys_target
|
|
|
|
| 199 |
picked_roots = pick_subtrees(
|
| 200 |
+
N=n_logical,
|
| 201 |
+
key_positions=key_positions_t,
|
| 202 |
+
depth_of=depth_of_t,
|
| 203 |
+
children_of=children_of_t,
|
| 204 |
mlm_masked_positions=mlm_masked_positions,
|
| 205 |
rng=random,
|
| 206 |
+
descendants_cache={
|
| 207 |
+
kp: descendants_of(kp, children_of_t)
|
| 208 |
+
for kp in key_positions_t
|
| 209 |
+
if children_of_t[kp]
|
| 210 |
+
},
|
| 211 |
)
|
| 212 |
+
subtree_mask = torch.zeros(n_logical, dtype=torch.bool)
|
|
|
|
| 213 |
picked_positions_all: set[int] = set()
|
| 214 |
bag_targets: list[torch.Tensor] = []
|
|
|
|
| 215 |
position_to_key_str = {
|
| 216 |
+
i: nodes[i].token for i in key_positions_t
|
|
|
|
|
|
|
| 217 |
}
|
| 218 |
for root_pos in picked_roots:
|
| 219 |
+
descs = {
|
| 220 |
+
d for d in descendants_of(root_pos, children_of_t)
|
| 221 |
+
if d < n_logical
|
| 222 |
+
}
|
| 223 |
picked_positions_all |= descs
|
| 224 |
bag_targets.append(bag_of_keys_target(
|
| 225 |
subtree_positions=descs,
|
|
|
|
| 227 |
atomic_vocab=self.vocab.atomic_target_vocab,
|
| 228 |
vocab_size=self.vocab.atomic_target_vocab_size,
|
| 229 |
))
|
| 230 |
+
# Apply [MASK] to ALL subwords of each logical position in the picked subtree
|
| 231 |
+
for lpos in picked_positions_all:
|
| 232 |
+
subtree_mask[lpos] = True
|
| 233 |
+
start, end = per_logical_subword_spans[lpos]
|
| 234 |
+
for p in range(start, end):
|
| 235 |
+
sub_token_ids[p] = mask_id
|
| 236 |
+
result["token_ids"] = torch.tensor(sub_token_ids, dtype=torch.long)
|
| 237 |
result["subtree_mask"] = subtree_mask
|
| 238 |
+
result["subtree_roots"] = picked_roots
|
| 239 |
+
result["bag_of_keys_targets"] = bag_targets
|
| 240 |
result["_atomic_vocab_size"] = self.vocab.atomic_target_vocab_size
|
| 241 |
|
| 242 |
return result
|
|
|
|
| 246 |
"children_info",
|
| 247 |
"subtree_roots",
|
| 248 |
"bag_of_keys_targets",
|
| 249 |
+
"subtree_mask",
|
| 250 |
+
"_atomic_vocab_size",
|
| 251 |
+
"n_logical",
|
| 252 |
})
|
| 253 |
|
| 254 |
|
| 255 |
def collate_fn(batch: list[dict]) -> dict:
|
| 256 |
+
"""Pad subword-level tensors AND logical-level tensors.
|
| 257 |
+
|
| 258 |
+
Subword-level (per-position): token_ids, node_types, depths, sibling_indices,
|
| 259 |
+
logical_ids — padded to max subword length
|
| 260 |
+
Logical-level (per-logical-node): atomic_labels — padded to max logical count
|
| 261 |
+
"""
|
| 262 |
+
max_sub_len = max(item["token_ids"].size(0) for item in batch)
|
| 263 |
+
max_logical = max(item["n_logical"] for item in batch)
|
| 264 |
+
|
| 265 |
+
subword_keys = ("token_ids", "node_types", "depths", "sibling_indices",
|
| 266 |
+
"logical_ids")
|
| 267 |
+
padded_sub: dict[str, list[torch.Tensor]] = {k: [] for k in subword_keys}
|
| 268 |
+
padded_labels: list[torch.Tensor] = []
|
| 269 |
padding_masks: list[torch.Tensor] = []
|
| 270 |
batch_info: list[dict] = []
|
| 271 |
+
n_logical_per_doc: list[int] = []
|
| 272 |
|
| 273 |
for item in batch:
|
| 274 |
+
sub_len = item["token_ids"].size(0)
|
| 275 |
+
pad_sub = max_sub_len - sub_len
|
| 276 |
+
for k in subword_keys:
|
| 277 |
+
pad_value = -1 if k == "logical_ids" else 0
|
| 278 |
+
if pad_sub > 0:
|
| 279 |
+
padding = torch.full((pad_sub,), pad_value, dtype=torch.long)
|
| 280 |
+
padded_sub[k].append(torch.cat([item[k], padding]))
|
| 281 |
else:
|
| 282 |
+
padded_sub[k].append(item[k])
|
| 283 |
+
|
| 284 |
+
labels = item["atomic_labels"]
|
| 285 |
+
pad_lab = max_logical - labels.size(0)
|
| 286 |
+
if pad_lab > 0:
|
| 287 |
+
padded_labels.append(torch.cat([
|
| 288 |
+
labels, torch.full((pad_lab,), -100, dtype=torch.long),
|
| 289 |
+
]))
|
| 290 |
+
else:
|
| 291 |
+
padded_labels.append(labels)
|
| 292 |
+
|
| 293 |
mask = torch.cat([
|
| 294 |
+
torch.zeros(sub_len, dtype=torch.bool),
|
| 295 |
+
torch.ones(pad_sub, dtype=torch.bool),
|
| 296 |
+
]) if pad_sub > 0 else torch.zeros(sub_len, dtype=torch.bool)
|
| 297 |
padding_masks.append(mask)
|
| 298 |
batch_info.append(item["children_info"])
|
| 299 |
+
n_logical_per_doc.append(item["n_logical"])
|
| 300 |
|
| 301 |
+
result = {k: torch.stack(v) for k, v in padded_sub.items()}
|
| 302 |
+
result["atomic_labels"] = torch.stack(padded_labels)
|
| 303 |
result["padding_mask"] = torch.stack(padding_masks)
|
| 304 |
result["batch_info"] = batch_info
|
| 305 |
+
result["n_logical_per_doc"] = torch.tensor(n_logical_per_doc, dtype=torch.long)
|
| 306 |
|
| 307 |
+
# parent_of_tensor and top_level_key_mask now operate at LOGICAL level
|
| 308 |
B = len(batch)
|
| 309 |
+
L = max_logical
|
| 310 |
+
parent_of_tensor = torch.full((B, L), -1, dtype=torch.long)
|
| 311 |
+
top_level_key_mask = torch.zeros((B, L), dtype=torch.bool)
|
|
|
|
| 312 |
for b_idx, info in enumerate(batch_info):
|
| 313 |
+
parent_of = info["parent_of"]
|
| 314 |
n_b = len(parent_of)
|
| 315 |
if n_b > 0:
|
| 316 |
parent_of_tensor[b_idx, :n_b] = torch.tensor(parent_of, dtype=torch.long)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
depth_of = info["depth_of"]
|
| 318 |
depth_zero_kps = [kp for kp in info["key_positions"] if depth_of[kp] == 0]
|
| 319 |
if depth_zero_kps:
|
| 320 |
top_level_key_mask[b_idx, depth_zero_kps] = True
|
| 321 |
|
|
|
|
|
|
|
| 322 |
edges_by_depth: dict[int, list[tuple[int, int, int]]] = {}
|
| 323 |
parents_set_by_depth: dict[int, set[tuple[int, int]]] = {}
|
| 324 |
for b_idx, info in enumerate(batch_info):
|
|
|
|
| 347 |
for d, parents_set in parents_set_by_depth.items()
|
| 348 |
}
|
| 349 |
|
|
|
|
| 350 |
if "subtree_mask" in batch[0]:
|
|
|
|
| 351 |
subtree_masks: list[torch.Tensor] = []
|
| 352 |
for item in batch:
|
| 353 |
sm = item["subtree_mask"]
|
| 354 |
+
pad_len = max_logical - sm.size(0)
|
| 355 |
if pad_len > 0:
|
| 356 |
subtree_masks.append(torch.cat([
|
| 357 |
sm, torch.zeros(pad_len, dtype=torch.bool),
|
|
|
|
| 360 |
subtree_masks.append(sm)
|
| 361 |
result["subtree_mask"] = torch.stack(subtree_masks)
|
| 362 |
|
|
|
|
|
|
|
| 363 |
flat_roots: list[tuple[int, int]] = []
|
| 364 |
flat_targets: list[torch.Tensor] = []
|
| 365 |
for b_idx, item in enumerate(batch):
|
|
|
|
| 374 |
)
|
| 375 |
result["bag_of_keys_targets_flat"] = torch.stack(flat_targets)
|
| 376 |
else:
|
| 377 |
+
result["subtree_roots_flat"] = torch.zeros((0, 2), dtype=torch.long)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
v = batch[0].get("_atomic_vocab_size", 0)
|
| 379 |
result["bag_of_keys_targets_flat"] = torch.zeros(
|
| 380 |
(0, v), dtype=torch.float,
|
yaml_bert/embedding.py
CHANGED
|
@@ -7,28 +7,25 @@ from yaml_bert.config import TreePosVariant, YamlBertConfig
|
|
| 7 |
|
| 8 |
|
| 9 |
class YamlBertEmbedding(nn.Module):
|
| 10 |
-
"""
|
| 11 |
|
| 12 |
Produces input vectors by summing:
|
| 13 |
-
-
|
|
|
|
| 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 |
-
|
| 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.
|
| 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)
|
|
@@ -54,14 +51,9 @@ class YamlBertEmbedding(nn.Module):
|
|
| 54 |
depths: torch.Tensor,
|
| 55 |
sibling_indices: torch.Tensor,
|
| 56 |
) -> torch.Tensor:
|
| 57 |
-
|
| 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
|
| 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:
|
|
@@ -69,7 +61,7 @@ class YamlBertEmbedding(nn.Module):
|
|
| 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
|
| 73 |
torch.arange(seq_len, device=token_ids.device)
|
| 74 |
.clamp(max=max_pos - 1)
|
| 75 |
.unsqueeze(0)
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
class YamlBertEmbedding(nn.Module):
|
| 10 |
+
"""v9 embedding layer: single subword table + tree positional encoding.
|
| 11 |
|
| 12 |
Produces input vectors by summing:
|
| 13 |
+
- Subword embedding (looked up by token_id; same table for KEY and VALUE
|
| 14 |
+
positions — what they ARE is signalled separately via node_type_emb)
|
| 15 |
- Tree positional encoding (composition depends on config.tree_pos_variant)
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
|
| 18 |
def __init__(
|
| 19 |
self,
|
| 20 |
config: YamlBertConfig,
|
| 21 |
+
subword_vocab_size: int,
|
|
|
|
| 22 |
) -> None:
|
| 23 |
super().__init__()
|
| 24 |
d: int = config.d_model
|
| 25 |
variant: TreePosVariant = config.tree_pos_variant
|
| 26 |
self.variant: TreePosVariant = variant
|
| 27 |
|
| 28 |
+
self.subword_embedding: nn.Embedding = nn.Embedding(subword_vocab_size, d)
|
|
|
|
| 29 |
self.node_type_embedding: nn.Embedding = nn.Embedding(4, d)
|
| 30 |
|
| 31 |
use_depth: bool = variant in (TreePosVariant.FULL, TreePosVariant.NO_SIBLING)
|
|
|
|
| 51 |
depths: torch.Tensor,
|
| 52 |
sibling_indices: torch.Tensor,
|
| 53 |
) -> torch.Tensor:
|
| 54 |
+
token_emb = self.subword_embedding(token_ids)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
+
tree_pos = self.node_type_embedding(node_types)
|
| 57 |
if self.depth_embedding is not None:
|
| 58 |
tree_pos = tree_pos + self.depth_embedding(depths)
|
| 59 |
if self.sibling_embedding is not None:
|
|
|
|
| 61 |
if self.pos_embedding is not None:
|
| 62 |
seq_len: int = token_ids.size(1)
|
| 63 |
max_pos: int = self.pos_embedding.num_embeddings
|
| 64 |
+
positions = (
|
| 65 |
torch.arange(seq_len, device=token_ids.device)
|
| 66 |
.clamp(max=max_pos - 1)
|
| 67 |
.unsqueeze(0)
|
yaml_bert/model.py
CHANGED
|
@@ -84,6 +84,8 @@ class YamlBertModel(nn.Module):
|
|
| 84 |
batch_info: list[dict],
|
| 85 |
padding_mask: torch.Tensor | None = None,
|
| 86 |
*,
|
|
|
|
|
|
|
| 87 |
parent_of_tensor: torch.Tensor | None = None,
|
| 88 |
top_level_key_mask: torch.Tensor | None = None,
|
| 89 |
edges_by_depth: dict[int, torch.Tensor] | None = None,
|
|
@@ -93,14 +95,21 @@ class YamlBertModel(nn.Module):
|
|
| 93 |
) -> tuple:
|
| 94 |
"""Returns (logits, doc_vec) or (logits, doc_vec, recon_logits).
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
x = self.embedding(token_ids, node_types, depths, sibling_indices)
|
| 99 |
x = self.encoder(x, src_key_padding_mask=padding_mask)
|
| 100 |
|
| 101 |
-
# Aggregator
|
| 102 |
subtree_vecs, doc_vec = self.aggregator(
|
| 103 |
x, batch_info,
|
|
|
|
|
|
|
| 104 |
parent_of_tensor=parent_of_tensor,
|
| 105 |
top_level_key_mask=top_level_key_mask,
|
| 106 |
edges_by_depth=edges_by_depth,
|
|
@@ -108,51 +117,60 @@ class YamlBertModel(nn.Module):
|
|
| 108 |
subtree_mask=subtree_mask,
|
| 109 |
)
|
| 110 |
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
if parent_of_tensor is not None:
|
| 114 |
-
|
| 115 |
-
# precompute kwargs were provided (aggregator enforces all-or-none).
|
| 116 |
-
safe_parent = parent_of_tensor.clamp(min=0) # (B, N)
|
| 117 |
s_parent = torch.gather(
|
| 118 |
subtree_vecs, dim=1,
|
| 119 |
index=safe_parent.unsqueeze(-1).expand(-1, -1, d),
|
| 120 |
-
)
|
| 121 |
-
no_parent_mask = (parent_of_tensor == -1).unsqueeze(-1)
|
| 122 |
s_parent = torch.where(
|
| 123 |
no_parent_mask, doc_vec.unsqueeze(1), s_parent,
|
| 124 |
)
|
| 125 |
else:
|
| 126 |
-
|
| 127 |
-
s_parent = torch.zeros_like(x)
|
| 128 |
for doc_idx in range(b):
|
| 129 |
parent_of = batch_info[doc_idx]["parent_of"]
|
| 130 |
-
for i in range(min(
|
| 131 |
p = parent_of[i]
|
| 132 |
if p >= 0:
|
| 133 |
s_parent[doc_idx, i] = subtree_vecs[doc_idx, p]
|
| 134 |
else:
|
| 135 |
s_parent[doc_idx, i] = doc_vec[doc_idx]
|
| 136 |
|
| 137 |
-
doc_vec_broadcast = doc_vec.unsqueeze(1).expand(b,
|
| 138 |
-
head_input = torch.cat([
|
| 139 |
-
logits = self.token_head(head_input)
|
| 140 |
|
| 141 |
-
# Reconstruction path: only if caller provided subtree roots
|
| 142 |
if subtree_roots_flat is not None and subtree_roots_flat.size(0) > 0:
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
recon_logits = self.recon_head(doc_vec_per_root, pos_emb_per_root)
|
| 158 |
return logits, doc_vec, recon_logits
|
|
|
|
| 84 |
batch_info: list[dict],
|
| 85 |
padding_mask: torch.Tensor | None = None,
|
| 86 |
*,
|
| 87 |
+
logical_ids: torch.Tensor,
|
| 88 |
+
n_logical_per_doc: torch.Tensor,
|
| 89 |
parent_of_tensor: torch.Tensor | None = None,
|
| 90 |
top_level_key_mask: torch.Tensor | None = None,
|
| 91 |
edges_by_depth: dict[int, torch.Tensor] | None = None,
|
|
|
|
| 95 |
) -> tuple:
|
| 96 |
"""Returns (logits, doc_vec) or (logits, doc_vec, recon_logits).
|
| 97 |
|
| 98 |
+
v9: token_ids/node_types/depths/sibling_indices/logical_ids/padding_mask
|
| 99 |
+
are SUBWORD-level (B, N_sub). atomic_labels and the aggregator output
|
| 100 |
+
are LOGICAL-level (B, L_max). The Token Head consumes per-logical-node
|
| 101 |
+
pooled hidden state.
|
| 102 |
+
"""
|
| 103 |
+
from yaml_bert.aggregator import _pool_subwords
|
| 104 |
+
|
| 105 |
x = self.embedding(token_ids, node_types, depths, sibling_indices)
|
| 106 |
x = self.encoder(x, src_key_padding_mask=padding_mask)
|
| 107 |
|
| 108 |
+
# Aggregator pools internally; (subtree_vecs, doc_vec) are logical-level.
|
| 109 |
subtree_vecs, doc_vec = self.aggregator(
|
| 110 |
x, batch_info,
|
| 111 |
+
logical_ids=logical_ids,
|
| 112 |
+
n_logical_per_doc=n_logical_per_doc,
|
| 113 |
parent_of_tensor=parent_of_tensor,
|
| 114 |
top_level_key_mask=top_level_key_mask,
|
| 115 |
edges_by_depth=edges_by_depth,
|
|
|
|
| 117 |
subtree_mask=subtree_mask,
|
| 118 |
)
|
| 119 |
|
| 120 |
+
# Re-pool subword hiddens to logical level for the Token Head input.
|
| 121 |
+
# (One extra index_add call; the plan flags this as a follow-up.)
|
| 122 |
+
h_logical = _pool_subwords(x, logical_ids, n_logical_per_doc)
|
| 123 |
+
b, L_max, d = h_logical.shape
|
| 124 |
|
| 125 |
if parent_of_tensor is not None:
|
| 126 |
+
safe_parent = parent_of_tensor.clamp(min=0)
|
|
|
|
|
|
|
| 127 |
s_parent = torch.gather(
|
| 128 |
subtree_vecs, dim=1,
|
| 129 |
index=safe_parent.unsqueeze(-1).expand(-1, -1, d),
|
| 130 |
+
)
|
| 131 |
+
no_parent_mask = (parent_of_tensor == -1).unsqueeze(-1)
|
| 132 |
s_parent = torch.where(
|
| 133 |
no_parent_mask, doc_vec.unsqueeze(1), s_parent,
|
| 134 |
)
|
| 135 |
else:
|
| 136 |
+
s_parent = torch.zeros_like(h_logical)
|
|
|
|
| 137 |
for doc_idx in range(b):
|
| 138 |
parent_of = batch_info[doc_idx]["parent_of"]
|
| 139 |
+
for i in range(min(L_max, len(parent_of))):
|
| 140 |
p = parent_of[i]
|
| 141 |
if p >= 0:
|
| 142 |
s_parent[doc_idx, i] = subtree_vecs[doc_idx, p]
|
| 143 |
else:
|
| 144 |
s_parent[doc_idx, i] = doc_vec[doc_idx]
|
| 145 |
|
| 146 |
+
doc_vec_broadcast = doc_vec.unsqueeze(1).expand(b, L_max, d)
|
| 147 |
+
head_input = torch.cat([h_logical, doc_vec_broadcast, s_parent], dim=-1)
|
| 148 |
+
logits = self.token_head(head_input) # (B, L_max, atomic_vocab_size)
|
| 149 |
|
|
|
|
| 150 |
if subtree_roots_flat is not None and subtree_roots_flat.size(0) > 0:
|
| 151 |
+
batch_idx_per_root = subtree_roots_flat[:, 0]
|
| 152 |
+
root_pos_per_root = subtree_roots_flat[:, 1]
|
| 153 |
+
doc_vec_per_root = doc_vec[batch_idx_per_root]
|
| 154 |
+
# Root positions live in LOGICAL coords. Look up depth from batch_info;
|
| 155 |
+
# sibling: find any subword with logical_id == root_pos, use its sibling.
|
| 156 |
+
root_depths_list = [
|
| 157 |
+
batch_info[bi]["depth_of"][rp]
|
| 158 |
+
for bi, rp in zip(batch_idx_per_root.tolist(), root_pos_per_root.tolist())
|
| 159 |
+
]
|
| 160 |
+
root_siblings_list = []
|
| 161 |
+
for bi, rp in zip(batch_idx_per_root.tolist(), root_pos_per_root.tolist()):
|
| 162 |
+
positions = (logical_ids[bi] == rp).nonzero(as_tuple=True)[0]
|
| 163 |
+
if len(positions) > 0:
|
| 164 |
+
root_siblings_list.append(int(sibling_indices[bi, positions[0]].item()))
|
| 165 |
+
else:
|
| 166 |
+
# Shouldn't happen: a recon root must have at least one subword.
|
| 167 |
+
root_siblings_list.append(0)
|
| 168 |
+
root_depths = torch.tensor(root_depths_list, device=depths.device, dtype=torch.long)
|
| 169 |
+
root_siblings = torch.tensor(root_siblings_list, device=depths.device, dtype=torch.long)
|
| 170 |
+
|
| 171 |
+
depth_e = self.embedding.depth_embedding(root_depths)
|
| 172 |
+
sibling_e = self.embedding.sibling_embedding(root_siblings)
|
| 173 |
+
pos_emb_per_root = torch.cat([depth_e, sibling_e], dim=-1)
|
| 174 |
|
| 175 |
recon_logits = self.recon_head(doc_vec_per_root, pos_emb_per_root)
|
| 176 |
return logits, doc_vec, recon_logits
|
yaml_bert/suggest.py
CHANGED
|
@@ -118,11 +118,14 @@ def suggest_missing_fields(
|
|
| 118 |
return [], {}
|
| 119 |
annotator.annotate(nodes)
|
| 120 |
|
| 121 |
-
mask_id: int = vocab.
|
| 122 |
|
| 123 |
# Build atomic reverse map for decoding
|
| 124 |
id_to_atomic: dict[int, str] = {v: k for k, v in vocab.atomic_target_vocab.items()}
|
| 125 |
-
id_to_special: dict[int, str] = {
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
# Group key nodes by parent_path
|
| 128 |
keys_by_parent: dict[str, set[str]] = {}
|
|
@@ -301,9 +304,12 @@ def _run_probe_v8(
|
|
| 301 |
ds = YamlBertDataset([fake_nodes], vocab, infer_config)
|
| 302 |
item = ds[0]
|
| 303 |
|
| 304 |
-
#
|
|
|
|
| 305 |
item["token_ids"] = item["token_ids"].clone()
|
| 306 |
-
item["
|
|
|
|
|
|
|
| 307 |
|
| 308 |
batch = collate_fn([item])
|
| 309 |
|
|
@@ -315,14 +321,18 @@ def _run_probe_v8(
|
|
| 315 |
sibling_indices=batch["sibling_indices"],
|
| 316 |
batch_info=batch["batch_info"],
|
| 317 |
padding_mask=batch["padding_mask"],
|
|
|
|
|
|
|
| 318 |
parent_of_tensor=batch["parent_of_tensor"],
|
| 319 |
top_level_key_mask=batch["top_level_key_mask"],
|
| 320 |
edges_by_depth=batch["edges_by_depth"],
|
| 321 |
parents_by_depth=batch["parents_by_depth"],
|
| 322 |
)
|
| 323 |
|
| 324 |
-
# YamlBertModel returns (logits, doc_vec)
|
| 325 |
-
|
|
|
|
|
|
|
| 326 |
probs = F.softmax(logits[0, insert_pos], dim=-1)
|
| 327 |
topk = probs.topk(top_k + 5)
|
| 328 |
|
|
|
|
| 118 |
return [], {}
|
| 119 |
annotator.annotate(nodes)
|
| 120 |
|
| 121 |
+
mask_id: int = vocab.mask_id
|
| 122 |
|
| 123 |
# Build atomic reverse map for decoding
|
| 124 |
id_to_atomic: dict[int, str] = {v: k for k, v in vocab.atomic_target_vocab.items()}
|
| 125 |
+
id_to_special: dict[int, str] = {
|
| 126 |
+
vocab.pad_id: "[PAD]", vocab.unk_id: "[UNK]",
|
| 127 |
+
vocab.mask_id: "[MASK]", vocab.long_value_id: "[LONG_VALUE]",
|
| 128 |
+
}
|
| 129 |
|
| 130 |
# Group key nodes by parent_path
|
| 131 |
keys_by_parent: dict[str, set[str]] = {}
|
|
|
|
| 304 |
ds = YamlBertDataset([fake_nodes], vocab, infer_config)
|
| 305 |
item = ds[0]
|
| 306 |
|
| 307 |
+
# v9 whole-word masking: insert_pos is a LOGICAL position (index into
|
| 308 |
+
# fake_nodes). Mask ALL subword positions whose logical_id == insert_pos.
|
| 309 |
item["token_ids"] = item["token_ids"].clone()
|
| 310 |
+
sub_positions = (item["logical_ids"] == insert_pos).nonzero(as_tuple=True)[0]
|
| 311 |
+
for p in sub_positions:
|
| 312 |
+
item["token_ids"][p] = mask_id
|
| 313 |
|
| 314 |
batch = collate_fn([item])
|
| 315 |
|
|
|
|
| 321 |
sibling_indices=batch["sibling_indices"],
|
| 322 |
batch_info=batch["batch_info"],
|
| 323 |
padding_mask=batch["padding_mask"],
|
| 324 |
+
logical_ids=batch["logical_ids"],
|
| 325 |
+
n_logical_per_doc=batch["n_logical_per_doc"],
|
| 326 |
parent_of_tensor=batch["parent_of_tensor"],
|
| 327 |
top_level_key_mask=batch["top_level_key_mask"],
|
| 328 |
edges_by_depth=batch["edges_by_depth"],
|
| 329 |
parents_by_depth=batch["parents_by_depth"],
|
| 330 |
)
|
| 331 |
|
| 332 |
+
# v9 YamlBertModel returns (logits, doc_vec) where logits is
|
| 333 |
+
# (B, L_max, V_atomic) — indexed by LOGICAL position. insert_pos is
|
| 334 |
+
# already a logical index.
|
| 335 |
+
logits = out[0]
|
| 336 |
probs = F.softmax(logits[0, insert_pos], dim=-1)
|
| 337 |
topk = probs.topk(top_k + 5)
|
| 338 |
|
yaml_bert/tokenizer.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Subword tokenizer wrapper for YAML-BERT v9.
|
| 2 |
+
|
| 3 |
+
Wraps the HF `tokenizers` library so the rest of yaml_bert depends on
|
| 4 |
+
this stable interface, not on HF internals.
|
| 5 |
+
|
| 6 |
+
Vocabulary semantics:
|
| 7 |
+
- Special tokens reserved at training time: [PAD], [UNK], [MASK], [LONG_VALUE]
|
| 8 |
+
- Otherwise byte-level BPE; any string can be encoded.
|
| 9 |
+
|
| 10 |
+
Long-value rule (values only; keys never get this treatment):
|
| 11 |
+
- value length >= LONG_VALUE_THRESHOLD chars → single [LONG_VALUE] token
|
| 12 |
+
- MAX_CHARS_VALUE < value length < LONG_VALUE_THRESHOLD → truncate to MAX_CHARS_VALUE chars, then BPE
|
| 13 |
+
- shorter → BPE in full
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from tokenizers import Tokenizer
|
| 18 |
+
|
| 19 |
+
PAD_TOKEN = "[PAD]"
|
| 20 |
+
UNK_TOKEN = "[UNK]"
|
| 21 |
+
MASK_TOKEN = "[MASK]"
|
| 22 |
+
LONG_VALUE_TOKEN = "[LONG_VALUE]"
|
| 23 |
+
|
| 24 |
+
MAX_CHARS_VALUE = 64
|
| 25 |
+
LONG_VALUE_THRESHOLD = 256
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class SubwordTokenizer:
|
| 29 |
+
"""Wraps an HF Tokenizer with YAML-BERT-specific value-length rules."""
|
| 30 |
+
|
| 31 |
+
def __init__(self, hf_tokenizer: Tokenizer) -> None:
|
| 32 |
+
self._tok = hf_tokenizer
|
| 33 |
+
self.pad_id = hf_tokenizer.token_to_id(PAD_TOKEN)
|
| 34 |
+
self.unk_id = hf_tokenizer.token_to_id(UNK_TOKEN)
|
| 35 |
+
self.mask_id = hf_tokenizer.token_to_id(MASK_TOKEN)
|
| 36 |
+
self.long_value_id = hf_tokenizer.token_to_id(LONG_VALUE_TOKEN)
|
| 37 |
+
for name, val in (
|
| 38 |
+
(PAD_TOKEN, self.pad_id), (UNK_TOKEN, self.unk_id),
|
| 39 |
+
(MASK_TOKEN, self.mask_id), (LONG_VALUE_TOKEN, self.long_value_id),
|
| 40 |
+
):
|
| 41 |
+
if val is None:
|
| 42 |
+
raise ValueError(
|
| 43 |
+
f"SubwordTokenizer: required special token {name!r} not "
|
| 44 |
+
f"in tokenizer vocab — was the tokenizer trained with "
|
| 45 |
+
f"this special token reserved?"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
@classmethod
|
| 49 |
+
def load(cls, path: str) -> "SubwordTokenizer":
|
| 50 |
+
return cls(Tokenizer.from_file(path))
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def vocab_size(self) -> int:
|
| 54 |
+
return self._tok.get_vocab_size()
|
| 55 |
+
|
| 56 |
+
def encode_token(self, token: str, *, is_value: bool) -> list[int]:
|
| 57 |
+
"""Encode one linearizer-node token to a list of subword ids.
|
| 58 |
+
|
| 59 |
+
See module docstring for the value-length rule. Keys are always
|
| 60 |
+
BPE-encoded in full regardless of length.
|
| 61 |
+
"""
|
| 62 |
+
if is_value:
|
| 63 |
+
if len(token) >= LONG_VALUE_THRESHOLD:
|
| 64 |
+
return [self.long_value_id]
|
| 65 |
+
if len(token) > MAX_CHARS_VALUE:
|
| 66 |
+
token = token[:MAX_CHARS_VALUE]
|
| 67 |
+
return self._tok.encode(token).ids
|
| 68 |
+
|
| 69 |
+
def decode(self, ids: list[int]) -> str:
|
| 70 |
+
return self._tok.decode(ids)
|
yaml_bert/vocab.py
CHANGED
|
@@ -1,308 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
| 4 |
-
from yaml_bert.
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
SPECIAL_TOKENS = ["[PAD]", "[UNK]", "[MASK]"]
|
| 8 |
|
| 9 |
# Canonical casing for known Kubernetes kinds.
|
| 10 |
-
# Maps lowercase → canonical form. Populated by VocabBuilder.
|
| 11 |
_KIND_CANONICAL: dict[str, str] = {}
|
| 12 |
|
| 13 |
|
| 14 |
def normalize_kind(kind: str) -> str:
|
| 15 |
-
"""Normalize kind value to canonical casing.
|
| 16 |
-
|
| 17 |
-
'configmap' → 'ConfigMap', 'pod' → 'Pod', etc.
|
| 18 |
-
Unknown kinds are returned as-is.
|
| 19 |
-
"""
|
| 20 |
return _KIND_CANONICAL.get(kind.lower(), kind)
|
| 21 |
|
| 22 |
|
| 23 |
class Vocabulary:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
def __init__(
|
| 25 |
self,
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
atomic_target_vocab: dict[str, int] | None = None,
|
| 30 |
) -> None:
|
| 31 |
-
self.
|
| 32 |
-
self.
|
| 33 |
-
self.
|
| 34 |
-
|
| 35 |
-
self.
|
| 36 |
-
self.
|
| 37 |
-
self.
|
|
|
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def decode_value(self, id: int) -> str:
|
| 51 |
-
if id in self._id_to_special:
|
| 52 |
-
return self._id_to_special[id]
|
| 53 |
-
return self._id_to_value.get(id, "[UNK]")
|
| 54 |
-
|
| 55 |
-
def encode_atomic_target(self, target: str) -> int:
|
| 56 |
-
"""Encode a single key token as its atomic target id.
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
as `key_vocab`.
|
| 61 |
-
"""
|
| 62 |
-
return self.atomic_target_vocab.get(target, self.special_tokens["[UNK]"])
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
return len(self.key_vocab) + len(self.special_tokens)
|
| 67 |
|
| 68 |
@property
|
| 69 |
-
def
|
| 70 |
-
return
|
| 71 |
|
| 72 |
@property
|
| 73 |
def atomic_target_vocab_size(self) -> int:
|
| 74 |
-
|
|
|
|
| 75 |
|
| 76 |
def save(self, path: str) -> None:
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
}
|
| 83 |
with open(path, "w") as f:
|
| 84 |
-
json.dump(
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
@classmethod
|
| 87 |
-
def load(cls, path: str) -> Vocabulary:
|
| 88 |
with open(path) as f:
|
| 89 |
data = json.load(f)
|
| 90 |
-
return cls(
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
special_tokens=data["special_tokens"],
|
| 94 |
-
atomic_target_vocab=data.get("atomic_target_vocab", {}),
|
| 95 |
)
|
| 96 |
|
| 97 |
|
| 98 |
class VocabBuilder:
|
| 99 |
-
|
| 100 |
-
self,
|
| 101 |
-
nodes: list[YamlNode],
|
| 102 |
-
min_freq: int = 1,
|
| 103 |
-
*,
|
| 104 |
-
key_min_freq: int | None = None,
|
| 105 |
-
value_min_freq: int | None = None,
|
| 106 |
-
) -> Vocabulary:
|
| 107 |
-
"""Build vocab with optional per-category min_freq thresholds.
|
| 108 |
-
|
| 109 |
-
Per-category thresholds default to the global `min_freq` for backward
|
| 110 |
-
compatibility.
|
| 111 |
-
"""
|
| 112 |
-
key_min_freq = key_min_freq if key_min_freq is not None else min_freq
|
| 113 |
-
value_min_freq = value_min_freq if value_min_freq is not None else min_freq
|
| 114 |
-
key_counts: dict[str, int] = {}
|
| 115 |
-
value_counts: dict[str, int] = {}
|
| 116 |
-
kind_set: set[str] = set()
|
| 117 |
-
|
| 118 |
-
# First pass: collect all kind values to build canonical casing map.
|
| 119 |
-
# The casing map (_KIND_CANONICAL) is needed by normalize_kind() which
|
| 120 |
-
# is used downstream by _extract_kind in the types module.
|
| 121 |
-
raw_kinds: dict[str, dict[str, int]] = {} # lowercase → {variant: count}
|
| 122 |
-
prev_was_kind_key = False
|
| 123 |
-
for node in nodes:
|
| 124 |
-
if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
|
| 125 |
-
prev_was_kind_key = (node.token == "kind" and node.depth == 0)
|
| 126 |
-
elif node.node_type in (NodeType.VALUE, NodeType.LIST_VALUE):
|
| 127 |
-
if prev_was_kind_key:
|
| 128 |
-
lower = node.token.lower()
|
| 129 |
-
raw_kinds.setdefault(lower, {})
|
| 130 |
-
raw_kinds[lower][node.token] = raw_kinds[lower].get(node.token, 0) + 1
|
| 131 |
-
prev_was_kind_key = False
|
| 132 |
-
|
| 133 |
-
# Build canonical map: most frequent casing wins
|
| 134 |
-
_KIND_CANONICAL.clear()
|
| 135 |
-
for lower, variants in raw_kinds.items():
|
| 136 |
-
canonical = max(variants, key=variants.get)
|
| 137 |
-
_KIND_CANONICAL[lower] = canonical
|
| 138 |
-
|
| 139 |
-
# Second pass: count tokens with normalized kinds
|
| 140 |
-
prev_was_kind_key = False
|
| 141 |
-
for node in nodes:
|
| 142 |
-
if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
|
| 143 |
-
key_counts[node.token] = key_counts.get(node.token, 0) + 1
|
| 144 |
-
prev_was_kind_key = (node.token == "kind" and node.depth == 0)
|
| 145 |
-
elif node.node_type in (NodeType.VALUE, NodeType.LIST_VALUE):
|
| 146 |
-
value_counts[node.token] = value_counts.get(node.token, 0) + 1
|
| 147 |
-
if prev_was_kind_key:
|
| 148 |
-
kind_set.add(normalize_kind(node.token))
|
| 149 |
-
prev_was_kind_key = False
|
| 150 |
-
|
| 151 |
-
# Atomic target vocab: single key tokens. Uses the same filter
|
| 152 |
-
# threshold as keys since the entries ARE keys.
|
| 153 |
-
atomic_target_set: set[str] = {
|
| 154 |
-
t for t, c in key_counts.items() if c >= key_min_freq
|
| 155 |
-
}
|
| 156 |
-
|
| 157 |
-
return self.build_from_counts(
|
| 158 |
-
key_counts, value_counts, key_min_freq, kind_set,
|
| 159 |
-
value_min_freq=value_min_freq,
|
| 160 |
-
atomic_target_set=atomic_target_set,
|
| 161 |
-
)
|
| 162 |
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
key_counts: dict[str, int],
|
| 166 |
-
value_counts: dict[str, int],
|
| 167 |
-
min_freq: int = 1,
|
| 168 |
-
kind_set: set[str] | None = None,
|
| 169 |
-
*,
|
| 170 |
-
value_min_freq: int | None = None,
|
| 171 |
-
atomic_target_set: set[str] | None = None,
|
| 172 |
-
) -> Vocabulary:
|
| 173 |
-
"""Build vocabulary from pre-computed token counts.
|
| 174 |
-
|
| 175 |
-
`min_freq` filters keys (and values if value_min_freq is None).
|
| 176 |
-
`value_min_freq` overrides for values only.
|
| 177 |
-
"""
|
| 178 |
-
if value_min_freq is None:
|
| 179 |
-
value_min_freq = min_freq
|
| 180 |
-
# Derive atomic_target_set from key_counts when not supplied. The atomic
|
| 181 |
-
# set IS-A subset of keys by definition, so this is self-consistent and
|
| 182 |
-
# prevents callers (e.g. build_from_huggingface) from silently producing
|
| 183 |
-
# an empty atomic_target_vocab — which would break training (output head
|
| 184 |
-
# would collapse to ~3 classes and every target would map to [UNK]).
|
| 185 |
-
if atomic_target_set is None:
|
| 186 |
-
atomic_target_set = {t for t, c in key_counts.items() if c >= min_freq}
|
| 187 |
-
special_tokens = {tok: i for i, tok in enumerate(SPECIAL_TOKENS)}
|
| 188 |
-
offset = len(special_tokens)
|
| 189 |
-
|
| 190 |
-
key_vocab = {
|
| 191 |
-
token: i + offset
|
| 192 |
-
for i, token in enumerate(
|
| 193 |
-
sorted(t for t, c in key_counts.items() if c >= min_freq)
|
| 194 |
-
)
|
| 195 |
-
}
|
| 196 |
-
|
| 197 |
-
# Build value vocab: min_freq filtered, but always include kind values
|
| 198 |
-
value_tokens = set(t for t, c in value_counts.items() if c >= value_min_freq)
|
| 199 |
-
if kind_set:
|
| 200 |
-
value_tokens.update(kind_set) # kinds always in value vocab
|
| 201 |
-
value_vocab = {
|
| 202 |
-
token: i + offset
|
| 203 |
-
for i, token in enumerate(sorted(value_tokens))
|
| 204 |
-
}
|
| 205 |
-
|
| 206 |
-
atomic_target_vocab = {
|
| 207 |
-
target: i + offset
|
| 208 |
-
for i, target in enumerate(sorted(atomic_target_set or []))
|
| 209 |
-
}
|
| 210 |
-
|
| 211 |
-
return Vocabulary(
|
| 212 |
-
key_vocab, value_vocab, special_tokens,
|
| 213 |
-
atomic_target_vocab=atomic_target_vocab,
|
| 214 |
-
)
|
| 215 |
|
| 216 |
@staticmethod
|
| 217 |
-
def
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
"""Save raw token counts to a JSON file for reuse."""
|
| 223 |
-
with open(path, "w") as f:
|
| 224 |
-
json.dump({"key_counts": key_counts, "value_counts": value_counts}, f)
|
| 225 |
-
print(f"Token counts saved: {path} ({len(key_counts)} keys, {len(value_counts)} values)")
|
| 226 |
|
| 227 |
-
|
| 228 |
-
def load_counts(path: str) -> tuple[dict[str, int], dict[str, int]]:
|
| 229 |
-
"""Load raw token counts from a JSON file."""
|
| 230 |
-
with open(path) as f:
|
| 231 |
-
data = json.load(f)
|
| 232 |
-
key_counts: dict[str, int] = data["key_counts"]
|
| 233 |
-
value_counts: dict[str, int] = data["value_counts"]
|
| 234 |
-
print(f"Token counts loaded: {path} ({len(key_counts)} keys, {len(value_counts)} values)")
|
| 235 |
-
return key_counts, value_counts
|
| 236 |
-
|
| 237 |
-
def build_from_huggingface(
|
| 238 |
-
self,
|
| 239 |
-
dataset_name: str,
|
| 240 |
-
linearizer: "YamlLinearizer",
|
| 241 |
-
annotator: "DomainAnnotator",
|
| 242 |
-
max_docs: int | None = None,
|
| 243 |
-
min_freq: int = 1,
|
| 244 |
-
counts_path: str | None = None,
|
| 245 |
-
) -> Vocabulary:
|
| 246 |
-
"""Build vocabulary by scanning a HuggingFace dataset.
|
| 247 |
-
|
| 248 |
-
Args:
|
| 249 |
-
dataset_name: HuggingFace dataset ID
|
| 250 |
-
linearizer: YamlLinearizer instance
|
| 251 |
-
annotator: DomainAnnotator instance
|
| 252 |
-
max_docs: Scan at most this many docs for vocab (None = all)
|
| 253 |
-
min_freq: Minimum frequency to include a token
|
| 254 |
-
counts_path: If set, save raw counts here (and load if file exists)
|
| 255 |
"""
|
| 256 |
-
import
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
continue
|
| 280 |
-
if nodes:
|
| 281 |
-
annotator.annotate(nodes)
|
| 282 |
-
all_nodes.extend(nodes)
|
| 283 |
-
|
| 284 |
-
if (i + 1) % 10000 == 0:
|
| 285 |
-
print(f" {i + 1:,} / {total:,} scanned ({len(all_nodes):,} nodes)")
|
| 286 |
-
|
| 287 |
-
print(f"Scanned {total - skipped:,} docs, {len(all_nodes):,} nodes ({skipped} skipped)")
|
| 288 |
-
|
| 289 |
-
# Compute counts
|
| 290 |
-
key_counts: dict[str, int] = {}
|
| 291 |
-
value_counts: dict[str, int] = {}
|
| 292 |
-
kind_set: set[str] = set()
|
| 293 |
-
prev_was_kind_key: bool = False
|
| 294 |
-
for node in all_nodes:
|
| 295 |
-
if node.node_type in (NodeType.KEY, NodeType.LIST_KEY):
|
| 296 |
-
key_counts[node.token] = key_counts.get(node.token, 0) + 1
|
| 297 |
-
prev_was_kind_key = (node.token == "kind" and node.depth == 0)
|
| 298 |
-
elif node.node_type in (NodeType.VALUE, NodeType.LIST_VALUE):
|
| 299 |
-
value_counts[node.token] = value_counts.get(node.token, 0) + 1
|
| 300 |
-
if prev_was_kind_key:
|
| 301 |
-
kind_set.add(node.token)
|
| 302 |
-
prev_was_kind_key = False
|
| 303 |
-
|
| 304 |
-
# Save counts for reuse
|
| 305 |
-
if counts_path:
|
| 306 |
-
self.save_counts(key_counts, value_counts, counts_path)
|
| 307 |
-
|
| 308 |
-
return self.build_from_counts(key_counts, value_counts, min_freq, kind_set)
|
|
|
|
| 1 |
+
"""v9 Vocabulary: subword tokenizer + atomic_target_vocab.
|
| 2 |
+
|
| 3 |
+
The v8 key_vocab + value_vocab were merged into a single subword vocabulary
|
| 4 |
+
exposed via SubwordTokenizer. The Token Head still predicts over an atomic
|
| 5 |
+
target vocab built from frequent KEY tokens in the training corpus.
|
| 6 |
+
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import json
|
| 10 |
+
from yaml_bert.tokenizer import SubwordTokenizer
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
# Canonical casing for known Kubernetes kinds.
|
| 13 |
+
# Maps lowercase → canonical form. Populated by VocabBuilder.build_atomic_target_vocab.
|
| 14 |
_KIND_CANONICAL: dict[str, str] = {}
|
| 15 |
|
| 16 |
|
| 17 |
def normalize_kind(kind: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
return _KIND_CANONICAL.get(kind.lower(), kind)
|
| 19 |
|
| 20 |
|
| 21 |
class Vocabulary:
|
| 22 |
+
"""Holds a subword tokenizer + an atomic target vocab.
|
| 23 |
+
|
| 24 |
+
`atomic_target_vocab` maps whole KEY strings (e.g. "containers",
|
| 25 |
+
"restartPolicy") to integer class ids for the Token Head's output.
|
| 26 |
+
The 4 special tokens (pad/unk/mask/long_value) get the first 4 ids.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
def __init__(
|
| 30 |
self,
|
| 31 |
+
tokenizer: SubwordTokenizer,
|
| 32 |
+
atomic_target_vocab: dict[str, int],
|
| 33 |
+
tokenizer_path: str | None = None,
|
|
|
|
| 34 |
) -> None:
|
| 35 |
+
self.tokenizer = tokenizer
|
| 36 |
+
self.atomic_target_vocab = atomic_target_vocab
|
| 37 |
+
self.tokenizer_path = tokenizer_path
|
| 38 |
+
# Convenience: expose the 4 special-token ids at the vocab level
|
| 39 |
+
self.pad_id = tokenizer.pad_id
|
| 40 |
+
self.unk_id = tokenizer.unk_id
|
| 41 |
+
self.mask_id = tokenizer.mask_id
|
| 42 |
+
self.long_value_id = tokenizer.long_value_id
|
| 43 |
|
| 44 |
+
@classmethod
|
| 45 |
+
def from_tokenizer_path(
|
| 46 |
+
cls,
|
| 47 |
+
tokenizer_path: str,
|
| 48 |
+
atomic_target_vocab: dict[str, int],
|
| 49 |
+
) -> "Vocabulary":
|
| 50 |
+
return cls(
|
| 51 |
+
tokenizer=SubwordTokenizer.load(tokenizer_path),
|
| 52 |
+
atomic_target_vocab=atomic_target_vocab,
|
| 53 |
+
tokenizer_path=tokenizer_path,
|
| 54 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
+
def encode_token(self, token: str, *, is_value: bool) -> list[int]:
|
| 57 |
+
return self.tokenizer.encode_token(token, is_value=is_value)
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
+
def encode_atomic_target(self, key: str) -> int:
|
| 60 |
+
return self.atomic_target_vocab.get(key, self.unk_id)
|
|
|
|
| 61 |
|
| 62 |
@property
|
| 63 |
+
def subword_vocab_size(self) -> int:
|
| 64 |
+
return self.tokenizer.vocab_size
|
| 65 |
|
| 66 |
@property
|
| 67 |
def atomic_target_vocab_size(self) -> int:
|
| 68 |
+
# +4 to reserve ids 0-3 for special tokens (consistent with v8 layout)
|
| 69 |
+
return len(self.atomic_target_vocab) + 4
|
| 70 |
|
| 71 |
def save(self, path: str) -> None:
|
| 72 |
+
if self.tokenizer_path is None:
|
| 73 |
+
raise ValueError(
|
| 74 |
+
"Vocabulary.save requires tokenizer_path to be set "
|
| 75 |
+
"(use Vocabulary.from_tokenizer_path)."
|
| 76 |
+
)
|
|
|
|
| 77 |
with open(path, "w") as f:
|
| 78 |
+
json.dump({
|
| 79 |
+
"tokenizer_path": self.tokenizer_path,
|
| 80 |
+
"atomic_target_vocab": self.atomic_target_vocab,
|
| 81 |
+
}, f, indent=2)
|
| 82 |
|
| 83 |
@classmethod
|
| 84 |
+
def load(cls, path: str) -> "Vocabulary":
|
| 85 |
with open(path) as f:
|
| 86 |
data = json.load(f)
|
| 87 |
+
return cls.from_tokenizer_path(
|
| 88 |
+
tokenizer_path=data["tokenizer_path"],
|
| 89 |
+
atomic_target_vocab=data["atomic_target_vocab"],
|
|
|
|
|
|
|
| 90 |
)
|
| 91 |
|
| 92 |
|
| 93 |
class VocabBuilder:
|
| 94 |
+
"""Builds the atomic_target_vocab from a corpus.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
+
The subword tokenizer is built separately by scripts/train_unified_tokenizer.py.
|
| 97 |
+
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
@staticmethod
|
| 100 |
+
def build_atomic_target_vocab(
|
| 101 |
+
nodes_per_doc: list[list],
|
| 102 |
+
min_freq: int,
|
| 103 |
+
) -> dict[str, int]:
|
| 104 |
+
"""Scan KEY tokens across docs, return {token: id} for keys appearing >= min_freq times.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
+
IDs start at 4 (0-3 are reserved for special tokens).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
"""
|
| 108 |
+
from yaml_bert.types import NodeType
|
| 109 |
+
counts: dict[str, int] = {}
|
| 110 |
+
for nodes in nodes_per_doc:
|
| 111 |
+
for n in nodes:
|
| 112 |
+
if n.node_type in (NodeType.KEY, NodeType.LIST_KEY):
|
| 113 |
+
counts[n.token] = counts.get(n.token, 0) + 1
|
| 114 |
+
|
| 115 |
+
# Also populate kind-canonical map as a side effect (used by suggest.py)
|
| 116 |
+
prev_kind_key = False
|
| 117 |
+
for nodes in nodes_per_doc:
|
| 118 |
+
for n in nodes:
|
| 119 |
+
if n.node_type in (NodeType.KEY, NodeType.LIST_KEY):
|
| 120 |
+
prev_kind_key = (n.token == "kind" and n.depth == 0)
|
| 121 |
+
elif n.node_type in (NodeType.VALUE, NodeType.LIST_VALUE):
|
| 122 |
+
if prev_kind_key:
|
| 123 |
+
lower = n.token.lower()
|
| 124 |
+
existing = _KIND_CANONICAL.get(lower)
|
| 125 |
+
if existing is None:
|
| 126 |
+
_KIND_CANONICAL[lower] = n.token
|
| 127 |
+
prev_kind_key = False
|
| 128 |
+
|
| 129 |
+
kept = sorted(t for t, c in counts.items() if c >= min_freq)
|
| 130 |
+
return {t: 4 + i for i, t in enumerate(kept)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|