eumora-api / backend /src /pooling.py
GitHub Action
deploy 3d50547
037f340
Raw History Blame Contribute Delete
5.93 kB
"""Optional pooling heads for the research/ablation track.
DEPLOYMENT NOTE
---------------
The production model MUST remain a stock `DebertaV2ForSequenceClassification`
so the HF Inference API can serve it through the standard `text-classification`
pipeline (no `trust_remote_code` on the Inference API). Everything in this file
is for the ablation table in the paper, not for the deployed checkpoint.
Motivation: DeBERTa's default `ContextPooler` reads only the [CLS] token. That is
reasonable for a tweet, where the emotional payload is one clause. A verse of
lyrics spreads the signal across the whole passage — "and at once I knew / I was
not magnificent" carries its weight in the second line, not the first token. Mean
and attention pooling recover that.
Use `--pooling attention` in train_v2.py to run the ablation.
"""
from __future__ import annotations
import torch
import torch.nn as nn
import torch.nn.functional as F
from transformers import AutoConfig, AutoModel
from transformers.modeling_outputs import SequenceClassifierOutput
class AttentionPooling(nn.Module):
"""Learned additive attention over token hidden states.
Computes a scalar score per token, softmaxes over the (masked) sequence, and
returns the weighted sum. Unlike [CLS] pooling this lets the model learn that
e.g. "hollow", "still", "again" carry the emotional load in a lyric line.
"""
def __init__(self, hidden_size: int, dropout: float = 0.1):
super().__init__()
self.attn = nn.Sequential(
nn.Linear(hidden_size, hidden_size),
nn.Tanh(),
nn.Dropout(dropout),
nn.Linear(hidden_size, 1),
)
def forward(self, hidden: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
# hidden: (B, T, H), mask: (B, T)
scores = self.attn(hidden).squeeze(-1) # (B, T)
scores = scores.masked_fill(mask == 0, torch.finfo(scores.dtype).min)
weights = F.softmax(scores, dim=-1).unsqueeze(-1) # (B, T, 1)
return (hidden * weights).sum(dim=1) # (B, H)
class MeanMaxAttentionPool(nn.Module):
"""Concatenation of masked-mean, masked-max, and attention pooling.
The three views are complementary: mean captures overall tone, max captures
the single most charged token, attention learns a task-specific weighting.
Output dimension is 3 * hidden_size.
"""
def __init__(self, hidden_size: int, dropout: float = 0.1):
super().__init__()
self.attention = AttentionPooling(hidden_size, dropout)
self.output_size = hidden_size * 3
def forward(self, hidden: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
mask_f = mask.unsqueeze(-1).float()
summed = (hidden * mask_f).sum(dim=1)
counts = mask_f.sum(dim=1).clamp(min=1e-9)
mean_pooled = summed / counts
max_pooled = hidden.masked_fill(mask.unsqueeze(-1) == 0, torch.finfo(hidden.dtype).min)
max_pooled = max_pooled.max(dim=1).values
attn_pooled = self.attention(hidden, mask)
return torch.cat([mean_pooled, max_pooled, attn_pooled], dim=-1)
class MultiSampleDropoutHead(nn.Module):
"""Classifier that averages logits over several independent dropout masks.
Cheap variance reduction: one forward pass through the backbone, N cheap
passes through the linear head. Converges faster and generalizes better than
a single-mask head, which matters when the tail classes have few examples.
Reference: Inoue 2019, "Multi-Sample Dropout for Accelerated Training and
Better Generalization" (arXiv:1905.09788)
"""
def __init__(self, input_size: int, num_labels: int, num_samples: int = 5, p: float = 0.3):
super().__init__()
self.dropouts = nn.ModuleList([nn.Dropout(p) for _ in range(num_samples)])
self.classifier = nn.Linear(input_size, num_labels)
def forward(self, features: torch.Tensor) -> torch.Tensor:
return torch.stack(
[self.classifier(d(features)) for d in self.dropouts], dim=0
).mean(dim=0)
class EumoraClassifier(nn.Module):
"""DeBERTa backbone + rich pooling + multi-sample dropout head.
Research-track model. Same backbone weights as the deployed checkpoint, so a
TAPT-adapted encoder can be dropped into either path for a clean ablation.
"""
def __init__(
self,
model_name: str,
num_labels: int,
pooling: str = "attention",
dropout: float = 0.3,
num_dropout_samples: int = 5,
):
super().__init__()
self.config = AutoConfig.from_pretrained(model_name, num_labels=num_labels)
self.backbone = AutoModel.from_pretrained(model_name, config=self.config)
hidden = self.config.hidden_size
self.pooling_type = pooling
if pooling == "attention":
self.pool = MeanMaxAttentionPool(hidden, dropout=0.1)
head_input = self.pool.output_size
elif pooling == "mean":
self.pool = None
head_input = hidden
else: # "cls"
self.pool = None
head_input = hidden
self.head = MultiSampleDropoutHead(
head_input, num_labels, num_samples=num_dropout_samples, p=dropout
)
def forward(self, input_ids=None, attention_mask=None, labels=None, **kwargs):
hidden = self.backbone(
input_ids=input_ids, attention_mask=attention_mask
).last_hidden_state
if self.pooling_type == "attention":
features = self.pool(hidden, attention_mask)
elif self.pooling_type == "mean":
mask_f = attention_mask.unsqueeze(-1).float()
features = (hidden * mask_f).sum(dim=1) / mask_f.sum(dim=1).clamp(min=1e-9)
else:
features = hidden[:, 0]
logits = self.head(features)
return SequenceClassifierOutput(logits=logits)