canalan commited on
Commit
4737a47
·
verified ·
1 Parent(s): 4245aeb

Add SBAN stacking model bundle and inference entrypoint

Browse files
LICENSE ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works, within the Source form or
113
+ documentation, if provided along with the Derivative Works, or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ Copyright 2026 Berke Can Alan
179
+
180
+ Licensed under the Apache License, Version 2.0 (the "License");
181
+ you may not use this file except in compliance with the License.
182
+ You may obtain a copy of the License at
183
+
184
+ http://www.apache.org/licenses/LICENSE-2.0
185
+
186
+ Unless required by applicable law or agreed to in writing, software
187
+ distributed under the License is distributed on an "AS IS" BASIS,
188
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
189
+ See the License for the specific language governing permissions and
190
+ limitations under the License.
README.md CHANGED
@@ -1,3 +1,194 @@
1
  ---
2
  license: apache-2.0
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: apache-2.0
3
+ library_name: sklearn
4
+ tags:
5
+ - malware
6
+ - multi-class-classification
7
+ - stacking
8
+ - sban
9
+ pipeline_tag: text-classification
10
  ---
11
+
12
+ # MalwareDatasetClassification (SBAN)
13
+
14
+ **[Türkçe dokümantasyon](README.tr.md)**
15
+
16
+ Multiclass machine learning pipeline for **malware dataset origin classification** on the [SBAN](https://github.com/ma-soreto/SBAN) benchmark: given four synchronized representations of the same sample, predict which source corpus it belongs to among `bodmas`, `dike`, `malwarebazaar`, and `sorel20m`.
17
+
18
+ This repository ships **code, notebooks, and the trained model weights**. It does **not** redistribute SBAN data or train/validation/test parquet files. You must acquire SBAN under its own terms and run the pipeline locally.
19
+
20
+ | Resource | Location |
21
+ |----------|----------|
22
+ | Source code | GitHub: [MalwareDatasetClassification](https://github.com/berkecanalan/MalwareDatasetClassification) |
23
+ | Model weights | Same repo root + Hugging Face: [MalwareDatasetClassification](https://huggingface.co/berkecanalan/MalwareDatasetClassification) |
24
+ | License | Apache-2.0 ([LICENSE](LICENSE)) |
25
+
26
+ ---
27
+
28
+ ## Problem formulation
29
+
30
+ - **Input:** One malware sample described by four text fields (assembly listing, binary hex dump, source code, natural-language description NLD).
31
+ - **Output:** One of four dataset labels (which SBAN sub-corpus the sample comes from).
32
+ - **Note:** This is **dataset / provenance classification**, not a generic “malware vs benign” detector.
33
+
34
+ Reported metrics on held-out splits (from `inference.ipynb` / Colab run with exported bundle):
35
+
36
+ | Split | Accuracy | Macro F1 |
37
+ |-------|----------|----------|
38
+ | Validation | 0.9413 | 0.9097 |
39
+ | Test | (run locally with your SBAN test parquet) | |
40
+
41
+ Per-class validation F1 (macro average 0.9097): strong on `bodmas` and `sorel20m`; smaller classes `dike` and `malwarebazaar` are harder (typical class imbalance).
42
+
43
+ ---
44
+
45
+ ## Model architecture (production bundle)
46
+
47
+ The released artifact `sban_weighted_stacking_model.joblib` is produced in `svc_sban-temiz.ipynb`. High-level flow:
48
+
49
+ ```text
50
+ For each representation r in {asm, binary, source, nld}:
51
+ raw text → TF-IDF (+ binary byte-tokenization for hex)
52
+ → numeric stats (char/line/token counts, entropy, …)
53
+ → StandardScaler on numeric block
54
+ → sparse hstack → feature selection (selected_indices)
55
+ → LinearSVC base model → decision_function scores
56
+
57
+ Meta layer:
58
+ concat(all base decision scores + all scaled numeric blocks)
59
+ → HistGradientBoostingClassifier (weighted / tuned per representation)
60
+ → argmax → label_encoder inverse → prediction + predict_proba
61
+ ```
62
+
63
+ The joblib dict (`bundle_version: 1`) includes:
64
+
65
+ - `representation_order`, `representation_columns`, `numeric_feature_names`
66
+ - `label_encoder`, `meta_model`
67
+ - per-representation: `vectorizer`, `numeric_scaler`, `selected_indices`, `base_model`
68
+ - metadata: `sklearn_version`, `random_state`, `selected_class_weight_configs`
69
+
70
+ **Binary representation:** hex lines are parsed into space-separated byte tokens with `instsep` line separators before TF-IDF (see `inference.py`).
71
+
72
+ ---
73
+
74
+ ## Data schema (inference)
75
+
76
+ Parquet or DataFrame rows must include:
77
+
78
+ | Column | Role |
79
+ |--------|------|
80
+ | `ID` | Sample identifier (optional for inference, kept in output) |
81
+ | `dataset_name` | Ground-truth label (only for evaluation) |
82
+ | `assembly_code` | Assembly text |
83
+ | `binary_code` | Hex dump text |
84
+ | `source_code` | Source text |
85
+ | `NLD` | Natural language description |
86
+
87
+ ---
88
+
89
+ ## Repository layout
90
+
91
+ | Path | Purpose |
92
+ |------|---------|
93
+ | `01_make_a_dataframe.py` | Build unified SBAN parquet from raw JSON layout under `data/` |
94
+ | `02_validate_data.py` | Validation checks |
95
+ | `03_make_clean_dataframe.py` | Cleaning |
96
+ | `04_analyze_prompt_residue.py` | Prompt residue analysis |
97
+ | `05_split_dataframe.py` | Train/val/test split |
98
+ | `06_make_features.py` | TF-IDF feature export to `features/` (sparse npz) |
99
+ | `07_audit_source_code.py` | Source audit |
100
+ | `08_clean_source_code.py` | Source cleaning |
101
+ | `09_audit_assembly_code.py` | Assembly audit |
102
+ | `10_audit_binary_code.py` | Binary audit |
103
+ | `11_audit_nld.py` | NLD audit |
104
+ | `baseline.ipynb` | Baseline stacking experiments |
105
+ | `svc_sban-temiz.ipynb` | Final weighted stacking training + **model export** |
106
+ | `inference.ipynb` | Colab/local inference, metrics, **demo row** |
107
+ | `inference.py` | Canonical Python inference + CLI |
108
+ | `sban_weighted_stacking_model.joblib` | Exported model (~6.4 MB) — add to clone if not using Git LFS |
109
+
110
+ Generated paths (`data/`, `*.parquet`, `features/`, `reports/`) are gitignored.
111
+
112
+ ---
113
+
114
+ ## Installation
115
+
116
+ **Inference only** (recommended for downstream use):
117
+
118
+ ```bash
119
+ python -m venv .venv
120
+ source .venv/bin/activate
121
+ pip install -r requirements-inference.txt
122
+ ```
123
+
124
+ **Full pipeline + notebooks:**
125
+
126
+ ```bash
127
+ pip install -r requirements.txt
128
+ ```
129
+
130
+ Use **scikit-learn 1.6.1** (pinned) when loading the bundle to match training.
131
+
132
+ ---
133
+
134
+ ## Inference
135
+
136
+ ### Command line
137
+
138
+ ```bash
139
+ python inference.py \
140
+ --model-path sban_weighted_stacking_model.joblib \
141
+ --input /path/to/SBAN_val.parquet \
142
+ --output predictions.parquet \
143
+ --evaluate
144
+ ```
145
+
146
+ Output columns: all input columns plus `prediction` and `prob_<class>` for each label.
147
+
148
+ ### Python API
149
+
150
+ ```python
151
+ import pandas as pd
152
+ from inference import load_predictor
153
+
154
+ predictor = load_predictor("sban_weighted_stacking_model.joblib")
155
+ df = pd.read_parquet("/path/to/samples.parquet")
156
+ result = predictor.predict(df)
157
+ ```
158
+
159
+ ### Notebook
160
+
161
+ Open `inference.ipynb`: run **Colab** or **local** setup, then the demo cell (single synthetic row). Point `VAL_PATH` / `TEST_PATH` to your SBAN splits for full evaluation.
162
+
163
+ ---
164
+
165
+ ## Reproducing training
166
+
167
+ 1. Place SBAN raw files under `data/M1/SBAN-MA-JUN25` (see `01_make_a_dataframe.py`).
168
+ 2. Run numbered scripts through `06_make_features.py` as needed for your experiment.
169
+ 3. Train and export with `svc_sban-temiz.ipynb` (Colab-oriented; expects parquet splits and writes `sban_weighted_stacking_model.joblib`).
170
+
171
+ ---
172
+
173
+ ## Hugging Face
174
+
175
+ Upload the model repository with:
176
+
177
+ - `sban_weighted_stacking_model.joblib`
178
+ - `README.md` (this file — YAML front matter is model-card compatible)
179
+ - `inference.py`, `requirements-inference.txt`, `LICENSE`
180
+
181
+ Select **Apache-2.0** as the Hub license to match this repo.
182
+
183
+ ---
184
+
185
+ ## Citation and data
186
+
187
+ - **SBAN dataset:** cite and obtain data from the official SBAN project; this repo does not host samples.
188
+ - **This model/code:** if you use the weights or pipeline, cite this repository and respect Apache-2.0.
189
+
190
+ ---
191
+
192
+ ## Security note
193
+
194
+ `joblib.load` executes pickled objects. Only load `sban_weighted_stacking_model.joblib` from this project or your own trusted export.
inference.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stacking classifier inference for SBAN multi-representation malware dataset classification.
3
+
4
+ Loads sban_weighted_stacking_model.joblib and predicts which source dataset
5
+ (bodmas, dike, malwarebazaar, sorel20m) each sample belongs to.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import math
12
+ import re
13
+ from collections import Counter
14
+ from pathlib import Path
15
+
16
+ import joblib
17
+ import numpy as np
18
+ import pandas as pd
19
+ from scipy.sparse import csr_matrix, hstack
20
+ from sklearn.metrics import (
21
+ accuracy_score,
22
+ classification_report,
23
+ f1_score,
24
+ )
25
+
26
+ PROJECT_DIR = Path(__file__).resolve().parent
27
+ DEFAULT_MODEL_PATH = PROJECT_DIR / "sban_weighted_stacking_model.joblib"
28
+
29
+ ID_COLUMN = "ID"
30
+ LABEL_COLUMN = "dataset_name"
31
+
32
+ REPRESENTATION_COLUMNS = {
33
+ "asm": "assembly_code",
34
+ "binary": "binary_code",
35
+ "source": "source_code",
36
+ "nld": "NLD",
37
+ }
38
+
39
+ NUMERIC_FEATURE_NAMES = [
40
+ "char_count",
41
+ "line_count",
42
+ "token_count",
43
+ "avg_line_length",
44
+ "unique_token_ratio",
45
+ "char_entropy",
46
+ ]
47
+
48
+
49
+ def binary_to_byte_tokens(text: str) -> str:
50
+ tokens: list[str] = []
51
+
52
+ for line in text.splitlines():
53
+ line = re.sub(r"\s+", "", line)
54
+
55
+ if not line or re.fullmatch(r"[0-9a-fA-F]+", line) is None:
56
+ continue
57
+
58
+ byte_count = len(line) // 2
59
+ tokens.extend(line[i * 2 : (i + 1) * 2].lower() for i in range(byte_count))
60
+ tokens.append("instsep")
61
+
62
+ return " ".join(tokens)
63
+
64
+
65
+ def calculate_char_entropy(text: str) -> float:
66
+ if not text:
67
+ return 0.0
68
+
69
+ counts = Counter(text)
70
+ length = len(text)
71
+
72
+ return -sum(
73
+ (count / length) * math.log2(count / length) for count in counts.values()
74
+ )
75
+
76
+
77
+ def extract_numeric_features(text: str) -> list[float]:
78
+ lines = text.splitlines()
79
+ tokens = text.split()
80
+
81
+ char_count = len(text)
82
+ line_count = len(lines)
83
+ token_count = len(tokens)
84
+
85
+ avg_line_length = (
86
+ sum(len(line) for line in lines) / line_count if line_count else 0.0
87
+ )
88
+
89
+ unique_token_ratio = (
90
+ len(set(tokens)) / token_count if token_count else 0.0
91
+ )
92
+
93
+ return [
94
+ float(char_count),
95
+ float(line_count),
96
+ float(token_count),
97
+ float(avg_line_length),
98
+ float(unique_token_ratio),
99
+ calculate_char_entropy(text),
100
+ ]
101
+
102
+
103
+ def build_numeric_features(series: pd.Series) -> np.ndarray:
104
+ return np.asarray(
105
+ [extract_numeric_features(text) for text in series.astype(str)],
106
+ dtype=np.float32,
107
+ )
108
+
109
+
110
+ def build_inference_features(
111
+ representation_name: str,
112
+ text_series: pd.Series,
113
+ representation_bundle: dict,
114
+ ) -> tuple[csr_matrix, np.ndarray]:
115
+ texts = text_series.fillna("").astype(str).tolist()
116
+
117
+ if representation_name == "binary":
118
+ tfidf_texts = [binary_to_byte_tokens(text) for text in texts]
119
+ else:
120
+ tfidf_texts = texts
121
+
122
+ vectorizer = representation_bundle["vectorizer"]
123
+ numeric_scaler = representation_bundle["numeric_scaler"]
124
+
125
+ x_tfidf = vectorizer.transform(tfidf_texts)
126
+ x_numeric = build_numeric_features(text_series)
127
+ x_numeric_scaled = numeric_scaler.transform(x_numeric)
128
+
129
+ x_full = hstack(
130
+ [x_tfidf, csr_matrix(x_numeric_scaled)],
131
+ format="csr",
132
+ dtype=np.float32,
133
+ )
134
+
135
+ return x_full, np.asarray(x_numeric_scaled, dtype=np.float32)
136
+
137
+
138
+ class StackingPredictor:
139
+ """Wrapper around the exported joblib inference bundle."""
140
+
141
+ def __init__(self, model_path: Path | str = DEFAULT_MODEL_PATH) -> None:
142
+ self.model_path = Path(model_path)
143
+ self.model_bundle = joblib.load(self.model_path)
144
+ self.representation_order = self.model_bundle["representation_order"]
145
+ self.representation_columns = self.model_bundle["representation_columns"]
146
+ self.label_encoder = self.model_bundle["label_encoder"]
147
+ self.meta_model = self.model_bundle["meta_model"]
148
+ self.representation_bundles = self.model_bundle["representations"]
149
+
150
+ @property
151
+ def classes(self) -> list[str]:
152
+ return self.label_encoder.classes_.tolist()
153
+
154
+ def predict(self, df: pd.DataFrame) -> pd.DataFrame:
155
+ missing = [
156
+ col
157
+ for rep in self.representation_order
158
+ if (col := self.representation_columns[rep]) not in df.columns
159
+ ]
160
+ if missing:
161
+ raise ValueError(f"Missing required columns: {missing}")
162
+
163
+ meta_features: list[np.ndarray] = []
164
+
165
+ for representation_name in self.representation_order:
166
+ bundle = self.representation_bundles[representation_name]
167
+
168
+ x_full, x_numeric = build_inference_features(
169
+ representation_name,
170
+ df[self.representation_columns[representation_name]],
171
+ bundle,
172
+ )
173
+
174
+ x_selected = x_full[:, bundle["selected_indices"]]
175
+ decision_scores = bundle["base_model"].decision_function(x_selected)
176
+
177
+ meta_features.append(decision_scores)
178
+ meta_features.append(x_numeric)
179
+
180
+ x_meta = np.hstack(meta_features)
181
+
182
+ encoded_predictions = self.meta_model.predict(x_meta)
183
+ prediction_probabilities = self.meta_model.predict_proba(x_meta)
184
+
185
+ result = df.copy()
186
+ result["prediction"] = self.label_encoder.inverse_transform(
187
+ encoded_predictions
188
+ )
189
+
190
+ for index, class_name in enumerate(self.label_encoder.classes_):
191
+ result[f"prob_{class_name}"] = prediction_probabilities[:, index]
192
+
193
+ return result
194
+
195
+
196
+ def load_predictor(model_path: Path | str = DEFAULT_MODEL_PATH) -> StackingPredictor:
197
+ return StackingPredictor(model_path)
198
+
199
+
200
+ def evaluate_predictions(
201
+ predictions: pd.DataFrame,
202
+ label_column: str = LABEL_COLUMN,
203
+ ) -> None:
204
+ if label_column not in predictions.columns:
205
+ print(f"Skip evaluation: column {label_column!r} not in input.")
206
+ return
207
+
208
+ y_true = predictions[label_column]
209
+ y_pred = predictions["prediction"]
210
+
211
+ print(f"Accuracy : {accuracy_score(y_true, y_pred):.6f}")
212
+ print(f"Macro F1 : {f1_score(y_true, y_pred, average='macro'):.6f}")
213
+ print(f"Weighted F1 : {f1_score(y_true, y_pred, average='weighted'):.6f}")
214
+ print("\nClassification Report\n")
215
+ print(
216
+ classification_report(
217
+ y_true,
218
+ y_pred,
219
+ digits=4,
220
+ target_names=sorted(y_true.unique()),
221
+ )
222
+ )
223
+
224
+
225
+ def parse_args() -> argparse.Namespace:
226
+ parser = argparse.ArgumentParser(
227
+ description="Run SBAN weighted stacking inference on a parquet file.",
228
+ )
229
+ parser.add_argument(
230
+ "--model-path",
231
+ type=Path,
232
+ default=DEFAULT_MODEL_PATH,
233
+ help="Path to sban_weighted_stacking_model.joblib",
234
+ )
235
+ parser.add_argument(
236
+ "--input",
237
+ type=Path,
238
+ required=True,
239
+ help="Input parquet with representation columns",
240
+ )
241
+ parser.add_argument(
242
+ "--output",
243
+ type=Path,
244
+ required=True,
245
+ help="Output parquet with prediction and prob_* columns",
246
+ )
247
+ parser.add_argument(
248
+ "--evaluate",
249
+ action="store_true",
250
+ help="Print metrics when dataset_name column is present",
251
+ )
252
+ return parser.parse_args()
253
+
254
+
255
+ def main() -> None:
256
+ args = parse_args()
257
+
258
+ if not args.model_path.is_file():
259
+ raise FileNotFoundError(f"Model not found: {args.model_path}")
260
+ if not args.input.is_file():
261
+ raise FileNotFoundError(f"Input not found: {args.input}")
262
+
263
+ predictor = load_predictor(args.model_path)
264
+ df = pd.read_parquet(args.input)
265
+ predictions = predictor.predict(df)
266
+
267
+ args.output.parent.mkdir(parents=True, exist_ok=True)
268
+ predictions.to_parquet(args.output, index=False)
269
+
270
+ print(f"Wrote {len(predictions)} rows to {args.output}")
271
+
272
+ if args.evaluate:
273
+ evaluate_predictions(predictions)
274
+
275
+
276
+ if __name__ == "__main__":
277
+ main()
requirements-inference.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ joblib==1.5.3
2
+ numpy==2.0.2
3
+ pandas==2.2.2
4
+ pyarrow==18.1.0
5
+ scikit-learn==1.6.1
6
+ scipy==1.16.3
sban_weighted_stacking_model.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:85b20ae6a6ef98c5dd0891ca42f95f298c5f01240f2b998bb74283e0e8bac6ed
3
+ size 6711061