|
1 | 1 | # Run the trained phrase tagger over license rules and mark its predictions. |
| 2 | +from dataclasses import dataclass |
2 | 3 | import json |
3 | 4 | import os |
4 | 5 | import sys |
|
22 | 23 | from licensedcode.tokenize import required_phrase_splitter |
23 | 24 |
|
24 | 25 | from train_model import extract_spans |
| 26 | +from train_model import first_subword_positions |
25 | 27 | from train_model import ID2LABEL |
26 | 28 | from train_model import load_final_model |
27 | 29 |
|
|
31 | 33 | MAX_RULE_TEXT = 4000 |
32 | 34 |
|
33 | 35 |
|
| 36 | +@dataclass(frozen=True) |
| 37 | +class PhrasePrediction: |
| 38 | + """One predicted required phrase.""" |
| 39 | + |
| 40 | + text: str |
| 41 | + start_word: int |
| 42 | + end_word: int |
| 43 | + confidence: float |
| 44 | + |
| 45 | + |
| 46 | +@dataclass(frozen=True) |
| 47 | +class PredictionResult: |
| 48 | + """Predictions and tokenization details for one rule.""" |
| 49 | + |
| 50 | + words: tuple[str, ...] |
| 51 | + phrases: tuple[PhrasePrediction, ...] |
| 52 | + truncated: bool |
| 53 | + |
| 54 | + |
34 | 55 | def load_model(model, hf_token=None): |
35 | 56 | """Load and validate a local or Hugging Face Final_Model.""" |
36 | 57 | model_dir = Path(model) |
@@ -111,6 +132,79 @@ def encode_words(tokenizer, words, max_length): |
111 | 132 | return encoding, complete_words < len(words) |
112 | 133 |
|
113 | 134 |
|
| 135 | +def span_confidence(crf, word_emissions, tags, mask, free, span): |
| 136 | + """Return the CRF probability mass agreeing with one decoded span.""" |
| 137 | + start, end = span |
| 138 | + pinned = word_emissions.clone() |
| 139 | + floor = float(word_emissions.min()) - 10000.0 |
| 140 | + |
| 141 | + for position in range(start, end + 1): |
| 142 | + label = int(tags[0, position]) |
| 143 | + keep = float(pinned[0, position, label]) |
| 144 | + pinned[0, position] = floor |
| 145 | + pinned[0, position, label] = keep |
| 146 | + |
| 147 | + constrained = crf(pinned, tags, mask=mask, reduction="none") |
| 148 | + confidence = float((free - constrained).detach().exp()) |
| 149 | + return min(max(confidence, 0.0), 1.0) |
| 150 | + |
| 151 | + |
| 152 | +def predict_rule(tagger, tokenizer, max_length, text): |
| 153 | + """Return phrase predictions for rule text without changing a rule.""" |
| 154 | + import torch |
| 155 | + |
| 156 | + words = words_from_text(text) |
| 157 | + if not words: |
| 158 | + return PredictionResult(words=(), phrases=(), truncated=False) |
| 159 | + |
| 160 | + encoding, truncated = encode_words(tokenizer, words, max_length) |
| 161 | + word_ids = encoding.word_ids() |
| 162 | + positions = first_subword_positions(word_ids) |
| 163 | + device = next(tagger.parameters()).device |
| 164 | + input_ids = torch.tensor([encoding["input_ids"]], dtype=torch.long, device=device) |
| 165 | + attention_mask = torch.tensor( |
| 166 | + [encoding["attention_mask"]], |
| 167 | + dtype=torch.long, |
| 168 | + device=device, |
| 169 | + ) |
| 170 | + |
| 171 | + with torch.inference_mode(): |
| 172 | + emissions = tagger.emissions(input_ids, attention_mask) |
| 173 | + word_emissions = emissions[:, positions].float() |
| 174 | + mask = torch.ones( |
| 175 | + word_emissions.shape[:2], |
| 176 | + dtype=torch.bool, |
| 177 | + device=word_emissions.device, |
| 178 | + ) |
| 179 | + decoded = tagger.crf.decode(word_emissions, mask=mask)[0] |
| 180 | + tags = torch.tensor([decoded], device=word_emissions.device) |
| 181 | + free = tagger.crf(word_emissions, tags, mask=mask, reduction="none") |
| 182 | + labels = [ID2LABEL[int(label)] for label in decoded] |
| 183 | + predictions = [ |
| 184 | + PhrasePrediction( |
| 185 | + text=" ".join(words[start : end + 1]), |
| 186 | + start_word=start, |
| 187 | + end_word=end, |
| 188 | + confidence=span_confidence( |
| 189 | + tagger.crf, |
| 190 | + word_emissions, |
| 191 | + tags, |
| 192 | + mask, |
| 193 | + free, |
| 194 | + (start, end), |
| 195 | + ), |
| 196 | + ) |
| 197 | + for start, end in extract_spans(labels) |
| 198 | + ] |
| 199 | + |
| 200 | + predictions.sort(key=lambda prediction: (prediction.start_word, prediction.end_word)) |
| 201 | + return PredictionResult( |
| 202 | + words=tuple(words), |
| 203 | + phrases=tuple(predictions), |
| 204 | + truncated=truncated, |
| 205 | + ) |
| 206 | + |
| 207 | + |
114 | 208 | def phrases_from_tags(tags, words): |
115 | 209 | """Return unique predicted phrase texts, longest first.""" |
116 | 210 | phrases = { |
|
0 commit comments