Skip to content

Commit 52cbf24

Browse files
Add hardened required phrase model inference
Signed-off-by: Kaushik Kumar <kaushikrjpm10@gmail.com>
1 parent 676b47f commit 52cbf24

2 files changed

Lines changed: 655 additions & 0 deletions

File tree

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
# Run the trained phrase tagger over license rules and mark its predictions.
2+
import json
3+
import os
4+
import sys
5+
import unicodedata
6+
from numbers import Integral
7+
from pathlib import Path
8+
9+
import click
10+
11+
# Avoid importing TensorFlow through Transformers.
12+
os.environ.setdefault("USE_TF", "0")
13+
14+
sys.path.insert(0, str(Path(__file__).parent))
15+
16+
from licensedcode.models import rules_data_dir
17+
from licensedcode.required_phrases import add_required_phrase_to_rule
18+
from licensedcode.required_phrases import find_phrase_spans_in_text
19+
from licensedcode.required_phrases import get_base_rules_by_expression
20+
from licensedcode.required_phrases import RequiredPhraseRuleCandidate
21+
from licensedcode.tokenize import get_existing_required_phrase_spans
22+
from licensedcode.tokenize import required_phrase_splitter
23+
24+
from train_model import extract_spans
25+
from train_model import ID2LABEL
26+
from train_model import load_final_model
27+
28+
29+
MIN_TOKENS = 2
30+
MIN_SINGLE_TOKEN_LEN = 5
31+
MAX_RULE_TEXT = 4000
32+
33+
34+
def load_model(model, hf_token=None):
35+
"""Load and validate a local or Hugging Face Final_Model."""
36+
model_dir = Path(model)
37+
if not model_dir.is_dir():
38+
from huggingface_hub import snapshot_download
39+
40+
model_dir = Path(snapshot_download(repo_id=model, token=hf_token))
41+
42+
tagger, tokenizer = load_final_model(model_dir, offline=True)
43+
config = json.loads((model_dir / "train_config.json").read_text(encoding="utf-8"))
44+
return tagger, tokenizer, config["max_length"]
45+
46+
47+
def words_from_text(text):
48+
"""Return words tokenized as they are in the training dataset."""
49+
text = text.replace("\r\n", "\n").replace("\r", "\n")
50+
return required_phrase_splitter(unicodedata.normalize("NFKC", text))
51+
52+
53+
def is_updatable(rule):
54+
"""Return True if a rule can receive predicted required phrases."""
55+
if rule.is_from_license:
56+
return False
57+
if len(rule.text) > MAX_RULE_TEXT:
58+
return False
59+
if not rule.is_approx_matchable:
60+
return False
61+
if rule.skip_for_required_phrase_generation:
62+
return False
63+
return not get_existing_required_phrase_spans(rule.text)
64+
65+
66+
def select_rules(license_expression=None):
67+
"""Return eligible rules grouped by license expression."""
68+
try:
69+
rules_by_expression = get_base_rules_by_expression(license_expression)
70+
except KeyError:
71+
raise click.ClickException(
72+
f"No rules for license expression: {license_expression}"
73+
) from None
74+
75+
selected = {}
76+
for expression, rules in rules_by_expression.items():
77+
updatable = [rule for rule in rules if is_updatable(rule)]
78+
if updatable:
79+
selected[expression] = updatable
80+
return selected
81+
82+
83+
def _word_counts(word_ids):
84+
counts = {}
85+
for word_id in word_ids:
86+
if word_id is not None:
87+
counts[word_id] = counts.get(word_id, 0) + 1
88+
return counts
89+
90+
91+
def encode_words(tokenizer, words, max_length):
92+
"""Encode the longest complete-word prefix and report truncation."""
93+
call = dict(is_split_into_words=True, add_special_tokens=True)
94+
full = tokenizer(words, truncation=False, **call)
95+
encoding = tokenizer(words, truncation=True, max_length=max_length, **call)
96+
97+
full_counts = _word_counts(full.word_ids())
98+
retained_counts = _word_counts(encoding.word_ids())
99+
covered_words = max(retained_counts, default=-1) + 1
100+
complete_words = covered_words
101+
102+
if covered_words and retained_counts[covered_words - 1] != full_counts[covered_words - 1]:
103+
complete_words -= 1
104+
encoding = tokenizer(words[:complete_words], truncation=False, **call)
105+
106+
if not complete_words:
107+
raise ValueError("Tokenizer retained no complete words")
108+
if len(encoding["input_ids"]) > max_length:
109+
raise ValueError("Complete-word encoding exceeds the model maximum length")
110+
111+
return encoding, complete_words < len(words)
112+
113+
114+
def phrases_from_tags(tags, words):
115+
"""Return unique predicted phrase texts, longest first."""
116+
phrases = {
117+
" ".join(words[start : end + 1])
118+
for start, end in extract_spans(tags)
119+
}
120+
return sorted(phrases, key=lambda phrase: (-len(phrase), phrase))
121+
122+
123+
def predict_phrases(tagger, tokenizer, max_length, words):
124+
"""Return predicted phrases and whether the rule was truncated."""
125+
if not words:
126+
return [], False
127+
128+
import torch
129+
130+
encoding, truncated = encode_words(tokenizer, words, max_length)
131+
word_ids = encoding.word_ids()
132+
input_ids = torch.tensor([encoding["input_ids"]], dtype=torch.long)
133+
attention_mask = torch.tensor([encoding["attention_mask"]], dtype=torch.long)
134+
135+
with torch.no_grad():
136+
predicted = tagger.predict_words(input_ids, attention_mask, word_ids)
137+
138+
word_count = len(set(word_id for word_id in word_ids if word_id is not None))
139+
if len(predicted) != word_count:
140+
raise ValueError("Model returned a different number of labels than encoded words")
141+
142+
tags = []
143+
for label in predicted:
144+
if isinstance(label, bool) or not isinstance(label, Integral) or label not in ID2LABEL:
145+
raise ValueError(f"Model returned an invalid label ID: {label!r}")
146+
tags.append(ID2LABEL[int(label)])
147+
148+
return phrases_from_tags(tags, words), truncated
149+
150+
151+
def new_counts():
152+
return dict(
153+
rules=0,
154+
truncated=0,
155+
rejected=0,
156+
not_found=0,
157+
injected=0,
158+
skipped=0,
159+
written=0,
160+
)
161+
162+
163+
def inject(rule, phrases, counts, dry_run=False, verbose=False):
164+
"""Validate and add predicted phrases, writing the rule at most once."""
165+
candidates = []
166+
for phrase in phrases:
167+
candidate = RequiredPhraseRuleCandidate.create(rule.license_expression, phrase)
168+
if not candidate.is_good(rule, MIN_TOKENS, MIN_SINGLE_TOKEN_LEN):
169+
counts["rejected"] += 1
170+
continue
171+
if not find_phrase_spans_in_text(rule.text, phrase):
172+
counts["not_found"] += 1
173+
continue
174+
candidates.append(phrase)
175+
176+
if not candidates:
177+
return False
178+
179+
original_text = rule.text
180+
original_source = rule.source
181+
source = f"{original_source} ml_model" if original_source else "ml_model"
182+
183+
for phrase in candidates:
184+
updated = add_required_phrase_to_rule(
185+
rule=rule,
186+
required_phrase=phrase,
187+
source=source,
188+
debug=verbose,
189+
dry_run=True,
190+
)
191+
if updated:
192+
counts["injected"] += 1
193+
else:
194+
counts["skipped"] += 1
195+
196+
if rule.text == original_text:
197+
return False
198+
if not dry_run:
199+
rule.dump(rules_data_dir)
200+
return True
201+
202+
203+
def process_rules(
204+
selected,
205+
tagger,
206+
tokenizer,
207+
max_length,
208+
dry_run=False,
209+
limit=0,
210+
verbose=False,
211+
):
212+
"""Predict and mark phrases in selected rules and return run counts."""
213+
counts = new_counts()
214+
total = sum(len(rules) for rules in selected.values())
215+
click.echo(f"Tagging {total} rules in {len(selected)} license expressions")
216+
217+
for expression, rules in selected.items():
218+
if verbose:
219+
click.echo(f"{expression}: {len(rules)} rules")
220+
221+
for rule in rules:
222+
if limit and counts["rules"] >= limit:
223+
click.echo(f"Stopping at {limit} rules")
224+
return counts
225+
226+
counts["rules"] += 1
227+
words = words_from_text(rule.text)
228+
phrases, truncated = predict_phrases(tagger, tokenizer, max_length, words)
229+
if truncated:
230+
counts["truncated"] += 1
231+
if not phrases:
232+
continue
233+
234+
if verbose:
235+
click.echo(f" {rule.identifier}: {phrases}")
236+
if inject(rule, phrases, counts, dry_run=dry_run, verbose=verbose):
237+
counts["written"] += 1
238+
239+
return counts
240+
241+
242+
@click.command()
243+
@click.option(
244+
"--model",
245+
required=True,
246+
help="Final model directory or Hugging Face repository.",
247+
)
248+
@click.option(
249+
"--license-expression",
250+
help="Only update rules for this license expression.",
251+
)
252+
@click.option(
253+
"--dry-run",
254+
is_flag=True,
255+
help="Predict and validate phrases without saving rules.",
256+
)
257+
@click.option(
258+
"--limit",
259+
default=0,
260+
type=click.IntRange(min=0),
261+
help="Stop after this many rules; zero processes all rules.",
262+
)
263+
@click.option(
264+
"-v",
265+
"--verbose",
266+
is_flag=True,
267+
help="Print predictions for each rule.",
268+
)
269+
@click.help_option("-h", "--help")
270+
def main(model, license_expression, dry_run, limit, verbose):
271+
"""Add model-predicted required phrases to license rules."""
272+
selected = select_rules(license_expression=license_expression)
273+
if not selected:
274+
click.echo("No eligible rules found")
275+
return
276+
277+
tagger, tokenizer, max_length = load_model(
278+
model,
279+
hf_token=os.environ.get("HF_TOKEN"),
280+
)
281+
counts = process_rules(
282+
selected=selected,
283+
tagger=tagger,
284+
tokenizer=tokenizer,
285+
max_length=max_length,
286+
dry_run=dry_run,
287+
limit=limit,
288+
verbose=verbose,
289+
)
290+
291+
click.echo(f"\nrules processed : {counts['rules']}")
292+
click.echo(f" truncated : {counts['truncated']}")
293+
click.echo(f"phrases injected : {counts['injected']}")
294+
click.echo(f" rejected : {counts['rejected']}")
295+
click.echo(f" not found : {counts['not_found']}")
296+
click.echo(f" nothing to add : {counts['skipped']}")
297+
click.echo(f"rules written : {counts['written']}")
298+
299+
if dry_run:
300+
click.echo("Dry run: no rules were saved")
301+
elif counts["written"]:
302+
click.echo("Run scancode-reindex-licenses to use the new required phrases")
303+
304+
305+
if __name__ == "__main__":
306+
main()

0 commit comments

Comments
 (0)