|
| 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