|
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 |
3 | 14 | import hashlib |
4 | 15 | import json |
5 | | -import unicodedata |
6 | | -from collections import Counter |
7 | 16 | from pathlib import Path |
| 17 | +import unicodedata |
| 18 | + |
8 | 19 | import click |
9 | 20 |
|
10 | 21 | from licensedcode.models import Rule |
11 | 22 | from licensedcode.models import rules_data_dir as default_rules_data_dir |
12 | 23 | from licensedcode.required_phrases import get_required_phrase_verbatim |
| 24 | +from licensedcode.tokenize import get_existing_required_phrase_spans |
13 | 25 | from licensedcode.tokenize import required_phrase_splitter |
14 | 26 |
|
15 | 27 |
|
16 | 28 | 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): |
22 | 32 | return flag |
| 33 | + if rule.is_false_positive: |
| 34 | + return 'is_false_positive' |
23 | 35 | return 'unknown' |
24 | 36 |
|
25 | 37 |
|
26 | 38 | 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.""" |
28 | 40 | tokens = [] |
29 | 41 | labels = [] |
30 | 42 | in_phrase = False |
31 | | - count = 0 |
| 43 | + phrase_length = 0 |
32 | 44 |
|
33 | | - for tok in required_phrase_splitter(text): |
34 | | - if tok == '{{': |
| 45 | + for token in required_phrase_splitter(text): |
| 46 | + if token == '{{': |
35 | 47 | in_phrase = True |
36 | | - count = 0 |
| 48 | + phrase_length = 0 |
37 | 49 | 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' |
41 | 54 | in_phrase = False |
42 | | - count = 0 |
| 55 | + phrase_length = 0 |
43 | 56 | continue |
44 | | - tokens.append(tok) |
| 57 | + |
| 58 | + tokens.append(token) |
45 | 59 | 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 |
48 | 62 | else: |
49 | 63 | labels.append('O') |
50 | 64 |
|
51 | | - assert len(tokens) == len(labels), f'token/label mismatch: {len(tokens)} vs {len(labels)}' |
52 | 65 | return tokens, labels |
53 | 66 |
|
54 | 67 |
|
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] |
61 | 151 |
|
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) |
72 | 153 |
|
73 | | - return heavy, assignment |
| 154 | + return splits |
74 | 155 |
|
75 | 156 |
|
76 | 157 | @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 | +) |
81 | 170 | 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 = [] |
94 | 175 |
|
95 | 176 | 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') |
155 | 192 |
|
156 | 193 | 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}') |
161 | 202 |
|
162 | 203 |
|
163 | 204 | if __name__ == '__main__': |
|
0 commit comments