Skip to content

Commit fa2c593

Browse files
Add required phrase dataset extraction
Build BIOES training records from eligible annotated license rules and split them deterministically with the validated hybrid strategy. Add focused tests for extraction, labeling, exclusions, splitting, and command output. References #5077 Signed-off-by: Kaushik Kumar <kaushikrjpm10@gmail.com>
1 parent 058f439 commit fa2c593

2 files changed

Lines changed: 429 additions & 0 deletions

File tree

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (c) nexB Inc. and others. All rights reserved.
4+
# ScanCode is a trademark of nexB Inc.
5+
# SPDX-License-Identifier: Apache-2.0
6+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
7+
# See https://github.com/nexB/scancode-toolkit for support or download.
8+
# See https://aboutcode.org for more information about nexB OSS projects.
9+
#
10+
11+
"""Build a BIOES dataset from required phrases marked in license rules."""
12+
13+
from collections import Counter
14+
import hashlib
15+
import json
16+
from pathlib import Path
17+
import unicodedata
18+
19+
import click
20+
21+
from licensedcode.models import load_rules
22+
from licensedcode.models import rules_data_dir as default_rules_data_dir
23+
from licensedcode.required_phrases import get_required_phrase_verbatim
24+
from licensedcode.tokenize import get_existing_required_phrase_spans
25+
from licensedcode.tokenize import required_phrase_splitter
26+
27+
28+
def get_rule_type(rule):
29+
"""Return the first license rule type set on ``rule``."""
30+
for flag in rule.license_flag_names:
31+
if getattr(rule, flag):
32+
return flag
33+
if rule.is_false_positive:
34+
return 'is_false_positive'
35+
return 'unknown'
36+
37+
38+
def tag_tokens(text):
39+
"""Return rule text tokens and their required phrase BIOES labels."""
40+
tokens = []
41+
labels = []
42+
in_phrase = False
43+
phrase_length = 0
44+
45+
for token in required_phrase_splitter(text):
46+
if token == '{{':
47+
in_phrase = True
48+
phrase_length = 0
49+
continue
50+
51+
if token == '}}':
52+
if in_phrase and phrase_length:
53+
labels[-1] = 'S-REQ' if phrase_length == 1 else 'E-REQ'
54+
in_phrase = False
55+
phrase_length = 0
56+
continue
57+
58+
tokens.append(token)
59+
if in_phrase:
60+
labels.append('B-REQ' if phrase_length == 0 else 'I-REQ')
61+
phrase_length += 1
62+
else:
63+
labels.append('O')
64+
65+
return tokens, labels
66+
67+
68+
def build_record(rule):
69+
"""Return a dataset record for an eligible annotated rule, or None."""
70+
if (
71+
rule.is_required_phrase
72+
or rule.is_false_positive
73+
or rule.is_license_intro
74+
or rule.is_license_clue
75+
or rule.is_deprecated
76+
or not rule.license_expression
77+
or not rule.text
78+
):
79+
return
80+
81+
text = rule.text.replace('\r\n', '\n').replace('\r', '\n')
82+
text = unicodedata.normalize('NFKC', text)
83+
84+
# Fail on invalid nested, empty, or dangling required phrase markers.
85+
get_existing_required_phrase_spans(text)
86+
if not any(get_required_phrase_verbatim(text)):
87+
return
88+
89+
tokens, bioes_labels = tag_tokens(text)
90+
return {
91+
'identifier': rule.identifier,
92+
'license_expression': rule.license_expression or '',
93+
'rule_type': get_rule_type(rule),
94+
'text': text.replace('{{', '').replace('}}', ''),
95+
'tokens': tokens,
96+
'bioes_labels': bioes_labels,
97+
}
98+
99+
100+
def split_records(records, common_expression_threshold=50):
101+
"""
102+
Return train, validation, and test records using a hybrid split.
103+
104+
Keep rare license expressions in one split. Distribute records from common
105+
expressions by identifier so each split represents their varied rule text.
106+
"""
107+
expression_counts = Counter(
108+
record['license_expression']
109+
for record in records
110+
)
111+
common_expressions = {
112+
expression
113+
for expression, count in expression_counts.items()
114+
if count >= common_expression_threshold
115+
}
116+
117+
rare_expressions = sorted(
118+
(
119+
expression
120+
for expression in expression_counts
121+
if expression not in common_expressions
122+
),
123+
key=lambda expression: (-expression_counts[expression], expression),
124+
)
125+
rare_record_count = sum(
126+
expression_counts[expression]
127+
for expression in rare_expressions
128+
)
129+
targets = {
130+
'train': 0.8 * rare_record_count,
131+
'val': 0.1 * rare_record_count,
132+
'test': 0.1 * rare_record_count,
133+
}
134+
assigned_counts = {name: 0 for name in targets}
135+
rare_assignments = {}
136+
137+
for expression in rare_expressions:
138+
split = min(
139+
targets,
140+
key=lambda name: assigned_counts[name] / targets[name],
141+
)
142+
rare_assignments[expression] = split
143+
assigned_counts[split] += expression_counts[expression]
144+
145+
splits = {name: [] for name in targets}
146+
for record in records:
147+
expression = record['license_expression']
148+
if expression in common_expressions:
149+
identifier = record['identifier'].encode('utf-8')
150+
bucket = int(hashlib.md5(identifier).hexdigest(), 16) % 100
151+
if bucket < 80:
152+
split = 'train'
153+
elif bucket < 90:
154+
split = 'val'
155+
else:
156+
split = 'test'
157+
else:
158+
split = rare_assignments[expression]
159+
160+
splits[split].append(record)
161+
162+
return splits
163+
164+
165+
@click.command()
166+
@click.option(
167+
'--rules-dir',
168+
type=click.Path(exists=True, file_okay=False),
169+
default=None,
170+
help='Path to rules directory (defaults to the ScanCode rules directory).',
171+
)
172+
@click.option(
173+
'--output-dir',
174+
type=click.Path(file_okay=False),
175+
default='dataset-output',
176+
help='Output directory for train, validation, and test JSONL files.',
177+
)
178+
def main(rules_dir, output_dir):
179+
"""Extract marked required phrases into a BIOES training dataset."""
180+
rules_path = Path(rules_dir or default_rules_data_dir)
181+
rule_files = sorted(rules_path.glob('*.RULE'))
182+
records = []
183+
184+
click.echo(f'scanning rules from: {rules_path}')
185+
for rule in load_rules(rules_data_dir=str(rules_path)):
186+
record = build_record(rule)
187+
if record:
188+
records.append(record)
189+
190+
splits = split_records(records)
191+
output_path = Path(output_dir)
192+
output_path.mkdir(parents=True, exist_ok=True)
193+
194+
for split_name, records_in_split in splits.items():
195+
split_file = output_path / f'{split_name}.jsonl'
196+
with split_file.open('w', encoding='utf-8') as output:
197+
for record in records_in_split:
198+
output.write(json.dumps(record, ensure_ascii=False) + '\n')
199+
200+
click.echo('\ndone')
201+
click.echo(f' rules scanned: {len(rule_files)}')
202+
click.echo(f' annotated: {len(records)}')
203+
click.echo(
204+
f' train: {len(splits["train"])} '
205+
f'val: {len(splits["val"])} '
206+
f'test: {len(splits["test"])}'
207+
)
208+
click.echo(f' output: {output_path}')
209+
210+
211+
if __name__ == '__main__':
212+
main()

0 commit comments

Comments
 (0)