Skip to content

Commit 8327f4f

Browse files
add a CRF decode path for inference
forward() needs labels to locate the first subword of each word, so there was no way to run the tagger without them
1 parent 6cf9076 commit 8327f4f

4 files changed

Lines changed: 107 additions & 0 deletions

File tree

etc/requirements-ml.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ accelerate>=0.33
1313
# CRF layer on top of the token classifier
1414
pytorch-crf>=0.7.2
1515

16+
# reading the trained weights and pulling the model from the hub
17+
safetensors>=0.4
18+
huggingface-hub>=0.24
19+
1620
# 8 bit optimizer to fit deberta-large on a 16gb gpu, optional at runtime
1721
bitsandbytes>=0.43
1822

etc/scripts/dataset_pipeline/phrase_model.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from transformers import AutoModel
99
from transformers import Trainer
1010

11+
from train_model import first_subword_positions
1112
from train_model import IGNORE_INDEX
1213
from train_model import LABELS
1314

@@ -125,6 +126,25 @@ def forward(self, input_ids, attention_mask, labels=None):
125126

126127
return result
127128

129+
def predict_words(self, input_ids, attention_mask, word_ids):
130+
"""Label id per word for a single rule, without labels
131+
132+
forward() needs labels to find the first subword of each word, which we
133+
do not have at inference time, so take those positions from the
134+
tokenizer word_ids and decode from the CRF directly
135+
"""
136+
positions = first_subword_positions(word_ids)
137+
if not positions:
138+
return []
139+
140+
emissions = self.emissions(input_ids, attention_mask)
141+
word_emissions = emissions[0, positions].unsqueeze(0).float()
142+
if not self.use_crf:
143+
return word_emissions.argmax(dim=-1)[0].tolist()
144+
145+
mask = torch.ones(word_emissions.shape[:2], dtype=torch.bool, device=emissions.device)
146+
return self.crf.decode(word_emissions, mask=mask)[0]
147+
128148
def pad_decoded(self, decoded, width, device):
129149
"""Turn the variable length CRF paths into a padded tensor"""
130150
preds = torch.full((len(decoded), width), IGNORE_INDEX, dtype=torch.long, device=device)

etc/scripts/dataset_pipeline/test_train_model.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
from train_model import align_labels
88
from train_model import decode_row
99
from train_model import extract_spans
10+
from train_model import first_subword_positions
1011
from train_model import IGNORE_INDEX
1112
from train_model import LABEL2ID
13+
from train_model import LABELS
1214

1315

1416
class FakeEncoding(dict):
@@ -85,6 +87,67 @@ def test_all_outside(self):
8587
assert encoding['labels'] == [IGNORE_INDEX, 0, 0, IGNORE_INDEX]
8688

8789

90+
class TestFirstSubwordPositions:
91+
92+
def test_skips_specials_and_continuations(self):
93+
# CLS w0 w1a w1b w2 SEP
94+
assert first_subword_positions([None, 0, 1, 1, 2, None]) == [1, 2, 4]
95+
96+
def test_without_special_tokens(self):
97+
# depending on its files a tokenizer may add no CLS or SEP at all
98+
assert first_subword_positions([0, 1, 2]) == [0, 1, 2]
99+
100+
def test_long_word(self):
101+
assert first_subword_positions([None, 0, 0, 0, 1, None]) == [1, 4]
102+
103+
def test_nothing_to_tag(self):
104+
assert first_subword_positions([None, None]) == []
105+
assert first_subword_positions([]) == []
106+
107+
108+
def make_crf_tagger():
109+
"""A PhraseTagger with only the CRF built, the backbone needs a download"""
110+
import torch
111+
from torchcrf import CRF
112+
from phrase_model import PhraseTagger
113+
114+
tagger = PhraseTagger.__new__(PhraseTagger)
115+
torch.nn.Module.__init__(tagger)
116+
tagger.use_crf = True
117+
tagger.num_labels = len(LABELS)
118+
tagger.crf = CRF(len(LABELS), batch_first=True)
119+
# zero transitions make the decode a plain argmax, so the expected path is obvious
120+
with torch.no_grad():
121+
for param in tagger.crf.parameters():
122+
param.zero_()
123+
return tagger
124+
125+
126+
def test_predict_words_uses_the_first_subword():
127+
import torch
128+
129+
tagger = make_crf_tagger()
130+
# CLS w0 w1a w1b SEP, the score on the continuation subword must be ignored
131+
emissions = torch.zeros((1, 5, len(LABELS)))
132+
emissions[0, 1, LABEL2ID['B-REQ']] = 9.0
133+
emissions[0, 2, LABEL2ID['E-REQ']] = 9.0
134+
emissions[0, 3, LABEL2ID['S-REQ']] = 9.0
135+
tagger.emissions = lambda input_ids, attention_mask: emissions
136+
137+
ids = torch.zeros((1, 5), dtype=torch.long)
138+
tags = tagger.predict_words(ids, ids, [None, 0, 1, 1, None])
139+
assert tags == [LABEL2ID['B-REQ'], LABEL2ID['E-REQ']]
140+
141+
142+
def test_predict_words_with_no_words():
143+
import torch
144+
145+
tagger = make_crf_tagger()
146+
tagger.emissions = lambda input_ids, attention_mask: torch.zeros((1, 2, len(LABELS)))
147+
ids = torch.zeros((1, 2), dtype=torch.long)
148+
assert tagger.predict_words(ids, ids, [None, None]) == []
149+
150+
88151
def test_viterbi_with_zero_transitions_is_argmax():
89152
import numpy as np
90153
from export_onnx import viterbi_decode

etc/scripts/dataset_pipeline/train_model.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,26 @@ def align_labels(tokens, word_labels, tokenizer, max_length):
113113
return encoding
114114

115115

116+
def first_subword_positions(word_ids):
117+
"""Subword positions that start a new word
118+
119+
word_ids comes from a fast tokenizer encoding and has None for special
120+
tokens. Training locates words through the labels, inference has none, so
121+
the tagger uses these positions instead
122+
"""
123+
positions = []
124+
previous = None
125+
for i, word_id in enumerate(word_ids):
126+
if word_id is None:
127+
previous = None
128+
continue
129+
if word_id != previous:
130+
positions.append(i)
131+
previous = word_id
132+
133+
return positions
134+
135+
116136
class PhraseDataset:
117137
"""Reads a BIOES JSONL split and encodes each rule for the model"""
118138

0 commit comments

Comments
 (0)