github-actions[bot] commited on
Commit
e71990c
·
0 Parent(s):

Sync from GitHub e9a00339708d14fd15e64679f58d1c321b37ea9d

Browse files
.github/workflows/ci.yml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - name: Set up Python
16
+ uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.10"
19
+ cache: "pip"
20
+
21
+ - name: Install dependencies
22
+ run: pip install -r requirements.txt
23
+
24
+ - name: Run tests
25
+ run: pytest src/ -v
.github/workflows/sync-to-hf-space.yml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync to Hugging Face Space
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ sync:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - name: Push to Hugging Face Space
15
+ env:
16
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
17
+ run: |
18
+ git config user.name "github-actions[bot]"
19
+ git config user.email "github-actions[bot]@users.noreply.github.com"
20
+ git remote add space https://Evasim:${HF_TOKEN}@huggingface.co/spaces/Evasim/Simple-Text-Classifier
21
+ git checkout --orphan space-sync
22
+ git add -A
23
+ git commit -m "Sync from GitHub ${GITHUB_SHA}"
24
+ git push --force space space-sync:main
.gitignore ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Data (regenerate via src/data_ingestion.py)
151
+ data/*.csv
152
+
153
+ # Environments
154
+ .env
155
+ .envrc
156
+ .venv
157
+ env/
158
+ venv/
159
+ ENV/
160
+ env.bak/
161
+ venv.bak/
162
+
163
+ # Spyder project settings
164
+ .spyderproject
165
+ .spyproject
166
+
167
+ # Rope project settings
168
+ .ropeproject
169
+
170
+ # mkdocs documentation
171
+ /site
172
+
173
+ # mypy
174
+ .mypy_cache/
175
+ .dmypy.json
176
+ dmypy.json
177
+
178
+ # Pyre type checker
179
+ .pyre/
180
+
181
+ # pytype static type analyzer
182
+ .pytype/
183
+
184
+ # Cython debug symbols
185
+ cython_debug/
186
+
187
+ # PyCharm
188
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
189
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
190
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
191
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
192
+ # .idea/
193
+
194
+ # Abstra
195
+ # Abstra is an AI-powered process automation framework.
196
+ # Ignore directories containing user credentials, local state, and settings.
197
+ # Learn more at https://abstra.io/docs
198
+ .abstra/
199
+
200
+ # Visual Studio Code
201
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
202
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
203
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
204
+ # you could uncomment the following to ignore the entire vscode folder
205
+ # .vscode/
206
+ # Temporary file for partial code execution
207
+ tempCodeRunnerFile.py
208
+
209
+ # Ruff stuff:
210
+ .ruff_cache/
211
+
212
+ # PyPI configuration file
213
+ .pypirc
214
+
215
+ # Marimo
216
+ marimo/_static/
217
+ marimo/_lsp/
218
+ __marimo__/
219
+
220
+ # Streamlit
221
+ .streamlit/secrets.toml
222
+
223
+ efs/
224
+ *.pt
225
+ *.pkl
226
+ *.onnx
227
+
228
+ venv/
229
+ .venv/
230
+ __pycache__
231
+
232
+ .DS_Store
233
+ .gradio/
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.10.11
README.md ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Simple Text Classifier
3
+ emoji: 📰
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: gradio
7
+ sdk_version: 6.24.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: mit
11
+ short_description: Classifies news into World, Sports, Business, or Sci/Tech.
12
+ ---
13
+
14
+ # My first MLOps Project
15
+
16
+ [![CI](https://github.com/evasim/my-first-MLOPS-project/actions/workflows/ci.yml/badge.svg)](https://github.com/evasim/my-first-MLOPS-project/actions/workflows/ci.yml)
17
+
18
+ 🚧 Currently just started to learn MLOps (how to build and deploy models) — following the [Made With ML](https://madewithml.com/) course.
19
+
20
+ 📊 Using one of the public datasets on Hugging Face, [fancyzhx/ag_news](https://huggingface.co/datasets/fancyzhx/ag_news), to classify news headlines into World, Sports, Business, or Sci/Tech.
21
+
22
+ 🚀 **Live demo:** [huggingface.co/spaces/Evasim/Simple-Text-Classifier](https://huggingface.co/spaces/Evasim/Simple-Text-Classifier)
23
+
24
+ 🤗 **Trained model:** [huggingface.co/Evasim/First_Project](https://huggingface.co/Evasim/First_Project) (fine-tuned SciBERT)
25
+
26
+ ## Reproducing locally
27
+
28
+ ```bash
29
+ git clone https://github.com/evasim/my-first-MLOPS-project.git
30
+ cd my-first-MLOPS-project
31
+ pip install -r requirements.txt
32
+
33
+ # regenerate the dataset (not committed — it's public and regenerable)
34
+ python src/data_ingestion.py
35
+
36
+ # run the test suite
37
+ pytest src/
38
+ ```
39
+
40
+ To run predictions against the trained model, add to a `.env` file:
41
+ ```
42
+ HF_REPO_ID=Evasim/First_Project
43
+ ```
44
+ Then: `python -m src.predict "your headline here"`
45
+
46
+ Training happens in [notebooks/MLOPS_project.ipynb](notebooks/MLOPS_project.ipynb).
app.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from dotenv import load_dotenv
3
+
4
+ from src.predict import predict
5
+
6
+ load_dotenv()
7
+
8
+ demo = gr.Interface(
9
+ fn=predict,
10
+ inputs=gr.Textbox(lines=3, placeholder="Enter a news headline or article", label="Text"),
11
+ outputs=gr.Textbox(label="Predicted category"),
12
+ title="AG News Classifier",
13
+ description="Fine-tuned SciBERT model that classifies text into World, Sports, Business, or Sci/Tech.",
14
+ examples=[
15
+ "Wall Street stocks rallied after the Federal Reserve meeting.",
16
+ "The team won the championship game last night.",
17
+ "Scientists discover a new exoplanet orbiting a distant star.",
18
+ "World leaders meet to discuss climate change policy.",
19
+ ],
20
+ )
21
+
22
+ if __name__ == "__main__":
23
+ demo.launch(share=True)
notebooks/MLOPS_project.ipynb ADDED
@@ -0,0 +1,650 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "code",
5
+ "execution_count": null,
6
+ "id": "0",
7
+ "metadata": {},
8
+ "outputs": [],
9
+ "source": [
10
+ "%pip install \"ray[default]\"\n",
11
+ "%pip install python-dotenv"
12
+ ]
13
+ },
14
+ {
15
+ "cell_type": "code",
16
+ "execution_count": null,
17
+ "id": "1",
18
+ "metadata": {},
19
+ "outputs": [],
20
+ "source": [
21
+ "import os \n",
22
+ "import ray "
23
+ ]
24
+ },
25
+ {
26
+ "cell_type": "code",
27
+ "execution_count": null,
28
+ "id": "2",
29
+ "metadata": {},
30
+ "outputs": [],
31
+ "source": "import sys; sys.path.append(\"..\")\nimport warnings; warnings.filterwarnings(\"ignore\")\nfrom dotenv import load_dotenv; load_dotenv(override=True)\n%load_ext autoreload\n%autoreload 2"
32
+ },
33
+ {
34
+ "cell_type": "code",
35
+ "execution_count": null,
36
+ "id": "3",
37
+ "metadata": {},
38
+ "outputs": [],
39
+ "source": "if ray.is_initialized():\n ray.shutdown()\nray.init(\n num_cpus= 4,\n object_store_memory=2 * 1024 * 1024 * 1024,\n runtime_env={\"working_dir\": \"..\", \"env_vars\": {\"USE_LIBUV\": \"0\"}}\n)"
40
+ },
41
+ {
42
+ "cell_type": "code",
43
+ "execution_count": null,
44
+ "id": "4",
45
+ "metadata": {},
46
+ "outputs": [],
47
+ "source": [
48
+ "ray.cluster_resources()"
49
+ ]
50
+ },
51
+ {
52
+ "cell_type": "code",
53
+ "execution_count": null,
54
+ "id": "5",
55
+ "metadata": {},
56
+ "outputs": [],
57
+ "source": [
58
+ "num_workers = 2\n",
59
+ "resources_per_worker = {\"CPU\": 1, \"GPU\": 1}"
60
+ ]
61
+ },
62
+ {
63
+ "cell_type": "code",
64
+ "execution_count": null,
65
+ "id": "6",
66
+ "metadata": {},
67
+ "outputs": [],
68
+ "source": [
69
+ "import os\n",
70
+ "from pathlib import Path\n",
71
+ "\n",
72
+ "if os.path.exists(\"/efs\"):\n",
73
+ " EFS_DIR = f\"/efs/shared_storage/MY-FIRST-MLOPS-PROJECT/{os.environ.get('Eva', 'default_user')}\"\n",
74
+ "else: \n",
75
+ " EFS_DIR = str(Path(os.getcwd()).parent / \"local_storage\")\n",
76
+ " os.makedirs(EFS_DIR, exist_ok=True)\n",
77
+ "\n",
78
+ "print(f\"Current active storage directory: {EFS_DIR}\")"
79
+ ]
80
+ },
81
+ {
82
+ "cell_type": "code",
83
+ "execution_count": null,
84
+ "id": "7",
85
+ "metadata": {},
86
+ "outputs": [],
87
+ "source": [
88
+ "import pandas as pd "
89
+ ]
90
+ },
91
+ {
92
+ "cell_type": "code",
93
+ "execution_count": null,
94
+ "id": "8",
95
+ "metadata": {},
96
+ "outputs": [],
97
+ "source": "from pathlib import Path\ndataset = str(Path.cwd().parent / \"data\" / \"raw_dataset.csv\")\ndf = pd.read_csv(dataset)\ndf.head()"
98
+ },
99
+ {
100
+ "cell_type": "code",
101
+ "execution_count": null,
102
+ "id": "9",
103
+ "metadata": {},
104
+ "outputs": [],
105
+ "source": [
106
+ "from sklearn.model_selection import train_test_split"
107
+ ]
108
+ },
109
+ {
110
+ "cell_type": "code",
111
+ "execution_count": null,
112
+ "id": "10",
113
+ "metadata": {},
114
+ "outputs": [],
115
+ "source": [
116
+ "df.label.value_counts()"
117
+ ]
118
+ },
119
+ {
120
+ "cell_type": "code",
121
+ "execution_count": null,
122
+ "id": "11",
123
+ "metadata": {},
124
+ "outputs": [],
125
+ "source": [
126
+ "test_size = 0.2 \n",
127
+ "train_df, val_df = train_test_split(df, stratify=df.label, test_size=test_size, random_state=42)"
128
+ ]
129
+ },
130
+ {
131
+ "cell_type": "code",
132
+ "execution_count": null,
133
+ "id": "12",
134
+ "metadata": {},
135
+ "outputs": [],
136
+ "source": [
137
+ "train_df.label.value_counts()"
138
+ ]
139
+ },
140
+ {
141
+ "cell_type": "code",
142
+ "execution_count": null,
143
+ "id": "13",
144
+ "metadata": {},
145
+ "outputs": [],
146
+ "source": [
147
+ "val_df.label.value_counts() * int((1 - test_size) / test_size)"
148
+ ]
149
+ },
150
+ {
151
+ "cell_type": "code",
152
+ "execution_count": null,
153
+ "id": "14",
154
+ "metadata": {},
155
+ "outputs": [],
156
+ "source": [
157
+ "from collections import Counter \n",
158
+ "import matplotlib.pyplot as plt \n",
159
+ "import seaborn as sns; sns.set_theme()\n",
160
+ "import warnings; warnings.filterwarnings(\"ignore\")\n",
161
+ "from wordcloud import WordCloud, STOPWORDS"
162
+ ]
163
+ },
164
+ {
165
+ "cell_type": "code",
166
+ "execution_count": null,
167
+ "id": "15",
168
+ "metadata": {},
169
+ "outputs": [],
170
+ "source": [
171
+ "tags = Counter(df.label)\n",
172
+ "tags.most_common()"
173
+ ]
174
+ },
175
+ {
176
+ "cell_type": "code",
177
+ "execution_count": null,
178
+ "id": "28",
179
+ "metadata": {},
180
+ "outputs": [],
181
+ "source": "from src.data import data, stratify_split, preprocess\nray.data.DataContext.get_current().execution_options.preserve_order = True"
182
+ },
183
+ {
184
+ "cell_type": "code",
185
+ "execution_count": null,
186
+ "id": "33",
187
+ "metadata": {},
188
+ "outputs": [],
189
+ "source": "import os \nimport random\nimport numpy as np\nimport torch \nfrom ray.data.preprocessor import Preprocessor\n\ndef setting_seeds(seed=42):\n np.random.seed(seed)\n random.seed(seed)\n torch.manual_seed(seed)\n torch.cuda.manual_seed(seed)\n eval(\"setattr(torch.backends.cudnn, 'deterministic', True)\") # forces GPU to always perform calculation in exact sequence\n eval(\"setattr(torch.backends.cudnn, 'benchmark', False)\") \n os.environ[\"PYTHONHASHSEED\"] = str(seed)"
190
+ },
191
+ {
192
+ "cell_type": "code",
193
+ "execution_count": null,
194
+ "id": "35",
195
+ "metadata": {},
196
+ "outputs": [],
197
+ "source": [
198
+ "class CustomPreprocessor():\n",
199
+ " \"\"\"Custom Preprocess.\"\"\"\n",
200
+ " def __init__(self, label_decoder = None):\n",
201
+ " self.label_decoder = label_decoder or {\n",
202
+ " 0: \"World\",\n",
203
+ " 1: \"Sports\",\n",
204
+ " 2: \"Business\",\n",
205
+ " 3: \"Sci/Tech\"\n",
206
+ " }\n",
207
+ " self.class_to_index = {v:k for k, v in self.label_decoder.items()}\n",
208
+ "\n",
209
+ " def fitting(self, ds):\n",
210
+ " _ = ds.unique(column=\"label\")\n",
211
+ " return self\n",
212
+ " \n",
213
+ " def transforming(self, ds):\n",
214
+ " return ds.map_batches(\n",
215
+ " preprocess, \n",
216
+ " batch_format = \"pandas\")"
217
+ ]
218
+ },
219
+ {
220
+ "cell_type": "code",
221
+ "execution_count": null,
222
+ "id": "36",
223
+ "metadata": {},
224
+ "outputs": [],
225
+ "source": "import json\nimport torch\nimport torch.nn as nn\nfrom transformers import BertModel\nfrom transformers import AutoTokenizer"
226
+ },
227
+ {
228
+ "cell_type": "code",
229
+ "execution_count": null,
230
+ "id": "37",
231
+ "metadata": {},
232
+ "outputs": [],
233
+ "source": [
234
+ "model_name = \"allenai/scibert_scivocab_uncased\"\n",
235
+ "tokenizer = AutoTokenizer.from_pretrained(model_name)\n",
236
+ "llm = BertModel.from_pretrained(model_name, return_dict = False)\n",
237
+ "embedding_dim = llm.config.hidden_size\n",
238
+ "num_classes = 4"
239
+ ]
240
+ },
241
+ {
242
+ "cell_type": "code",
243
+ "execution_count": null,
244
+ "id": "38",
245
+ "metadata": {},
246
+ "outputs": [],
247
+ "source": [
248
+ "class FinetunedLLM(nn.Module):\n",
249
+ " def __init__(self, llm, dropout_p, embedding_dim, num_classes):\n",
250
+ " super(FinetunedLLM, self).__init__()\n",
251
+ " self.llm = llm\n",
252
+ " self.dropout_p = dropout_p\n",
253
+ " self.embedding_dim = embedding_dim\n",
254
+ " self.num_classes = num_classes\n",
255
+ " self.dropout = torch.nn.Dropout(dropout_p)\n",
256
+ " self.fc1 =torch.nn.Linear(embedding_dim, num_classes)\n",
257
+ "\n",
258
+ " def forward(self, batch):\n",
259
+ " ids, masks = batch[\"ids\"], batch[\"masks\"]\n",
260
+ " seq, pool = self.llm(input_ids = ids, attention_mask = masks)\n",
261
+ " z = self.dropout(pool)\n",
262
+ " z = self.fc1(z)\n",
263
+ " return z\n",
264
+ " \n",
265
+ " @torch.inference_mode()\n",
266
+ " def predict(self,batch):\n",
267
+ " self.eval()\n",
268
+ " z = self(batch)\n",
269
+ " y_pred = torch.argmax(z, dim=1).cpu().numpy()\n",
270
+ " return y_pred\n",
271
+ " \n",
272
+ " def save(self, dp):\n",
273
+ " with open(Path(dp, \"args.json\"), \"w\") as fp:\n",
274
+ " contents = {\n",
275
+ " \"dropout_p\": self.dropout_p,\n",
276
+ " \"embedding_dim\": self.embedding_dim,\n",
277
+ " \"num_classes\": self.num_classes,\n",
278
+ " }\n",
279
+ " json.dump(contents, fp, indent=4, sort_keys=False)\n",
280
+ " torch.save(self.state_dict(), os.path.join(dp, \"model.pt\"))\n",
281
+ "\n",
282
+ " @classmethod\n",
283
+ " def load(cls, args_fp, state_dict_fp):\n",
284
+ " with open(args_fp, \"r\") as fp:\n",
285
+ " kwargs = json.load(fp = fp)\n",
286
+ " llm = BertModel.from_pretrained(model_name, return_dict = False)\n",
287
+ " model = cls(llm = llm, **kwargs)\n",
288
+ " model.load_state_dict(torch.load(state_dict_fp, map_location=torch.device(\"cpu\")))\n",
289
+ " return model "
290
+ ]
291
+ },
292
+ {
293
+ "cell_type": "code",
294
+ "execution_count": null,
295
+ "id": "39",
296
+ "metadata": {},
297
+ "outputs": [],
298
+ "source": [
299
+ "model = FinetunedLLM(llm=llm, dropout_p=0.5, embedding_dim=embedding_dim, num_classes=num_classes)\n",
300
+ "print(model.named_parameters)"
301
+ ]
302
+ },
303
+ {
304
+ "cell_type": "code",
305
+ "execution_count": null,
306
+ "id": "40",
307
+ "metadata": {},
308
+ "outputs": [],
309
+ "source": [
310
+ "from ray.train.torch import get_device"
311
+ ]
312
+ },
313
+ {
314
+ "cell_type": "code",
315
+ "execution_count": null,
316
+ "id": "41",
317
+ "metadata": {},
318
+ "outputs": [],
319
+ "source": [
320
+ "def pad_array(arr, dtype=np.int32):\n",
321
+ " max_len = max(len(row) for row in arr)\n",
322
+ " padded_arr = np.zeros((arr.shape[0], max_len), dtype=dtype)\n",
323
+ " for i, row in enumerate(arr):\n",
324
+ " padded_arr[i][:len(row)] = row\n",
325
+ " return padded_arr"
326
+ ]
327
+ },
328
+ {
329
+ "cell_type": "code",
330
+ "execution_count": null,
331
+ "id": "42",
332
+ "metadata": {},
333
+ "outputs": [],
334
+ "source": [
335
+ "def collate_fn(batch):\n",
336
+ " batch[\"ids\"] = pad_array(batch[\"ids\"])\n",
337
+ " batch[\"masks\"] = pad_array(batch[\"masks\"])\n",
338
+ " dtypes = {\"ids\": torch.int32, \"masks\": torch.int32, \"targets\": torch.int64}\n",
339
+ " tensor_batch = {}\n",
340
+ " for key, array in batch.items():\n",
341
+ " tensor_batch[key] = torch.as_tensor(array, dtype=dtypes[key], device=get_device())\n",
342
+ " return tensor_batch"
343
+ ]
344
+ },
345
+ {
346
+ "cell_type": "code",
347
+ "execution_count": null,
348
+ "id": "43",
349
+ "metadata": {},
350
+ "outputs": [],
351
+ "source": [
352
+ "from pathlib import Path \n",
353
+ "import ray.train as train\n",
354
+ "from ray.train import Checkpoint, CheckpointConfig, DataConfig, RunConfig, ScalingConfig\n",
355
+ "from ray.train.torch import TorchCheckpoint, TorchTrainer, TorchConfig\n",
356
+ "import tempfile\n",
357
+ "import torch.nn.functional as F\n",
358
+ "from torch.nn.parallel.distributed import DistributedDataParallel"
359
+ ]
360
+ },
361
+ {
362
+ "cell_type": "code",
363
+ "execution_count": null,
364
+ "id": "44",
365
+ "metadata": {},
366
+ "outputs": [],
367
+ "source": [
368
+ "def train_step(ds, batch_size, model, num_classes, loss_fn, optimizer):\n",
369
+ " model.train()\n",
370
+ " loss = 0.0\n",
371
+ " ds_generator = ds.iter_torch_batches(batch_size=batch_size, collate_fn=collate_fn)\n",
372
+ " for i, batch in enumerate(ds_generator):\n",
373
+ " optimizer.zero_grad()\n",
374
+ " z = model(batch)\n",
375
+ " targets = F.one_hot(batch[\"targets\"], num_classes=num_classes).float()\n",
376
+ " J = loss_fn(z, targets)\n",
377
+ " J.backward()\n",
378
+ " optimizer.step()\n",
379
+ " loss += (J.detach().item() - loss) / (i+1)\n",
380
+ " return loss\n"
381
+ ]
382
+ },
383
+ {
384
+ "cell_type": "code",
385
+ "execution_count": null,
386
+ "id": "45",
387
+ "metadata": {},
388
+ "outputs": [],
389
+ "source": [
390
+ "def eval_step(ds, batch_size, model, num_classes, loss_fn):\n",
391
+ " model.eval()\n",
392
+ " loss = 0.0 \n",
393
+ " y_trues, y_preds = [], []\n",
394
+ " ds_generator = ds.iter_torch_batches(batch_size=batch_size, collate_fn=collate_fn)\n",
395
+ " with torch.inference_mode():\n",
396
+ " for i, batch in enumerate(ds_generator):\n",
397
+ " z = model(batch)\n",
398
+ " targets = F.one_hot(batch[\"targets\"], num_classes=num_classes).float()\n",
399
+ " J = loss_fn(z, targets).item()\n",
400
+ " loss += (J-loss)/(i+1)\n",
401
+ " y_trues.extend(batch[\"targets\"].cpu().numpy())\n",
402
+ " y_preds.extend(torch.argmax(z, dim=1).cpu().numpy())\n",
403
+ " return loss, np.vstack(y_trues), np.vstack(y_preds)"
404
+ ]
405
+ },
406
+ {
407
+ "cell_type": "code",
408
+ "execution_count": null,
409
+ "id": "46",
410
+ "metadata": {},
411
+ "outputs": [],
412
+ "source": [
413
+ "def train_loop_per_worker(config):\n",
414
+ " dropout_p = config[\"dropout_p\"]\n",
415
+ " lr = config[\"lr\"]\n",
416
+ " lr_factor = config[\"lr_factor\"]\n",
417
+ " lr_patience = config[\"lr_patience\"]\n",
418
+ " num_epochs = config[\"num_epochs\"]\n",
419
+ " batch_size = config[\"batch_size\"]\n",
420
+ " num_classes = config[\"num_classes\"]\n",
421
+ "\n",
422
+ " setting_seeds()\n",
423
+ " train_ds = train.get_dataset_shard(\"train\")\n",
424
+ " val_ds = train.get_dataset_shard(\"val\")\n",
425
+ " \n",
426
+ " llm = BertModel.from_pretrained(\"allenai/scibert_scivocab_uncased\", return_dict = False)\n",
427
+ " model = FinetunedLLM(llm=llm, dropout_p=dropout_p, embedding_dim=llm.config.hidden_size, num_classes=num_classes)\n",
428
+ " model = train.torch.prepare_model(model)\n",
429
+ "\n",
430
+ " loss_fn = nn.BCEWithLogitsLoss()\n",
431
+ " optimizer = torch.optim.Adam(model.parameters(), lr=lr)\n",
432
+ " scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode=\"min\", factor=lr_factor, patience=lr_patience)\n",
433
+ "\n",
434
+ " num_workers = train.get_context().get_world_size()\n",
435
+ " batch_size_per_worker = batch_size // num_workers\n",
436
+ " for epoch in range(num_epochs):\n",
437
+ " train_loss = train_step(train_ds, batch_size_per_worker, model, num_classes, loss_fn, optimizer)\n",
438
+ " val_loss, _, _ = eval_step(val_ds, batch_size_per_worker, model, num_classes, loss_fn)\n",
439
+ " scheduler.step(val_loss)\n",
440
+ "\n",
441
+ " with tempfile.TemporaryDirectory() as dp:\n",
442
+ " if isinstance(model, DistributedDataParallel):\n",
443
+ " model.module.save(dp=dp)\n",
444
+ " else:\n",
445
+ " model.save(dp=dp)\n",
446
+ " metrics = dict(epoch=epoch, lr=optimizer.param_groups[0][\"lr\"], train_loss=train_loss, val_loss=val_loss)\n",
447
+ " checkpoint = Checkpoint.from_directory(dp)\n",
448
+ " train.report(metrics, checkpoint=checkpoint)"
449
+ ]
450
+ },
451
+ {
452
+ "cell_type": "code",
453
+ "execution_count": null,
454
+ "id": "47",
455
+ "metadata": {},
456
+ "outputs": [],
457
+ "source": [
458
+ "from src.config import EFS_DIR, BASE_DIR, DATA_DIR, ARTIFACTS_DIR"
459
+ ]
460
+ },
461
+ {
462
+ "cell_type": "code",
463
+ "execution_count": null,
464
+ "id": "48",
465
+ "metadata": {},
466
+ "outputs": [],
467
+ "source": [
468
+ "train_loop_config = {\n",
469
+ " \"dropout_p\": 0.5,\n",
470
+ " \"lr\": 1e-4,\n",
471
+ " \"lr_factor\": 0.8,\n",
472
+ " \"lr_patience\": 3,\n",
473
+ " \"num_epochs\": 10,\n",
474
+ " \"batch_size\": 256,\n",
475
+ " \"num_classes\": num_classes,\n",
476
+ "}"
477
+ ]
478
+ },
479
+ {
480
+ "cell_type": "code",
481
+ "execution_count": null,
482
+ "id": "49",
483
+ "metadata": {},
484
+ "outputs": [],
485
+ "source": [
486
+ "scaling_config = ScalingConfig(\n",
487
+ " num_workers=num_workers,\n",
488
+ " use_gpu=bool(resources_per_worker[\"GPU\"]),\n",
489
+ " resources_per_worker=resources_per_worker\n",
490
+ ")"
491
+ ]
492
+ },
493
+ {
494
+ "cell_type": "code",
495
+ "execution_count": null,
496
+ "id": "50",
497
+ "metadata": {},
498
+ "outputs": [],
499
+ "source": [
500
+ "checkpoint_config = CheckpointConfig(num_to_keep=1, checkpoint_score_attribute=\"val_loss\", checkpoint_score_order=\"min\")\n",
501
+ "run_config = RunConfig(name=\"llm\", checkpoint_config=checkpoint_config, storage_path=EFS_DIR)"
502
+ ]
503
+ },
504
+ {
505
+ "cell_type": "code",
506
+ "execution_count": null,
507
+ "id": "51",
508
+ "metadata": {},
509
+ "outputs": [],
510
+ "source": [
511
+ "ds = data(dataset_loc=dataset)\n",
512
+ "train_ds, val_ds = stratify_split(ds, stratify=\"label\", test_size=test_size)\n",
513
+ "\n",
514
+ "preprocessor = CustomPreprocessor()\n",
515
+ "preprocessor = preprocessor.fitting(train_ds)\n",
516
+ "# materialize now, while the full cluster is free, so trainer.fit() doesn't have to\n",
517
+ "# tokenize on the fly while competing with the training worker for CPU\n",
518
+ "train_ds = preprocessor.transforming(train_ds).materialize()\n",
519
+ "val_ds = preprocessor.transforming(val_ds).materialize()"
520
+ ]
521
+ },
522
+ {
523
+ "cell_type": "code",
524
+ "execution_count": null,
525
+ "id": "52",
526
+ "metadata": {},
527
+ "outputs": [],
528
+ "source": [
529
+ "trainer = TorchTrainer(\n",
530
+ " train_loop_per_worker=train_loop_per_worker,\n",
531
+ " train_loop_config=train_loop_config,\n",
532
+ " scaling_config=scaling_config,\n",
533
+ " run_config=run_config,\n",
534
+ " datasets={\"train\": train_ds, \"val\": val_ds},\n",
535
+ " dataset_config=DataConfig(datasets_to_split=[\"train\"]),\n",
536
+ " torch_config=TorchConfig(backend=\"gloo\"),\n",
537
+ ")\n",
538
+ "results = trainer.fit()\n",
539
+ "results"
540
+ ]
541
+ },
542
+ {
543
+ "cell_type": "code",
544
+ "execution_count": null,
545
+ "id": "53",
546
+ "metadata": {},
547
+ "outputs": [],
548
+ "source": [
549
+ "best_checkpoint = results.best_checkpoints[0][0]\n",
550
+ "best_metrics = results.best_checkpoints[0][1]\n",
551
+ "print(f\"best checkpoint: {best_checkpoint.path}\")\n",
552
+ "print(f\"best metrics: {best_metrics}\")"
553
+ ]
554
+ },
555
+ {
556
+ "cell_type": "code",
557
+ "execution_count": null,
558
+ "id": "55",
559
+ "metadata": {},
560
+ "outputs": [],
561
+ "source": [
562
+ "import os\n",
563
+ "from huggingface_hub import create_repo, upload_folder\n",
564
+ "\n",
565
+ "# points directly at the epoch-0 checkpoint already saved to disk\n",
566
+ "# (currently the best val_loss of any epoch trained so far)\n",
567
+ "checkpoint_dir = str(EFS_DIR / \"llm\" / \"checkpoint_2026-08-10_13-03-12.020315\")\n",
568
+ "\n",
569
+ "HF_REPO_ID = os.environ[\"HF_REPO_ID\"] # e.g. \"yourname/agnews-scibert-classifier\", set in .env\n",
570
+ "\n",
571
+ "create_repo(HF_REPO_ID, exist_ok=True, repo_type=\"model\", private=True)\n",
572
+ "upload_folder(\n",
573
+ " folder_path=checkpoint_dir,\n",
574
+ " repo_id=HF_REPO_ID,\n",
575
+ " repo_type=\"model\",\n",
576
+ " commit_message=\"Upload epoch 0 checkpoint (best val_loss so far)\",\n",
577
+ ")\n",
578
+ "print(f\"Uploaded to https://huggingface.co/{HF_REPO_ID}\")"
579
+ ]
580
+ },
581
+ {
582
+ "cell_type": "code",
583
+ "execution_count": null,
584
+ "id": "57",
585
+ "metadata": {},
586
+ "outputs": [],
587
+ "source": [
588
+ "import os\n",
589
+ "from huggingface_hub import snapshot_download\n",
590
+ "\n",
591
+ "HF_REPO_ID = os.environ[\"HF_REPO_ID\"]\n",
592
+ "eval_checkpoint_dir = snapshot_download(repo_id=HF_REPO_ID, repo_type=\"model\")\n",
593
+ "print(f\"Downloaded checkpoint to {eval_checkpoint_dir}\")"
594
+ ]
595
+ },
596
+ {
597
+ "cell_type": "code",
598
+ "execution_count": null,
599
+ "id": "58",
600
+ "metadata": {},
601
+ "outputs": [],
602
+ "source": [
603
+ "eval_model = FinetunedLLM.load(\n",
604
+ " args_fp=Path(eval_checkpoint_dir, \"args.json\"),\n",
605
+ " state_dict_fp=Path(eval_checkpoint_dir, \"model.pt\"),\n",
606
+ ")\n",
607
+ "eval_device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n",
608
+ "eval_model = eval_model.to(eval_device)\n",
609
+ "print(f\"Loaded checkpoint onto {eval_device}\")"
610
+ ]
611
+ },
612
+ {
613
+ "cell_type": "code",
614
+ "execution_count": null,
615
+ "id": "59",
616
+ "metadata": {},
617
+ "outputs": [],
618
+ "source": "def eval_collate_fn(batch):\n batch[\"ids\"] = pad_array(batch[\"ids\"])\n batch[\"masks\"] = pad_array(batch[\"masks\"])\n dtypes = {\"ids\": torch.int32, \"masks\": torch.int32, \"targets\": torch.int64}\n return {key: torch.as_tensor(array, dtype=dtypes[key], device=eval_device) for key, array in batch.items()}\n\ndef evaluate_checkpoint(ds, batch_size, model, num_classes):\n model.eval()\n loss_fn = nn.BCEWithLogitsLoss()\n loss = 0.0\n y_trues, y_preds = [], []\n ds_generator = ds.iter_torch_batches(batch_size=batch_size, collate_fn=eval_collate_fn)\n model_device = next(model.parameters()).device\n with torch.inference_mode():\n for i, batch in enumerate(ds_generator):\n batch = {key: value.to(model_device) for key, value in batch.items()}\n z = model(batch)\n targets = F.one_hot(batch[\"targets\"], num_classes=num_classes).float()\n J = loss_fn(z, targets).item()\n loss += (J - loss) / (i + 1)\n y_trues.extend(batch[\"targets\"].cpu().numpy())\n y_preds.extend(torch.argmax(z, dim=1).cpu().numpy())\n return loss, np.array(y_trues), np.array(y_preds)"
619
+ },
620
+ {
621
+ "cell_type": "code",
622
+ "execution_count": null,
623
+ "id": "60",
624
+ "metadata": {},
625
+ "outputs": [],
626
+ "source": "from sklearn.metrics import classification_report\n\nval_loss, y_true, y_pred = evaluate_checkpoint(val_ds, batch_size=64, model=eval_model, num_classes=num_classes)\nprint(f\"val_loss: {val_loss:.4f}\")\nprint(classification_report(y_true, y_pred, target_names=[preprocessor.label_decoder[i] for i in sorted(preprocessor.label_decoder)]))"
627
+ }
628
+ ],
629
+ "metadata": {
630
+ "kernelspec": {
631
+ "display_name": "Python 3",
632
+ "language": "python",
633
+ "name": "python3"
634
+ },
635
+ "language_info": {
636
+ "codemirror_mode": {
637
+ "name": "ipython",
638
+ "version": 3
639
+ },
640
+ "file_extension": ".py",
641
+ "mimetype": "text/x-python",
642
+ "name": "python",
643
+ "nbconvert_exporter": "python",
644
+ "pygments_lexer": "ipython3",
645
+ "version": "3.10.11"
646
+ }
647
+ },
648
+ "nbformat": 4,
649
+ "nbformat_minor": 5
650
+ }
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ray[default]==2.56.1
2
+ torch==2.11.0
3
+ transformers==5.15.0
4
+ datasets==5.0.1
5
+ huggingface_hub==1.27.0
6
+ python-dotenv==1.2.2
7
+ pandas==2.3.3
8
+ numpy==2.2.6
9
+ scikit-learn==1.7.2
10
+ nltk==3.10.2
11
+ matplotlib==3.10.9
12
+ seaborn==0.13.2
13
+ wordcloud==1.9.6
14
+ pyarrow==25.0.0
15
+ tqdm==4.70.0
16
+ pytest==8.3.4
17
+ gradio==6.24.0
18
+ spaces
src/__init__.py ADDED
File without changes
src/config.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+ SRC_DIR = Path(__file__).parent.absolute()
5
+ BASE_DIR = SRC_DIR.parent
6
+ DATA_DIR = BASE_DIR / "data"
7
+ EFS_DIR = Path(os.getenv("EFS_DIR", BASE_DIR / "efs"))
8
+ ARTIFACTS_DIR = EFS_DIR / "artifacts"
9
+ ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
10
+
11
+ STOPWORDS = [
12
+ "i",
13
+ "me",
14
+ "my",
15
+ "myself",
16
+ "we",
17
+ "our",
18
+ "ours",
19
+ "ourselves",
20
+ "you",
21
+ "you're",
22
+ "you've",
23
+ "you'll",
24
+ "you'd",
25
+ "your",
26
+ "yours",
27
+ "yourself",
28
+ "yourselves",
29
+ "he",
30
+ "him",
31
+ "his",
32
+ "himself",
33
+ "she",
34
+ "she's",
35
+ "her",
36
+ "hers",
37
+ "herself",
38
+ "it",
39
+ "it's",
40
+ "its",
41
+ "itself",
42
+ "they",
43
+ "them",
44
+ "their",
45
+ "theirs",
46
+ "themselves",
47
+ "what",
48
+ "which",
49
+ "who",
50
+ "whom",
51
+ "this",
52
+ "that",
53
+ "that'll",
54
+ "these",
55
+ "those",
56
+ "am",
57
+ "is",
58
+ "are",
59
+ "was",
60
+ "were",
61
+ "be",
62
+ "been",
63
+ "being",
64
+ "have",
65
+ "has",
66
+ "had",
67
+ "having",
68
+ "do",
69
+ "does",
70
+ "did",
71
+ "doing",
72
+ "a",
73
+ "an",
74
+ "the",
75
+ "and",
76
+ "but",
77
+ "if",
78
+ "or",
79
+ "because",
80
+ "as",
81
+ "until",
82
+ "while",
83
+ "of",
84
+ "at",
85
+ "by",
86
+ "for",
87
+ "with",
88
+ "about",
89
+ "against",
90
+ "between",
91
+ "into",
92
+ "through",
93
+ "during",
94
+ "before",
95
+ "after",
96
+ "above",
97
+ "below",
98
+ "to",
99
+ "from",
100
+ "up",
101
+ "down",
102
+ "in",
103
+ "out",
104
+ "on",
105
+ "off",
106
+ "over",
107
+ "under",
108
+ "again",
109
+ "further",
110
+ "then",
111
+ "once",
112
+ "here",
113
+ "there",
114
+ "when",
115
+ "where",
116
+ "why",
117
+ "how",
118
+ "all",
119
+ "any",
120
+ "both",
121
+ "each",
122
+ "few",
123
+ "more",
124
+ "most",
125
+ "other",
126
+ "some",
127
+ "such",
128
+ "no",
129
+ "nor",
130
+ "not",
131
+ "only",
132
+ "own",
133
+ "same",
134
+ "so",
135
+ "than",
136
+ "too",
137
+ "very",
138
+ "s",
139
+ "t",
140
+ "can",
141
+ "will",
142
+ "just",
143
+ "don",
144
+ "don't",
145
+ "should",
146
+ "should've",
147
+ "now",
148
+ "d",
149
+ "ll",
150
+ "m",
151
+ "o",
152
+ "re",
153
+ "ve",
154
+ "y",
155
+ "ain",
156
+ "aren",
157
+ "aren't",
158
+ "couldn",
159
+ "couldn't",
160
+ "didn",
161
+ "didn't",
162
+ "doesn",
163
+ "doesn't",
164
+ "hadn",
165
+ "hadn't",
166
+ "hasn",
167
+ "hasn't",
168
+ "haven",
169
+ "haven't",
170
+ "isn",
171
+ "isn't",
172
+ "ma",
173
+ "mightn",
174
+ "mightn't",
175
+ "mustn",
176
+ "mustn't",
177
+ "needn",
178
+ "needn't",
179
+ "shan",
180
+ "shan't",
181
+ "shouldn",
182
+ "shouldn't",
183
+ "wasn",
184
+ "wasn't",
185
+ "weren",
186
+ "weren't",
187
+ "won",
188
+ "won't",
189
+ "wouldn",
190
+ "wouldn't",
191
+ ]
src/data.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import ray
3
+ import numpy as np
4
+ import pandas as pd
5
+ from ray.data import Dataset
6
+ from sklearn.model_selection import train_test_split
7
+ from src.config import STOPWORDS
8
+ from typing import Dict,List,Tuple
9
+ from transformers import BertTokenizer
10
+
11
+ def data(dataset_loc: str, num_samples: int = None) -> ray.data.Dataset:
12
+ dataset = ray.data.read_csv(dataset_loc)
13
+ dataset = dataset.random_shuffle(seed = 1234)
14
+ dataset = ray.data.from_items(dataset.take(num_samples)) if num_samples else dataset
15
+ return dataset
16
+
17
+ def stratify_split(
18
+ ds: Dataset,
19
+ stratify: str,
20
+ test_size: float,
21
+ shuffle: bool = True,
22
+ seed: int = 1234,
23
+ ) -> Tuple[Dataset, Dataset]:
24
+ def _add_split(df: pd.DataFrame) -> pd.DataFrame:
25
+ train, test = train_test_split(df, test_size=test_size, shuffle=shuffle, random_state=seed)
26
+ train["_split"]= "train"
27
+ test["_split"] = "test"
28
+ return pd.concat([train,test])
29
+
30
+ def _filter_split(df: pd.DataFrame, split: str) -> pd.DataFrame:
31
+ return df[df["_split"] == split].drop("_split", axis = 1)
32
+
33
+ grouped = ds.groupby(stratify).map_groups(_add_split, batch_format="pandas")
34
+ train_ds = grouped.map_batches(_filter_split, fn_kwargs={"split":"train"}, batch_format="pandas")
35
+ test_ds = grouped.map_batches(_filter_split, fn_kwargs={"split":"test"}, batch_format="pandas")
36
+
37
+ train_ds = train_ds.random_shuffle(seed=seed)
38
+ test_ds = test_ds.random_shuffle(seed=seed)
39
+
40
+ return train_ds, test_ds
41
+
42
+ def clean_text(text: str) -> str:
43
+ # change every words into lower case
44
+ text = text.lower()
45
+
46
+ text = re.sub(r"http\S+", "", text) # remove links (must run before punctuation gets spaced apart below)
47
+
48
+ # removing stopwords such as "is", "the" and so on
49
+ pattern = re.compile(r'\b(' + r"|".join(STOPWORDS)+ r")\b\s*")
50
+ text = pattern.sub('', text)
51
+
52
+ text = re.sub(r"([!\"'#$%&()*\+,-./:;<=>?@\\\[\]^_`{|}~])", r" \1 ", text) # add space
53
+ text = re.sub("[^A-Za-z0-9]+", " ", text) # remove other than words and numbers
54
+ text = re.sub(" +", " ", text) # remove all extra spaces
55
+ text = text.strip() # remove spaces at the start and at the end
56
+
57
+ return text
58
+
59
+ _tokenizer_cache = {}
60
+
61
+ def _get_tokenizer(model_name: str = "allenai/scibert_scivocab_uncased"):
62
+ # cache per-process so map_batches workers don't reload the tokenizer on every batch
63
+ if model_name not in _tokenizer_cache:
64
+ _tokenizer_cache[model_name] = BertTokenizer.from_pretrained(model_name, return_dict=False)
65
+ return _tokenizer_cache[model_name]
66
+
67
+ def tokenize(batch: Dict) -> Dict:
68
+ tokenizer = _get_tokenizer()
69
+ encoded = tokenizer(batch["text"].tolist(), return_tensors="np", padding = "longest")
70
+ return dict(ids=encoded["input_ids"], masks=encoded["attention_mask"], targets=np.array(batch["label"]))
71
+
72
+ def preprocess(df: pd.DataFrame):
73
+ df["text"] = df.text.apply(clean_text)
74
+ targets = tokenize(df)
75
+ return targets
76
+
77
+ class CustomPreprocessor():
78
+ """Custom Preprocess."""
79
+ def __init__(self, label_decoder = None):
80
+ self.label_decoder = label_decoder or {
81
+ 0: "World",
82
+ 1: "Sports",
83
+ 2: "Business",
84
+ 3: "Sci/Tech"
85
+ }
86
+ self.class_to_index = {v:k for k, v in self.label_decoder.items()}
87
+
88
+ def fitting(self, ds):
89
+ _ = ds.unique(column="label")
90
+ return self
91
+
92
+ def transforming(self, ds):
93
+ return ds.map_batches(
94
+ preprocess,
95
+ batch_format = "pandas")
src/data_ingestion.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import pandas as pd
3
+ from datasets import load_dataset
4
+
5
+ def download_and_save_ag_news():
6
+ # Create data folder if missing
7
+ os.makedirs("data", exist_ok=True)
8
+
9
+ print("⏳ Fetching ag_news from Hugging Face...")
10
+ # Load from HF Hub
11
+ dataset = load_dataset("fancyzhx/ag_news")
12
+
13
+ # Convert training split to a DataFrame
14
+ train_df = pd.DataFrame(dataset["train"])
15
+
16
+ # Save locally as a raw CSV
17
+ output_path = "data/raw_dataset.csv"
18
+ train_df.to_csv(output_path, index=False)
19
+ print(f"✅ Success! ag_news dataset saved locally to: {output_path}")
20
+
21
+ if __name__ == "__main__":
22
+ download_and_save_ag_news()
src/models.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+
5
+ import torch
6
+ import torch.nn as nn
7
+ from transformers import BertModel
8
+
9
+ MODEL_NAME = "allenai/scibert_scivocab_uncased"
10
+
11
+
12
+ class FinetunedLLM(nn.Module):
13
+ def __init__(self, llm, dropout_p, embedding_dim, num_classes):
14
+ super().__init__()
15
+ self.llm = llm
16
+ self.dropout_p = dropout_p
17
+ self.embedding_dim = embedding_dim
18
+ self.num_classes = num_classes
19
+ self.dropout = nn.Dropout(dropout_p)
20
+ self.fc1 = nn.Linear(embedding_dim, num_classes)
21
+
22
+ def forward(self, batch):
23
+ ids, masks = batch["ids"], batch["masks"]
24
+ seq, pool = self.llm(input_ids=ids, attention_mask=masks)
25
+ z = self.dropout(pool)
26
+ z = self.fc1(z)
27
+ return z
28
+
29
+ @torch.inference_mode()
30
+ def predict(self, batch):
31
+ self.eval()
32
+ z = self(batch)
33
+ return torch.argmax(z, dim=1).cpu().numpy()
34
+
35
+ def save(self, dp):
36
+ with open(Path(dp, "args.json"), "w") as fp:
37
+ json.dump(
38
+ {
39
+ "dropout_p": self.dropout_p,
40
+ "embedding_dim": self.embedding_dim,
41
+ "num_classes": self.num_classes,
42
+ },
43
+ fp,
44
+ indent=4,
45
+ sort_keys=False,
46
+ )
47
+ torch.save(self.state_dict(), os.path.join(dp, "model.pt"))
48
+
49
+ @classmethod
50
+ def load(cls, args_fp, state_dict_fp):
51
+ with open(args_fp, "r") as fp:
52
+ kwargs = json.load(fp)
53
+ llm = BertModel.from_pretrained(MODEL_NAME, return_dict=False)
54
+ model = cls(llm=llm, **kwargs)
55
+ model.load_state_dict(torch.load(state_dict_fp, map_location=torch.device("cpu")))
56
+ return model
src/predict.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import spaces
6
+ import torch
7
+ from dotenv import load_dotenv
8
+ from huggingface_hub import snapshot_download
9
+
10
+ from src.data import _get_tokenizer, clean_text
11
+ from src.models import FinetunedLLM
12
+
13
+ LABEL_DECODER = {0: "World", 1: "Sports", 2: "Business", 3: "Sci/Tech"}
14
+
15
+ _model_cache = {}
16
+
17
+
18
+ def load_model(repo_id: str = None) -> FinetunedLLM:
19
+ """Download (if needed) and load the checkpoint, caching it per process."""
20
+ repo_id = repo_id or os.environ["HF_REPO_ID"]
21
+ if repo_id not in _model_cache:
22
+ checkpoint_dir = snapshot_download(repo_id=repo_id, repo_type="model")
23
+ model = FinetunedLLM.load(
24
+ args_fp=Path(checkpoint_dir, "args.json"),
25
+ state_dict_fp=Path(checkpoint_dir, "model.pt"),
26
+ )
27
+ model.eval()
28
+ _model_cache[repo_id] = model
29
+ return _model_cache[repo_id]
30
+
31
+
32
+ @spaces.GPU
33
+ def predict(text: str, repo_id: str = None) -> str:
34
+ model = load_model(repo_id)
35
+ tokenizer = _get_tokenizer()
36
+ encoded = tokenizer([clean_text(text)], return_tensors="pt", padding="longest")
37
+ batch = {"ids": encoded["input_ids"], "masks": encoded["attention_mask"]}
38
+ device = "cuda" if torch.cuda.is_available() else "cpu"
39
+ model.to(device)
40
+ batch = {k: v.to(device) for k, v in batch.items()}
41
+ with torch.inference_mode():
42
+ pred_idx = torch.argmax(model(batch), dim=1).item()
43
+ return LABEL_DECODER[pred_idx]
44
+
45
+
46
+ if __name__ == "__main__":
47
+ load_dotenv()
48
+ text = " ".join(sys.argv[1:]) or "Wall Street stocks rallied after the Federal Reserve meeting."
49
+ print(predict(text))
src/test_data.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pandas as pd
3
+ import pytest
4
+ import ray
5
+
6
+ from src.data import clean_text, stratify_split, tokenize
7
+
8
+
9
+ @pytest.mark.parametrize(
10
+ "text, expected",
11
+ [
12
+ ("Hello, World!", "hello world"),
13
+ ("Stocks rally after the Fed meeting", "stocks rally fed meeting"),
14
+ ("Check https://example.com for details", "check details"),
15
+ ("", ""),
16
+ ],
17
+ )
18
+ def test_clean_text(text, expected):
19
+ assert clean_text(text) == expected
20
+
21
+
22
+ def test_clean_text_is_lowercase():
23
+ assert clean_text("UPPER CASE TEXT") == clean_text("UPPER CASE TEXT").lower()
24
+
25
+
26
+ def test_stratify_split_preserves_class_proportions():
27
+ df = pd.DataFrame(
28
+ {
29
+ "text": [f"sample {i}" for i in range(100)],
30
+ "label": [0] * 50 + [1] * 50,
31
+ }
32
+ )
33
+ ds = ray.data.from_pandas(df)
34
+ train_ds, test_ds = stratify_split(ds, stratify="label", test_size=0.2)
35
+
36
+ train_df = train_ds.to_pandas()
37
+ test_df = test_ds.to_pandas()
38
+
39
+ assert len(train_df) + len(test_df) == len(df)
40
+ assert set(train_df["label"].unique()) == {0, 1}
41
+ assert set(test_df["label"].unique()) == {0, 1}
42
+ assert train_df["label"].value_counts()[0] == train_df["label"].value_counts()[1]
43
+ assert test_df["label"].value_counts()[0] == test_df["label"].value_counts()[1]
44
+
45
+
46
+ def test_tokenize_shapes_match_batch_size():
47
+ batch = pd.DataFrame({"text": ["hello world", "a longer sentence here"], "label": [0, 1]})
48
+ result = tokenize(batch)
49
+
50
+ assert result["ids"].shape[0] == len(batch)
51
+ assert result["masks"].shape == result["ids"].shape
52
+ assert np.array_equal(result["targets"], np.array([0, 1]))
src/test_predict.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ import pytest
4
+
5
+ from src.predict import LABEL_DECODER, predict
6
+
7
+ pytestmark = pytest.mark.skipif(
8
+ "HF_REPO_ID" not in os.environ, reason="requires HF_REPO_ID (downloads the real checkpoint)"
9
+ )
10
+
11
+
12
+ def test_predict_returns_known_label():
13
+ label = predict("Wall Street stocks rallied after the Federal Reserve meeting.")
14
+ assert label in LABEL_DECODER.values()