Arielle Messer commited on
Commit
1159eb1
·
0 Parent(s):

Deploy to HF Spaces

Browse files
.env.example ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Supabase
2
+ SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
3
+ SUPABASE_KEY=YOUR_SUPABASE_ANON_OR_SERVICE_KEY
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+
5
+ **/.gradio/
6
+ **/.cache/
7
+ **/.ipynb_checkpoints/
8
+ **/artifacts/
9
+ **/__pycache__/
10
+
11
+ # notebooks
12
+ *.ipynb
13
+ !train_upload_notebook.ipynb
README.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Email Prioritization Demo
3
+ sdk: gradio
4
+ app_file: app.py
5
+ python_version: 3.11
6
+ sdk_version: 4.0.0
7
+ ---
8
+
9
+ # Email Prioritization Demo
10
+
11
+ Train and serve an email prioritization model with a Gradio UI for interactive predictions, metrics, and dataset preview.
12
+
13
+ [Live app](https://46ccf31bbf1fadf8e8.gradio.live/)
14
+
15
+ ## Table of Contents
16
+
17
+ [About](#about)
18
+
19
+ [Quickstart](#quickstart)
20
+
21
+ [Repo Structure](#repo-structure)
22
+
23
+ [Data](#data)
24
+
25
+ [Evaluation](#evaluation)
26
+
27
+ ## About
28
+
29
+ This project includes:
30
+
31
+ - a simple email prioritization model (training + inference)
32
+ - an event-style predictor that assigns a label to an email
33
+ - a Gradio web app that supports:
34
+ 1. pasting an email (subject/body) to view the predicted label, confidence, probabilities, and reasons
35
+ 2. viewing evaluation metrics on a fixed test split (confusion matrix + macro PR-AUC)
36
+ 3. previewing the dataset used for evaluation
37
+
38
+ ## Quickstart
39
+
40
+ ### UI
41
+
42
+ Use the **Simulate email event** tab in the [app](https://46ccf31bbf1fadf8e8.gradio.live/): paste an email `subject` + `body` to get a prediction.
43
+
44
+ ### Local Configuration
45
+
46
+ > Note: `src/app.py` may use `demo.launch(share=True)` for public demos. For local-only runs, set `share=False` or remove the argument.
47
+
48
+ #### Prereqs
49
+
50
+ - Python 3.11+ (tested on Python 3.12)
51
+
52
+ - Supabase project credentials in a local `.env` file (see `.env.example`).
53
+
54
+ The Gradio app entrypoint is `src/app.py`. Run it locally with:
55
+
56
+ ```bash
57
+ pip install -r requirements.txt
58
+ python3 src/app.py
59
+ ```
60
+
61
+ ## Repo Structure
62
+
63
+ ```text
64
+ src/
65
+ app.py # Gradio UI
66
+ train_upload_notebook.ipynb # where model is trained and uploaded to supabase
67
+ ml/
68
+ train.py # model construction / training helpers
69
+ predict.py # prediction + reasoning helpers
70
+ eval.py # confusion matrix + PR-AUC plotting
71
+ pre_process.py # preprocessing fcns
72
+ store/
73
+ supabase_io.py # Supabase fetch/upload/download utilities
74
+
75
+ ```
76
+
77
+ ## Data
78
+
79
+ - [Public source dataset](https://huggingface.co/datasets/jason23322/high-accuracy-email-classifier) used for training
80
+ - See details in [Writeup: dataset selection](writeup.md#dataset-selection)
81
+ - View dataset preview in **Dataframe** tab in [Gradio app](https://46ccf31bbf1fadf8e8.gradio.live/)
82
+ - For the demo app, the processed dataset is mirrored into Supabase and trained model is stored in Supabase Storage
83
+ - See details in [Writeup: storage selection](writeup.md#storage-and-deployment-choices)
84
+
85
+ ## Evaluation
86
+
87
+ - View performance metrics in the **Performance** tab of the [Gradio app](https://46ccf31bbf1fadf8e8.gradio.live/).
88
+ - Evaluation uses a seeded **stratified train/test split**
89
+ - **PR-AUC (Average Precision)** + PR curve (threshold-independent)
90
+ - **Confusion matrix (counts)** under the **argmax** decision policy
91
+ - More details: [Writeup: Evaluation](writeup.md#evaluation)
app.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ SRC_PATH = Path(__file__).parent / "src"
9
+ if str(SRC_PATH) not in sys.path:
10
+ sys.path.insert(0, str(SRC_PATH))
11
+
12
+ spec = importlib.util.spec_from_file_location("src_app", SRC_PATH / "app.py")
13
+ module = importlib.util.module_from_spec(spec)
14
+ sys.modules["src_app"] = module
15
+ assert spec.loader is not None
16
+ spec.loader.exec_module(module)
17
+
18
+ demo = module.demo
19
+
20
+ if __name__ == "__main__":
21
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.0
2
+ pandas>=2.0
3
+ numpy>=1.24
4
+ scikit-learn>=1.3
5
+ matplotlib>=3.7
6
+ joblib>=1.3
7
+ python-dotenv>=1.0
8
+ supabase>=2.0
src/app.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+ from pathlib import Path
5
+
6
+ import gradio as gr
7
+ import joblib
8
+ import pandas as pd
9
+ from dotenv import load_dotenv, find_dotenv
10
+ from sklearn.model_selection import train_test_split
11
+
12
+ from store import supabase_io
13
+ from ml import predict, evaluate, pre_process
14
+
15
+ # Load env once at startup
16
+ load_dotenv(find_dotenv())
17
+
18
+
19
+ # ----------------------------
20
+ # CACHED LOADERS
21
+ # ----------------------------
22
+ @lru_cache(maxsize=1)
23
+ def get_model():
24
+ lp = supabase_io.download_artifact(
25
+ bucket="models",
26
+ object_path="resend/v1/pipeline.joblib",
27
+ local_path=".cache/pipeline.joblib",
28
+ force=False, # set True only if retraining
29
+ )
30
+ return joblib.load(lp)
31
+
32
+ @lru_cache(maxsize=1)
33
+ def get_raw_df(limit: int = 20000) -> pd.DataFrame:
34
+ return supabase_io.fetch_df("emails_labeled", limit=limit)
35
+
36
+
37
+ @lru_cache(maxsize=1)
38
+ def get_eval_split():
39
+
40
+ df = get_raw_df()
41
+ X, y = pre_process.prepare_xy(df) # deduplication, fill na
42
+ X_train, X_test, y_train, y_test = train_test_split(
43
+ X, y, test_size=0.25, random_state=37, stratify=y
44
+ ) # fixed split
45
+ return X_train, X_test, y_train, y_test
46
+
47
+ # ----------------------------
48
+ # TAB 1: SIMULATE EMAIL EVENT
49
+ # ----------------------------
50
+ def ui_predict_one(subject: str, body: str):
51
+ model = get_model()
52
+
53
+ label, confidence, probs, reasons = predict.predict_one_with_reasons(
54
+ model, subject=subject, body=body, top_k=10
55
+ )
56
+
57
+ # probs can be pd.Series or dict; normalize to dict for gr.Label
58
+ if hasattr(probs, "to_dict"):
59
+ probs_dict = probs.to_dict()
60
+ else:
61
+ probs_dict = dict(probs)
62
+
63
+ # Reasons
64
+ reasons_md = "\n".join([f"- {r}" for r in reasons]) if reasons else "_No strong features found._"
65
+
66
+ return (
67
+ str(label),
68
+ float(confidence),
69
+ probs_dict, # for gr.Label (bars)
70
+ reasons_md,
71
+ )
72
+
73
+ # ----------------------------
74
+ # TAB 2: PERFORMANCE
75
+ # ----------------------------
76
+ def ui_run_eval():
77
+ model = get_model()
78
+ _, X_test, _, y_test = get_eval_split()
79
+
80
+ y_hat, proba = predict.probabilities_and_labels(model, X_test)
81
+
82
+ classes = list(model.named_steps["clf"].classes_)
83
+
84
+ # ensure proba is numpy array for PR code
85
+ if isinstance(proba, pd.DataFrame):
86
+ proba_np = proba[classes].to_numpy()
87
+ else:
88
+ proba_np = proba
89
+
90
+ cm_df, cm_fig = evaluate.plot_confusion_matrix_argmax(
91
+ y_test, y_hat, classes=classes, normalize=None
92
+ )
93
+ pr_auc, pr_fig = evaluate.plot_pr_auc_macro_from_proba(
94
+ y_test, proba_np, classes=classes
95
+ )
96
+
97
+ return cm_df, cm_fig, float(pr_auc), pr_fig
98
+
99
+
100
+ # ----------------------------
101
+ # TAB 3: DATAFRAME DISPLAY
102
+ # ----------------------------
103
+ def ui_show_df(n_rows: int):
104
+ df = get_raw_df()
105
+ return df.head(int(n_rows))
106
+
107
+
108
+ # ----------------------------
109
+ # APP
110
+ # ----------------------------
111
+ with gr.Blocks(title="Email Classifier Demo") as demo:
112
+ gr.Markdown(
113
+ """
114
+ # Email Classifier
115
+
116
+ **Data source:** [jason23322/high-accuracy-email-classifier](https://huggingface.co/datasets/jason23322/high-accuracy-email-classifier)
117
+ **License:** Apache-2.0
118
+
119
+ For training details, evaluation methodology, and how predictions/reasons are computed, see the **README** in
120
+ [repo](https://github.com/AFractalThought/email_prioritization)
121
+ """
122
+ )
123
+
124
+ with gr.Tabs():
125
+ # ---- Tab 1
126
+ with gr.Tab("Simulate email event"):
127
+ subject = gr.Textbox(label="Subject", lines=1, placeholder="e.g. Verify your email")
128
+ body = gr.Textbox(label="Body", lines=8, placeholder="Paste email body here...")
129
+
130
+ btn = gr.Button("Predict")
131
+
132
+ out_label = gr.Textbox(label="Prediction")
133
+ out_conf = gr.Number(label="Confidence (max probability)")
134
+ out_probs = gr.Label(label="Probabilities (all labels)", num_top_classes=10)
135
+ out_reasons = gr.Markdown(label="Reasons (top features)")
136
+
137
+ btn.click(
138
+ fn=ui_predict_one,
139
+ inputs=[subject, body],
140
+ outputs=[out_label, out_conf, out_probs, out_reasons],
141
+ )
142
+ # ---- Tab 2
143
+ with gr.Tab("Performance"):
144
+ gr.Markdown("Uses a fixed train/test split of the labeled dataset.")
145
+ run_eval = gr.Button("Run evaluation")
146
+
147
+ cm_table = gr.Dataframe(label="Confusion matrix (counts)")
148
+ cm_plot = gr.Plot(label="Confusion matrix plot")
149
+ pr_score = gr.Number(label="Macro PR-AUC (Average Precision)")
150
+ pr_plot = gr.Plot(label="Macro Precision–Recall curve")
151
+
152
+ run_eval.click(
153
+ fn=ui_run_eval,
154
+ inputs=[],
155
+ outputs=[cm_table, cm_plot, pr_score, pr_plot],
156
+ )
157
+
158
+ # ---- Tab 3
159
+ with gr.Tab("Dataframe"):
160
+ n_rows = gr.Slider(5, 500, value=50, step=5, label="Rows to display")
161
+ show = gr.Button("Show rows")
162
+ df_view = gr.Dataframe(label="emails_labeled (preview)", wrap=True)
163
+
164
+ show.click(fn=ui_show_df, inputs=[n_rows], outputs=[df_view])
165
+
166
+ if __name__ == "__main__":
167
+ demo.launch()
src/ml/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # ml/__init__.py
src/ml/evaluate.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pandas as pd
3
+ import matplotlib.pyplot as plt
4
+ from sklearn.pipeline import Pipeline
5
+ from sklearn.metrics import (
6
+ confusion_matrix,
7
+ ConfusionMatrixDisplay,
8
+ precision_recall_curve,
9
+ average_precision_score,
10
+ )
11
+
12
+
13
+ def plot_confusion_matrix_argmax(
14
+ y_true,
15
+ y_pred,
16
+ classes=None,
17
+ normalize: str | None = "true",
18
+ ):
19
+ """
20
+ Confusion matrix for an argmax classifier.
21
+ Returns (cm_df, fig).
22
+
23
+ normalize:
24
+ - "true": normalize rows
25
+ - "pred", "all", or None for counts
26
+ """
27
+ y_true = np.asarray(y_true).astype(str)
28
+ y_pred = np.asarray(y_pred).astype(str)
29
+
30
+ if classes is None:
31
+ classes = sorted(set(y_true) | set(y_pred))
32
+
33
+ cm = confusion_matrix(y_true, y_pred, labels=classes, normalize=normalize)
34
+ cm_df = pd.DataFrame(cm, index=classes, columns=classes)
35
+
36
+ fig, ax = plt.subplots()
37
+ ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=classes).plot(
38
+ ax=ax,
39
+ values_format=".2f" if normalize else "d",
40
+ colorbar=True,
41
+ )
42
+ ax.set_title("Confusion matrix" + (" (normalized by True label)" if normalize else " (counts)"))
43
+ fig.tight_layout()
44
+
45
+ return cm_df, fig
46
+
47
+
48
+
49
+ def plot_pr_auc_macro_from_proba(
50
+ y_true,
51
+ proba: np.ndarray,
52
+ classes,
53
+ ):
54
+ """
55
+ Compute macro-average AP (PR-AUC) for multiclass via one-vs-rest, and plot
56
+ a macro-averaged PR curve.
57
+
58
+ Inputs:
59
+ y_true: array-like of shape (n,)
60
+ proba: ndarray of shape (n, K) with predicted probabilities
61
+ classes: list/array of length K matching columns of proba
62
+
63
+ Returns: (macro_ap, fig)
64
+ """
65
+ y_true = np.asarray(y_true).astype(str)
66
+ proba = np.asarray(proba)
67
+ classes = list(classes)
68
+
69
+ if proba.ndim != 2 or proba.shape[0] != len(y_true) or proba.shape[1] != len(classes):
70
+ raise ValueError(f"Shape mismatch: y_true={len(y_true)}, proba={proba.shape}, classes={len(classes)}")
71
+
72
+ ap_list = []
73
+ precisions = []
74
+ recalls = []
75
+
76
+ for j, cls in enumerate(classes):
77
+ y_bin = (y_true == cls).astype(int)
78
+ if y_bin.sum() == 0:
79
+ continue # class not present in eval set
80
+
81
+ y_score = proba[:, j]
82
+ ap_list.append(average_precision_score(y_bin, y_score))
83
+
84
+ p, r, _ = precision_recall_curve(y_bin, y_score)
85
+ precisions.append(p)
86
+ recalls.append(r)
87
+
88
+ if not ap_list:
89
+ raise ValueError("No classes in y_true had positive examples; cannot compute PR-AUC.")
90
+
91
+ macro_ap = float(np.mean(ap_list))
92
+
93
+ # Macro PR curve by interpolating precision onto a common recall grid
94
+ recall_grid = np.linspace(0, 1, 200)
95
+ prec_on_grid = []
96
+ for p, r in zip(precisions, recalls):
97
+ order = np.argsort(r)
98
+ r_sorted = r[order]
99
+ p_sorted = p[order]
100
+ prec_on_grid.append(
101
+ np.interp(recall_grid, r_sorted, p_sorted, left=p_sorted[0], right=p_sorted[-1])
102
+ )
103
+
104
+ macro_precision = np.mean(np.vstack(prec_on_grid), axis=0)
105
+
106
+ fig, ax = plt.subplots()
107
+ ax.plot(recall_grid, macro_precision)
108
+ ax.set_xlim(0, 1)
109
+ ax.set_ylim(0, 1)
110
+ ax.set_xlabel("Recall")
111
+ ax.set_ylabel("Precision")
112
+ ax.set_title(f"Macro PR curve (macro AP = {macro_ap:.3f})")
113
+ fig.tight_layout()
114
+
115
+ return macro_ap, fig
src/ml/pre_process.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+
3
+ def prepare_xy(df: pd.DataFrame):
4
+ df = df.copy()
5
+ df["subject"] = df["subject"].fillna("").astype(str)
6
+ df["body"] = df["body"].fillna("").astype(str)
7
+ df["label"] = df["label"].astype(str)
8
+
9
+ df = df.drop_duplicates(subset=["subject", "body"]).copy()
10
+
11
+ X = df[["subject", "body"]]
12
+ y = df["label"]
13
+ return X, y
src/ml/predict.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sklearn.pipeline import Pipeline
2
+ import pandas as pd
3
+ import numpy as np
4
+ from typing import Tuple
5
+
6
+ def probabilities_and_labels(model: Pipeline, X: pd.DataFrame) -> pd.DataFrame:
7
+ predictions = model.predict(X)
8
+ probabilities = model.predict_proba(X)
9
+ classes = model.named_steps["clf"].classes_
10
+ proba_df = pd.DataFrame(probabilities, columns=classes, index=X.index)
11
+ return predictions, proba_df
12
+
13
+ def predict_one(model: Pipeline, subject: str, body: str) -> tuple[str, pd.Series]:
14
+ X = pd.DataFrame([{
15
+ "subject": subject or "",
16
+ "body": body or "",
17
+ }])
18
+ y_pred, proba_df = probabilities_and_labels(model, X)
19
+ return y_pred[0], proba_df.iloc[0]
20
+
21
+
22
+ def explain_linear_top_features(
23
+ model: Pipeline,
24
+ X: pd.DataFrame,
25
+ class_label: str,
26
+ top_k: int = 10,
27
+ ) -> list[str]:
28
+ """
29
+ Returns top_k feature contributions for `class_label` on the first row of X.
30
+ Contributions are in logit units (not probability).
31
+ """
32
+ text = model.named_steps["text"]
33
+ clf = model.named_steps["clf"]
34
+
35
+ Xv = text.transform(X) # sparse (1, n_features)
36
+ feature_names = text.get_feature_names_out()
37
+ classes = list(clf.classes_)
38
+ class_idx = classes.index(class_label)
39
+
40
+ coef = clf.coef_ # (K, n_features) for multiclass, or (1, n_features) for binary
41
+ if coef.shape[0] == 1 and len(classes) == 2:
42
+ # binary special-case: sklearn stores only coef for classes_[1]
43
+ coef_c = coef[0] if class_idx == 1 else -coef[0]
44
+ else:
45
+ coef_c = coef[class_idx]
46
+
47
+ row = Xv[0]
48
+ idx = row.indices
49
+ vals = row.data
50
+ if idx.size == 0:
51
+ return ["No nonzero TF-IDF features (empty subject/body after preprocessing)."]
52
+
53
+ contrib = vals * coef_c[idx] # per-feature contribution for this class
54
+ order = np.argsort(np.abs(contrib))[::-1][:top_k]
55
+
56
+ reasons = []
57
+ for k in order:
58
+ feat = feature_names[idx[k]] # e.g. "subject__password" or "body__unsubscribe"
59
+ reasons.append(f"{feat}: {contrib[k]:+.3f}")
60
+ return reasons
61
+
62
+
63
+ def predict_one_with_reasons(model: Pipeline, subject: str, body: str, top_k: int = 10):
64
+ X = pd.DataFrame([{"subject": subject or "", "body": body or ""}])
65
+
66
+ proba = model.predict_proba(X)[0]
67
+ classes = list(model.named_steps["clf"].classes_)
68
+ probs = pd.Series(proba, index=classes)
69
+
70
+ label = probs.idxmax()
71
+ confidence = float(probs.max())
72
+
73
+ reasons = explain_linear_top_features(model, X, class_label=label, top_k=top_k)
74
+ return label, confidence, probs, reasons
75
+
76
+
src/ml/train.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ from sklearn.pipeline import Pipeline
5
+ from sklearn.compose import ColumnTransformer
6
+ from sklearn.feature_extraction.text import TfidfVectorizer
7
+ from sklearn.linear_model import LogisticRegression
8
+ from sklearn.model_selection import train_test_split
9
+
10
+
11
+ from pathlib import Path
12
+ import joblib
13
+ from ml import pre_process
14
+
15
+ def fit(df: pd.DataFrame, test_size = 0.25) -> Pipeline:
16
+
17
+ # Preprocess table (de-duplicate etc)
18
+ X, y = pre_process.prepare_xy(df)
19
+
20
+ # Split train and test
21
+ X_train, X_test, y_train, y_test = train_test_split(
22
+ X, y, test_size=test_size, random_state=37, stratify=y
23
+ )
24
+
25
+ text = ColumnTransformer(
26
+ transformers=[
27
+ ("subject", TfidfVectorizer(ngram_range=(1, 2), min_df=2, max_features=50_000), "subject"),
28
+ ("body", TfidfVectorizer(ngram_range=(1, 2), min_df=2, max_features=200_000), "body"),
29
+ ],
30
+ remainder="drop",
31
+ sparse_threshold=0.3,
32
+ )
33
+
34
+ clf = LogisticRegression(
35
+ max_iter=3000,
36
+ class_weight="balanced",
37
+ )
38
+
39
+ pipe = Pipeline([
40
+ ("text", text),
41
+ ("clf", clf),
42
+ ])
43
+
44
+ return pipe.fit(X_train, y_train)
45
+
46
+ def save_model_local(model: Pipeline, path: str | Path) -> Path:
47
+ path = Path(path)
48
+ path.parent.mkdir(parents=True, exist_ok=True)
49
+ joblib.dump(model, path)
50
+ return path
src/store/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # store/__init__.py
src/store/supabase_io.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ import pandas as pd
6
+ from supabase import create_client
7
+
8
+
9
+ def make_supabase_client():
10
+ url = os.environ["SUPABASE_URL"]
11
+ key = os.environ["SUPABASE_ANON_KEY"]
12
+ return create_client(url, key)
13
+
14
+ def fetch_df(table_name: str, limit: int = 10000) -> pd.DataFrame:
15
+ supabase = make_supabase_client()
16
+ resp = supabase.table(table_name).select("*").limit(limit).execute()
17
+ return pd.DataFrame(resp.data)
18
+
19
+ def upload_artifact(local_path: str | Path, bucket: str, object_path: str, upsert: bool = True) -> None:
20
+ supabase = make_supabase_client()
21
+ p = Path(local_path)
22
+
23
+ with p.open("rb") as f:
24
+ supabase.storage.from_(bucket).upload(
25
+ path=object_path,
26
+ file=f,
27
+ file_options={"upsert": "true" if upsert else "false", "cache-control": "3600"},
28
+ )
29
+
30
+ def download_artifact(
31
+ bucket: str,
32
+ object_path: str,
33
+ local_path: str | Path,
34
+ force: bool = False,
35
+ ) -> Path:
36
+ """
37
+ Download an object from Supabase Storage to a local file.
38
+
39
+ - bucket: storage bucket name
40
+ - object_path: remote key inside bucket (e.g. "resend/models/model.joblib")
41
+ - local_path: where to save locally
42
+ - force: if True, re-download even if local file exists
43
+ """
44
+ supabase = make_supabase_client()
45
+ lp = Path(local_path)
46
+ lp.parent.mkdir(parents=True, exist_ok=True)
47
+
48
+ if lp.exists() and not force:
49
+ return lp
50
+
51
+ data = supabase.storage.from_(bucket).download(object_path) # bytes
52
+ lp.write_bytes(data)
53
+ return lp
src/train_upload_notebook.ipynb ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "code",
5
+ "execution_count": 1,
6
+ "id": "ce4c9651-f069-45ad-a66f-19f29c24c9ee",
7
+ "metadata": {},
8
+ "outputs": [],
9
+ "source": [
10
+ "%%capture output_var\n",
11
+ "\n",
12
+ "from dotenv import load_dotenv, find_dotenv\n",
13
+ "from store import supabase_io\n",
14
+ "from ml import train\n",
15
+ "\n",
16
+ "load_dotenv(find_dotenv())\n"
17
+ ]
18
+ },
19
+ {
20
+ "cell_type": "code",
21
+ "execution_count": 2,
22
+ "id": "5fa0089d-d797-430d-9ba0-20f2ed7094e6",
23
+ "metadata": {},
24
+ "outputs": [],
25
+ "source": [
26
+ "# Supabase table name\n",
27
+ "table = \"emails_labeled\"\n",
28
+ "\n",
29
+ "# Fetch table from Supabase\n",
30
+ "df = supabase_io.fetch_df(table, limit=20000)\n",
31
+ "\n",
32
+ "# Fit model\n",
33
+ "model = train.fit(df)"
34
+ ]
35
+ },
36
+ {
37
+ "cell_type": "code",
38
+ "execution_count": null,
39
+ "id": "043e29bd-480d-4ebd-9cdb-305d0122946e",
40
+ "metadata": {},
41
+ "outputs": [],
42
+ "source": [
43
+ "## Upload Artifact\n",
44
+ "# save model locally and upload to supabase\n",
45
+ "path = train.save_model_local(model, \"artifacts/pipeline.joblib\")\n",
46
+ "supabase_io.upload_artifact(path, bucket = \"models\", object_path = \"resend/v1/pipeline.joblib\", upsert = True)\n"
47
+ ]
48
+ }
49
+ ],
50
+ "metadata": {
51
+ "kernelspec": {
52
+ "display_name": "Python 3 (ipykernel)",
53
+ "language": "python",
54
+ "name": "python3"
55
+ },
56
+ "language_info": {
57
+ "codemirror_mode": {
58
+ "name": "ipython",
59
+ "version": 3
60
+ },
61
+ "file_extension": ".py",
62
+ "mimetype": "text/x-python",
63
+ "name": "python",
64
+ "nbconvert_exporter": "python",
65
+ "pygments_lexer": "ipython3",
66
+ "version": "3.12.2"
67
+ }
68
+ },
69
+ "nbformat": 4,
70
+ "nbformat_minor": 5
71
+ }
writeup.md ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data Scientist Trust & Safety Take Home Challenge — Write Up
2
+
3
+ This document summarizes the key implementation decisions behind the submission: dataset selection and label mapping, model/features, confidence + reasoning definitions, evaluation choices, and storage/deployment setup.
4
+
5
+ ## Dataset selection
6
+
7
+ ### Why I did not use the provided dataset
8
+
9
+ I initially explored the dataset included in the prompt, but it introduced several issues that would have made the model and evaluation misleading:
10
+
11
+ - **Weak feature coherence:** In many rows, the `From`, `Subject`, and `Body` fields did not appear semantically consistent (suggesting independent templating). This makes it difficult to learn realistic correlations across fields and undermines the intent of using all three signals.
12
+
13
+ - **Missing “priority” examples:** There were few/no clear MFA or verification-code examples, which is explicitly required for the `Prioritize` class.
14
+
15
+ - **Label mismatch with task:** Labels were `ham/spam`, while the assignment requires `priority/default/slow`. I could have generated new labels, but under time constraints it was more reliable to use a dataset whose categories already aligned with the goal.
16
+
17
+ ### Why I chose the Hugging Face dataset
18
+
19
+ I used a [a dataset obtained from Hugging Face](https://huggingface.co/datasets/jason23322/high-accuracy-email-classifier) `jason23322/high-accuracy-email-classifier` (Apache-2.0):
20
+
21
+ - The dataset’s categories map directly to the assignment’s required classes (especially verification codes).
22
+
23
+ - The examples are more internally coherent (subject/body alignment is generally consistent).
24
+
25
+ - Duplication exists (as in most datasets) but is manageable; I explicitly **de-duplicate identical subject/body pairs** before splitting to reduce leakage risk.
26
+
27
+ ### Caveats / limitations of the chosen dataset
28
+
29
+ - The dataset is still **simulated**, so performance is not representative of real email traffic.
30
+
31
+ - It does **not include a sender field**. I realize the assignment specifically mentions using `from` (available in the provided dataset). I chose not to fabricate sender values or mix in another dataset due to scope/complexity, and because subject/body alone were sufficient to demonstrate the approach.
32
+
33
+ ## Label mapping
34
+
35
+ ### Original dataset labels
36
+
37
+ `['promotions', 'spam', 'social_media', 'forum', 'verify_code', 'updates']`
38
+
39
+ ### Mapping to assignment labels
40
+
41
+ | Assignment label | Description (prompt) | Dataset categories mapped |
42
+ |---|---|---|
43
+ | **Prioritize** | MFA codes / verification | `verify_code` |
44
+ | **Default** | Generic messages | `social_media`, `forum`, `updates`|
45
+ | **Slow** | Non-urgent promotional messages |`promotions`
46
+ | **(Spam*)** | NOT included in assignment | `spam` |
47
+
48
+ ### Why I included `spam` as an extra class
49
+
50
+ The assignment defines three labels, but the dataset contains a clear spam category. I kept `spam` as a separate label instead of dropping it or mapping it into `slow`, because in a realistic pipeline you’d rather route obvious spam away from the inbox than treat it as “slow.” This makes the demo more practical, and the model still outputs probabilities across all classes.
51
+
52
+ ## Model approach
53
+
54
+ ### Vectorization
55
+
56
+ I used **TF–IDF** features for the `subject` and `body`.
57
+
58
+ - I considered embeddings/LLM-based approaches, but TF–IDF is a strong baseline, easy to debug, and well-suited to short templated text like verification codes and promotions.
59
+
60
+ - I intentionally vectorize **subject and body separately** (two TF–IDF vectorizers inside a `ColumnTransformer`). This preserves the distinction between words appearing in the subject vs the body (e.g., “verify” in a subject line is often more predictive than in a long email body).
61
+
62
+ ### Model choice
63
+
64
+ I used **multinomial Logistic Regression**.
65
+
66
+ - Logistic regression is interpretable, fast, and a good baseline for text classification.
67
+
68
+ - I did not run hyperparameter tuning (e.g., grid search) under time constraints; the goal was a clean, understandable pipeline and working demo or explore other model choices
69
+
70
+ ### Decision policy
71
+
72
+ The model outputs probabilities for each label via `predict_proba`.
73
+
74
+ - The default policy is **argmax**: choose the label with the highest probability.
75
+
76
+ - In a production system, you might use **class-specific thresholds** (e.g., prioritize high recall for `Prioritize`, high precision for `Spam`). I kept argmax to keep behavior simple and evaluation straightforward.
77
+
78
+ ## Confidence and reasoning
79
+
80
+ ### Confidence
81
+
82
+ **Confidence = max predicted probability** for the selected label (i.e., `max(p(label | email))`).
83
+
84
+ This is a standard definition for multi-class models. Note: probabilities are not guaranteed to be perfectly calibrated, but they are a useful relative measure for the demo.
85
+
86
+ ### Reasoning
87
+
88
+ To produce human-readable reasons, I compute **top contributing TF–IDF features** for the predicted class by combining:
89
+
90
+ - the email’s TF–IDF vector (non-zero features)
91
+ - the logistic regression coefficients for the predicted class
92
+
93
+ This yields a ranked list of tokens/ngrams that most influenced the decision (“top features” explanation).
94
+
95
+ **Interpretability note:** The “reasons” shown in the app come from logistic regression feature contributions (TF-IDF weights × class coefficients). This approach is specific to linear models. If I were to switch to non-linear models (e.g., tree ensembles or neural embeddings), I’d replace this with a model-agnostic explanation method such as SHAP (or integrated gradients for neural models), at the cost of additional complexity and runtime.
96
+
97
+ ## Evaluation
98
+
99
+ ### Metrics shown in the UI
100
+
101
+ I report two complementary views:
102
+
103
+ 1. **Confusion matrix (counts)** using the argmax policy
104
+ - Provides concrete error counts by class.
105
+ 2. **Macro PR-AUC (Average Precision)** as threshold-independent performance
106
+ - PR-AUC is appropriate under class imbalance and aligns with “ranking quality” of `predict_proba`.
107
+
108
+ ## Storage and deployment choices
109
+
110
+ ### Storage / data
111
+
112
+ I used **Supabase** because:
113
+
114
+ - it provides a simple Postgres backend for storing labeled examples
115
+
116
+ - it includes object storage suitable for model artifacts (similar to S3)
117
+
118
+ - I have used it before and it integrates cleanly with Python
119
+
120
+ ### App
121
+
122
+ I used **Gradio** because it supports:
123
+
124
+ - quick “send a test email event” workflows
125
+
126
+ - easy visualization of plots and dataframes
127
+
128
+ - simple deployment patterns
129
+
130
+ ## How the system works end-to-end
131
+
132
+ ### Training (local)
133
+
134
+ - Fetch training data from Supabase
135
+
136
+ - Preprocess (de-duplicate, split)
137
+
138
+ - Vectorize text with TF–IDF + Logistic Regression pipeline
139
+
140
+ - Save model artifact locally (`joblib`)
141
+
142
+ - Upload artifact to Supabase Storage
143
+
144
+ ### App (online)
145
+
146
+ - Download the trained artifact from Supabase Storage (cached locally)
147
+
148
+ - Fetch the dataset from Supabase (cached)
149
+
150
+ - Tabs:
151
+ - **Simulate email event:** user enters subject/body → app returns label, confidence, per-class probabilities, and reasons
152
+ - **Performance:** computes confusion matrix + PR curve / macro PR-AUC on a fixed split
153
+ - **Dataframe:** displays a preview of the dataset
154
+
155
+ ## Notes on real-world performance and model building
156
+
157
+ The model performs well on this simulated dataset, but performance will drop on real email traffic due to:
158
+
159
+ - broader vocabulary and writing styles
160
+
161
+ - adversarial spam behavior
162
+
163
+ - distribution shifts (new templates, new senders, different user contexts)
164
+
165
+ A production-grade version would require:
166
+
167
+ - real emails
168
+
169
+ - better calibration + thresholding policies
170
+
171
+ - continuous evaluation / monitoring
172
+
173
+ - richer features including sender domain reputation and message metadata
174
+
175
+ - trying multiple models, and policies, hyperparameter tuning