File size: 6,904 Bytes
34f3bc9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
"""Tokenize free-text annotation columns (e.g. `annotation.substask`) into
fixed-length token tensors keyed by `annotation_tokens__<field>` /
`annotation_mask__<field>`.

The model-side CE loss branch consumes these keys; see
`modeling_labvla._compute_annotation_ce`.

Placed late in the transform chain but BEFORE the final
`UnifyLabVLAInputsTransformFn` so that:
  - the adapter has already put the raw annotation string into the sample dict
  - the downstream Unify step can pass `annotation_tokens__*` through to the
    collated batch

This transform is a no-op when `annotation_specs` is empty, so datasets
without annotation_losses incur zero cost.
"""
from __future__ import annotations

import logging
from dataclasses import dataclass, field, replace
from typing import Any

import torch

from src.schema.annotation_loss import AnnotationLossSpec
from src.transforms.core import DataDict, DataTransformFn


@DataTransformFn.register_subclass("annotation_tokenize")
@dataclass
class AnnotationTokenizeTransformFn(DataTransformFn):
    """Tokenize each configured annotation field with the VLM tokenizer.

    Args:
        tokenizer_path:
            HF model id or local path used to instantiate a fresh tokenizer.
            Should match the VLM tokenizer used by
            `Qwen3_VLProcessorTransformFn` to ensure token-id consistency.
        annotation_specs:
            Tuple of AnnotationLossSpec; one tokenization pass per entry.
    """

    tokenizer_path: str = "Qwen/Qwen3-VL-4B-Instruct"
    # Populated by hydrate_all from schema.annotation_losses. Default empty
    # tuple → this transform is a cheap passthrough (no-op).
    annotation_specs: tuple[AnnotationLossSpec, ...] = ()
    # When true, emit tensors at the effective per-sample cap instead of the
    # schema union cap. The LabVLA collator pads variable 1D token tensors back
    # to the batch-local max, preserving collatability.
    dynamic_shape: bool = False

    # Instantiated lazily in __post_init__; not a dataclass init arg.
    tokenizer: Any = field(default=None, init=False, repr=False)

    def __post_init__(self) -> None:
        if not self.annotation_specs:
            # No-op mode: skip tokenizer load entirely. Keeps OXE-only runs
            # (no annotations) free of unused HF downloads / CPU cost.
            self.tokenizer = None
            return
        from transformers import AutoTokenizer
        self.tokenizer = AutoTokenizer.from_pretrained(self.tokenizer_path)

    def __call__(self, data: DataDict) -> DataDict:
        if not self.annotation_specs or self.tokenizer is None:
            return data
        for spec in self.annotation_specs:
            raw = data.get(spec.field, "")
            if not isinstance(raw, str):
                # Some annotations are stored as bytes or json strings;
                # coerce to plain text. Non-coercible → empty string (fully
                # masked below).
                try:
                    raw = str(raw) if raw is not None else ""
                except Exception:
                    raw = ""
            # Per-sample max_length override. Adapters that
            # know a tighter cap for the current sample (e.g. RoboInter VQA's
            # task-family budgets: understanding=8, planning=64) can attach
            # `f"{spec.field}_max_length"` to the sample dict. We always clamp
            # against the schema-level `spec.max_length` (the schema value is
            # the union upper bound; per-sample can only TIGHTEN it).
            #
            # Tensor SHAPE is preserved at `(spec.max_length,)` so the default
            # collator can stack heterogeneous task-family samples into one
            # batch. The cap manifests as: real tokens are truncated to
            # `effective_max_length`; mask positions in
            # `[effective_max_length, spec.max_length)` are forced to 0; ID
            # positions in that range are zero-filled (so even if the LM ever
            # ran on them, CE would be ignored via mask).
            per_sample_key = f"{spec.field}_max_length"
            per_sample_cap = data.get(per_sample_key, None)
            if isinstance(per_sample_cap, int) and per_sample_cap > 0:
                effective_max_length = min(per_sample_cap, spec.max_length)
            else:
                effective_max_length = spec.max_length
            tensor_max_length = effective_max_length if self.dynamic_shape else spec.max_length
            enc = self.tokenizer(
                raw if raw else "",
                max_length=tensor_max_length,
                padding="max_length",
                truncation=True,
                return_tensors="pt",        # direct tensor, skip list round-trip
                add_special_tokens=False,   # never pollute annotation with BOS/EOS
            )
            # return_tensors="pt" yields shape (1, L); squeeze to (L,).
            ids = enc["input_ids"][0].long()
            mask = enc["attention_mask"][0].bool()
            # Samples with empty annotation string still get non-zero length
            # (BOS/EOS depending on tokenizer) but attention_mask zeros out
            # every position — guarantees zero CE contribution.
            if not raw:
                mask = torch.zeros_like(mask)
            # Apply per-sample cap by zeroing IDs and mask beyond
            # `effective_max_length`. Done in-place on the freshly-allocated
            # squeezed tensors (no aliasing concern). When per-sample cap ==
            # spec.max_length (the default no-override path), this is a no-op.
            if not self.dynamic_shape and effective_max_length < spec.max_length:
                ids[effective_max_length:] = 0
                mask[effective_max_length:] = False
            data[spec.batch_key_tokens()] = ids
            data[spec.batch_key_mask()] = mask
            # Per-field weight carried inside the batch so the model can
            # compose the loss without a cross-cutting schema reference. All
            # samples from the same dataset carry the same scalar — this is
            # a per-sample-shape (not per-value) carrier for a schema-level
            # constant.
            data[spec.batch_key_weight()] = torch.tensor(
                float(spec.weight), dtype=torch.float32
            )
        return data

    def hydrate(self, ctx) -> "AnnotationTokenizeTransformFn":
        # Inject this dataset's annotation specs. Empty tuple → no-op path,
        # zero tokenizer load, zero overhead for OXE-style datasets.
        t = replace(self, annotation_specs=tuple(ctx.schema.annotation_losses))
        logging.info(
            f"Hydrated {t.__class__.__name__} with "
            f"{len(t.annotation_specs)} annotation_specs "
            f"dynamic_shape={getattr(t, 'dynamic_shape', False)} "
            f"({ctx.schema.schema_id})"
        )
        return t