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 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
+
@@ -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,6 @@
1
+ """pyt-crf: a modern linear-chain CRF layer for PyTorch."""
2
+
3
+ from pyt_crf.crf import CRF
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["CRF", "__version__"]
@@ -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