pyt-crf 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyt_crf-0.1.1/PKG-INFO +84 -0
- pyt_crf-0.1.1/README.md +74 -0
- pyt_crf-0.1.1/pyproject.toml +16 -0
- pyt_crf-0.1.1/src/pyt_crf/__init__.py +6 -0
- pyt_crf-0.1.1/src/pyt_crf/crf.py +328 -0
- pyt_crf-0.1.1/src/pyt_crf/py.typed +0 -0
pyt_crf-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pyt-crf
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A modern linear-chain CRF (Conditional Random Field) layer for PyTorch
|
|
5
|
+
Author: id4thomas
|
|
6
|
+
Author-email: id4thomas <id2thomas@gmail.com>
|
|
7
|
+
Requires-Dist: torch>=2.10
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# pyt-crf
|
|
12
|
+
|
|
13
|
+
A modern linear-chain CRF (Conditional Random Field) layer for PyTorch.
|
|
14
|
+
|
|
15
|
+
- `forward()` returns the **negative log-likelihood** directly (ready for `.backward()`)
|
|
16
|
+
- `decode()` runs batched Viterbi decoding
|
|
17
|
+
- Batch-first (`batch, seq_len, num_tags`) by default
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- Python >= 3.12
|
|
22
|
+
- PyTorch >= 2.10
|
|
23
|
+
|
|
24
|
+
## Build
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
conda activate py312
|
|
28
|
+
|
|
29
|
+
# build sdist + wheel into dist/ (uses the uv_build backend)
|
|
30
|
+
uv build
|
|
31
|
+
|
|
32
|
+
# or, without uv:
|
|
33
|
+
pip install build
|
|
34
|
+
python -m build
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# from the built wheel
|
|
41
|
+
pip install dist/pyt_crf-0.1.0-py3-none-any.whl
|
|
42
|
+
|
|
43
|
+
# or editable, for development
|
|
44
|
+
pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick usage
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import torch
|
|
51
|
+
from pyt_crf import CRF
|
|
52
|
+
|
|
53
|
+
crf = CRF(num_tags=5)
|
|
54
|
+
emissions = torch.randn(2, 7, 5) # (batch, seq_len, num_tags)
|
|
55
|
+
tags = torch.randint(5, (2, 7)) # (batch, seq_len)
|
|
56
|
+
mask = torch.ones(2, 7, dtype=torch.bool) # True = real token, False = padding
|
|
57
|
+
|
|
58
|
+
loss = crf(emissions, tags, mask) # scalar NLL loss
|
|
59
|
+
loss.backward()
|
|
60
|
+
|
|
61
|
+
best_paths = crf.decode(emissions, mask) # List[List[int]]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
> [!WARNING]
|
|
65
|
+
> **Run the CRF in float32.** The forward/Viterbi recursions accumulate
|
|
66
|
+
> `logsumexp`/max scores over the whole sequence, which is numerically unstable
|
|
67
|
+
> in bf16/fp16. Keep the CRF parameters in fp32 and cast emissions before the
|
|
68
|
+
> layer — `crf(emissions.float(), ...)` — even when the backbone runs in
|
|
69
|
+
> bf16/autocast.
|
|
70
|
+
|
|
71
|
+
## Examples
|
|
72
|
+
|
|
73
|
+
- [`examples/conll2003`](examples/conll2003) — NER fine-tuning on
|
|
74
|
+
[tner/conll2003](https://huggingface.co/datasets/tner/conll2003) with a
|
|
75
|
+
Gemma3-based backbone + CRF head.
|
|
76
|
+
|
|
77
|
+
## Changelog
|
|
78
|
+
|
|
79
|
+
- [0.1.0](docs/changelogs/0_1_0.md) — initial release; full list of differences from pytorch-crf.
|
|
80
|
+
|
|
81
|
+
## Acknowledgements
|
|
82
|
+
|
|
83
|
+
- This package mostly follows implementation of [kmkurn/pytorch-crf](https://github.com/kmkurn/pytorch-crf).
|
|
84
|
+
|
pyt_crf-0.1.1/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# pyt-crf
|
|
2
|
+
|
|
3
|
+
A modern linear-chain CRF (Conditional Random Field) layer for PyTorch.
|
|
4
|
+
|
|
5
|
+
- `forward()` returns the **negative log-likelihood** directly (ready for `.backward()`)
|
|
6
|
+
- `decode()` runs batched Viterbi decoding
|
|
7
|
+
- Batch-first (`batch, seq_len, num_tags`) by default
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Python >= 3.12
|
|
12
|
+
- PyTorch >= 2.10
|
|
13
|
+
|
|
14
|
+
## Build
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
conda activate py312
|
|
18
|
+
|
|
19
|
+
# build sdist + wheel into dist/ (uses the uv_build backend)
|
|
20
|
+
uv build
|
|
21
|
+
|
|
22
|
+
# or, without uv:
|
|
23
|
+
pip install build
|
|
24
|
+
python -m build
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# from the built wheel
|
|
31
|
+
pip install dist/pyt_crf-0.1.0-py3-none-any.whl
|
|
32
|
+
|
|
33
|
+
# or editable, for development
|
|
34
|
+
pip install -e .
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick usage
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import torch
|
|
41
|
+
from pyt_crf import CRF
|
|
42
|
+
|
|
43
|
+
crf = CRF(num_tags=5)
|
|
44
|
+
emissions = torch.randn(2, 7, 5) # (batch, seq_len, num_tags)
|
|
45
|
+
tags = torch.randint(5, (2, 7)) # (batch, seq_len)
|
|
46
|
+
mask = torch.ones(2, 7, dtype=torch.bool) # True = real token, False = padding
|
|
47
|
+
|
|
48
|
+
loss = crf(emissions, tags, mask) # scalar NLL loss
|
|
49
|
+
loss.backward()
|
|
50
|
+
|
|
51
|
+
best_paths = crf.decode(emissions, mask) # List[List[int]]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
> [!WARNING]
|
|
55
|
+
> **Run the CRF in float32.** The forward/Viterbi recursions accumulate
|
|
56
|
+
> `logsumexp`/max scores over the whole sequence, which is numerically unstable
|
|
57
|
+
> in bf16/fp16. Keep the CRF parameters in fp32 and cast emissions before the
|
|
58
|
+
> layer — `crf(emissions.float(), ...)` — even when the backbone runs in
|
|
59
|
+
> bf16/autocast.
|
|
60
|
+
|
|
61
|
+
## Examples
|
|
62
|
+
|
|
63
|
+
- [`examples/conll2003`](examples/conll2003) — NER fine-tuning on
|
|
64
|
+
[tner/conll2003](https://huggingface.co/datasets/tner/conll2003) with a
|
|
65
|
+
Gemma3-based backbone + CRF head.
|
|
66
|
+
|
|
67
|
+
## Changelog
|
|
68
|
+
|
|
69
|
+
- [0.1.0](docs/changelogs/0_1_0.md) — initial release; full list of differences from pytorch-crf.
|
|
70
|
+
|
|
71
|
+
## Acknowledgements
|
|
72
|
+
|
|
73
|
+
- This package mostly follows implementation of [kmkurn/pytorch-crf](https://github.com/kmkurn/pytorch-crf).
|
|
74
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyt-crf"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "A modern linear-chain CRF (Conditional Random Field) layer for PyTorch"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "id4thomas", email = "id2thomas@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"torch>=2.10",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["uv_build>=0.10.12,<0.11.0"]
|
|
16
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Linear-chain Conditional Random Field (CRF) layer for PyTorch.
|
|
2
|
+
|
|
3
|
+
This module implements a first-order linear-chain CRF commonly stacked on top
|
|
4
|
+
of sequence encoders (BiLSTM, Transformer, ...) for tagging tasks such as NER
|
|
5
|
+
and POS tagging.
|
|
6
|
+
|
|
7
|
+
Design notes (how this differs from the classic ``pytorch-crf`` package):
|
|
8
|
+
|
|
9
|
+
* Batch-first ``(batch, seq_len, num_tags)`` is the default and the internal
|
|
10
|
+
canonical layout, matching modern PyTorch conventions.
|
|
11
|
+
* ``forward`` returns the **negative** log-likelihood, i.e. a loss you can
|
|
12
|
+
backpropagate directly without negating it yourself.
|
|
13
|
+
* The gold-path score is computed fully vectorized (no Python loop over time).
|
|
14
|
+
* The Viterbi backtrace is vectorized over the batch dimension instead of
|
|
15
|
+
looping over each sequence in Python.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
import torch
|
|
21
|
+
from torch import Tensor, nn
|
|
22
|
+
|
|
23
|
+
__all__ = ["CRF"]
|
|
24
|
+
|
|
25
|
+
# Valid values for the `reduction` argument of CRF.forward.
|
|
26
|
+
_REDUCTIONS = ("none", "sum", "mean", "token_mean")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CRF(nn.Module):
|
|
30
|
+
"""Linear-chain CRF layer.
|
|
31
|
+
|
|
32
|
+
The layer learns three sets of scores:
|
|
33
|
+
|
|
34
|
+
* ``start_transitions[j]`` -- score for a sequence that begins with tag ``j``.
|
|
35
|
+
* ``end_transitions[j]`` -- score for a sequence that ends with tag ``j``.
|
|
36
|
+
* ``transitions[i, j]`` -- score for moving from tag ``i`` to tag ``j``.
|
|
37
|
+
|
|
38
|
+
Together with the emission scores produced by an upstream encoder, these
|
|
39
|
+
define a globally normalized distribution over whole tag sequences.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
num_tags: Number of distinct tags. Must be positive.
|
|
43
|
+
batch_first: If ``True`` (default), inputs are ``(batch, seq_len, ...)``;
|
|
44
|
+
otherwise ``(seq_len, batch, ...)``.
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
>>> crf = CRF(num_tags=5)
|
|
48
|
+
>>> emissions = torch.randn(2, 7, 5) # (batch, seq_len, num_tags)
|
|
49
|
+
>>> tags = torch.randint(5, (2, 7)) # (batch, seq_len)
|
|
50
|
+
>>> loss = crf(emissions, tags) # scalar loss, ready for .backward()
|
|
51
|
+
>>> best_paths = crf.decode(emissions) # List[List[int]] of best tag ids
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, num_tags: int, batch_first: bool = True) -> None:
|
|
55
|
+
# A CRF over zero tags is meaningless; fail fast with a clear message.
|
|
56
|
+
if num_tags <= 0:
|
|
57
|
+
raise ValueError(f"num_tags must be positive, got {num_tags}")
|
|
58
|
+
super().__init__()
|
|
59
|
+
# Remember the tag-set size for validation and for __repr__.
|
|
60
|
+
self.num_tags = num_tags
|
|
61
|
+
# Remember the expected input layout; internally we always work batch-first.
|
|
62
|
+
self.batch_first = batch_first
|
|
63
|
+
# Score of starting a sequence with each tag: shape (num_tags,).
|
|
64
|
+
self.start_transitions = nn.Parameter(torch.empty(num_tags))
|
|
65
|
+
# Score of ending a sequence with each tag: shape (num_tags,).
|
|
66
|
+
self.end_transitions = nn.Parameter(torch.empty(num_tags))
|
|
67
|
+
# transitions[i, j] = score of moving from tag i to tag j: (num_tags, num_tags).
|
|
68
|
+
self.transitions = nn.Parameter(torch.empty(num_tags, num_tags))
|
|
69
|
+
# Fill all three parameter tensors with small random values.
|
|
70
|
+
self.reset_parameters()
|
|
71
|
+
|
|
72
|
+
def reset_parameters(self) -> None:
|
|
73
|
+
"""(Re-)initialize all transition scores uniformly in [-0.1, 0.1]."""
|
|
74
|
+
# Small symmetric init keeps early training close to "emissions only".
|
|
75
|
+
for param in (self.start_transitions, self.end_transitions, self.transitions):
|
|
76
|
+
nn.init.uniform_(param, -0.1, 0.1)
|
|
77
|
+
|
|
78
|
+
def extra_repr(self) -> str:
|
|
79
|
+
# Shown inside the standard nn.Module repr, e.g. CRF(num_tags=5, batch_first=True).
|
|
80
|
+
return f"num_tags={self.num_tags}, batch_first={self.batch_first}"
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# Public API
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
def forward(
|
|
86
|
+
self,
|
|
87
|
+
emissions: Tensor,
|
|
88
|
+
tags: Tensor,
|
|
89
|
+
mask: Optional[Tensor] = None,
|
|
90
|
+
reduction: str = "mean",
|
|
91
|
+
) -> Tensor:
|
|
92
|
+
"""Compute the negative log-likelihood of ``tags`` given ``emissions``.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
emissions: Emission score tensor of shape ``(batch, seq_len, num_tags)``
|
|
96
|
+
(or ``(seq_len, batch, num_tags)`` when ``batch_first=False``).
|
|
97
|
+
tags: Gold tag indices, shape ``(batch, seq_len)``.
|
|
98
|
+
mask: Optional boolean tensor of the same shape as ``tags``; ``True``
|
|
99
|
+
marks real tokens, ``False`` marks padding. The first timestep of
|
|
100
|
+
every sequence must be ``True``. Defaults to all-``True``.
|
|
101
|
+
``False`` positions may also appear mid-sequence (e.g. sub-word
|
|
102
|
+
continuations labeled ``-100``): they are skipped entirely, with
|
|
103
|
+
a single transition bridging each gap.
|
|
104
|
+
reduction: One of ``"none"`` (per-sequence losses), ``"sum"``,
|
|
105
|
+
``"mean"`` (default, mean over sequences), or ``"token_mean"``
|
|
106
|
+
(sum divided by the number of unmasked tokens).
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The NLL loss: a scalar unless ``reduction="none"``, in which case a
|
|
110
|
+
``(batch,)`` tensor.
|
|
111
|
+
"""
|
|
112
|
+
# Reject typos early instead of silently falling through.
|
|
113
|
+
if reduction not in _REDUCTIONS:
|
|
114
|
+
raise ValueError(f"reduction must be one of {_REDUCTIONS}, got {reduction!r}")
|
|
115
|
+
# Normalize layout/dtype and run all shape checks in one place.
|
|
116
|
+
emissions, tags, mask = self._canonicalize(emissions, tags, mask)
|
|
117
|
+
# Unnormalized log-score of the gold tag path: shape (batch,).
|
|
118
|
+
gold_score = self._joint_score(emissions, tags, mask)
|
|
119
|
+
# log of the partition function Z (sum over ALL tag paths): shape (batch,).
|
|
120
|
+
log_z = self._log_partition(emissions, mask)
|
|
121
|
+
# NLL = -log p(tags | emissions) = log Z - score(gold path).
|
|
122
|
+
nll = log_z - gold_score
|
|
123
|
+
# Apply the requested reduction over the batch.
|
|
124
|
+
if reduction == "none":
|
|
125
|
+
return nll
|
|
126
|
+
if reduction == "sum":
|
|
127
|
+
return nll.sum()
|
|
128
|
+
if reduction == "mean":
|
|
129
|
+
return nll.mean()
|
|
130
|
+
# "token_mean": normalize by the total number of real (unmasked) tokens.
|
|
131
|
+
return nll.sum() / mask.sum()
|
|
132
|
+
|
|
133
|
+
@torch.no_grad()
|
|
134
|
+
def decode(
|
|
135
|
+
self,
|
|
136
|
+
emissions: Tensor,
|
|
137
|
+
mask: Optional[Tensor] = None,
|
|
138
|
+
) -> List[List[int]]:
|
|
139
|
+
"""Find the highest-scoring tag sequence for each batch element (Viterbi).
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
emissions: Emission scores, same layout as in :meth:`forward`.
|
|
143
|
+
mask: Optional boolean padding mask, same semantics as in :meth:`forward`.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
A list of tag-id lists; entry ``b`` covers exactly the timesteps of
|
|
147
|
+
sequence ``b`` where ``mask`` is ``True``.
|
|
148
|
+
"""
|
|
149
|
+
# Reuse the same canonicalization path; `tags=None` skips tag checks.
|
|
150
|
+
emissions, _, mask = self._canonicalize(emissions, None, mask)
|
|
151
|
+
# Run batched Viterbi and unpad the results into plain Python lists.
|
|
152
|
+
return self._viterbi(emissions, mask)
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Input handling
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
def _canonicalize(
|
|
158
|
+
self,
|
|
159
|
+
emissions: Tensor,
|
|
160
|
+
tags: Optional[Tensor],
|
|
161
|
+
mask: Optional[Tensor],
|
|
162
|
+
) -> tuple[Tensor, Tensor, Tensor]:
|
|
163
|
+
"""Validate inputs and return them in batch-first layout with a bool mask."""
|
|
164
|
+
# Emissions must be a 3-D tensor: two sequence dims plus the tag dim.
|
|
165
|
+
if emissions.dim() != 3:
|
|
166
|
+
raise ValueError(f"emissions must be 3-dimensional, got {emissions.dim()}")
|
|
167
|
+
# The size of the tag dimension must match this layer's tag set.
|
|
168
|
+
if emissions.size(2) != self.num_tags:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"expected {self.num_tags} tags in the last dimension of emissions, "
|
|
171
|
+
f"got {emissions.size(2)}"
|
|
172
|
+
)
|
|
173
|
+
# Bring everything into (batch, seq_len, ...) order if it isn't already.
|
|
174
|
+
if not self.batch_first:
|
|
175
|
+
emissions = emissions.transpose(0, 1)
|
|
176
|
+
if tags is not None:
|
|
177
|
+
tags = tags.transpose(0, 1)
|
|
178
|
+
if mask is not None:
|
|
179
|
+
mask = mask.transpose(0, 1)
|
|
180
|
+
# Tags, when given, must line up with emissions on (batch, seq_len).
|
|
181
|
+
if tags is not None and tags.shape != emissions.shape[:2]:
|
|
182
|
+
raise ValueError(
|
|
183
|
+
f"tags shape {tuple(tags.shape)} does not match emissions "
|
|
184
|
+
f"batch/sequence dims {tuple(emissions.shape[:2])}"
|
|
185
|
+
)
|
|
186
|
+
if mask is None:
|
|
187
|
+
# No mask means every position is a real token.
|
|
188
|
+
mask = emissions.new_ones(emissions.shape[:2], dtype=torch.bool)
|
|
189
|
+
else:
|
|
190
|
+
# Accept 0/1 float or int masks too by casting to bool.
|
|
191
|
+
mask = mask.to(dtype=torch.bool)
|
|
192
|
+
# The mask must also line up with emissions on (batch, seq_len).
|
|
193
|
+
if mask.shape != emissions.shape[:2]:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
f"mask shape {tuple(mask.shape)} does not match emissions "
|
|
196
|
+
f"batch/sequence dims {tuple(emissions.shape[:2])}"
|
|
197
|
+
)
|
|
198
|
+
# Every sequence must contain at least its first token; the recursions
|
|
199
|
+
# below are seeded from timestep 0 and cannot represent empty sequences.
|
|
200
|
+
if not bool(mask[:, 0].all()):
|
|
201
|
+
raise ValueError("mask must be True at the first timestep of every sequence")
|
|
202
|
+
# When tags are absent (decode path) return a dummy to keep the tuple shape.
|
|
203
|
+
if tags is None:
|
|
204
|
+
tags = emissions.new_zeros(emissions.shape[:2], dtype=torch.long)
|
|
205
|
+
return emissions, tags, mask
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _last_valid_index(mask: Tensor) -> Tensor:
|
|
209
|
+
"""Return the index of the last ``True`` timestep for each sequence.
|
|
210
|
+
|
|
211
|
+
Computed as an argmax over actual positions rather than ``sum(mask) - 1``,
|
|
212
|
+
so it stays correct even if a mask has ``False`` holes in the middle.
|
|
213
|
+
"""
|
|
214
|
+
# positions[b, t] = t for every batch element: shape (batch, seq_len).
|
|
215
|
+
positions = torch.arange(mask.size(1), device=mask.device).expand_as(mask)
|
|
216
|
+
# Zero out masked positions, then take the max position that survived.
|
|
217
|
+
# (Position 0 is always valid, so the zero fallback is never wrong.)
|
|
218
|
+
return torch.where(mask, positions, positions.new_zeros(())).amax(dim=1)
|
|
219
|
+
|
|
220
|
+
# ------------------------------------------------------------------
|
|
221
|
+
# Score computation
|
|
222
|
+
# ------------------------------------------------------------------
|
|
223
|
+
def _joint_score(self, emissions: Tensor, tags: Tensor, mask: Tensor) -> Tensor:
|
|
224
|
+
"""Score of the gold tag path, fully vectorized. Returns shape ``(batch,)``.
|
|
225
|
+
|
|
226
|
+
The path is defined over the *unmasked* timesteps only:
|
|
227
|
+
|
|
228
|
+
score = start(first tag) + sum emit(t, tags[t]) over unmasked t
|
|
229
|
+
+ sum trans(previous unmasked tag, tags[t]) + end(last tag)
|
|
230
|
+
|
|
231
|
+
Transitions bridge over masked holes (matching the forward algorithm,
|
|
232
|
+
which freezes its recursion at masked steps), so tags at masked
|
|
233
|
+
positions never contribute — not even as transition sources.
|
|
234
|
+
"""
|
|
235
|
+
# Cast the mask once; used to zero out contributions from padding.
|
|
236
|
+
mask_f = mask.to(emissions.dtype)
|
|
237
|
+
# Emission score of the gold tag at every timestep: gather along the tag
|
|
238
|
+
# dimension, giving shape (batch, seq_len); masked steps are zeroed.
|
|
239
|
+
emit_scores = emissions.gather(2, tags.unsqueeze(2)).squeeze(2) * mask_f
|
|
240
|
+
# For every position t, find the most recent unmasked position <= t:
|
|
241
|
+
# masked positions carry -1, and a running max propagates the latest
|
|
242
|
+
# unmasked index forward. Shape (batch, seq_len).
|
|
243
|
+
positions = torch.arange(mask.size(1), device=mask.device).expand_as(mask)
|
|
244
|
+
last_seen = torch.where(mask, positions, positions.new_full((), -1)).cummax(dim=1).values
|
|
245
|
+
# Shift right by one step to get the unmasked position strictly BEFORE t
|
|
246
|
+
# (-1 when there is none, i.e. at t = 0).
|
|
247
|
+
prev_pos = torch.cat(
|
|
248
|
+
[last_seen.new_full((mask.size(0), 1), -1), last_seen[:, :-1]], dim=1
|
|
249
|
+
)
|
|
250
|
+
# A transition is scored for every unmasked step that has an unmasked
|
|
251
|
+
# predecessor: (previous unmasked tag) -> (current tag).
|
|
252
|
+
pair_ok = (mask & (prev_pos >= 0)).to(emissions.dtype)
|
|
253
|
+
prev_tags = tags.gather(1, prev_pos.clamp(min=0))
|
|
254
|
+
trans_scores = self.transitions[prev_tags, tags] * pair_ok
|
|
255
|
+
# Score for starting with the first gold tag (step 0 is always unmasked).
|
|
256
|
+
start_scores = self.start_transitions[tags[:, 0]]
|
|
257
|
+
# Find the gold tag at each sequence's last real timestep...
|
|
258
|
+
last_tags = tags.gather(1, self._last_valid_index(mask).unsqueeze(1)).squeeze(1)
|
|
259
|
+
# ...and add the score for ending the sequence with that tag.
|
|
260
|
+
end_scores = self.end_transitions[last_tags]
|
|
261
|
+
# Total path score: sum every component over time, per batch element.
|
|
262
|
+
return start_scores + emit_scores.sum(dim=1) + trans_scores.sum(dim=1) + end_scores
|
|
263
|
+
|
|
264
|
+
def _log_partition(self, emissions: Tensor, mask: Tensor) -> Tensor:
|
|
265
|
+
"""log Z via the forward algorithm in log space. Returns shape ``(batch,)``."""
|
|
266
|
+
seq_len = emissions.size(1)
|
|
267
|
+
# alpha[b, j] = logsumexp of scores of all partial paths that end in tag j
|
|
268
|
+
# at the current timestep. Seed with the start scores + first emissions.
|
|
269
|
+
alpha = self.start_transitions + emissions[:, 0] # (batch, num_tags)
|
|
270
|
+
# Walk the sequence left to right, updating alpha one step at a time.
|
|
271
|
+
for t in range(1, seq_len):
|
|
272
|
+
# Combine every (previous tag i -> current tag j) hypothesis:
|
|
273
|
+
# alpha as (batch, i, 1) + transitions (i, j) + emissions (batch, 1, j)
|
|
274
|
+
step_scores = (
|
|
275
|
+
alpha.unsqueeze(2) + self.transitions + emissions[:, t].unsqueeze(1)
|
|
276
|
+
)
|
|
277
|
+
# Marginalize over the previous tag i in log space: (batch, num_tags).
|
|
278
|
+
next_alpha = torch.logsumexp(step_scores, dim=1)
|
|
279
|
+
# Sequences already past their end keep their old alpha (frozen).
|
|
280
|
+
alpha = torch.where(mask[:, t].unsqueeze(1), next_alpha, alpha)
|
|
281
|
+
# Close every path with its end-transition score, then sum over final tags.
|
|
282
|
+
return torch.logsumexp(alpha + self.end_transitions, dim=1)
|
|
283
|
+
|
|
284
|
+
# ------------------------------------------------------------------
|
|
285
|
+
# Viterbi decoding
|
|
286
|
+
# ------------------------------------------------------------------
|
|
287
|
+
def _viterbi(self, emissions: Tensor, mask: Tensor) -> List[List[int]]:
|
|
288
|
+
"""Batched Viterbi: max-product forward pass + vectorized backtrace."""
|
|
289
|
+
batch_size, seq_len, _ = emissions.shape
|
|
290
|
+
# score[b, j] = best score of any partial path ending in tag j right now.
|
|
291
|
+
score = self.start_transitions + emissions[:, 0] # (batch, num_tags)
|
|
292
|
+
# backpointers[t-1][b, j] = best previous tag when arriving at tag j at step t.
|
|
293
|
+
backpointers: List[Tensor] = []
|
|
294
|
+
# Forward pass: identical shape dance to _log_partition, but with max.
|
|
295
|
+
for t in range(1, seq_len):
|
|
296
|
+
# All (previous tag i -> current tag j) scores: (batch, i, j).
|
|
297
|
+
step_scores = (
|
|
298
|
+
score.unsqueeze(2) + self.transitions + emissions[:, t].unsqueeze(1)
|
|
299
|
+
)
|
|
300
|
+
# Keep only the best previous tag for each current tag.
|
|
301
|
+
best_scores, best_prev = step_scores.max(dim=1) # both (batch, num_tags)
|
|
302
|
+
# Frozen (padded) sequences carry their old scores forward.
|
|
303
|
+
score = torch.where(mask[:, t].unsqueeze(1), best_scores, score)
|
|
304
|
+
# Record where each tag's best score came from, for the backtrace.
|
|
305
|
+
backpointers.append(best_prev)
|
|
306
|
+
# Add the cost of ending on each tag before picking the winner.
|
|
307
|
+
score = score + self.end_transitions
|
|
308
|
+
# Best final tag per sequence: shape (batch,).
|
|
309
|
+
current = score.argmax(dim=1)
|
|
310
|
+
# Where each sequence actually ends (last unmasked timestep).
|
|
311
|
+
last_idx = self._last_valid_index(mask)
|
|
312
|
+
# Buffer for the decoded path of every sequence: (batch, seq_len).
|
|
313
|
+
paths = emissions.new_zeros((batch_size, seq_len), dtype=torch.long)
|
|
314
|
+
# The final position of every path holds the best final tag.
|
|
315
|
+
paths[:, seq_len - 1] = current
|
|
316
|
+
# Backtrace right to left, for the whole batch at once.
|
|
317
|
+
for t in range(seq_len - 2, -1, -1):
|
|
318
|
+
# Look up the predecessor of the current tag: backpointers[t] maps
|
|
319
|
+
# tags at step t+1 back to tags at step t. Shape (batch,).
|
|
320
|
+
prev = backpointers[t].gather(1, current.unsqueeze(1)).squeeze(1)
|
|
321
|
+
# Only step back where step t+1 was a real, in-range token; otherwise
|
|
322
|
+
# (padding or a masked hole) the chain just carries the current tag.
|
|
323
|
+
follow = mask[:, t + 1] & (t + 1 <= last_idx)
|
|
324
|
+
current = torch.where(follow, prev, current)
|
|
325
|
+
# Record the tag chosen for timestep t.
|
|
326
|
+
paths[:, t] = current
|
|
327
|
+
# Strip padding (and masked holes): keep only timesteps where mask is True.
|
|
328
|
+
return [paths[b, mask[b]].tolist() for b in range(batch_size)]
|
|
File without changes
|