Skip to content

Commit 4ccfe39

Browse files
Add required phrase model training and export
Train the DeBERTa BIOES and CRF model with validated datasets and best-checkpoint selection. Add strict model export, reproducibility metadata, and focused offline tests. Signed-off-by: Kaushik Kumar <kaushikrjpm10@gmail.com>
1 parent 058f439 commit 4ccfe39

7 files changed

Lines changed: 1757 additions & 0 deletions

File tree

etc/requirements-ml.txt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Dependencies for training and exporting the required phrase model.
2+
# Install with: pip install -r etc/requirements-ml.txt
3+
4+
torch>=2.0
5+
transformers>=4.44
6+
sentencepiece>=0.2
7+
protobuf>=3.20
8+
accelerate>=0.33
9+
pytorch-crf>=0.7.2
10+
safetensors>=0.4
11+
12+
# Used explicitly for DeBERTa-large training on a 16 GB GPU.
13+
bitsandbytes>=0.43
14+
15+
# ONNX export and CPU verification.
16+
numpy>=1.24
17+
onnx>=1.16
18+
onnxruntime>=1.18
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (c) nexB Inc. and others. All rights reserved.
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
"""Export a trained required phrase tagger for CPU inference."""
7+
8+
import hashlib
9+
import json
10+
import os
11+
from pathlib import Path
12+
from types import SimpleNamespace
13+
14+
import click
15+
16+
os.environ.setdefault("USE_TF", "0")
17+
18+
19+
def viterbi_decode(emissions, start_transitions, transitions, end_transitions):
20+
"""Return the best tag path for one sequence."""
21+
sequence_length = emissions.shape[0]
22+
score = start_transitions + emissions[0]
23+
backpointers = []
24+
25+
for step in range(1, sequence_length):
26+
candidates = score[:, None] + transitions
27+
best_source = candidates.argmax(axis=0)
28+
score = candidates.max(axis=0) + emissions[step]
29+
backpointers.append(best_source)
30+
31+
score = score + end_transitions
32+
best = int(score.argmax())
33+
path = [best]
34+
for sources in reversed(backpointers):
35+
best = int(sources[best])
36+
path.append(best)
37+
path.reverse()
38+
return path
39+
40+
41+
def sha256(path):
42+
"""Return the hexadecimal SHA256 digest of a file."""
43+
digest = hashlib.sha256()
44+
with open(path, "rb") as stream:
45+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
46+
digest.update(chunk)
47+
return digest.hexdigest()
48+
49+
50+
def build_emissions_module(tagger):
51+
"""Wrap the trained backbone and classifier for ONNX export."""
52+
import torch.nn as nn
53+
54+
class EmissionsModule(nn.Module):
55+
def __init__(self):
56+
super().__init__()
57+
self.backbone = tagger.backbone
58+
self.classifier = tagger.classifier
59+
60+
def forward(self, input_ids, attention_mask):
61+
hidden = self.backbone(
62+
input_ids=input_ids,
63+
attention_mask=attention_mask,
64+
).last_hidden_state
65+
return self.classifier(hidden)
66+
67+
return EmissionsModule().eval()
68+
69+
70+
def load_tagger(model_dir, train_config):
71+
"""Rebuild a tagger and strictly load its saved weights."""
72+
import torch
73+
from safetensors.torch import load_file
74+
75+
from phrase_model import PhraseTagger
76+
77+
config = SimpleNamespace(
78+
model_name=train_config["model_name"],
79+
model_revision=train_config.get("model_revision"),
80+
use_crf=train_config["use_crf"],
81+
aux_ce_weight=0.0,
82+
label_weights=[1.0] * len(train_config["labels"]),
83+
)
84+
tagger = PhraseTagger(config)
85+
86+
model_dir = Path(model_dir)
87+
safetensors_file = model_dir / "model.safetensors"
88+
pytorch_file = model_dir / "pytorch_model.bin"
89+
if safetensors_file.exists():
90+
state = load_file(str(safetensors_file))
91+
elif pytorch_file.exists():
92+
state = torch.load(pytorch_file, map_location="cpu", weights_only=True)
93+
else:
94+
raise FileNotFoundError(f"No model weights found in {model_dir}")
95+
96+
# Checkpoints created before class weights became non-persistent contain
97+
# this training-only tensor.
98+
state.pop("class_weights", None)
99+
for name, tensor in state.items():
100+
if not torch.isfinite(tensor).all():
101+
raise ValueError(f"Checkpoint tensor {name!r} contains non-finite values")
102+
103+
tagger.load_state_dict(state, strict=True)
104+
return tagger.eval()
105+
106+
107+
def check_viterbi_matches_crf(tagger, num_tags):
108+
"""Verify NumPy and pytorch-crf return identical paths."""
109+
import torch
110+
111+
start = tagger.crf.start_transitions.detach().cpu().numpy()
112+
transitions = tagger.crf.transitions.detach().cpu().numpy()
113+
end = tagger.crf.end_transitions.detach().cpu().numpy()
114+
115+
emissions = torch.randn(3, 14, num_tags)
116+
mask = torch.ones(3, 14, dtype=torch.bool)
117+
crf_paths = tagger.crf.decode(emissions, mask=mask)
118+
for row in range(emissions.size(0)):
119+
numpy_path = viterbi_decode(emissions[row].numpy(), start, transitions, end)
120+
if numpy_path != crf_paths[row]:
121+
raise AssertionError("NumPy Viterbi disagrees with pytorch-crf decoding")
122+
123+
return start, transitions, end
124+
125+
126+
def export(model_dir, output_dir, opset):
127+
"""Export ONNX emissions, CRF transitions, and a checksum manifest."""
128+
import numpy as np
129+
import torch
130+
from transformers import AutoTokenizer
131+
132+
model_dir = Path(model_dir)
133+
output_dir = Path(output_dir)
134+
output_dir.mkdir(parents=True, exist_ok=True)
135+
136+
config_path = model_dir / "train_config.json"
137+
if not config_path.exists():
138+
raise FileNotFoundError(f"No train_config.json found in {model_dir}")
139+
train_config = json.loads(config_path.read_text(encoding="utf-8"))
140+
labels = train_config["labels"]
141+
use_crf = train_config["use_crf"]
142+
143+
tagger = load_tagger(model_dir, train_config)
144+
emissions_module = build_emissions_module(tagger)
145+
tokenizer = AutoTokenizer.from_pretrained(str(model_dir), use_fast=True)
146+
147+
sample = tokenizer(
148+
"Licensed under the Apache License Version 2.0",
149+
return_tensors="pt",
150+
)
151+
inputs = sample["input_ids"], sample["attention_mask"]
152+
153+
onnx_path = output_dir / "model.onnx"
154+
torch.onnx.export(
155+
emissions_module,
156+
inputs,
157+
str(onnx_path),
158+
input_names=["input_ids", "attention_mask"],
159+
output_names=["emissions"],
160+
dynamic_axes={
161+
"input_ids": {0: "batch", 1: "sequence"},
162+
"attention_mask": {0: "batch", 1: "sequence"},
163+
"emissions": {0: "batch", 1: "sequence"},
164+
},
165+
opset_version=opset,
166+
do_constant_folding=True,
167+
)
168+
169+
manifest = {
170+
"labels": labels,
171+
"onnx_model": sha256(onnx_path),
172+
}
173+
174+
if use_crf:
175+
start, transitions, end = check_viterbi_matches_crf(tagger, len(labels))
176+
transitions_path = output_dir / "crf_transitions.npz"
177+
np.savez(
178+
transitions_path,
179+
start=start,
180+
transitions=transitions,
181+
end=end,
182+
)
183+
manifest["crf_transitions"] = sha256(transitions_path)
184+
185+
import onnxruntime
186+
187+
session = onnxruntime.InferenceSession(
188+
str(onnx_path),
189+
providers=["CPUExecutionProvider"],
190+
)
191+
feeds = {
192+
"input_ids": sample["input_ids"].numpy(),
193+
"attention_mask": sample["attention_mask"].numpy(),
194+
}
195+
onnx_emissions = session.run(["emissions"], feeds)[0]
196+
torch_emissions = emissions_module(*inputs).detach().numpy()
197+
if not np.allclose(onnx_emissions, torch_emissions, atol=1e-3):
198+
raise AssertionError("ONNX emissions differ from PyTorch emissions")
199+
200+
manifest_path = output_dir / "manifest.json"
201+
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
202+
click.echo(f"wrote {onnx_path}")
203+
if use_crf:
204+
click.echo(f"wrote {output_dir / 'crf_transitions.npz'}")
205+
click.echo(f"wrote {manifest_path}")
206+
207+
208+
@click.command()
209+
@click.option(
210+
"--model-dir",
211+
required=True,
212+
type=click.Path(exists=True, file_okay=False, path_type=Path),
213+
help="Directory containing the trained model and train_config.json.",
214+
)
215+
@click.option(
216+
"--output-dir",
217+
default=None,
218+
type=click.Path(file_okay=False, path_type=Path),
219+
help="Output directory; defaults to the model directory.",
220+
)
221+
@click.option("--opset", default=14, type=int, show_default=True)
222+
def main(model_dir, output_dir, opset):
223+
"""Export a trained required phrase tagger to ONNX."""
224+
try:
225+
export(model_dir, output_dir or model_dir, opset)
226+
except ImportError as error:
227+
raise click.ClickException(
228+
f"{error}; install scancode-required-phrases[training,onnx]"
229+
) from error
230+
231+
232+
if __name__ == "__main__":
233+
main()

0 commit comments

Comments
 (0)