TRALSem_sentiment / model.py
muhhehs's picture
Fix with custom TRALSem model
ca86f7f
Raw
History Blame Contribute Delete
17.3 kB
"""
model.py – TRALSem: Textual Robust Adversarial Learning for Sentiment Analysis
=================================================================================
Architecture (Paper Β§3):
Input
β†’ BERT Tokeniser (token_ids, segment_ids, position_ids)
β†’ Data Encoder (token + position + segment embeddings)
β†’ Transformer Encoder (12 layers, multi-head self-attention)
β†’ [CLS] pooling
β†’ Linear Layer (regression-style projection)
β†’ Softmax (classification output)
The Random-Seed mechanism (LCG) ensures reproducibility, and adversarial
perturbations are injected at the embedding level during training (see
adversarial.py and train.py).
"""
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
from config import (
HIDDEN_SIZE, NUM_ATTENTION_HEADS, NUM_HIDDEN_LAYERS,
INTERMEDIATE_SIZE, HIDDEN_DROPOUT, ATTENTION_DROPOUT,
MAX_SEQ_LEN, LCG_A, LCG_C, LCG_M, LCG_SEED,
)
# ═══════════════════════════════════════════════════════════════════════════════
# Random Seed Generator (LCG) – Paper Β§3.2
# ═══════════════════════════════════════════════════════════════════════════════
class LCGRandomSeed:
"""
Linear Congruential Generator for deterministic seed production.
s_{n+1} = (a Β· s_n + c) mod m
Used to set random seeds at each training step for exact reproducibility.
"""
def __init__(self, a: int = LCG_A, c: int = LCG_C,
m: int = LCG_M, seed: int = LCG_SEED):
self.a = a
self.c = c
self.m = m
self.state = seed
def next(self) -> int:
self.state = (self.a * self.state + self.c) % self.m
return self.state
def set_global_seed(self):
"""Push deterministic seed into PyTorch + Python."""
s = self.next()
torch.manual_seed(s)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(s)
return s
# ═══════════════════════════════════════════════════════════════════════════════
# Multi-Head Self-Attention – Paper Eq: Attention(Q,K,V)=softmax(QK^T/√d_k)V
# ═══════════════════════════════════════════════════════════════════════════════
class MultiHeadSelfAttention(nn.Module):
"""Exact implementation of scaled dot-product multi-head attention."""
def __init__(self, hidden_size: int = HIDDEN_SIZE,
num_heads: int = NUM_ATTENTION_HEADS,
dropout: float = ATTENTION_DROPOUT):
super().__init__()
assert hidden_size % num_heads == 0
self.num_heads = num_heads
self.head_dim = hidden_size // num_heads # d_k
self.query = nn.Linear(hidden_size, hidden_size)
self.key = nn.Linear(hidden_size, hidden_size)
self.value = nn.Linear(hidden_size, hidden_size)
self.out = nn.Linear(hidden_size, hidden_size)
self.dropout = nn.Dropout(dropout)
def forward(self, hidden_states: torch.Tensor,
attention_mask: torch.Tensor = None) -> torch.Tensor:
B, L, _ = hidden_states.size()
# Linear projections β†’ (B, num_heads, L, head_dim)
Q = self.query(hidden_states).view(B, L, self.num_heads, self.head_dim).transpose(1, 2)
K = self.key(hidden_states).view(B, L, self.num_heads, self.head_dim).transpose(1, 2)
V = self.value(hidden_states).view(B, L, self.num_heads, self.head_dim).transpose(1, 2)
# Scaled dot-product attention
scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.head_dim)
if attention_mask is not None:
# attention_mask: (B, 1, 1, L) β€” broadcast across heads & queries
scores = scores + attention_mask
attn_weights = F.softmax(scores, dim=-1)
attn_weights = self.dropout(attn_weights)
context = torch.matmul(attn_weights, V) # (B, H, L, d_k)
context = context.transpose(1, 2).contiguous().view(B, L, -1)
return self.out(context)
# ═══════════════════════════════════════════════════════════════════════════════
# Transformer Encoder Block (one layer)
# ═══════════════════════════════════════════════════════════════════════════════
class TransformerBlock(nn.Module):
"""Pre-LN transformer block: Attention β†’ Add & Norm β†’ FFN β†’ Add & Norm."""
def __init__(self, hidden_size: int = HIDDEN_SIZE,
num_heads: int = NUM_ATTENTION_HEADS,
intermediate_size: int = INTERMEDIATE_SIZE,
dropout: float = HIDDEN_DROPOUT):
super().__init__()
self.attention = MultiHeadSelfAttention(hidden_size, num_heads)
self.norm1 = nn.LayerNorm(hidden_size)
self.ffn = nn.Sequential(
nn.Linear(hidden_size, intermediate_size),
nn.GELU(),
nn.Linear(intermediate_size, hidden_size),
)
self.norm2 = nn.LayerNorm(hidden_size)
self.dropout = nn.Dropout(dropout)
def forward(self, x: torch.Tensor, attention_mask=None) -> torch.Tensor:
# Self-attention sub-layer
attn_out = self.attention(self.norm1(x), attention_mask)
x = x + self.dropout(attn_out)
# Feed-forward sub-layer
ffn_out = self.ffn(self.norm2(x))
x = x + self.dropout(ffn_out)
return x
# ═══════════════════════════════════════════════════════════════════════════════
# Data Encoder (Token + Position + Segment embeddings) – Paper Β§3.1
# ═══════════════════════════════════════════════════════════════════════════════
class DataEncoder(nn.Module):
"""
Converts token ids, position ids, and segment ids into a continuous
embedding E = E_token + E_position + E_segment, followed by LayerNorm
and dropout β€” identical to the BERT embedding layer.
"""
def __init__(self, vocab_size: int = 30522,
hidden_size: int = HIDDEN_SIZE,
max_position: int = MAX_SEQ_LEN,
type_vocab_size: int = 2,
dropout: float = HIDDEN_DROPOUT):
super().__init__()
self.word_embeddings = nn.Embedding(vocab_size, hidden_size)
self.position_embeddings = nn.Embedding(max_position, hidden_size)
self.token_type_embeddings = nn.Embedding(type_vocab_size, hidden_size)
self.norm = nn.LayerNorm(hidden_size)
self.dropout = nn.Dropout(dropout)
def forward(self, input_ids: torch.Tensor,
token_type_ids: torch.Tensor = None) -> torch.Tensor:
B, L = input_ids.size()
position_ids = torch.arange(L, device=input_ids.device).unsqueeze(0).expand(B, -1)
if token_type_ids is None:
token_type_ids = torch.zeros_like(input_ids)
embeddings = (
self.word_embeddings(input_ids)
+ self.position_embeddings(position_ids)
+ self.token_type_embeddings(token_type_ids)
)
return self.dropout(self.norm(embeddings))
# ═══════════════════════════════════════════════════════════════════════════════
# TRALSem – Full Model
# ═══════════════════════════════════════════════════════════════════════════════
class TRALSem(nn.Module):
"""
TRALSem: Textual Robust Adversarial Learning for Sentiment Analysis.
Pipeline:
Input β†’ DataEncoder β†’ [Transformer Γ— 12] β†’ [CLS] β†’ LinearLayer β†’ Softmax
The Linear Layer produces a regression-style scalar per class (paper Β§3.4),
and Softmax converts to probabilities. RMSE loss is used during training.
"""
def __init__(self, vocab_size: int = 30522,
num_labels: int = 5,
hidden_size: int = HIDDEN_SIZE,
num_layers: int = NUM_HIDDEN_LAYERS,
num_heads: int = NUM_ATTENTION_HEADS,
intermediate_size: int = INTERMEDIATE_SIZE,
dropout: float = HIDDEN_DROPOUT,
max_seq_len: int = MAX_SEQ_LEN):
super().__init__()
self.num_labels = num_labels
# ── Data Encoder ─────────────────────────────────────────────────
self.encoder = DataEncoder(vocab_size, hidden_size, max_seq_len,
dropout=dropout)
# ── Transformer Encoder (12 layers) ──────────────────────────────
self.transformer_layers = nn.ModuleList([
TransformerBlock(hidden_size, num_heads, intermediate_size, dropout)
for _ in range(num_layers)
])
self.final_norm = nn.LayerNorm(hidden_size)
# ── Linear Layer (LL) – regression-style head (Paper Β§3.4) ──────
self.classifier = nn.Sequential(
nn.Linear(hidden_size, hidden_size),
nn.Tanh(),
nn.Dropout(dropout),
nn.Linear(hidden_size, num_labels),
)
self._init_weights()
# ── weight initialisation ─────────────────────────────────────────────
def _init_weights(self):
for module in self.modules():
if isinstance(module, nn.Linear):
nn.init.xavier_uniform_(module.weight)
if module.bias is not None:
nn.init.zeros_(module.bias)
elif isinstance(module, nn.Embedding):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
elif isinstance(module, nn.LayerNorm):
nn.init.ones_(module.weight)
nn.init.zeros_(module.bias)
# ── load pre-trained BERT weights ─────────────────────────────────────
def load_pretrained_bert(self, bert_model):
"""
Copy weights from a HuggingFace BertModel into our custom layers.
This bridges the gap between our from-scratch architecture and
pretrained BERT for transfer learning.
"""
# Embeddings
self.encoder.word_embeddings.weight.data.copy_(
bert_model.embeddings.word_embeddings.weight.data)
self.encoder.position_embeddings.weight.data.copy_(
bert_model.embeddings.position_embeddings.weight.data[:MAX_SEQ_LEN])
self.encoder.token_type_embeddings.weight.data.copy_(
bert_model.embeddings.token_type_embeddings.weight.data)
self.encoder.norm.weight.data.copy_(
bert_model.embeddings.LayerNorm.weight.data)
self.encoder.norm.bias.data.copy_(
bert_model.embeddings.LayerNorm.bias.data)
# Transformer layers
for i, layer in enumerate(self.transformer_layers):
bl = bert_model.encoder.layer[i]
# Attention
layer.attention.query.weight.data.copy_(bl.attention.self.query.weight.data)
layer.attention.query.bias.data.copy_(bl.attention.self.query.bias.data)
layer.attention.key.weight.data.copy_(bl.attention.self.key.weight.data)
layer.attention.key.bias.data.copy_(bl.attention.self.key.bias.data)
layer.attention.value.weight.data.copy_(bl.attention.self.value.weight.data)
layer.attention.value.bias.data.copy_(bl.attention.self.value.bias.data)
layer.attention.out.weight.data.copy_(bl.attention.output.dense.weight.data)
layer.attention.out.bias.data.copy_(bl.attention.output.dense.bias.data)
# LayerNorm 1
layer.norm1.weight.data.copy_(bl.attention.output.LayerNorm.weight.data)
layer.norm1.bias.data.copy_(bl.attention.output.LayerNorm.bias.data)
# FFN
layer.ffn[0].weight.data.copy_(bl.intermediate.dense.weight.data)
layer.ffn[0].bias.data.copy_(bl.intermediate.dense.bias.data)
layer.ffn[2].weight.data.copy_(bl.output.dense.weight.data)
layer.ffn[2].bias.data.copy_(bl.output.dense.bias.data)
# LayerNorm 2
layer.norm2.weight.data.copy_(bl.output.LayerNorm.weight.data)
layer.norm2.bias.data.copy_(bl.output.LayerNorm.bias.data)
# ── forward pass ──────────────────────────────────────────────────────
def forward(self, input_ids: torch.Tensor,
attention_mask: torch.Tensor = None,
token_type_ids: torch.Tensor = None) -> torch.Tensor:
"""
Returns raw logits (before softmax) of shape (B, num_labels).
Softmax is applied externally in loss / inference.
"""
# 1. Data Encoder
hidden = self.encoder(input_ids, token_type_ids)
# 2. Prepare attention mask β†’ (B, 1, 1, L), -10000 for padded tokens
if attention_mask is not None:
extended_mask = attention_mask[:, None, None, :]
extended_mask = (1.0 - extended_mask) * -1e4
else:
extended_mask = None
# 3. Transformer Encoder (12 layers)
for layer in self.transformer_layers:
hidden = layer(hidden, extended_mask)
hidden = self.final_norm(hidden)
# 4. [CLS] token pooling
cls_output = hidden[:, 0]
# 5. Linear Layer β†’ logits
logits = self.classifier(cls_output)
return logits
# ── convenience: softmax probabilities ────────────────────────────────
def predict_proba(self, input_ids, attention_mask=None,
token_type_ids=None) -> torch.Tensor:
logits = self.forward(input_ids, attention_mask, token_type_ids)
return F.softmax(logits, dim=-1)
# ═══════════════════════════════════════════════════════════════════════════════
# RMSE Loss – Paper Β§3.5
# ═══════════════════════════════════════════════════════════════════════════════
class RMSELoss(nn.Module):
"""
Root Mean Squared Error loss operating on softmax probabilities.
Converts labels to one-hot and computes RMSE between predicted
probability vector and target vector.
"""
def __init__(self, num_classes: int):
super().__init__()
self.num_classes = num_classes
def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
probs = F.softmax(logits, dim=-1)
one_hot = F.one_hot(targets, num_classes=self.num_classes).float()
mse = torch.mean((probs - one_hot) ** 2)
return torch.sqrt(mse + 1e-8)
# ═══════════════════════════════════════════════════════════════════════════════
# Forward-function helper for adversarial modules
# ═══════════════════════════════════════════════════════════════════════════════
def model_forward(model, input_ids, attention_mask, token_type_ids):
"""Plain forward β€” used as a callable by FreeLB / YOPO."""
return model(input_ids, attention_mask, token_type_ids)