Skip to content

Commit 1745091

Browse files
Add model prediction and rule integration
Signed-off-by: Kaushik Kumar <kaushikrjpm10@gmail.com>
1 parent b2d9a3d commit 1745091

9 files changed

Lines changed: 585 additions & 14 deletions

File tree

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ v0.0.0
1010
- Add required phrase dataset extraction.
1111
- Add composite rule required phrase updates.
1212
- Add required phrase model training and ONNX export.
13+
- Add model prediction and rule integration.

README.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,20 @@ changing a ScanCode rule or file:
6666
6767
Predictions require human review before they are added to license rules.
6868

69+
Add predicted phrases to rules
70+
==============================
71+
72+
Review predictions before modifying rules. Then run the integration command on
73+
a final model directory or Hugging Face repository:
74+
75+
.. code-block:: console
76+
77+
add-model-required-phrases --model model-output/final-model --dry-run --verbose
78+
79+
The command validates each candidate with ScanCode's required-phrase helpers and
80+
writes each changed rule once. Rebuild the ScanCode license index after applying
81+
changes without ``--dry-run``.
82+
6983
Development
7084
===========
7185

docs/source/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ license rules.
1111
dataset
1212
composite_rules
1313
training
14+
model_rules
1415
contribute/contrib_doc
1516

1617
Indices and tables

docs/source/model_rules.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
Add model-predicted required phrases
2+
====================================
3+
4+
Use the command after reviewing predictions from a validated final model:
5+
6+
.. code-block:: console
7+
8+
add-model-required-phrases \
9+
--model model-output/final-model \
10+
--dry-run \
11+
--verbose
12+
13+
``--model`` accepts a local final-model directory or a Hugging Face repository.
14+
The model must pass the hardened publication checks before inference starts.
15+
16+
The command skips rules that cannot receive generated required phrases and
17+
rules that already contain required-phrase markers. Each prediction must pass
18+
ScanCode's candidate and locatability checks. Accepted phrases are applied in
19+
memory and each changed rule is written once.
20+
21+
Use ``--license-expression`` to process one expression and ``--limit`` for a
22+
small review run. Remove ``--dry-run`` only after reviewing the predictions.
23+
Rebuild the ScanCode license index after writing rules.

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ where = src
5555
[options.entry_points]
5656
console_scripts =
5757
add-composite-required-phrases = scancode_required_phrases.composite_rules:add_composite_required_phrases
58+
add-model-required-phrases = scancode_required_phrases.model_rules:add_model_required_phrases
5859
build-required-phrases-dataset = scancode_required_phrases.dataset:main
5960
export-required-phrase-model = scancode_required_phrases.export:main
6061
train-required-phrase-model = scancode_required_phrases.training:main

src/scancode_required_phrases/inference.py

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,41 @@ def words_from_text(text):
4141
return required_phrase_splitter(unicodedata.normalize("NFKC", text))
4242

4343

44+
def _word_counts(word_ids):
45+
counts = {}
46+
for word_id in word_ids:
47+
if word_id is not None:
48+
counts[word_id] = counts.get(word_id, 0) + 1
49+
return counts
50+
51+
52+
def encode_words(tokenizer, words, max_length):
53+
"""Encode the longest complete-word prefix and report truncation."""
54+
call = dict(
55+
is_split_into_words=True,
56+
add_special_tokens=True,
57+
return_tensors="pt",
58+
)
59+
full = tokenizer(words, truncation=False, **call)
60+
encoding = tokenizer(words, truncation=True, max_length=max_length, **call)
61+
62+
full_counts = _word_counts(full.word_ids())
63+
retained_counts = _word_counts(encoding.word_ids())
64+
covered_words = max(retained_counts, default=-1) + 1
65+
complete_words = covered_words
66+
67+
if covered_words and retained_counts[covered_words - 1] != full_counts[covered_words - 1]:
68+
complete_words -= 1
69+
encoding = tokenizer(words[:complete_words], truncation=False, **call)
70+
71+
if not complete_words:
72+
raise ValueError("Tokenizer retained no complete words")
73+
if encoding["input_ids"].shape[1] > max_length:
74+
raise ValueError("Complete-word encoding exceeds the model maximum length")
75+
76+
return encoding, complete_words < len(words)
77+
78+
4479
def span_confidence(crf, word_emissions, tags, mask, free, span):
4580
"""Return the CRF probability mass agreeing with one decoded span."""
4681
start, end = span
@@ -82,12 +117,10 @@ def predict(self, text):
82117
if not words:
83118
return PredictionResult(words=(), phrases=(), truncated=False)
84119

85-
encoding = self.tokenizer(
86-
words,
87-
is_split_into_words=True,
88-
truncation=True,
120+
encoding, truncated = encode_words(
121+
tokenizer=self.tokenizer,
122+
words=words,
89123
max_length=self.max_length,
90-
return_tensors="pt",
91124
)
92125
positions = first_subword_positions(encoding.word_ids())
93126
if not positions:
@@ -110,11 +143,8 @@ def predict(self, text):
110143
free = self.model.crf(word_emissions, tags, mask=mask, reduction="none")
111144

112145
labels = [ID2LABEL[int(label)] for label in decoded]
113-
truncated = len(labels) < len(words)
114146
predictions = []
115147
for start, end in extract_spans(labels):
116-
if truncated and end == len(labels) - 1:
117-
continue
118148
predictions.append(
119149
PhrasePrediction(
120150
text=" ".join(words[start : end + 1]),
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
# Copyright (c) nexB Inc. and others. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Add model-predicted required phrases to ScanCode license rules."""
5+
6+
import os
7+
from pathlib import Path
8+
9+
import click
10+
11+
from licensedcode.models import rules_data_dir
12+
from licensedcode.required_phrases import add_required_phrase_to_rule
13+
from licensedcode.required_phrases import find_phrase_spans_in_text
14+
from licensedcode.required_phrases import get_base_rules_by_expression
15+
from licensedcode.required_phrases import RequiredPhraseRuleCandidate
16+
from licensedcode.tokenize import get_existing_required_phrase_spans
17+
18+
from scancode_required_phrases.inference import RequiredPhrasePredictor
19+
20+
21+
MIN_TOKENS = 2
22+
MIN_SINGLE_TOKEN_LEN = 5
23+
MAX_RULE_TEXT = 4000
24+
25+
26+
def load_predictor(model, hf_token=None):
27+
"""Load a predictor from a local directory or Hugging Face repository."""
28+
model_dir = Path(model)
29+
if not model_dir.is_dir():
30+
from huggingface_hub import snapshot_download
31+
32+
model_dir = Path(snapshot_download(repo_id=model, token=hf_token))
33+
return RequiredPhrasePredictor.from_model_dir(model_dir)
34+
35+
36+
def is_updatable(rule):
37+
"""Return True if a rule can receive predicted required phrases."""
38+
if rule.is_from_license:
39+
return False
40+
if len(rule.text) > MAX_RULE_TEXT:
41+
return False
42+
if not rule.is_approx_matchable:
43+
return False
44+
if rule.skip_for_required_phrase_generation:
45+
return False
46+
return not get_existing_required_phrase_spans(rule.text)
47+
48+
49+
def select_rules(license_expression=None):
50+
"""Return eligible rules grouped by license expression."""
51+
try:
52+
rules_by_expression = get_base_rules_by_expression(license_expression)
53+
except KeyError:
54+
raise click.ClickException(
55+
f"No rules for license expression: {license_expression}"
56+
) from None
57+
58+
selected = {}
59+
for expression, rules in rules_by_expression.items():
60+
updatable = [rule for rule in rules if is_updatable(rule)]
61+
if updatable:
62+
selected[expression] = updatable
63+
return selected
64+
65+
66+
def new_counts():
67+
return dict(
68+
rules=0,
69+
truncated=0,
70+
rejected=0,
71+
not_found=0,
72+
injected=0,
73+
skipped=0,
74+
written=0,
75+
)
76+
77+
78+
def add_predicted_phrases(rule, phrases, counts, dry_run=False, verbose=False):
79+
"""Validate and add predicted phrases, writing the rule at most once."""
80+
candidates = []
81+
for phrase in phrases:
82+
candidate = RequiredPhraseRuleCandidate.create(rule.license_expression, phrase)
83+
if not candidate.is_good(rule, MIN_TOKENS, MIN_SINGLE_TOKEN_LEN):
84+
counts["rejected"] += 1
85+
continue
86+
if not find_phrase_spans_in_text(rule.text, phrase):
87+
counts["not_found"] += 1
88+
continue
89+
candidates.append(phrase)
90+
91+
if not candidates:
92+
return False
93+
94+
original_text = rule.text
95+
original_source = rule.source
96+
source = f"{original_source} ml_model" if original_source else "ml_model"
97+
98+
for phrase in candidates:
99+
updated = add_required_phrase_to_rule(
100+
rule=rule,
101+
required_phrase=phrase,
102+
source=source,
103+
debug=verbose,
104+
dry_run=True,
105+
)
106+
if updated:
107+
counts["injected"] += 1
108+
else:
109+
counts["skipped"] += 1
110+
111+
if rule.text == original_text:
112+
return False
113+
if not dry_run:
114+
rule.dump(rules_data_dir)
115+
return True
116+
117+
118+
def update_rules_from_predictions(
119+
selected,
120+
predictor,
121+
dry_run=False,
122+
limit=0,
123+
verbose=False,
124+
):
125+
"""Predict and add phrases to selected rules and return run counts."""
126+
counts = new_counts()
127+
total = sum(len(rules) for rules in selected.values())
128+
click.echo(f"Predicting required phrases for {total} rules")
129+
130+
for expression, rules in selected.items():
131+
if verbose:
132+
click.echo(f"{expression}: {len(rules)} rules")
133+
134+
for rule in rules:
135+
if limit and counts["rules"] >= limit:
136+
click.echo(f"Stopping at {limit} rules")
137+
return counts
138+
139+
counts["rules"] += 1
140+
result = predictor.predict(rule.text)
141+
if result.truncated:
142+
counts["truncated"] += 1
143+
phrases = [prediction.text for prediction in result.phrases]
144+
if not phrases:
145+
continue
146+
147+
if verbose:
148+
click.echo(f" {rule.identifier}: {phrases}")
149+
if add_predicted_phrases(
150+
rule=rule,
151+
phrases=phrases,
152+
counts=counts,
153+
dry_run=dry_run,
154+
verbose=verbose,
155+
):
156+
counts["written"] += 1
157+
158+
return counts
159+
160+
161+
@click.command(name="add-model-required-phrases")
162+
@click.option(
163+
"--model",
164+
required=True,
165+
help="Final model directory or Hugging Face repository.",
166+
)
167+
@click.option(
168+
"--license-expression",
169+
help="Only update rules for this license expression.",
170+
)
171+
@click.option(
172+
"--dry-run",
173+
is_flag=True,
174+
help="Predict and validate phrases without saving rules.",
175+
)
176+
@click.option(
177+
"--limit",
178+
default=0,
179+
type=click.IntRange(min=0),
180+
help="Stop after this many rules; zero processes all rules.",
181+
)
182+
@click.option(
183+
"-v",
184+
"--verbose",
185+
is_flag=True,
186+
help="Print predictions for each rule.",
187+
)
188+
@click.help_option("-h", "--help")
189+
def add_model_required_phrases(model, license_expression, dry_run, limit, verbose):
190+
"""Add model-predicted required phrases to license rules."""
191+
selected = select_rules(license_expression=license_expression)
192+
if not selected:
193+
click.echo("No eligible rules found")
194+
return
195+
196+
predictor = load_predictor(model, hf_token=os.environ.get("HF_TOKEN"))
197+
counts = update_rules_from_predictions(
198+
selected=selected,
199+
predictor=predictor,
200+
dry_run=dry_run,
201+
limit=limit,
202+
verbose=verbose,
203+
)
204+
205+
click.echo(f"\nrules processed : {counts['rules']}")
206+
click.echo(f" truncated : {counts['truncated']}")
207+
click.echo(f"phrases injected : {counts['injected']}")
208+
click.echo(f" rejected : {counts['rejected']}")
209+
click.echo(f" not found : {counts['not_found']}")
210+
click.echo(f" nothing to add : {counts['skipped']}")
211+
click.echo(f"rules written : {counts['written']}")
212+
213+
if dry_run:
214+
click.echo("Dry run: no rules were saved")
215+
elif counts["written"]:
216+
click.echo("Run scancode-reindex-licenses to use the new required phrases")
217+
218+
219+
if __name__ == "__main__":
220+
add_model_required_phrases()

0 commit comments

Comments
 (0)