|
1 | 1 | # extracts required phrases from .RULE files |
2 | 2 | # outputs a JSONL dataset for NER model training |
| 3 | +import hashlib |
3 | 4 | import json |
4 | 5 | import re |
5 | 6 | import unicodedata |
| 7 | +from collections import Counter |
6 | 8 | from pathlib import Path |
7 | 9 | import click |
8 | 10 |
|
@@ -66,20 +68,41 @@ def tag_tokens(text): |
66 | 68 | return tokens, labels |
67 | 69 |
|
68 | 70 |
|
| 71 | +def assign_splits(results, threshold=50): |
| 72 | + """80/10/10 split. common expressions (>= threshold rules) get split per-rule, |
| 73 | + rare ones stay together in one split""" |
| 74 | + expr_counts = Counter(e['license_expression'] for e in results) |
| 75 | + heavy = {e for e, c in expr_counts.items() if c >= threshold} |
| 76 | + |
| 77 | + # rare expressions: assign each to the split that needs more rules |
| 78 | + light_exprs = sorted((e for e in expr_counts if e not in heavy), |
| 79 | + key=lambda x: (-expr_counts[x], x)) |
| 80 | + total = sum(expr_counts[e] for e in light_exprs) |
| 81 | + targets = {'train': 0.8 * total, 'val': 0.1 * total, 'test': 0.1 * total} |
| 82 | + filled = {'train': 0, 'val': 0, 'test': 0} |
| 83 | + assignment = {} |
| 84 | + for expr in light_exprs: |
| 85 | + best = min(targets, key=lambda s: filled[s] / max(targets[s], 1)) |
| 86 | + assignment[expr] = best |
| 87 | + filled[best] += expr_counts[expr] |
| 88 | + |
| 89 | + return heavy, assignment |
| 90 | + |
| 91 | + |
69 | 92 | @click.command() |
70 | 93 | @click.option('--rules-dir', type=click.Path(exists=True), default=None, |
71 | 94 | help='Path to rules directory (defaults to repo rules dir)') |
72 | | -@click.option('--output', default='dataset-output/required_phrases.jsonl', |
73 | | - help='Output JSONL path') |
74 | | -def main(rules_dir, output): |
| 95 | +@click.option('--output-dir', default='dataset-output', |
| 96 | + help='Output directory for train/val/test JSONL files') |
| 97 | +def main(rules_dir, output_dir): |
75 | 98 | """Extract required phrases from rule files for NER training""" |
76 | 99 | if not rules_dir: |
77 | 100 | repo_rules = Path(__file__).resolve().parents[3] / 'src' / 'licensedcode' / 'data' / 'rules' |
78 | 101 | rules_dir = str(repo_rules) if repo_rules.is_dir() else default_rules_data_dir |
79 | 102 |
|
80 | 103 | rules_path = Path(rules_dir) |
81 | | - out_path = Path(output) |
82 | | - out_path.parent.mkdir(parents=True, exist_ok=True) |
| 104 | + out_dir = Path(output_dir) |
| 105 | + out_dir.mkdir(parents=True, exist_ok=True) |
83 | 106 |
|
84 | 107 | total_rules = 0 |
85 | 108 | annotated = 0 |
@@ -133,21 +156,37 @@ def main(rules_dir, output): |
133 | 156 | 'required_phrases': valid_phrases, |
134 | 157 | }) |
135 | 158 |
|
136 | | - # write jsonl |
137 | | - with open(out_path, 'w', encoding='utf-8') as f: |
138 | | - for entry in results: |
139 | | - f.write(json.dumps(entry, ensure_ascii=False) + '\n') |
| 159 | + # split by license expression and write |
| 160 | + heavy, assignment = assign_splits(results) |
| 161 | + splits = {'train': [], 'val': [], 'test': []} |
| 162 | + for entry in results: |
| 163 | + expr = entry['license_expression'] |
| 164 | + if expr in heavy: |
| 165 | + # common expressions: hash rule name for 80/10/10 |
| 166 | + bucket = int(hashlib.md5(entry['identifier'].encode('utf-8')).hexdigest(), 16) % 100 |
| 167 | + if bucket < 80: |
| 168 | + splits['train'].append(entry) |
| 169 | + elif bucket < 90: |
| 170 | + splits['val'].append(entry) |
| 171 | + else: |
| 172 | + splits['test'].append(entry) |
| 173 | + else: |
| 174 | + splits[assignment[expr]].append(entry) |
| 175 | + |
| 176 | + for name, records in splits.items(): |
| 177 | + path = out_dir / f'{name}.jsonl' |
| 178 | + with open(path, 'w', encoding='utf-8') as f: |
| 179 | + for entry in records: |
| 180 | + f.write(json.dumps(entry, ensure_ascii=False) + '\n') |
140 | 181 |
|
141 | 182 | click.echo('\ndone') |
142 | 183 | click.echo(f' rules scanned: {total_rules}') |
143 | 184 | click.echo(f' annotated: {annotated}') |
144 | 185 | click.echo(f' phrases extracted: {total_phrases}') |
145 | | - click.echo(f' output: {out_path}') |
146 | | - |
| 186 | + click.echo(f' train: {len(splits["train"])} val: {len(splits["val"])} test: {len(splits["test"])}') |
| 187 | + click.echo(f' output: {out_dir}') |
147 | 188 |
|
148 | 189 | # stuff to do(follow up commits): |
149 | | -# train/val/test split by license expression |
150 | | - |
151 | | - |
| 190 | +# tests to be added in script |
152 | 191 | if __name__ == '__main__': |
153 | 192 | main() |
0 commit comments