Isa0 commited on
Commit
57700c9
·
1 Parent(s): e74af81

Add PyTorch training pipeline with ONNX/INT8 export and inference script

Browse files
Files changed (5) hide show
  1. hatespeech.py +267 -0
  2. pyproject.toml +15 -0
  3. requirements.txt +335 -0
  4. train.py +457 -0
  5. uv.lock +0 -0
hatespeech.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ hatespeech.py - ONNX Runtime Inference & Examples
4
+ Performs fast inference on text using exported ONNX (Raw or INT8 Quantized) models.
5
+
6
+ Key Features:
7
+ - Runs via ONNX Runtime (CPU or CUDA).
8
+ - Supports both raw `hatespeech.onnx` and quantized `hatespeech_int8.onnx`.
9
+ - Preprocesses input text and tokenizes inputs.
10
+ - Outputs predicted class, confidence scores across all classes, and latency in milliseconds.
11
+ - Provides interactive mode, CLI text argument, and built-in benchmark examples.
12
+ """
13
+
14
+ import os
15
+ import sys
16
+ import re
17
+ import html
18
+ import time
19
+ import argparse
20
+ from typing import List, Dict, Union, Any
21
+
22
+ import numpy as np
23
+ import onnxruntime as ort
24
+ from transformers import AutoTokenizer
25
+
26
+ LABEL_NAMES = {
27
+ 0: "Hate Speech",
28
+ 1: "Offensive Language",
29
+ 2: "Neither",
30
+ }
31
+
32
+
33
+ def clean_text(text: str) -> str:
34
+ """Preprocesses input text matching the training pipeline."""
35
+ if not isinstance(text, str):
36
+ return ""
37
+ text = html.unescape(text)
38
+ text = re.sub(r"https?://\S+|www\.\S+", "", text)
39
+ text = re.sub(r"@\w+", "", text)
40
+ text = re.sub(r"\s+", " ", text).strip()
41
+ return text
42
+
43
+
44
+ def softmax(x: np.ndarray) -> np.ndarray:
45
+ """Computes softmax over logits."""
46
+ e_x = np.exp(x - np.max(x, axis=-1, keepdims=True))
47
+ return e_x / e_x.sum(axis=-1, keepdims=True)
48
+
49
+
50
+ class HateSpeechDetector:
51
+ """
52
+ Hate Speech & Offensive Language Detector powered by ONNX Runtime.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ model_path: str = "./model/hatespeech_int8.onnx",
58
+ tokenizer_name_or_path: str = "./model",
59
+ fallback_model_name: str = "distilbert-base-uncased",
60
+ max_length: int = 128,
61
+ use_gpu: bool = False,
62
+ ):
63
+ if not os.path.exists(model_path):
64
+ raise FileNotFoundError(
65
+ f"Model file not found at: '{model_path}'.\n"
66
+ f"Please train the model first by running `python train.py`."
67
+ )
68
+
69
+ print(f"Loading ONNX model from: {model_path}")
70
+ providers = ["CUDAExecutionProvider", "CPUExecutionProvider"] if use_gpu else ["CPUExecutionProvider"]
71
+
72
+ # Configure ONNX runtime session
73
+ sess_options = ort.SessionOptions()
74
+ sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
75
+ self.session = ort.InferenceSession(model_path, sess_options=sess_options, providers=providers)
76
+
77
+ # Load tokenizer (first check local model dir, then fallback to HF hub)
78
+ if os.path.exists(os.path.join(tokenizer_name_or_path, "tokenizer_config.json")):
79
+ print(f"Loading tokenizer from local path: {tokenizer_name_or_path}")
80
+ self.tokenizer = AutoTokenizer.from_pretrained(tokenizer_name_or_path)
81
+ else:
82
+ print(f"Local tokenizer not found. Loading fallback: {fallback_model_name}")
83
+ self.tokenizer = AutoTokenizer.from_pretrained(fallback_model_name)
84
+
85
+ self.max_length = max_length
86
+ self.model_path = model_path
87
+ self.file_size_mb = os.path.getsize(model_path) / (1024 * 1024)
88
+
89
+ def predict(self, text: Union[str, List[str]]) -> List[Dict[str, Any]]:
90
+ """
91
+ Runs inference on a single string or a list of strings.
92
+ Returns prediction details including class label, confidence, and latency.
93
+ """
94
+ is_single = isinstance(text, str)
95
+ raw_texts = [text] if is_single else text
96
+ cleaned_texts = [clean_text(t) for t in raw_texts]
97
+
98
+ start_time = time.perf_counter()
99
+
100
+ # Tokenize
101
+ encoded = self.tokenizer(
102
+ cleaned_texts,
103
+ padding=True,
104
+ truncation=True,
105
+ max_length=self.max_length,
106
+ return_tensors="np",
107
+ )
108
+
109
+ ort_inputs = {
110
+ "input_ids": encoded["input_ids"].astype(np.int64),
111
+ "attention_mask": encoded["attention_mask"].astype(np.int64),
112
+ }
113
+
114
+ # Run ONNX inference
115
+ ort_outputs = self.session.run(None, ort_inputs)
116
+ logits = ort_outputs[0]
117
+ probs = softmax(logits)
118
+
119
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
120
+ latency_per_sample = elapsed_ms / max(len(raw_texts), 1)
121
+
122
+ results = []
123
+ for i, original_text in enumerate(raw_texts):
124
+ pred_class_id = int(np.argmax(probs[i]))
125
+ class_probs = {
126
+ LABEL_NAMES[c]: float(probs[i][c]) for c in range(3)
127
+ }
128
+ results.append({
129
+ "text": original_text,
130
+ "cleaned_text": cleaned_texts[i],
131
+ "label_id": pred_class_id,
132
+ "label_name": LABEL_NAMES[pred_class_id],
133
+ "confidence": float(probs[i][pred_class_id]),
134
+ "probabilities": class_probs,
135
+ "latency_ms": latency_per_sample,
136
+ })
137
+
138
+ return results
139
+
140
+
141
+ def run_examples(detector: HateSpeechDetector):
142
+ """Runs a series of representative test cases and prints formatted output."""
143
+ examples = [
144
+ "I really love this community, everyone is so supportive and kind!",
145
+ "Can you please send me the report by tomorrow morning?",
146
+ "Shut the fuck up, you are being so damn annoying tonight.",
147
+ "That bitch is crazy, get the hell out of here.",
148
+ "Kill all members of that group, they don't deserve to exist in our country.",
149
+ ]
150
+
151
+ print("\n" + "=" * 75)
152
+ print(" RUNNING BENCHMARK EXAMPLES")
153
+ print(f" Model: {detector.model_path} ({detector.file_size_mb:.2f} MB)")
154
+ print("=" * 75)
155
+
156
+ results = detector.predict(examples)
157
+ for i, res in enumerate(results, 1):
158
+ print(f"\n[Example {i}]")
159
+ print(f" Input : \"{res['text']}\"")
160
+ print(f" Prediction : {res['label_name']} (Class {res['label_id']})")
161
+ print(f" Confidence : {res['confidence'] * 100:.2f}%")
162
+ print(f" Latency : {res['latency_ms']:.2f} ms")
163
+ print(" Breakdown :")
164
+ for label, prob in res["probabilities"].items():
165
+ bar = "#" * int(prob * 20)
166
+ print(f" - {label:<20}: {prob * 100:>5.1f}% | {bar}")
167
+
168
+ print("\n" + "=" * 75)
169
+
170
+
171
+ def main():
172
+ parser = argparse.ArgumentParser(description="Hate Speech Detection with ONNX Runtime.")
173
+ parser.add_argument(
174
+ "--model",
175
+ type=str,
176
+ default=None,
177
+ help="Path to ONNX model file. If not set, automatically prefers INT8 or raw ONNX in ./model",
178
+ )
179
+ parser.add_argument(
180
+ "--raw",
181
+ action="store_true",
182
+ help="Use raw ONNX model (./model/hatespeech.onnx) instead of INT8 quantized model",
183
+ )
184
+ parser.add_argument(
185
+ "--text",
186
+ type=str,
187
+ default=None,
188
+ help="Specific text to classify via CLI",
189
+ )
190
+ parser.add_argument(
191
+ "--interactive",
192
+ action="store_true",
193
+ help="Start interactive prompt loop to test custom sentences",
194
+ )
195
+ parser.add_argument(
196
+ "--gpu",
197
+ action="store_true",
198
+ help="Use CUDA Execution Provider if available",
199
+ )
200
+ args = parser.parse_args()
201
+
202
+ # Determine model path
203
+ if args.model:
204
+ model_path = args.model
205
+ elif args.raw:
206
+ model_path = "./model/hatespeech.onnx"
207
+ else:
208
+ # Default to INT8 if present, else fallback to raw
209
+ if os.path.exists("./model/hatespeech_int8.onnx"):
210
+ model_path = "./model/hatespeech_int8.onnx"
211
+ else:
212
+ model_path = "./model/hatespeech.onnx"
213
+
214
+ try:
215
+ detector = HateSpeechDetector(
216
+ model_path=model_path,
217
+ tokenizer_name_or_path="./model",
218
+ use_gpu=args.gpu,
219
+ )
220
+ except FileNotFoundError as e:
221
+ print(f"\nError: {e}")
222
+ sys.exit(1)
223
+
224
+ # 1. Single text prediction via CLI
225
+ if args.text:
226
+ res = detector.predict(args.text)[0]
227
+ print("\n" + "=" * 50)
228
+ print(f"Text : \"{res['text']}\"")
229
+ print(f"Prediction : {res['label_name']} (Class {res['label_id']})")
230
+ print(f"Confidence : {res['confidence'] * 100:.2f}%")
231
+ print(f"Latency : {res['latency_ms']:.2f} ms")
232
+ print("Probabilities:")
233
+ for label, prob in res["probabilities"].items():
234
+ print(f" {label:<20}: {prob * 100:.1f}%")
235
+ print("=" * 50)
236
+ return
237
+
238
+ # 2. Interactive prompt loop
239
+ if args.interactive:
240
+ print("\n" + "=" * 60)
241
+ print(" INTERACTIVE HATE SPEECH DETECTION (Type 'exit' to quit)")
242
+ print(f" Model: {model_path}")
243
+ print("=" * 60)
244
+ while True:
245
+ try:
246
+ user_input = input("\nEnter text: ").strip()
247
+ if not user_input:
248
+ continue
249
+ if user_input.lower() in ("exit", "quit", "q"):
250
+ print("Exiting.")
251
+ break
252
+
253
+ res = detector.predict(user_input)[0]
254
+ print(f" -> Result : {res['label_name']} ({res['confidence'] * 100:.1f}%)")
255
+ print(f" -> Latency : {res['latency_ms']:.2f} ms")
256
+ print(f" -> Breakdown : " + ", ".join([f"{k}: {v*100:.1f}%" for k, v in res['probabilities'].items()]))
257
+ except (KeyboardInterrupt, EOFError):
258
+ print("\nExiting.")
259
+ break
260
+ return
261
+
262
+ # 3. Default: Run benchmark examples
263
+ run_examples(detector)
264
+
265
+
266
+ if __name__ == "__main__":
267
+ main()
pyproject.toml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "hatespeech"
3
+ version = "0.1.0"
4
+ description = "Hate speech and offensive language classifier with ONNX & 8q export"
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ dependencies = [
8
+ "torch>=2.0.0",
9
+ "transformers>=4.30.0",
10
+ "scikit-learn>=1.2.0",
11
+ "pandas>=2.0.0",
12
+ "numpy>=1.24.0",
13
+ "onnx>=1.14.0",
14
+ "onnxruntime>=1.15.0",
15
+ ]
requirements.txt ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This file was autogenerated by uv via the following command:
2
+ # uv export --no-hashes --output-file requirements.txt --no-emit-project
3
+ annotated-doc==0.0.5 ; python_full_version >= '3.10'
4
+ # via typer
5
+ anyio==4.15.0 ; python_full_version >= '3.10'
6
+ # via httpx
7
+ certifi==2026.7.22
8
+ # via
9
+ # httpcore
10
+ # httpx
11
+ # requests
12
+ charset-normalizer==3.5.1 ; python_full_version < '3.10'
13
+ # via requests
14
+ click==8.5.0 ; python_full_version >= '3.10'
15
+ # via huggingface-hub
16
+ cloudpickle==3.1.2 ; python_full_version >= '3.10'
17
+ # via joblib
18
+ colorama==0.4.6 ; sys_platform == 'win32'
19
+ # via
20
+ # tqdm
21
+ # typer
22
+ coloredlogs==15.0.1 ; python_full_version < '3.10'
23
+ # via onnxruntime
24
+ cuda-bindings==13.3.1 ; python_full_version >= '3.10' and python_full_version < '3.15' and sys_platform == 'linux'
25
+ # via torch
26
+ cuda-pathfinder==1.8.1 ; python_full_version >= '3.10' and python_full_version < '3.15' and sys_platform == 'linux'
27
+ # via cuda-bindings
28
+ cuda-toolkit==13.0.3.0 ; python_full_version >= '3.10' and sys_platform == 'linux'
29
+ # via torch
30
+ exceptiongroup==1.3.1 ; python_full_version == '3.10.*'
31
+ # via anyio
32
+ filelock==3.19.1 ; python_full_version < '3.10'
33
+ # via
34
+ # huggingface-hub
35
+ # torch
36
+ # transformers
37
+ filelock==3.32.5 ; python_full_version >= '3.10'
38
+ # via
39
+ # huggingface-hub
40
+ # torch
41
+ flatbuffers==25.12.19
42
+ # via onnxruntime
43
+ fsspec==2025.10.0 ; python_full_version < '3.10'
44
+ # via
45
+ # huggingface-hub
46
+ # torch
47
+ fsspec==2026.7.0 ; python_full_version >= '3.10'
48
+ # via
49
+ # huggingface-hub
50
+ # torch
51
+ h11==0.16.0 ; python_full_version >= '3.10'
52
+ # via httpcore
53
+ hf-xet==1.6.0 ; (python_full_version >= '3.10' and platform_machine == 'AMD64') or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'
54
+ # via huggingface-hub
55
+ httpcore==1.0.9 ; python_full_version >= '3.10'
56
+ # via httpx
57
+ httpx==0.28.1 ; python_full_version >= '3.10'
58
+ # via huggingface-hub
59
+ huggingface-hub==0.36.2 ; python_full_version < '3.10'
60
+ # via
61
+ # tokenizers
62
+ # transformers
63
+ huggingface-hub==1.30.0 ; python_full_version >= '3.10'
64
+ # via
65
+ # tokenizers
66
+ # transformers
67
+ humanfriendly==10.0 ; python_full_version < '3.10'
68
+ # via coloredlogs
69
+ idna==3.19
70
+ # via
71
+ # anyio
72
+ # httpx
73
+ # requests
74
+ importlib-metadata==8.7.1 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
75
+ # via triton
76
+ jinja2==3.1.6
77
+ # via torch
78
+ joblib==1.5.3 ; python_full_version < '3.10'
79
+ # via scikit-learn
80
+ joblib==1.6.0 ; python_full_version >= '3.10'
81
+ # via scikit-learn
82
+ markdown-it-py==4.2.0 ; python_full_version >= '3.10'
83
+ # via rich
84
+ markupsafe==3.0.3
85
+ # via jinja2
86
+ mdurl==0.1.2 ; python_full_version >= '3.10'
87
+ # via markdown-it-py
88
+ ml-dtypes==0.5.4 ; python_full_version < '3.10'
89
+ # via onnx
90
+ ml-dtypes==0.6.0 ; python_full_version >= '3.10'
91
+ # via onnx
92
+ mpmath==1.3.0
93
+ # via sympy
94
+ narwhals==2.25.0 ; python_full_version >= '3.11'
95
+ # via scikit-learn
96
+ networkx==3.2.1 ; python_full_version < '3.10'
97
+ # via torch
98
+ networkx==3.4.2 ; python_full_version == '3.10.*'
99
+ # via torch
100
+ networkx==3.6.1 ; python_full_version >= '3.11'
101
+ # via torch
102
+ numpy==2.0.2 ; python_full_version < '3.10'
103
+ # via
104
+ # hatespeech
105
+ # ml-dtypes
106
+ # onnx
107
+ # onnxruntime
108
+ # pandas
109
+ # scikit-learn
110
+ # scipy
111
+ # transformers
112
+ numpy==2.2.6 ; python_full_version == '3.10.*'
113
+ # via
114
+ # hatespeech
115
+ # ml-dtypes
116
+ # onnx
117
+ # onnxruntime
118
+ # pandas
119
+ # scikit-learn
120
+ # scipy
121
+ # transformers
122
+ numpy==2.4.6 ; python_full_version == '3.11.*'
123
+ # via
124
+ # hatespeech
125
+ # ml-dtypes
126
+ # onnx
127
+ # onnxruntime
128
+ # pandas
129
+ # scikit-learn
130
+ # scipy
131
+ # transformers
132
+ numpy==2.5.2 ; python_full_version >= '3.12'
133
+ # via
134
+ # hatespeech
135
+ # ml-dtypes
136
+ # onnx
137
+ # onnxruntime
138
+ # pandas
139
+ # scikit-learn
140
+ # scipy
141
+ # transformers
142
+ nvidia-cublas==13.1.1.3 ; python_full_version >= '3.10' and sys_platform == 'linux'
143
+ # via
144
+ # cuda-toolkit
145
+ # nvidia-cudnn-cu13
146
+ # nvidia-cusolver
147
+ nvidia-cublas-cu12==12.8.4.1 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
148
+ # via
149
+ # nvidia-cudnn-cu12
150
+ # nvidia-cusolver-cu12
151
+ # torch
152
+ nvidia-cuda-cupti==13.0.85 ; (python_full_version >= '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux')
153
+ # via cuda-toolkit
154
+ nvidia-cuda-cupti-cu12==12.8.90 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
155
+ # via torch
156
+ nvidia-cuda-nvrtc==13.0.88 ; python_full_version >= '3.10' and sys_platform == 'linux'
157
+ # via
158
+ # cuda-toolkit
159
+ # nvidia-cublas
160
+ nvidia-cuda-nvrtc-cu12==12.8.93 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
161
+ # via torch
162
+ nvidia-cuda-runtime==13.0.96 ; (python_full_version >= '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux')
163
+ # via cuda-toolkit
164
+ nvidia-cuda-runtime-cu12==12.8.90 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
165
+ # via torch
166
+ nvidia-cudnn-cu12==9.10.2.21 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
167
+ # via torch
168
+ nvidia-cudnn-cu13==9.24.0.43 ; python_full_version >= '3.10' and sys_platform == 'linux'
169
+ # via torch
170
+ nvidia-cufft==12.0.0.61 ; (python_full_version >= '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux')
171
+ # via cuda-toolkit
172
+ nvidia-cufft-cu12==11.3.3.83 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
173
+ # via torch
174
+ nvidia-cufile==1.15.1.6 ; (python_full_version >= '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux')
175
+ # via cuda-toolkit
176
+ nvidia-cufile-cu12==1.13.1.3 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
177
+ # via torch
178
+ nvidia-curand==10.4.0.35 ; (python_full_version >= '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux')
179
+ # via cuda-toolkit
180
+ nvidia-curand-cu12==10.3.9.90 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
181
+ # via torch
182
+ nvidia-cusolver==12.0.4.66 ; (python_full_version >= '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux')
183
+ # via cuda-toolkit
184
+ nvidia-cusolver-cu12==11.7.3.90 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
185
+ # via torch
186
+ nvidia-cusparse==12.6.3.3 ; (python_full_version >= '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux')
187
+ # via
188
+ # cuda-toolkit
189
+ # nvidia-cusolver
190
+ nvidia-cusparse-cu12==12.5.8.93 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
191
+ # via
192
+ # nvidia-cusolver-cu12
193
+ # torch
194
+ nvidia-cusparselt-cu12==0.7.1 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
195
+ # via torch
196
+ nvidia-cusparselt-cu13==0.8.1 ; python_full_version >= '3.10' and sys_platform == 'linux'
197
+ # via torch
198
+ nvidia-nccl-cu12==2.27.3 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
199
+ # via torch
200
+ nvidia-nccl-cu13==2.30.7 ; python_full_version >= '3.10' and sys_platform == 'linux'
201
+ # via torch
202
+ nvidia-nvjitlink==13.3.33 ; (python_full_version >= '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux')
203
+ # via
204
+ # cuda-toolkit
205
+ # nvidia-cufft
206
+ # nvidia-cusolver
207
+ # nvidia-cusparse
208
+ nvidia-nvjitlink-cu12==12.8.93 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
209
+ # via
210
+ # nvidia-cufft-cu12
211
+ # nvidia-cusolver-cu12
212
+ # nvidia-cusparse-cu12
213
+ # torch
214
+ nvidia-nvshmem-cu13==3.4.5 ; python_full_version >= '3.10' and sys_platform == 'linux'
215
+ # via torch
216
+ nvidia-nvtx==13.0.85 ; (python_full_version >= '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux')
217
+ # via cuda-toolkit
218
+ nvidia-nvtx-cu12==12.8.90 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
219
+ # via torch
220
+ onnx==1.19.1 ; python_full_version < '3.10'
221
+ # via hatespeech
222
+ onnx==1.22.0 ; python_full_version >= '3.10'
223
+ # via hatespeech
224
+ onnxruntime==1.20.1 ; python_full_version < '3.10'
225
+ # via hatespeech
226
+ onnxruntime==1.24.3 ; python_full_version == '3.10.*'
227
+ # via hatespeech
228
+ onnxruntime==1.29.0 ; python_full_version >= '3.11'
229
+ # via hatespeech
230
+ packaging==26.3
231
+ # via
232
+ # huggingface-hub
233
+ # onnxruntime
234
+ # transformers
235
+ pandas==2.3.3 ; python_full_version < '3.11'
236
+ # via hatespeech
237
+ pandas==3.0.5 ; python_full_version >= '3.11'
238
+ # via hatespeech
239
+ protobuf==6.33.6 ; python_full_version < '3.10'
240
+ # via
241
+ # onnx
242
+ # onnxruntime
243
+ protobuf==7.36.1 ; python_full_version >= '3.10'
244
+ # via
245
+ # onnx
246
+ # onnxruntime
247
+ pygments==2.21.0 ; python_full_version >= '3.10'
248
+ # via rich
249
+ pyreadline3==3.5.6 ; python_full_version < '3.10' and sys_platform == 'win32'
250
+ # via humanfriendly
251
+ python-dateutil==2.9.0.post0
252
+ # via pandas
253
+ pytz==2026.3.post1 ; python_full_version < '3.11'
254
+ # via pandas
255
+ pyyaml==6.0.3
256
+ # via
257
+ # huggingface-hub
258
+ # transformers
259
+ regex==2026.1.15 ; python_full_version < '3.10'
260
+ # via transformers
261
+ regex==2026.9.3 ; python_full_version >= '3.10'
262
+ # via transformers
263
+ requests==2.32.5 ; python_full_version < '3.10'
264
+ # via
265
+ # huggingface-hub
266
+ # transformers
267
+ rich==15.0.0 ; python_full_version >= '3.10'
268
+ # via typer
269
+ safetensors==0.7.0 ; python_full_version < '3.10'
270
+ # via transformers
271
+ safetensors==0.8.0 ; python_full_version >= '3.10'
272
+ # via transformers
273
+ scikit-learn==1.6.1 ; python_full_version < '3.10'
274
+ # via hatespeech
275
+ scikit-learn==1.7.2 ; python_full_version == '3.10.*'
276
+ # via hatespeech
277
+ scikit-learn==1.9.0 ; python_full_version >= '3.11'
278
+ # via hatespeech
279
+ scipy==1.13.1 ; python_full_version < '3.10'
280
+ # via scikit-learn
281
+ scipy==1.15.3 ; python_full_version == '3.10.*'
282
+ # via scikit-learn
283
+ scipy==1.17.1 ; python_full_version == '3.11.*'
284
+ # via scikit-learn
285
+ scipy==1.18.1 ; python_full_version >= '3.12'
286
+ # via scikit-learn
287
+ setuptools==82.0.1 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
288
+ # via triton
289
+ setuptools==84.0.0 ; python_full_version >= '3.10'
290
+ # via torch
291
+ shellingham==1.5.4 ; python_full_version >= '3.10'
292
+ # via typer
293
+ six==1.17.0
294
+ # via python-dateutil
295
+ sympy==1.14.0
296
+ # via
297
+ # onnxruntime
298
+ # torch
299
+ threadpoolctl==3.6.0
300
+ # via scikit-learn
301
+ tokenizers==0.22.2 ; python_full_version < '3.10'
302
+ # via transformers
303
+ tokenizers==0.23.2 ; python_full_version >= '3.10'
304
+ # via transformers
305
+ torch==2.8.0 ; python_full_version < '3.10'
306
+ # via hatespeech
307
+ torch==2.14.0 ; python_full_version >= '3.10'
308
+ # via hatespeech
309
+ tqdm==4.70.0
310
+ # via
311
+ # huggingface-hub
312
+ # transformers
313
+ transformers==4.57.6 ; python_full_version < '3.10'
314
+ # via hatespeech
315
+ transformers==5.16.1 ; python_full_version >= '3.10'
316
+ # via hatespeech
317
+ triton==3.4.0 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
318
+ # via torch
319
+ triton==3.8.0 ; python_full_version >= '3.10' and python_full_version < '3.15' and sys_platform == 'linux'
320
+ # via torch
321
+ typer==0.27.2 ; python_full_version >= '3.10'
322
+ # via transformers
323
+ typing-extensions==4.16.0
324
+ # via
325
+ # anyio
326
+ # exceptiongroup
327
+ # huggingface-hub
328
+ # onnx
329
+ # torch
330
+ tzdata==2026.3 ; python_full_version < '3.11' or sys_platform == 'emscripten' or sys_platform == 'win32'
331
+ # via pandas
332
+ urllib3==2.6.3 ; python_full_version < '3.10'
333
+ # via requests
334
+ zipp==3.23.1 ; python_full_version < '3.10' and platform_machine == 'x86_64' and sys_platform == 'linux'
335
+ # via importlib-metadata
train.py ADDED
@@ -0,0 +1,457 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ train.py - Hate Speech & Offensive Language Classifier
4
+ Trains a PyTorch transformer model on Davidson et al. labeled_data.csv.
5
+ Exports both raw ONNX and INT8 quantized (8q) ONNX models.
6
+
7
+ Key Features:
8
+ - Downloads dataset to a temporary directory and cleans it up after loading.
9
+ - Preprocessing: unescapes HTML, strips URLs and Twitter mentions.
10
+ - Prevents Overfitting & Underfitting:
11
+ * Stratified 80/10/10 train/val/test split.
12
+ * Balanced class weights for CrossEntropyLoss (combats ~5% hate speech imbalance).
13
+ * Weight decay (AdamW) + Linear learning rate warmup & decay.
14
+ * Early stopping tracking validation Macro F1 score.
15
+ - Epoch Optimization:
16
+ * Sequence length truncated to 128 (optimal for tweets, fast epochs).
17
+ * PyTorch Automatic Mixed Precision (AMP / FP16) on CUDA.
18
+ - Model Exports:
19
+ * Raw ONNX (FP32)
20
+ * Quantized INT8 ONNX (dynamic quantization for ~75% size reduction & fast inference)
21
+ * Tokenizer files saved alongside ONNX models for standalone portability.
22
+ """
23
+
24
+ import os
25
+ import sys
26
+ import re
27
+ import html
28
+ import time
29
+ import shutil
30
+ import tempfile
31
+ import urllib.request
32
+ import argparse
33
+ from typing import Tuple, Dict, Any
34
+
35
+ import numpy as np
36
+ import pandas as pd
37
+ import torch
38
+ import torch.nn as nn
39
+ from torch.utils.data import Dataset, DataLoader
40
+ from torch.optim import AdamW
41
+ from transformers import (
42
+ AutoTokenizer,
43
+ AutoModelForSequenceClassification,
44
+ get_linear_schedule_with_warmup,
45
+ )
46
+ from sklearn.model_selection import train_test_split
47
+ from sklearn.metrics import classification_report, f1_score, accuracy_score
48
+ import onnx
49
+ import onnxruntime
50
+ from onnxruntime.quantization import quantize_dynamic, QuantType
51
+
52
+ # Label mappings
53
+ LABEL_NAMES = {
54
+ 0: "Hate Speech",
55
+ 1: "Offensive Language",
56
+ 2: "Neither",
57
+ }
58
+
59
+ DATASET_URL = (
60
+ "https://raw.githubusercontent.com/t-davidson/hate-speech-and-offensive-language/"
61
+ "master/data/labeled_data.csv"
62
+ )
63
+
64
+
65
+ def clean_text(text: str) -> str:
66
+ """Cleans tweet text by unescaping HTML, normalizing handles, and stripping URLs."""
67
+ if not isinstance(text, str):
68
+ return ""
69
+ # Decode HTML entities (e.g., &amp; -> &, &lt; -> <)
70
+ text = html.unescape(text)
71
+ # Remove URLs
72
+ text = re.sub(r"https?://\S+|www\.\S+", "", text)
73
+ # Remove user mentions
74
+ text = re.sub(r"@\w+", "", text)
75
+ # Normalize excessive whitespaces
76
+ text = re.sub(r"\s+", " ", text).strip()
77
+ return text
78
+
79
+
80
+ def download_dataset_temp(url: str = DATASET_URL) -> pd.DataFrame:
81
+ """
82
+ Downloads labeled_data.csv into a temporary directory, loads it into pandas,
83
+ and cleans up the temporary directory immediately after.
84
+ """
85
+ print(f"\n[1/6] Fetching dataset from: {url}")
86
+ with tempfile.TemporaryDirectory() as temp_dir:
87
+ temp_file_path = os.path.join(temp_dir, "labeled_data.csv")
88
+ print(f" -> Temporary download location: {temp_file_path}")
89
+
90
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
91
+ with urllib.request.urlopen(req) as response, open(temp_file_path, "wb") as out_file:
92
+ shutil.copyfileobj(response, out_file)
93
+
94
+ file_size_mb = os.path.getsize(temp_file_path) / (1024 * 1024)
95
+ print(f" -> Download completed successfully ({file_size_mb:.2f} MB).")
96
+
97
+ df = pd.read_csv(temp_file_path)
98
+ # Required columns: 'class' (0, 1, 2) and 'tweet'
99
+ df = df[["class", "tweet"]].dropna()
100
+ df["tweet"] = df["tweet"].apply(clean_text)
101
+ df = df[df["tweet"].str.strip() != ""].reset_index(drop=True)
102
+
103
+ print(f" -> Loaded {len(df):,} valid samples.")
104
+ class_counts = df["class"].value_counts().sort_index()
105
+ for cls_id, count in class_counts.items():
106
+ pct = count / len(df) * 100
107
+ print(f" Class {cls_id} ({LABEL_NAMES.get(cls_id, 'Unknown')}): {count:,} ({pct:.1f}%)")
108
+
109
+ print(" -> Temporary directory and file automatically cleaned up.")
110
+ return df
111
+
112
+
113
+ class HateSpeechDataset(Dataset):
114
+ """PyTorch Dataset for tokenized hate speech data."""
115
+
116
+ def __init__(self, texts, labels, tokenizer, max_length: int = 128):
117
+ self.encodings = tokenizer(
118
+ texts,
119
+ truncation=True,
120
+ padding=True,
121
+ max_length=max_length,
122
+ return_tensors="pt",
123
+ )
124
+ self.labels = torch.tensor(labels, dtype=torch.long)
125
+
126
+ def __len__(self):
127
+ return len(self.labels)
128
+
129
+ def __getitem__(self, idx):
130
+ item = {key: val[idx] for key, val in self.encodings.items()}
131
+ item["labels"] = self.labels[idx]
132
+ return item
133
+
134
+
135
+ def compute_class_weights(labels: np.ndarray, num_classes: int = 3) -> torch.Tensor:
136
+ """
137
+ Computes balanced class weights inversely proportional to class frequencies.
138
+ Crucial to prevent under-learning the minority Hate Speech class (~5.7%).
139
+ """
140
+ counts = np.bincount(labels, minlength=num_classes)
141
+ total_samples = len(labels)
142
+ # Balanced weighting: total / (num_classes * count)
143
+ weights = total_samples / (num_classes * counts.astype(np.float32))
144
+ # Normalize so mean weight is 1.0 to preserve learning rate stability
145
+ weights = weights / np.mean(weights)
146
+ return torch.tensor(weights, dtype=torch.float)
147
+
148
+
149
+ def train_epoch(
150
+ model: nn.Module,
151
+ dataloader: DataLoader,
152
+ optimizer: optim.Optimizer,
153
+ scheduler: Any,
154
+ scaler: torch.amp.GradScaler,
155
+ criterion: nn.Module,
156
+ device: torch.device,
157
+ ) -> float:
158
+ """Trains the model for 1 epoch with optional AMP mixed-precision."""
159
+ model.train()
160
+ total_loss = 0.0
161
+ use_amp = (device.type == "cuda")
162
+
163
+ for batch in dataloader:
164
+ optimizer.zero_grad()
165
+ input_ids = batch["input_ids"].to(device)
166
+ attention_mask = batch["attention_mask"].to(device)
167
+ labels = batch["labels"].to(device)
168
+
169
+ if use_amp:
170
+ with torch.amp.autocast(device_type="cuda", dtype=torch.float16):
171
+ outputs = model(input_ids=input_ids, attention_mask=attention_mask)
172
+ loss = criterion(outputs.logits, labels)
173
+ scaler.scale(loss).backward()
174
+ scaler.unscale_(optimizer)
175
+ torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
176
+ scaler.step(optimizer)
177
+ scaler.update()
178
+ else:
179
+ outputs = model(input_ids=input_ids, attention_mask=attention_mask)
180
+ loss = criterion(outputs.logits, labels)
181
+ loss.backward()
182
+ torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
183
+ optimizer.step()
184
+
185
+ scheduler.step()
186
+ total_loss += loss.item()
187
+
188
+ return total_loss / len(dataloader)
189
+
190
+
191
+ def evaluate(
192
+ model: nn.Module,
193
+ dataloader: DataLoader,
194
+ criterion: nn.Module,
195
+ device: torch.device,
196
+ ) -> Tuple[float, float, float, np.ndarray, np.ndarray]:
197
+ """Evaluates the model and computes validation loss, accuracy, and Macro F1."""
198
+ model.eval()
199
+ total_loss = 0.0
200
+ all_preds = []
201
+ all_labels = []
202
+
203
+ with torch.no_grad():
204
+ for batch in dataloader:
205
+ input_ids = batch["input_ids"].to(device)
206
+ attention_mask = batch["attention_mask"].to(device)
207
+ labels = batch["labels"].to(device)
208
+
209
+ outputs = model(input_ids=input_ids, attention_mask=attention_mask)
210
+ loss = criterion(outputs.logits, labels)
211
+ total_loss += loss.item()
212
+
213
+ preds = torch.argmax(outputs.logits, dim=1).cpu().numpy()
214
+ all_preds.extend(preds)
215
+ all_labels.extend(labels.cpu().numpy())
216
+
217
+ avg_loss = total_loss / len(dataloader)
218
+ all_preds = np.array(all_preds)
219
+ all_labels = np.array(all_labels)
220
+ acc = accuracy_score(all_labels, all_preds)
221
+ macro_f1 = f1_score(all_labels, all_preds, average="macro")
222
+
223
+ return avg_loss, acc, macro_f1, all_preds, all_labels
224
+
225
+
226
+ def export_to_onnx(
227
+ model: nn.Module,
228
+ tokenizer: Any,
229
+ output_dir: str,
230
+ device: torch.device,
231
+ max_length: int = 128,
232
+ ) -> Tuple[str, str]:
233
+ """
234
+ Exports PyTorch model to:
235
+ 1. Raw ONNX format (`hatespeech.onnx`) with dynamic batch and sequence length axes.
236
+ 2. Quantized INT8 ONNX (`hatespeech_int8.onnx`) for CPU/edge speed and small file size.
237
+ """
238
+ os.makedirs(output_dir, exist_ok=True)
239
+ raw_onnx_path = os.path.join(output_dir, "hatespeech.onnx")
240
+ int8_onnx_path = os.path.join(output_dir, "hatespeech_int8.onnx")
241
+
242
+ model.eval()
243
+ model.to("cpu") # Export on CPU for universal ONNX portability
244
+
245
+ print("\n[5/6] Exporting to Raw ONNX...")
246
+ dummy_text = "Sample sentence for hate speech detection ONNX export."
247
+ dummy_encodings = tokenizer(
248
+ dummy_text,
249
+ max_length=max_length,
250
+ padding="max_length",
251
+ truncation=True,
252
+ return_tensors="pt",
253
+ )
254
+ dummy_inputs = (
255
+ dummy_encodings["input_ids"],
256
+ dummy_encodings["attention_mask"],
257
+ )
258
+
259
+ torch.onnx.export(
260
+ model,
261
+ dummy_inputs,
262
+ raw_onnx_path,
263
+ input_names=["input_ids", "attention_mask"],
264
+ output_names=["logits"],
265
+ dynamic_axes={
266
+ "input_ids": {0: "batch_size", 1: "sequence_length"},
267
+ "attention_mask": {0: "batch_size", 1: "sequence_length"},
268
+ "logits": {0: "batch_size"},
269
+ },
270
+ opset_version=14,
271
+ do_constant_folding=True,
272
+ )
273
+
274
+ # Verify raw ONNX model validity
275
+ onnx_model = onnx.load(raw_onnx_path)
276
+ onnx.checker.check_model(onnx_model)
277
+ raw_size_mb = os.path.getsize(raw_onnx_path) / (1024 * 1024)
278
+ print(f" -> Raw ONNX saved to: {raw_onnx_path} ({raw_size_mb:.2f} MB)")
279
+
280
+ print("\n[6/6] Quantizing ONNX model to INT8 (8q dynamic)...")
281
+ quantize_dynamic(
282
+ model_input=raw_onnx_path,
283
+ model_output=int8_onnx_path,
284
+ weight_type=QuantType.QInt8,
285
+ )
286
+ int8_size_mb = os.path.getsize(int8_onnx_path) / (1024 * 1024)
287
+ compression = (1.0 - (int8_size_mb / raw_size_mb)) * 100
288
+ print(f" -> INT8 ONNX saved to: {int8_onnx_path} ({int8_size_mb:.2f} MB, {compression:.1f}% smaller)")
289
+
290
+ # Save tokenizer files alongside models for offline independence
291
+ tokenizer.save_pretrained(output_dir)
292
+ print(f" -> Tokenizer assets saved in: {output_dir}")
293
+
294
+ return raw_onnx_path, int8_onnx_path
295
+
296
+
297
+ def main():
298
+ parser = argparse.ArgumentParser(description="Train Hate Speech Classifier and Export to ONNX & INT8.")
299
+ parser.add_argument("--model_name", type=str, default="distilbert-base-uncased",
300
+ help="Pretrained Hugging Face model architecture (default: distilbert-base-uncased)")
301
+ parser.add_argument("--epochs", type=int, default=3,
302
+ help="Maximum training epochs (default: 3, prevents overfitting)")
303
+ parser.add_argument("--batch_size", type=int, default=32,
304
+ help="Training batch size (default: 32)")
305
+ parser.add_argument("--lr", type=float, default=2e-5,
306
+ help="Learning rate for AdamW (default: 2e-5)")
307
+ parser.add_argument("--max_length", type=int, default=128,
308
+ help="Max sequence token length for tweets (default: 128 for fast epoch time)")
309
+ parser.add_argument("--patience", type=int, default=2,
310
+ help="Early stopping patience epochs based on Macro F1 (default: 2)")
311
+ parser.add_argument("--output_dir", type=str, default="./model",
312
+ help="Directory to save ONNX models and tokenizer (default: ./model)")
313
+ args = parser.parse_args()
314
+
315
+ # Hardware detection
316
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
317
+ print("=" * 65)
318
+ print(" HATE SPEECH & OFFENSIVE LANGUAGE CLASSIFIER TRAINING")
319
+ print("=" * 65)
320
+ print(f"Using Compute Device : {device}")
321
+ if device.type == "cuda":
322
+ print(f"GPU Model : {torch.cuda.get_device_name(0)}")
323
+ print(f"Automatic Mixed Prec : Enabled (FP16)")
324
+ print(f"Base Architecture : {args.model_name}")
325
+ print(f"Max Sequence Length : {args.max_length} tokens")
326
+ print(f"Batch Size / Max Epochs: {args.batch_size} / {args.epochs}")
327
+ print("=" * 65)
328
+
329
+ # 1. Download & Clean Dataset
330
+ df = download_dataset_temp(DATASET_URL)
331
+ texts = df["tweet"].tolist()
332
+ labels = df["class"].to_numpy()
333
+
334
+ # 2. Stratified Data Split (80% Train, 10% Validation, 10% Test)
335
+ print("\n[2/6] Preparing stratified data splits (80% Train, 10% Val, 10% Test)...")
336
+ train_texts, temp_texts, train_labels, temp_labels = train_test_split(
337
+ texts, labels, test_size=0.20, random_state=42, stratify=labels
338
+ )
339
+ val_texts, test_texts, val_labels, test_labels = train_test_split(
340
+ temp_texts, temp_labels, test_size=0.50, random_state=42, stratify=temp_labels
341
+ )
342
+ print(f" -> Train split : {len(train_labels):,} samples")
343
+ print(f" -> Val split : {len(val_labels):,} samples")
344
+ print(f" -> Test split : {len(test_labels):,} samples")
345
+
346
+ # 3. Tokenizer & DataLoaders
347
+ print(f"\n[3/6] Loading tokenizer '{args.model_name}' and tokenizing...")
348
+ tokenizer = AutoTokenizer.from_pretrained(args.model_name)
349
+
350
+ train_dataset = HateSpeechDataset(train_texts, train_labels, tokenizer, max_length=args.max_length)
351
+ val_dataset = HateSpeechDataset(val_texts, val_labels, tokenizer, max_length=args.max_length)
352
+ test_dataset = HateSpeechDataset(test_texts, test_labels, tokenizer, max_length=args.max_length)
353
+
354
+ train_loader = DataLoader(train_dataset, batch_size=args.batch_size, shuffle=True)
355
+ val_loader = DataLoader(val_dataset, batch_size=args.batch_size * 2, shuffle=False)
356
+ test_loader = DataLoader(test_dataset, batch_size=args.batch_size * 2, shuffle=False)
357
+
358
+ # 4. Model Setup & Anti-Overfitting Controls
359
+ print("\n[4/6] Initializing model & regularization controls...")
360
+ model = AutoModelForSequenceClassification.from_pretrained(
361
+ args.model_name,
362
+ num_labels=3,
363
+ classifier_dropout=0.2,
364
+ )
365
+ model.to(device)
366
+
367
+ # Balanced class weights to counteract the severe minority of class 0
368
+ class_weights = compute_class_weights(train_labels, num_classes=3).to(device)
369
+ print(f" -> Computed balanced class weights: {class_weights.cpu().numpy().round(3)}")
370
+ criterion = nn.CrossEntropyLoss(weight=class_weights)
371
+
372
+ # Optimizer with weight decay to block overfitting
373
+ optimizer = AdamW(model.parameters(), lr=args.lr, weight_decay=0.01)
374
+
375
+ # Linear warmup scheduler
376
+ total_steps = len(train_loader) * args.epochs
377
+ warmup_steps = int(0.1 * total_steps)
378
+ scheduler = get_linear_schedule_with_warmup(
379
+ optimizer, num_warmup_steps=warmup_steps, num_training_steps=total_steps
380
+ )
381
+ scaler = torch.amp.GradScaler("cuda", enabled=(device.type == "cuda"))
382
+
383
+ # Training Loop with Early Stopping monitoring Macro F1
384
+ best_val_macro_f1 = -1.0
385
+ best_model_state = None
386
+ patience_counter = 0
387
+
388
+ print("\n" + "-" * 75)
389
+ print(f"{'Epoch':<8}{'Train Loss':<14}{'Val Loss':<12}{'Val Acc':<12}{'Val Macro F1':<16}{'Time (s)':<10}")
390
+ print("-" * 75)
391
+
392
+ for epoch in range(1, args.epochs + 1):
393
+ start_time = time.time()
394
+ train_loss = train_epoch(
395
+ model, train_loader, optimizer, scheduler, scaler, criterion, device
396
+ )
397
+ val_loss, val_acc, val_f1, _, _ = evaluate(model, val_loader, criterion, device)
398
+ elapsed = time.time() - start_time
399
+
400
+ print(f"{epoch:<8}{train_loss:<14.4f}{val_loss:<12.4f}{val_acc*100:<12.2f}%{val_f1:<16.4f}{elapsed:<10.1f}")
401
+
402
+ # Check for best model based on Macro F1 (prevents skew towards majority class)
403
+ if val_f1 > best_val_macro_f1:
404
+ best_val_macro_f1 = val_f1
405
+ best_model_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}
406
+ patience_counter = 0
407
+ improved_mark = " (Best Saved)"
408
+ else:
409
+ patience_counter += 1
410
+ improved_mark = f" (Patience {patience_counter}/{args.patience})"
411
+
412
+ print(f" Status: Macro F1 = {val_f1:.4f}{improved_mark}")
413
+
414
+ if patience_counter >= args.patience:
415
+ print(f"\n[!] Early stopping triggered at epoch {epoch} to prevent overfitting.")
416
+ break
417
+
418
+ print("-" * 75)
419
+
420
+ # Load best checkpoint
421
+ if best_model_state is not None:
422
+ model.load_state_dict(best_model_state)
423
+ print(f"Loaded best checkpoint with Validation Macro F1: {best_val_macro_f1:.4f}")
424
+
425
+ # Final Evaluation on Independent Test Set
426
+ print("\n" + "=" * 65)
427
+ print(" FINAL EVALUATION ON TEST SET (10% HOLDOUT)")
428
+ print("=" * 65)
429
+ _, test_acc, test_macro_f1, test_preds, test_targets = evaluate(
430
+ model, test_loader, criterion, device
431
+ )
432
+ print(f"Overall Test Accuracy: {test_acc * 100:.2f}%")
433
+ print(f"Test Macro F1 Score : {test_macro_f1:.4f}\n")
434
+ print("Detailed Classification Report:")
435
+ target_names = [f"{i}: {LABEL_NAMES[i]}" for i in range(3)]
436
+ print(classification_report(test_targets, test_preds, target_names=target_names, digits=4))
437
+
438
+ # 5 & 6. Export to Raw ONNX & 8q Quantized ONNX
439
+ export_to_onnx(
440
+ model=model,
441
+ tokenizer=tokenizer,
442
+ output_dir=args.output_dir,
443
+ device=device,
444
+ max_length=args.max_length,
445
+ )
446
+
447
+ print("\n" + "=" * 65)
448
+ print(" TRAINING COMPLETE!")
449
+ print(f"Saved artifacts inside '{args.output_dir}':")
450
+ print(f" 1. Raw ONNX Model : {os.path.join(args.output_dir, 'hatespeech.onnx')}")
451
+ print(f" 2. Quantized INT8 ONNX: {os.path.join(args.output_dir, 'hatespeech_int8.onnx')}")
452
+ print(f" 3. Tokenizer Assets : {args.output_dir}/")
453
+ print("=" * 65)
454
+
455
+
456
+ if __name__ == "__main__":
457
+ main()
uv.lock ADDED
The diff for this file is too large to render. See raw diff