sysy9292's picture
release: publish validated v2.3 runtime artifact
e962f8e verified
Raw History Blame Contribute Delete
5.51 kB
"""Trigger boundary 학습의 class-balance와 negative-sampling 계약.
이 모듈은 Trigger neural Head를 소유하지 않는다. 동일 logits/target에 적용할 학습 목적만
교체하며, production 기본값은 full-source all-negative BCE와 ``pos_weight=4``다.
"""
from __future__ import annotations
import math
import torch
from torch import nn
from torch.nn import functional as F
from ..contracts import TriggerTrainingConfig
class TriggerBoundaryLossStrategy(nn.Module):
"""동일 boundary logits에 weighted BCE와 deterministic negative sampling을 적용한다."""
def __init__(self, config: TriggerTrainingConfig | None = None) -> None:
super().__init__()
self.config = config or TriggerTrainingConfig()
def forward(
self,
start_logits: torch.Tensor,
end_logits: torch.Tensor,
start_target: torch.Tensor,
end_target: torch.Tensor,
token_mask: torch.BoolTensor,
*,
sample_step: int = 0,
) -> tuple[torch.Tensor, dict[str, torch.Tensor]]:
if (
start_logits.shape != end_logits.shape
or start_target.shape != start_logits.shape
or end_target.shape != end_logits.shape
or token_mask.shape != start_logits.shape[:-1]
):
raise ValueError("Trigger boundary loss tensor shapes differ")
if sample_step < 0:
raise ValueError("Trigger sample_step must be non-negative")
valid = token_mask.unsqueeze(-1).expand_as(start_logits)
start_loss, start_stats = self._one_boundary(
start_logits,
start_target,
valid,
sample_step=sample_step,
stream=0,
)
end_loss, end_stats = self._one_boundary(
end_logits,
end_target,
valid,
sample_step=sample_step,
stream=1,
)
diagnostics = {
"trigger/start_loss": start_loss,
"trigger/end_loss": end_loss,
"trigger/effective_pos_weight": start_logits.new_tensor(
self.config.pos_weight
),
}
diagnostics.update(
{f"trigger/start_{name}": value for name, value in start_stats.items()}
)
diagnostics.update(
{f"trigger/end_{name}": value for name, value in end_stats.items()}
)
diagnostics["trigger/positive_boundary_count"] = (
start_stats["positive_count"] + end_stats["positive_count"]
)
diagnostics["trigger/negative_boundary_count"] = (
start_stats["negative_count"] + end_stats["negative_count"]
)
diagnostics["trigger/sampled_negative_count"] = (
start_stats["sampled_negative_count"]
+ end_stats["sampled_negative_count"]
)
return start_loss + end_loss, diagnostics
def _one_boundary(
self,
logits: torch.Tensor,
target: torch.Tensor,
valid: torch.BoolTensor,
*,
sample_step: int,
stream: int,
) -> tuple[torch.Tensor, dict[str, torch.Tensor]]:
positive = valid & target.gt(0.5)
negative = valid & ~positive
selected_negative = self._sample_negative(
negative,
positive_count=int(positive.sum().item()),
sample_step=sample_step,
stream=stream,
)
selected = positive | selected_negative
if not torch.any(selected):
loss = logits.sum() * 0.0
else:
arguments: dict[str, object] = {}
if self.config.pos_weight != 1.0:
arguments["pos_weight"] = logits.new_tensor(self.config.pos_weight)
loss = F.binary_cross_entropy_with_logits(
logits[selected],
target[selected].to(logits.dtype),
**arguments,
)
return loss, {
"positive_count": logits.new_tensor(float(positive.sum().item())),
"negative_count": logits.new_tensor(float(negative.sum().item())),
"sampled_negative_count": logits.new_tensor(
float(selected_negative.sum().item())
),
}
def _sample_negative(
self,
negative: torch.BoolTensor,
*,
positive_count: int,
sample_step: int,
stream: int,
) -> torch.BoolTensor:
ratio = self.config.negative_to_positive_ratio
if ratio is None:
return negative
negative_flat = torch.nonzero(
negative.detach().to(device="cpu").reshape(-1), as_tuple=False
).flatten()
# Zero-positive batches still contribute a small, deterministic negative signal.
requested = math.ceil(ratio * max(positive_count, 1))
count = min(int(negative_flat.numel()), requested)
selected_cpu = torch.zeros(negative.numel(), dtype=torch.bool)
if count:
generator = torch.Generator(device="cpu")
mixed_seed = (
self.config.sampling_seed
+ 1_000_003 * sample_step
+ 97_409 * stream
) % (2**63 - 1)
generator.manual_seed(mixed_seed)
order = torch.randperm(
negative_flat.numel(), generator=generator
)[:count]
selected_cpu[negative_flat[order]] = True
return selected_cpu.reshape(negative.shape).to(device=negative.device)