diff --git a/.gitignore b/.gitignore index ef1c844..eb9a66d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,5 @@ scaler.pkl instance/ shit/ *.db -.vscode/ \ No newline at end of file +.vscode/ +dataset/sample.csv \ No newline at end of file diff --git a/app/app.py b/app/app.py index b3efa14..4d71380 100644 --- a/app/app.py +++ b/app/app.py @@ -1,9 +1,13 @@ from .schema.validation import UserInput -from fastapi import FastAPI, Request , Depends +from fastapi import FastAPI, Request , Depends, UploadFile from fastapi.responses import JSONResponse, FileResponse +from fastapi.exceptions import HTTPException from fastapi.staticfiles import StaticFiles from typing import List, Tuple import numpy as np +import pandas as pd +import shutil +import uuid from sklearn.pipeline import Pipeline import joblib from contextlib import asynccontextmanager @@ -13,8 +17,11 @@ # paths to the pickle files (relative to project root) MODEL_PATH = Path(__file__).parent.parent / "models" / "estimator.pkl" NAMES_PATH = Path(__file__).parent.parent / "models" / "names.pkl" +# Directory for user-uploaded files +UPLOAD_DIR = Path("app","uploads") +UPLOAD_DIR.mkdir(exist_ok=True) -# if pickle files are not found, this function will train the model +# if pickle files are not found, this helper will train the model def ensure_models(): print("🔍 Checking for existing model files...") if not MODEL_PATH.is_file() or not NAMES_PATH.is_file(): @@ -30,6 +37,24 @@ def ensure_models(): print("✅ Model and feature names loaded successfully.") return model, names +# helper for validating the user-uploaded .csv file +def validate_csv(payload:pd.DataFrame, expected_columns:List)-> pd.DataFrame: + expected_columns = [r for r in expected_columns if r != "alert"] + if payload.columns.tolist() != expected_columns: + raise HTTPException( + status_code=422, + detail="Uploaded csv file does not match the expected column configuration" + ) + + try: + df = payload.astype(float) + except Exception: + raise HTTPException( + status_code=422, + detail="Value in the uploaded csv file must be numeric (float-compatible)" + ) + return df + @asynccontextmanager async def lifespan(app:FastAPI): pipe,feat_names = ensure_models() @@ -92,3 +117,38 @@ def predict_things(value:UserInput,dep:Tuple[Pipeline,np.ndarray] = Depends(get_ return JSONResponse( status_code=201, content=msg ) + +@app.post("/predict/batch",status_code=201) +def predict_things_in_batch(payload:UploadFile,dep:Tuple[Pipeline,np.ndarray] = Depends(get_things)): + pipe,feat_names = dep + feat_names:List[str] = feat_names.tolist() + + # Receive and validate incoming upload + valid_exts = [".csv"] + extension = Path(payload.filename).suffix + if extension not in valid_exts: + raise HTTPException( + status_code=422, detail=f"Only `.csv` files are accepted as input, got {extension} instead" + ) + df_prev = pd.read_csv(payload.file) + df: pd.DataFrame = validate_csv(df_prev,feat_names) + + # Run Predictions + user_inp = df.to_numpy() + pred_label:List[float] = pipe.predict(user_inp).tolist() + pred_proba:List[List[float]] = pipe.predict_proba(user_inp).tolist() + + # Postprocessing + label_map = {0: "green", 1: "orange", 2: "red", 3: "yellow"} + pred_label:List[str] = [label_map.get(r) for r in pred_label] + pred_proba = [[round(proba,3) for proba in sample] for sample in pred_proba] + + # Final Output + msg = { + "message": "batch prediction successful", + "prediction": pred_label, + "probabilities": pred_proba + } + return JSONResponse( + status_code=201, content=msg + ) \ No newline at end of file diff --git a/app/static/script.js b/app/static/script.js index e82fe9e..e4ed256 100644 --- a/app/static/script.js +++ b/app/static/script.js @@ -161,3 +161,228 @@ if (clearBtn && form) { } }); } + +// File input display +const csvFileInput = document.getElementById('csvfile'); +const fileNameDisplay = document.getElementById('fileName'); +if (csvFileInput && fileNameDisplay) { + csvFileInput.addEventListener('change', (e) => { + if (e.target.files.length > 0) { + const file = e.target.files[0]; + if (file.size > 5 * 1024 * 1024) { + showToast('File size exceeds 5MB limit. Please upload a smaller file.', 'error'); + file.value = ''; + fileNameDisplay.textContent = 'Choose CSV file...'; + return; + } + fileNameDisplay.textContent = file.name; + } + }); +} + +// Batch Prediction Form +const batchForm = document.getElementById('batchForm'); +const batchBtn = document.getElementById('batchPredictBtn'); +const batchLoader = document.getElementById('batchLoader'); + +if (batchForm && batchBtn) { + batchForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const fileInput = document.getElementById('csvfile'); + if (!fileInput || !fileInput.files.length) { + showToast('Please select a CSV file', 'error'); + return; + } + + const formData = new FormData(); + formData.append('payload', fileInput.files[0]); + + const btnText = batchBtn.querySelector('.btn-text'); + batchBtn.disabled = true; + if (btnText) { + btnText.innerHTML = ' Processing...'; + } + if (batchLoader) { + batchLoader.style.display = 'inline-block'; + } + + try { + const response = await fetch('/predict/batch', { + method: 'POST', + body: formData + }); + + const data = await response.json(); + + if (response.ok) { + showToast('Batch prediction successful!', 'success'); + displayBatchResults(data); + } else if (response.status === 422) { + if (data.detail) { + showToast(data.detail, 'error'); + } else if (Array.isArray(data.detail)) { + data.detail.forEach(err => { + const field = err.loc ? err.loc[err.loc.length - 1] : 'validation'; + showToast(`Validation Error (${field}): ${err.msg}`, 'error'); + }); + } else { + showToast('Validation Error', 'error'); + } + } else { + showToast(data.message || 'An error occurred', 'error'); + } + } catch (error) { + showToast('Network error or server down', 'error'); + } finally { + batchBtn.disabled = false; + if (btnText) { + btnText.innerHTML = ' Run Batch Prediction'; + } + if (batchLoader) { + batchLoader.style.display = 'none'; + } + } + }); +} + +function displayBatchResults(data) { + const container = document.getElementById('batch-result-container'); + if (!container) return; + + const predictions = data.prediction; + const probabilities = data.probabilities; + const count = predictions.length; + + const counts = { green: 0, orange: 0, red: 0, yellow: 0 }; + predictions.forEach(p => { + if (counts[p] !== undefined) counts[p]++; + }); + + let tableRows = ''; + const predictionDetails = { green: [], orange: [], red: [], yellow: [] }; + predictions.forEach((pred, i) => { + const probs = probabilities[i]; + const maxProb = Math.max(...probs) * 100; + predictionDetails[pred].push(i + 1); + tableRows += ` + + ${i + 1} + ${pred.toUpperCase()} + ${maxProb.toFixed(1)}% + + `; + }); + + container.innerHTML = ` +
+ +
+
+
+
${count}
+
Total Predictions
+
+
+
${counts.green}
+
Green
+
+
+
${counts.orange}
+
Orange
+
+
+
${counts.red}
+
Red
+
+
+
${counts.yellow}
+
Yellow
+
+
+ + + + + + + + + + ${tableRows} + +
#AlertConfidence
+ `; + + const ctx = document.getElementById('batchPieChart'); + if (ctx) { + new Chart(ctx, { + type: 'pie', + data: { + labels: ['Green', 'Orange', 'Red', 'Yellow'], + datasets: [{ + data: [counts.green, counts.orange, counts.red, counts.yellow], + backgroundColor: ['#22c55e', '#f97316', '#ef4444', '#eab308'], + borderColor: ['#22c55e', '#f97316', '#ef4444', '#eab308'], + borderWidth: 1 + }] + }, + options: { + responsive: true, + plugins: { + legend: { + position: 'bottom', + labels: { + color: '#8b949e' + } + }, + tooltip: { + callbacks: { + label: function(context) { + const label = context.label || ''; + const value = context.raw || 0; + const predictionList = predictionDetails[label.toLowerCase()]; + return `${label}: ${value} predictions`; + }, + afterLabel: function(context) { + const label = context.label || ''; + const predictionList = predictionDetails[label.toLowerCase()]; + if (predictionList && predictionList.length > 0) { + return `Rows: ${predictionList.join(', ')}`; + } + return ''; + } + } + } + } + } + }); + } +} + +// Clear batch button +const clearBatchBtn = document.getElementById('clearBatchBtn'); +if (clearBatchBtn && batchForm) { + clearBatchBtn.addEventListener('click', () => { + const fileInput = document.getElementById('csvfile'); + if (fileInput) fileInput.value = ''; + + const fileNameDisplay = document.getElementById('fileName'); + if (fileNameDisplay) fileNameDisplay.textContent = 'Choose CSV file...'; + + const batchContainer = document.getElementById('batch-result-container'); + if (batchContainer) { + batchContainer.innerHTML = ` +
+
+ +
+

Upload a CSV file and click "Run Batch Prediction"

+
+ + `; + } + }); +} diff --git a/app/static/style.css b/app/static/style.css index 4cbe0a9..15d0d34 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -166,6 +166,7 @@ body::before { .tab-panel { display: none; animation: fadeIn 0.3s ease; + min-height: calc(100vh - 200px); } .tab-panel.active { @@ -832,3 +833,153 @@ body::before { max-width: 100%; } } + +/* File Upload Styles */ +.file-upload-wrapper { + position: relative; +} + +.file-input { + position: absolute; + width: 0.1px; + height: 0.1px; + opacity: 0; + overflow: hidden; + z-index: -1; +} + +.file-label { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 1rem 1.5rem; + background: var(--bg-input); + border: 2px dashed var(--border); + border-radius: 10px; + color: var(--text-secondary); + font-size: 0.9rem; + cursor: pointer; + transition: all 0.3s ease; +} + +.file-label:hover { + border-color: var(--accent); + color: var(--accent); +} + +.file-input:focus + .file-label { + border-color: var(--accent); + box-shadow: var(--shadow-glow); +} + +.file-label i { + font-size: 1.2rem; +} + +/* Batch Results Table */ +.batch-results-table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; + font-size: 0.85rem; +} + +.batch-results-table th, +.batch-results-table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.batch-results-table th { + color: var(--text-secondary); + font-weight: 600; + background: var(--bg-tertiary); +} + +.batch-results-table td { + color: var(--text-primary); +} + +.batch-results-table tr:hover td { + background: var(--bg-tertiary); +} + +.batch-results-table .alert-cell { + font-weight: 600; + text-transform: uppercase; +} + +.batch-results-table .alert-cell.green { color: var(--green); } +.batch-results-table .alert-cell.orange { color: var(--orange); } +.batch-results-table .alert-cell.red { color: var(--red); } +.batch-results-table .alert-cell.yellow { color: var(--yellow); } + +.batch-summary { + display: flex; + gap: 1rem; + margin-top: 1rem; + flex-wrap: wrap; +} + +.batch-stat { + flex: 1; + min-width: 80px; + padding: 0.75rem; + background: var(--bg-tertiary); + border-radius: 8px; + text-align: center; +} + +.batch-stat-value { + font-size: 1.25rem; + font-weight: 700; + color: var(--accent); +} + +.batch-stat-label { + font-size: 0.75rem; + color: var(--text-secondary); + margin-top: 0.25rem; +} + +.chart-container { + margin: 1.5rem 0; + max-width: 400px; + margin-left: auto; + margin-right: auto; +} + +.batch-help { + margin-top: 1.5rem; + padding: 1rem; + background: var(--bg-tertiary); + border-radius: 10px; + border-left: 3px solid var(--accent); +} + +.batch-help h4 { + font-size: 0.9rem; + font-weight: 600; + color: var(--accent); + margin-bottom: 0.5rem; +} + +.batch-help p { + font-size: 0.8rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.batch-columns { + padding: 0.5rem; + background: var(--bg-input); + border-radius: 5px; + text-align: center; +} + +.batch-columns code { + color: var(--accent); + font-family: 'JetBrains Mono', monospace; +} diff --git a/app/templates/index.html b/app/templates/index.html index 1ed72f5..af28527 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -7,6 +7,7 @@ + @@ -24,6 +25,10 @@ Predict + + + + +
+

CSV Format Guide

+

Your CSV file must contain exactly 5 columns in this order:

+

magnitude, depth, cdi, mmi, sig

+

Important: Incorrect column names or order will result in validation errors. Ensure all values are numeric. Files larger than 5MB are not accepted.

+
+ + + + +
+
+

+ + Batch Results +

+
+ +
+
+
+ +
+

Upload a CSV file and click "Run Batch Prediction"

+
+ +
+
+ + +
diff --git a/dataset/__init__.py b/dataset/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dataset/sample_generator.py b/dataset/sample_generator.py new file mode 100644 index 0000000..55bed07 --- /dev/null +++ b/dataset/sample_generator.py @@ -0,0 +1,60 @@ +""" +sample_generator.py + +This script is used to generate samples directly from the main dataset. +These samples are used to test the `/predict/batch` route. +To run it, enter this in your command line: +``` +python -m datasets.sample_generator.py +``` +""" + +import pandas as pd +import numpy as np +from pathlib import Path + +np.random.seed(42) + +original_path = Path("dataset","earthquake_data.csv") +output_path = Path("dataset","sample.csv") + +df = pd.read_csv(original_path) + +class_ratios = df['alert'].value_counts(normalize=True) +print(f"Original class distribution:\n{class_ratios}\n") + +sample_fraction = 0.05 + +sampled_dfs = [] +for alert_class in df['alert'].unique(): + class_df = df[df['alert'] == alert_class] + n_samples = max(1, int(len(class_df) * sample_fraction)) + sampled = class_df.sample(n=n_samples, random_state=42) + sampled_dfs.append(sampled) + +sampled_df = pd.concat(sampled_dfs, ignore_index=True) + +numeric_cols = ['magnitude', 'depth', 'cdi', 'mmi', 'sig'] +perturbation_factors = { + 'magnitude': 0.05, + 'depth': 0.1, + 'cdi': 0.15, + 'mmi': 0.15, + 'sig': 0.2 +} + +for col in numeric_cols: + noise = sampled_df[col] * np.random.uniform(-perturbation_factors[col], perturbation_factors[col], size=len(sampled_df)) + sampled_df[col] = sampled_df[col] + noise + if col in ['magnitude', 'depth', 'cdi', 'mmi']: + sampled_df[col] = sampled_df[col].round(1) + else: + sampled_df[col] = sampled_df[col].round(0) + +sampled_df_out = sampled_df.drop(columns=["alert"]) +sampled_df_out.to_csv(output_path, index=False) + +new_class_ratios = sampled_df['alert'].value_counts(normalize=True) +print(f"New dataset class distribution:\n{new_class_ratios}\n") +print(f"Original samples: {len(df)}, New samples: {len(sampled_df)}") +print(f"Saved to: {output_path}") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 094571c..a30c5b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,7 @@ httpcore==1.0.9 httpx==0.28.1 idna==3.11 imbalanced-learn==0.14.1 +iniconfig==2.3.0 itsdangerous==2.2.0 jedi==0.19.2 Jinja2==3.1.6 @@ -34,14 +35,16 @@ packaging==25.0 pandas==2.3.3 parso==0.8.6 pillow==12.1.0 +pluggy==1.6.0 psutil==7.2.2 -pyarrow==23.0.1 pydantic==2.12.5 pydantic_core==2.41.5 Pygments==2.20.0 pymdown-extensions==10.21.2 pyparsing==3.3.1 +pytest==9.0.3 python-dateutil==2.9.0.post0 +python-multipart==0.0.24 pytz==2025.2 PyYAML==6.0.3 pyzmq==27.1.0 diff --git a/tests/test_app.py b/tests/test_app.py index 6f39a8b..2d21b4a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,4 +1,5 @@ import pytest +import io from fastapi.testclient import TestClient from app.app import app @@ -22,3 +23,45 @@ def test_predict_endpoint_fail_on_missing_data(client): # But since we are in a 'with' block, lifespan will have run. response = client.post("/predict", json={}) assert response.status_code == 422 # Pydantic validation error + +def test_batch_predict_endpoint(client): + # Create a valid CSV file + csv_content = "magnitude,depth,cdi,mmi,sig\n5.5,25.0,6.0,5.5,500\n4.2,10.0,3.5,3.0,100" + csv_file = io.BytesIO(csv_content.encode()) + csv_file.name = "test_data.csv" + + response = client.post( + "/predict/batch", + files={"payload": ("test_data.csv", csv_file, "text/csv")} + ) + + assert response.status_code == 201 + data = response.json() + assert "prediction" in data + assert "probabilities" in data + assert len(data["prediction"]) == 2 + +def test_batch_predict_endpoint_invalid_file(client): + # Upload non-CSV file should fail + text_file = io.BytesIO(b"not a csv") + text_file.name = "test.txt" + + response = client.post( + "/predict/batch", + files={"payload": ("test.txt", text_file, "text/plain")} + ) + + assert response.status_code == 422 + +def test_batch_predict_endpoint_wrong_columns(client): + # CSV with wrong columns should fail validation + csv_content = "wrong_column1,wrong_column2\n1,2\n3,4" + csv_file = io.BytesIO(csv_content.encode()) + csv_file.name = "test_data.csv" + + response = client.post( + "/predict/batch", + files={"payload": ("test_data.csv", csv_file, "text/csv")} + ) + + assert response.status_code == 422