Mask padding tokens during text pooling

#7
by fepegar - opened
src/colipri/model/text.py CHANGED
@@ -36,7 +36,10 @@ class TextEncoder(nn.Module, ModelMixin):
36
  output = self.backbone(token_ids, attention_mask=attention_mask)
37
  embeddings = output["last_hidden_state"]
38
  if pool:
39
- embeddings = self.pooler(embeddings)
 
 
 
40
  if normalize:
41
  embeddings = F.normalize(embeddings, dim=1)
42
  return embeddings
 
36
  output = self.backbone(token_ids, attention_mask=attention_mask)
37
  embeddings = output["last_hidden_state"]
38
  if pool:
39
+ embeddings = self.pooler(
40
+ embeddings,
41
+ attention_mask=attention_mask,
42
+ )
43
  if normalize:
44
  embeddings = F.normalize(embeddings, dim=1)
45
  return embeddings
src/colipri/pooling.py CHANGED
@@ -4,6 +4,57 @@ from torch import nn
4
 
5
  from .types import TypePooledEmbeddings
6
  from .types import TypeSequenceEmbeddings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
 
9
  class AttentionPool1D(nn.Module):
@@ -11,10 +62,24 @@ class AttentionPool1D(nn.Module):
11
  super().__init__()
12
  self.attn = nn.MultiheadAttention(embed_dim, num_heads, batch_first=True)
13
 
14
- def forward(self, x: TypeSequenceEmbeddings) -> TypePooledEmbeddings:
15
- query = x.mean(dim=1, keepdim=True)
 
 
 
 
 
 
 
 
16
  key = value = x
17
- pooled, _ = self.attn(query, key, value)
 
 
 
 
 
 
18
  return rearrange(pooled, "batch 1 embed_dim -> batch embed_dim")
19
 
20
  def to_dense(self):
@@ -62,13 +127,24 @@ class MultiLearnedQueryAttentionPool1D(AttentionPool1D):
62
  # 4 Queries instead of 1
63
  self.query = nn.Parameter(torch.randn(1, 4, embed_dim) / embed_dim**0.5)
64
 
65
- def forward(self, x: TypeSequenceEmbeddings) -> TypePooledEmbeddings:
 
 
 
 
66
  """
67
  x: [B, T, D] — sequence of token embeddings
68
  returns: [B, D] — pooled representation
69
  """
70
- B, T, D = x.shape
71
- query = self.query.expand(B, -1, -1) # [B, 4, D]
72
- pooled, _ = self.attn(query, x, x) # [B, 4, D]
 
 
 
 
 
 
 
73
  # pooled: [4, B, D_out], want [B, D_out] by pooling over queries (mean)
74
  return pooled.mean(dim=1) # [B, D]
 
4
 
5
  from .types import TypePooledEmbeddings
6
  from .types import TypeSequenceEmbeddings
7
+ from .types import TypeTextAttentionMask
8
+ from .types import TypeValidTokenMask
9
+
10
+
11
+ def _get_valid_token_mask(
12
+ sequence: TypeSequenceEmbeddings,
13
+ attention_mask: TypeTextAttentionMask | None,
14
+ ) -> TypeValidTokenMask | None:
15
+ """Get a validated Boolean mask for valid sequence positions.
16
+
17
+ Args:
18
+ sequence: Sequence embeddings.
19
+ attention_mask: Mask where nonzero entries identify valid positions.
20
+
21
+ Returns:
22
+ A Boolean mask on the sequence device, or `None` when no mask is provided.
23
+
24
+ Raises:
25
+ ValueError: If the mask shape does not match the sequence or any sequence
26
+ contains no valid positions.
27
+ """
28
+ if attention_mask is None:
29
+ return None
30
+
31
+ expected_shape = sequence.shape[:2]
32
+ if attention_mask.shape != expected_shape:
33
+ msg = (
34
+ f"Expected attention_mask shape {expected_shape},"
35
+ f" but got {attention_mask.shape}"
36
+ )
37
+ raise ValueError(msg)
38
+
39
+ valid_mask = attention_mask.to(device=sequence.device, dtype=torch.bool)
40
+ if not valid_mask.any(dim=1).all():
41
+ msg = "Each sequence must contain at least one valid token"
42
+ raise ValueError(msg)
43
+ return valid_mask
44
+
45
+
46
+ def _compute_masked_mean(
47
+ sequence: TypeSequenceEmbeddings,
48
+ valid_mask: TypeValidTokenMask | None,
49
+ ) -> TypePooledEmbeddings:
50
+ """Compute the sequence mean while excluding masked positions."""
51
+ if valid_mask is None:
52
+ return sequence.mean(dim=1)
53
+ weights = rearrange(
54
+ valid_mask,
55
+ "batch length -> batch length 1",
56
+ ).to(sequence.dtype)
57
+ return (sequence * weights).sum(dim=1) / weights.sum(dim=1)
58
 
59
 
60
  class AttentionPool1D(nn.Module):
 
62
  super().__init__()
63
  self.attn = nn.MultiheadAttention(embed_dim, num_heads, batch_first=True)
64
 
65
+ def forward(
66
+ self,
67
+ x: TypeSequenceEmbeddings,
68
+ attention_mask: TypeTextAttentionMask | None = None,
69
+ ) -> TypePooledEmbeddings:
70
+ valid_mask = _get_valid_token_mask(x, attention_mask)
71
+ query = rearrange(
72
+ _compute_masked_mean(x, valid_mask),
73
+ "batch embed_dim -> batch 1 embed_dim",
74
+ )
75
  key = value = x
76
+ key_padding_mask = None if valid_mask is None else ~valid_mask
77
+ pooled, _ = self.attn(
78
+ query,
79
+ key,
80
+ value,
81
+ key_padding_mask=key_padding_mask,
82
+ )
83
  return rearrange(pooled, "batch 1 embed_dim -> batch embed_dim")
84
 
85
  def to_dense(self):
 
127
  # 4 Queries instead of 1
128
  self.query = nn.Parameter(torch.randn(1, 4, embed_dim) / embed_dim**0.5)
129
 
130
+ def forward(
131
+ self,
132
+ x: TypeSequenceEmbeddings,
133
+ attention_mask: TypeTextAttentionMask | None = None,
134
+ ) -> TypePooledEmbeddings:
135
  """
136
  x: [B, T, D] — sequence of token embeddings
137
  returns: [B, D] — pooled representation
138
  """
139
+ valid_mask = _get_valid_token_mask(x, attention_mask)
140
+ batch_size = x.shape[0]
141
+ query = self.query.expand(batch_size, -1, -1) # [B, 4, D]
142
+ key_padding_mask = None if valid_mask is None else ~valid_mask
143
+ pooled, _ = self.attn(
144
+ query,
145
+ x,
146
+ x,
147
+ key_padding_mask=key_padding_mask,
148
+ )
149
  # pooled: [4, B, D_out], want [B, D_out] by pooling over queries (mean)
150
  return pooled.mean(dim=1) # [B, D]
src/colipri/types.py CHANGED
@@ -2,6 +2,7 @@ import os
2
  from typing import TypeAlias
3
 
4
  import torchio as tio
 
5
  from jaxtyping import Float
6
  from jaxtyping import Int64
7
  from torch import Tensor
@@ -25,6 +26,7 @@ TypeImagesTensor: TypeAlias = Float[Tensor, "batch 1 x_in y_in z_in"]
25
 
26
  TypeTextTokenIds: TypeAlias = Int64[Tensor, "batch text_length"]
27
  TypeTextAttentionMask: TypeAlias = Int64[Tensor, "batch text_length"]
 
28
 
29
  # Outputs
30
  TypeSequenceEmbeddings: TypeAlias = Float[Tensor, "batch sequence_length embed_dim"]
 
2
  from typing import TypeAlias
3
 
4
  import torchio as tio
5
+ from jaxtyping import Bool
6
  from jaxtyping import Float
7
  from jaxtyping import Int64
8
  from torch import Tensor
 
26
 
27
  TypeTextTokenIds: TypeAlias = Int64[Tensor, "batch text_length"]
28
  TypeTextAttentionMask: TypeAlias = Int64[Tensor, "batch text_length"]
29
+ TypeValidTokenMask: TypeAlias = Bool[Tensor, "batch sequence_length"]
30
 
31
  # Outputs
32
  TypeSequenceEmbeddings: TypeAlias = Float[Tensor, "batch sequence_length embed_dim"]
tests/test_pooling.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import torch
3
+
4
+ from colipri.pooling import AttentionPool1D
5
+ from colipri.pooling import MultiLearnedQueryAttentionPool1D
6
+
7
+
8
+ @pytest.mark.parametrize(
9
+ "pool_type",
10
+ [AttentionPool1D, MultiLearnedQueryAttentionPool1D],
11
+ )
12
+ def test_ignores_masked_tokens(pool_type):
13
+ """Ignore padded positions when pooling language embeddings."""
14
+ torch.manual_seed(0)
15
+ pool = pool_type(embed_dim=8, num_heads=2)
16
+ valid = torch.randn(2, 3, 8)
17
+ padded = torch.cat([valid, torch.full((2, 5, 8), 1e6)], dim=1)
18
+ valid_mask = torch.ones(2, 3, dtype=torch.long)
19
+ padded_mask = torch.cat(
20
+ [valid_mask, torch.zeros(2, 5, dtype=torch.long)],
21
+ dim=1,
22
+ )
23
+
24
+ expected = pool(valid)
25
+ result = pool(padded, attention_mask=padded_mask)
26
+
27
+ torch.testing.assert_close(result, expected)
28
+
29
+
30
+ def test_rejects_incorrect_attention_mask_shape():
31
+ """Reject an attention mask that does not match the input sequence."""
32
+ pool = AttentionPool1D(embed_dim=8, num_heads=2)
33
+ sequence = torch.randn(2, 3, 8)
34
+ attention_mask = torch.ones(2, 4, dtype=torch.long)
35
+
36
+ with pytest.raises(ValueError, match="attention_mask shape"):
37
+ pool(sequence, attention_mask=attention_mask)
38
+
39
+
40
+ def test_rejects_fully_masked_sequence():
41
+ """Reject a sequence without any valid tokens."""
42
+ pool = AttentionPool1D(embed_dim=8, num_heads=2)
43
+ sequence = torch.randn(2, 3, 8)
44
+ attention_mask = torch.tensor([[1, 1, 1], [0, 0, 0]])
45
+
46
+ with pytest.raises(ValueError, match="at least one valid token"):
47
+ pool(sequence, attention_mask=attention_mask)
tests/test_text.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch import nn
3
+
4
+ from colipri.model.text import TextEncoder
5
+ from colipri.pooling import AttentionPool1D
6
+
7
+
8
+ class _StubBackbone(nn.Module):
9
+ """Encode token IDs as deterministic embeddings."""
10
+
11
+ def __init__(self):
12
+ """Initialize embeddings with a distinctive padding-token vector."""
13
+ super().__init__()
14
+ weights = torch.tensor(
15
+ [
16
+ [100.0, -100.0, 50.0, -50.0],
17
+ [1.0, 0.0, 0.0, 0.0],
18
+ [0.0, 1.0, 0.0, 0.0],
19
+ [0.0, 0.0, 1.0, 0.0],
20
+ ]
21
+ )
22
+ self.embedding = nn.Embedding.from_pretrained(weights, freeze=False)
23
+
24
+ def forward(self, token_ids, attention_mask=None): # noqa: ARG002
25
+ """Encode token IDs without altering padded positions."""
26
+ return {"last_hidden_state": self.embedding(token_ids)}
27
+
28
+
29
+ def test_text_encoder_ignores_masked_padding():
30
+ """Preserve the pooled embedding when masked padding is appended."""
31
+ torch.manual_seed(0)
32
+ encoder = TextEncoder(
33
+ backbone=_StubBackbone(),
34
+ pooler=AttentionPool1D(embed_dim=4, num_heads=2),
35
+ )
36
+ valid_ids = torch.tensor([[1, 2, 3]])
37
+ padded_ids = torch.tensor([[1, 2, 3, 0, 0]])
38
+ valid_mask = torch.ones_like(valid_ids)
39
+ padded_mask = torch.tensor([[1, 1, 1, 0, 0]])
40
+
41
+ expected = encoder(
42
+ valid_ids,
43
+ valid_mask,
44
+ normalize=False,
45
+ )
46
+ result = encoder(
47
+ padded_ids,
48
+ padded_mask,
49
+ normalize=False,
50
+ )
51
+
52
+ torch.testing.assert_close(result, expected)