Skip to content

Commit 2017f96

Browse files
Refactor required phrase dataset extraction
Signed-off-by: Kaushik Kumar <kaushikrjpm10@gmail.com>
1 parent ca4e0c6 commit 2017f96

3 files changed

Lines changed: 358 additions & 189 deletions

File tree

etc/scripts/dataset_pipeline/build_dataset.py

Lines changed: 159 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,163 +1,204 @@
1-
# extracts required phrases from .RULE files
2-
# outputs a JSONL dataset for NER model training
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
314
import hashlib
415
import json
5-
import unicodedata
6-
from collections import Counter
716
from pathlib import Path
17+
import unicodedata
18+
819
import click
920

1021
from licensedcode.models import Rule
1122
from licensedcode.models import rules_data_dir as default_rules_data_dir
1223
from licensedcode.required_phrases import get_required_phrase_verbatim
24+
from licensedcode.tokenize import get_existing_required_phrase_spans
1325
from licensedcode.tokenize import required_phrase_splitter
1426

1527

1628
def get_rule_type(rule):
17-
"""Return the is_* flag set on the rule"""
18-
for flag in ('is_license_text', 'is_license_notice', 'is_license_reference',
19-
'is_license_tag', 'is_license_intro', 'is_license_clue',
20-
'is_false_positive'):
21-
if getattr(rule, flag, False):
29+
"""Return the first license rule type set on ``rule``."""
30+
for flag in rule.license_flag_names:
31+
if getattr(rule, flag):
2232
return flag
33+
if rule.is_false_positive:
34+
return 'is_false_positive'
2335
return 'unknown'
2436

2537

2638
def tag_tokens(text):
27-
"""Tag each word token with a BIOES label based on {{ }} markers"""
39+
"""Return rule text tokens and their required phrase BIOES labels."""
2840
tokens = []
2941
labels = []
3042
in_phrase = False
31-
count = 0
43+
phrase_length = 0
3244

33-
for tok in required_phrase_splitter(text):
34-
if tok == '{{':
45+
for token in required_phrase_splitter(text):
46+
if token == '{{':
3547
in_phrase = True
36-
count = 0
48+
phrase_length = 0
3749
continue
38-
if tok == '}}':
39-
if in_phrase and count > 0:
40-
labels[-1] = 'S-REQ' if count == 1 else 'E-REQ'
50+
51+
if token == '}}':
52+
if in_phrase and phrase_length:
53+
labels[-1] = 'S-REQ' if phrase_length == 1 else 'E-REQ'
4154
in_phrase = False
42-
count = 0
55+
phrase_length = 0
4356
continue
44-
tokens.append(tok)
57+
58+
tokens.append(token)
4559
if in_phrase:
46-
labels.append('B-REQ' if count == 0 else 'I-REQ')
47-
count += 1
60+
labels.append('B-REQ' if phrase_length == 0 else 'I-REQ')
61+
phrase_length += 1
4862
else:
4963
labels.append('O')
5064

51-
assert len(tokens) == len(labels), f'token/label mismatch: {len(tokens)} vs {len(labels)}'
5265
return tokens, labels
5366

5467

55-
def assign_splits(results, threshold=50):
56-
"""80/10/10 split by license expression to prevent data leakage.
57-
Expressions with >= threshold rules get split per-rule via hash,
58-
rare ones stay together in one split"""
59-
expr_counts = Counter(e['license_expression'] for e in results)
60-
heavy = {e for e, c in expr_counts.items() if c >= threshold}
68+
def build_record(rule):
69+
"""Return a dataset record for an annotated rule, or None."""
70+
if rule.is_required_phrase or not rule.text:
71+
return
72+
73+
text = rule.text.replace('\r\n', '\n').replace('\r', '\n')
74+
text = unicodedata.normalize('NFKC', text)
75+
76+
# Fail on invalid nested, empty, or dangling required phrase markers.
77+
get_existing_required_phrase_spans(text)
78+
if not any(get_required_phrase_verbatim(text)):
79+
return
80+
81+
tokens, bioes_labels = tag_tokens(text)
82+
return {
83+
'identifier': rule.identifier,
84+
'license_expression': rule.license_expression or '',
85+
'rule_type': get_rule_type(rule),
86+
'text': text.replace('{{', '').replace('}}', ''),
87+
'tokens': tokens,
88+
'bioes_labels': bioes_labels,
89+
}
90+
91+
92+
def split_records(records, common_expression_threshold=50):
93+
"""
94+
Return train, validation, and test records using a hybrid split.
95+
96+
Keep rare license expressions in one split. Distribute records from common
97+
expressions by identifier so each split represents their varied rule text.
98+
"""
99+
expression_counts = Counter(
100+
record['license_expression']
101+
for record in records
102+
)
103+
common_expressions = {
104+
expression
105+
for expression, count in expression_counts.items()
106+
if count >= common_expression_threshold
107+
}
108+
109+
rare_expressions = sorted(
110+
(
111+
expression
112+
for expression in expression_counts
113+
if expression not in common_expressions
114+
),
115+
key=lambda expression: (-expression_counts[expression], expression),
116+
)
117+
rare_record_count = sum(
118+
expression_counts[expression]
119+
for expression in rare_expressions
120+
)
121+
targets = {
122+
'train': 0.8 * rare_record_count,
123+
'val': 0.1 * rare_record_count,
124+
'test': 0.1 * rare_record_count,
125+
}
126+
assigned_counts = {name: 0 for name in targets}
127+
rare_assignments = {}
128+
129+
for expression in rare_expressions:
130+
split = min(
131+
targets,
132+
key=lambda name: assigned_counts[name] / targets[name],
133+
)
134+
rare_assignments[expression] = split
135+
assigned_counts[split] += expression_counts[expression]
136+
137+
splits = {name: [] for name in targets}
138+
for record in records:
139+
expression = record['license_expression']
140+
if expression in common_expressions:
141+
identifier = record['identifier'].encode('utf-8')
142+
bucket = int(hashlib.md5(identifier).hexdigest(), 16) % 100
143+
if bucket < 80:
144+
split = 'train'
145+
elif bucket < 90:
146+
split = 'val'
147+
else:
148+
split = 'test'
149+
else:
150+
split = rare_assignments[expression]
61151

62-
light_exprs = sorted((e for e in expr_counts if e not in heavy),
63-
key=lambda x: (-expr_counts[x], x))
64-
total = sum(expr_counts[e] for e in light_exprs)
65-
targets = {'train': 0.8 * total, 'val': 0.1 * total, 'test': 0.1 * total}
66-
filled = {'train': 0, 'val': 0, 'test': 0}
67-
assignment = {}
68-
for expr in light_exprs:
69-
best = min(targets, key=lambda s: filled[s] / max(targets[s], 1))
70-
assignment[expr] = best
71-
filled[best] += expr_counts[expr]
152+
splits[split].append(record)
72153

73-
return heavy, assignment
154+
return splits
74155

75156

76157
@click.command()
77-
@click.option('--rules-dir', type=click.Path(exists=True), default=None,
78-
help='Path to rules directory (defaults to repo rules dir)')
79-
@click.option('--output-dir', default='dataset-output',
80-
help='Output directory for train/val/test JSONL files')
158+
@click.option(
159+
'--rules-dir',
160+
type=click.Path(exists=True, file_okay=False),
161+
default=None,
162+
help='Path to rules directory (defaults to the ScanCode rules directory).',
163+
)
164+
@click.option(
165+
'--output-dir',
166+
type=click.Path(file_okay=False),
167+
default='dataset-output',
168+
help='Output directory for train, validation, and test JSONL files.',
169+
)
81170
def main(rules_dir, output_dir):
82-
"""Extract required phrases from rule files for NER training"""
83-
if not rules_dir:
84-
repo_rules = Path(__file__).resolve().parents[3] / 'src' / 'licensedcode' / 'data' / 'rules'
85-
rules_dir = str(repo_rules) if repo_rules.is_dir() else default_rules_data_dir
86-
87-
rules_path = Path(rules_dir)
88-
out_dir = Path(output_dir)
89-
out_dir.mkdir(parents=True, exist_ok=True)
90-
91-
total_rules = 0
92-
annotated = 0
93-
results = []
171+
"""Extract marked required phrases into a BIOES training dataset."""
172+
rules_path = Path(rules_dir or default_rules_data_dir)
173+
rule_files = sorted(rules_path.glob('*.RULE'))
174+
records = []
94175

95176
click.echo(f'scanning rules from: {rules_path}')
96-
for rf in sorted(rules_path.glob('*.RULE')):
97-
try:
98-
rule = Rule.from_file(rule_file=str(rf))
99-
except Exception as e:
100-
click.echo(f' skipping {rf.name}: {e}', err=True)
101-
continue
102-
total_rules += 1
103-
104-
if getattr(rule, 'is_required_phrase', False):
105-
continue
106-
107-
text = rule.text or ''
108-
if not text:
109-
continue
110-
111-
# normalize line endings and unicode
112-
text = text.replace('\r\n', '\n').replace('\r', '\n')
113-
text = unicodedata.normalize('NFKC', text)
114-
115-
phrases = list(get_required_phrase_verbatim(text))
116-
if not phrases:
117-
continue
118-
119-
tokens, bioes_labels = tag_tokens(text)
120-
121-
# strip markers for the clean text field
122-
clean_text = text.replace('{{', '').replace('}}', '')
123-
124-
annotated += 1
125-
results.append({
126-
'identifier': rule.identifier,
127-
'license_expression': rule.license_expression or '',
128-
'rule_type': get_rule_type(rule),
129-
'text': clean_text,
130-
'tokens': tokens,
131-
'bioes_labels': bioes_labels,
132-
})
133-
134-
# split by license expression and write
135-
heavy, assignment = assign_splits(results)
136-
splits = {'train': [], 'val': [], 'test': []}
137-
for entry in results:
138-
expr = entry['license_expression']
139-
if expr in heavy:
140-
bucket = int(hashlib.md5(entry['identifier'].encode('utf-8')).hexdigest(), 16) % 100
141-
if bucket < 80:
142-
splits['train'].append(entry)
143-
elif bucket < 90:
144-
splits['val'].append(entry)
145-
else:
146-
splits['test'].append(entry)
147-
else:
148-
splits[assignment[expr]].append(entry)
149-
150-
for name, records in splits.items():
151-
path = out_dir / f'{name}.jsonl'
152-
with open(path, 'w', encoding='utf-8') as f:
153-
for entry in records:
154-
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
177+
for rule_file in rule_files:
178+
rule = Rule.from_file(rule_file=str(rule_file))
179+
record = build_record(rule)
180+
if record:
181+
records.append(record)
182+
183+
splits = split_records(records)
184+
output_path = Path(output_dir)
185+
output_path.mkdir(parents=True, exist_ok=True)
186+
187+
for split_name, records_in_split in splits.items():
188+
split_file = output_path / f'{split_name}.jsonl'
189+
with split_file.open('w', encoding='utf-8') as output:
190+
for record in records_in_split:
191+
output.write(json.dumps(record, ensure_ascii=False) + '\n')
155192

156193
click.echo('\ndone')
157-
click.echo(f' rules scanned: {total_rules}')
158-
click.echo(f' annotated: {annotated}')
159-
click.echo(f' train: {len(splits["train"])} val: {len(splits["val"])} test: {len(splits["test"])}')
160-
click.echo(f' output: {out_dir}')
194+
click.echo(f' rules scanned: {len(rule_files)}')
195+
click.echo(f' annotated: {len(records)}')
196+
click.echo(
197+
f' train: {len(splits["train"])} '
198+
f'val: {len(splits["val"])} '
199+
f'test: {len(splits["test"])}'
200+
)
201+
click.echo(f' output: {output_path}')
161202

162203

163204
if __name__ == '__main__':

etc/scripts/dataset_pipeline/test_build_dataset.py

Lines changed: 0 additions & 71 deletions
This file was deleted.

0 commit comments

Comments
 (0)