doc-to-lora/tmp/test_per_ctx_loss.py
2025-09-29 00:40:51 +09:00

243 lines
8.2 KiB
Python

"""Test / diagnostic script for comparing current per_ctx_loss implementation
against a proposed fixed version that detects label spans via mask transitions.
Run:
python tmp/test_per_ctx_loss.py
It will:
* Construct synthetic batches with known label span structure.
* Call the existing per_ctx_loss (imported from ctx_to_lora.trainer).
* Call a fixed implementation (fixed_per_ctx_loss) using label_mask transitions.
* Show detected spans, per-query losses, per-context losses, and scaling ratios.
* Highlight cases where the current implementation fails (empty spans / mismatch).
Note: The current per_ctx_loss assumes the provided `loss` vector is ordered
exactly as the concatenation of all label-token losses (no unlabeled tokens),
matching DistillationTrainer's usage, but NOT CrossEntropyTrainer's raw CE output.
"""
from __future__ import annotations
import math
# Ensure "src" is on path so we can import project modules when running from project root
import sys
import traceback
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
import torch
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
from ctx_to_lora.trainer import per_ctx_loss # type: ignore
@dataclass
class SyntheticCase:
name: str
label_mask: list[bool]
n_queries_per_ctx: list[int]
description: str
def per_ctx_loss(inputs, labels, loss):
n_queries_per_ctx = inputs["n_queries"].tolist()
position_ids = inputs["position_ids"].squeeze(0)
# account only label positions
label_mask = labels.squeeze(0) != -100
label_pos_ids = label_mask * position_ids
label_pos_ids_diff = label_pos_ids.diff(
append=torch.tensor([0], device=position_ids.device)
)
# assumes the input starts with non-assistant tokens
start_label_pos = torch.where((label_pos_ids_diff > 0) * ~label_mask)[0]
end_label_pos = torch.where((label_pos_ids_diff < 0) * label_mask)[0]
label_seq_lens = end_label_pos - start_label_pos
cu_label_seq_lens = torch.cumsum(label_seq_lens, dim=0)
start_indices = torch.cat(
(
torch.tensor([0], device=cu_label_seq_lens.device),
cu_label_seq_lens[:-1],
)
)
# these stack and split can be optimized but let's keep it simple
# mean across tokens of each q
qa_losses = torch.stack(
[loss[start:end].mean() for start, end in zip(start_indices, cu_label_seq_lens)]
)
# mean across queries of each ctx
per_ctx_losses = [ql.mean() for ql in torch.split(qa_losses, n_queries_per_ctx)]
# per-ctx loss
loss = torch.stack(per_ctx_losses)
return loss
def fixed_per_ctx_loss(inputs, labels, loss_vec: torch.Tensor) -> torch.Tensor:
"""Reference implementation using label_mask transitions.
Args:
inputs: dict with keys: n_queries, position_ids
labels: [1, L]
loss_vec: tensor of shape (#label_tokens,) in label-token order.
Returns:
Tensor of shape (#contexts,) containing mean loss per context.
"""
n_queries_per_ctx = inputs["n_queries"].tolist()
label_mask = labels.squeeze(0) != -100
lm = label_mask.to(torch.int)
if lm.numel() == 0:
return torch.empty(0)
if lm.numel() == 1:
spans = [(0, 1)] if lm[0] == 1 else []
else:
transition = lm[1:] - lm[:-1]
starts = (transition == 1).nonzero(as_tuple=False).flatten() + 1
ends = (transition == -1).nonzero(as_tuple=False).flatten() + 1
if lm[0] == 1:
starts = torch.cat([torch.tensor([0], device=starts.device), starts])
if lm[-1] == 1:
ends = torch.cat([ends, torch.tensor([lm.numel()], device=ends.device)])
assert starts.numel() == ends.numel()
spans = list(zip(starts.tolist(), ends.tolist()))
# Extract per-query mean losses (loss_vec is already label-token sequential).
lengths = [e - s for s, e in spans]
assert sum(lengths) == loss_vec.numel(), (
"Mismatch: total span label tokens != loss vector length."
)
cu_lengths = torch.tensor(lengths).cumsum(0)
starts_loss = torch.cat([torch.tensor([0]), cu_lengths[:-1]])
qa_means = [loss_vec[a:b].mean() for a, b in zip(starts_loss, cu_lengths)]
qa_means_t = torch.stack(qa_means)
# Group into contexts
split_q = torch.split(qa_means_t, n_queries_per_ctx)
per_ctx = torch.stack([ctx.mean() for ctx in split_q])
return per_ctx
def build_labels_from_mask(mask: Sequence[bool]) -> torch.Tensor:
# Assign dummy token id 1 where True, -100 elsewhere.
data = [1 if m else -100 for m in mask]
return torch.tensor([data]) # shape [1, L]
def build_position_ids(L: int) -> torch.Tensor:
return torch.arange(L).unsqueeze(0)
def make_loss_vector(mask: Sequence[bool]) -> torch.Tensor:
# Provide a smoothly varying loss value per label token to visualize averaging.
# Example: losses increase linearly → weighting differences are obvious.
values = []
counter = 0
for m in mask:
if m:
# synthetic harder tokens later
values.append(0.5 + 0.1 * counter)
counter += 1
return torch.tensor(values)
def run_case(case: SyntheticCase):
print(f"\n=== Case: {case.name} ===")
print(case.description)
mask = case.label_mask
labels = build_labels_from_mask(mask)
position_ids = build_position_ids(len(mask))
loss_vec = make_loss_vector(mask)
inputs = {
"n_queries": torch.tensor(case.n_queries_per_ctx),
"position_ids": position_ids,
}
print(f"Label mask: {''.join('1' if m else '.' for m in mask)}")
print(f"Loss vector (per label token order): {loss_vec.tolist()}")
# Attempt current implementation
try:
current = per_ctx_loss(inputs, labels, loss_vec)
print(
"Current per_ctx_loss output:",
current.tolist(),
"shape=",
list(current.shape),
)
except Exception as e:
print("Current per_ctx_loss raised exception:")
traceback.print_exc()
current = None
# Fixed implementation
try:
fixed = fixed_per_ctx_loss(inputs, labels, loss_vec)
print(
"Fixed per_ctx_loss output: ", fixed.tolist(), "shape=", list(fixed.shape)
)
except Exception as e:
print("Fixed implementation raised exception:")
traceback.print_exc()
fixed = None
# Compare magnitudes if both succeeded
if current is not None and fixed is not None and current.numel() == fixed.numel():
ratio = (
(current.mean() / fixed.mean()).item() if fixed.mean() != 0 else math.nan
)
print(f"Mean(current)/Mean(fixed) ratio: {ratio:.4f}")
print("---")
def main():
cases = [
# SyntheticCase(
# name="Two spans, single context",
# label_mask=[False, False, True, True, True, False, False, True, True],
# n_queries_per_ctx=[2],
# description="Two answer/query spans of lengths 3 and 2 in one context.",
# ),
SyntheticCase(
name="Single span",
label_mask=[False, True, True, True, False],
n_queries_per_ctx=[1],
description="One continuous labeled span.",
),
# SyntheticCase(
# name="Three contexts (2,1 queries)",
# label_mask=[False, True, True, False, True, False, True, True, True, False],
# n_queries_per_ctx=[1, 1, 1],
# description="Three separate single-query contexts.",
# ),
# SyntheticCase(
# name="Edge case: starts labeled",
# label_mask=[True, True, False, True, False],
# n_queries_per_ctx=[2],
# description="Sequence begins with a labeled span then another later.",
# ),
# SyntheticCase(
# name="Edge case: ends labeled",
# label_mask=[False, True, False, True, True],
# n_queries_per_ctx=[2],
# description="Sequence ends with a labeled span.",
# ),
]
for case in cases:
run_case(case)
print("\nDone. Inspect above for discrepancies.\n")
if __name__ == "__main__":
main()