GitHub Action commited on
Commit
0cea9d2
Β·
1 Parent(s): 2154823

Sync from GitHub: 7341e56760b533299dbaa7cc93ac040d699ffe2c

Browse files
Files changed (3) hide show
  1. main.py +189 -1
  2. requirements.txt +1 -0
  3. training_service.py +166 -0
main.py CHANGED
@@ -7,22 +7,26 @@ import os
7
  import sys
8
  import time
9
  import uuid
 
 
10
  from typing import Optional, Dict, List
11
  from datetime import datetime
12
 
13
  import pandas as pd
14
  import numpy as np
15
- from fastapi import FastAPI, HTTPException, Header
16
  from fastapi.middleware.cors import CORSMiddleware
17
  from fastapi.responses import JSONResponse, StreamingResponse
18
  from pydantic import BaseModel, Field, validator
19
  import uvicorn
 
20
 
21
  # Add current directory to path for imports
22
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
 
24
  from inference import FraudInference, load_inference_engine
25
  from simulation import simulation_manager, SimulationConfig
 
26
  import warnings
27
 
28
  # Suppress warnings
@@ -111,6 +115,32 @@ class BatchPredictResponse(BaseModel):
111
  processing_time_ms: int
112
  total_transactions: int
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  # ============================================================================
115
  # FASTAPI APP
116
  # ============================================================================
@@ -142,6 +172,11 @@ model_loading_lock = False
142
  MODEL_VERSION = "1.0.0"
143
  MODEL_THRESHOLD = float(os.getenv("MODEL_THRESHOLD", "0.0793"))
144
 
 
 
 
 
 
145
  # Risk thresholds (matching config.py)
146
  RISK_THRESHOLDS = {
147
  "pass": 0.30,
@@ -803,6 +838,159 @@ async def stream_simulation():
803
  media_type="text/event-stream"
804
  )
805
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
806
  # ============================================================================
807
  # ERROR HANDLERS
808
  # ============================================================================
 
7
  import sys
8
  import time
9
  import uuid
10
+ import shutil
11
+ import json
12
  from typing import Optional, Dict, List
13
  from datetime import datetime
14
 
15
  import pandas as pd
16
  import numpy as np
17
+ from fastapi import FastAPI, HTTPException, Header, BackgroundTasks, UploadFile, File, Form
18
  from fastapi.middleware.cors import CORSMiddleware
19
  from fastapi.responses import JSONResponse, StreamingResponse
20
  from pydantic import BaseModel, Field, validator
21
  import uvicorn
22
+ from supabase import create_client, Client
23
 
24
  # Add current directory to path for imports
25
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
 
27
  from inference import FraudInference, load_inference_engine
28
  from simulation import simulation_manager, SimulationConfig
29
+ from training_service import train_model_async
30
  import warnings
31
 
32
  # Suppress warnings
 
115
  processing_time_ms: int
116
  total_transactions: int
117
 
118
+ class TrainingConfig(BaseModel):
119
+ """Configuration for model training"""
120
+ test_split: float = Field(default=0.05, ge=0.01, le=0.5)
121
+ n_estimators: int = Field(default=300, ge=10, le=2000)
122
+ max_depth: int = Field(default=6, ge=1, le=20)
123
+ learning_rate: float = Field(default=0.05, ge=0.001, le=1.0)
124
+ pagerank_limit: int = Field(default=10000, ge=0)
125
+ name: str = Field(default="Custom Model")
126
+ version: str = Field(default="1.0")
127
+
128
+ class TrainingResponse(BaseModel):
129
+ """Response for training initiation"""
130
+ job_id: str
131
+ status: str
132
+ message: str
133
+
134
+ class ModelItem(BaseModel):
135
+ """Model registry item"""
136
+ id: str
137
+ name: str
138
+ version: Optional[str]
139
+ status: str
140
+ metrics: Optional[Dict]
141
+ is_active: bool
142
+ created_at: str
143
+
144
  # ============================================================================
145
  # FASTAPI APP
146
  # ============================================================================
 
172
  MODEL_VERSION = "1.0.0"
173
  MODEL_THRESHOLD = float(os.getenv("MODEL_THRESHOLD", "0.0793"))
174
 
175
+ # Supabase Client
176
+ supabase_url: str = os.environ.get("SUPABASE_URL")
177
+ supabase_key: str = os.environ.get("SUPABASE_SERVICE_ROLE_KEY") # Use Service Role Key for backend updates
178
+ supabase: Client = create_client(supabase_url, supabase_key) if supabase_url and supabase_key else None
179
+
180
  # Risk thresholds (matching config.py)
181
  RISK_THRESHOLDS = {
182
  "pass": 0.30,
 
838
  media_type="text/event-stream"
839
  )
840
 
841
+ # ============================================================================
842
+ # TRAINING ENDPOINTS
843
+ # ============================================================================
844
+
845
+ @app.post("/train", response_model=TrainingResponse)
846
+ async def train_model_endpoint(
847
+ background_tasks: BackgroundTasks,
848
+ config: str = Form(...),
849
+ file: UploadFile = File(...)
850
+ ):
851
+ """
852
+ Start a background training job.
853
+ Accepts a CSV file and a JSON string for configuration.
854
+ """
855
+ try:
856
+ config_dict = json.loads(config)
857
+ # Validate config using Pydantic
858
+ training_config = TrainingConfig(**config_dict)
859
+ except Exception as e:
860
+ raise HTTPException(status_code=400, detail=f"Invalid configuration JSON: {str(e)}")
861
+
862
+ if not file.filename.endswith('.csv'):
863
+ raise HTTPException(status_code=400, detail="Only CSV files are supported")
864
+
865
+ job_id = str(uuid.uuid4())
866
+
867
+ # Save uploaded file to temp location
868
+ temp_dir = "temp_uploads"
869
+ os.makedirs(temp_dir, exist_ok=True)
870
+ file_path = os.path.join(temp_dir, f"{job_id}_{file.filename}")
871
+
872
+ try:
873
+ with open(file_path, "wb") as buffer:
874
+ shutil.copyfileobj(file.file, buffer)
875
+ except Exception as e:
876
+ raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}")
877
+
878
+ # Create initial record in Supabase
879
+ if supabase:
880
+ try:
881
+ supabase.table("model_registry").insert({
882
+ "id": job_id,
883
+ "name": training_config.name,
884
+ "version": training_config.version,
885
+ "status": "pending",
886
+ "is_active": False
887
+ }).execute()
888
+ except Exception as e:
889
+ # Clean up file if DB insert fails
890
+ if os.path.exists(file_path):
891
+ os.remove(file_path)
892
+ raise HTTPException(status_code=500, detail=f"Failed to create job record: {str(e)}")
893
+ else:
894
+ print("⚠️ Supabase not connected. Training will proceed but status won't be persisted.")
895
+
896
+ # Trigger background task
897
+ params = training_config.dict()
898
+ background_tasks.add_task(train_model_async, job_id, file_path, params)
899
+
900
+ return TrainingResponse(
901
+ job_id=job_id,
902
+ status="pending",
903
+ message="Training job started in background"
904
+ )
905
+
906
+ @app.get("/models", response_model=List[ModelItem])
907
+ async def list_models():
908
+ """List all trained models from registry"""
909
+ if not supabase:
910
+ # Return mock data if no DB (or raise error)
911
+ # For development ease, we can return checking a local dir, but let's stick to DB
912
+ return []
913
+
914
+ try:
915
+ response = supabase.table("model_registry").select("*").order("created_at", desc=True).execute()
916
+
917
+ # Convert created_at to string if needed or Pydantic handles it
918
+ models = []
919
+ for item in response.data:
920
+ models.append(ModelItem(
921
+ id=item['id'],
922
+ name=item['name'],
923
+ version=item.get('version'),
924
+ status=item['status'],
925
+ metrics=item.get('metrics'),
926
+ is_active=item.get('is_active', False),
927
+ created_at=item['created_at']
928
+ ))
929
+ return models
930
+ except Exception as e:
931
+ raise HTTPException(status_code=500, detail=f"Failed to fetch models: {str(e)}")
932
+
933
+ @app.post("/models/{model_id}/activate")
934
+ async def activate_model(model_id: str):
935
+ """Activate a specific model version"""
936
+ if not supabase:
937
+ raise HTTPException(status_code=503, detail="Database not available")
938
+
939
+ # 1. Get model details
940
+ try:
941
+ response = supabase.table("model_registry").select("*").eq("id", model_id).single().execute()
942
+ model_data = response.data
943
+ except Exception:
944
+ raise HTTPException(status_code=404, detail="Model not found")
945
+
946
+ if model_data['status'] != 'ready':
947
+ raise HTTPException(status_code=400, detail="Model is not in 'ready' state")
948
+
949
+ model_path = model_data.get('file_path')
950
+ if not model_path:
951
+ raise HTTPException(status_code=400, detail="Model file path missing")
952
+
953
+ # 2. Update DB (set active=True for this, False for others)
954
+ # The trigger 'trigger_ensure_single_active_model' handles the "False for others" part!
955
+ try:
956
+ supabase.table("model_registry").update({"is_active": True}).eq("id", model_id).execute()
957
+ except Exception as e:
958
+ raise HTTPException(status_code=500, detail=f"Failed to update database: {str(e)}")
959
+
960
+ # 3. Hot-swap in memory
961
+ # We need to set the environment variable or just reload logic
962
+ # Best way: Update global variable directly
963
+ full_path = os.path.abspath(model_path) if os.path.isabs(model_path) else os.path.join(os.path.dirname(__file__), model_path)
964
+
965
+ if not os.path.exists(full_path):
966
+ # Try relative to Models/ if not found
967
+ full_path = os.path.join(os.path.dirname(__file__), "Models", os.path.basename(model_path))
968
+
969
+ if not os.path.exists(full_path):
970
+ raise HTTPException(status_code=500, detail=f"Model file not found on disk: {full_path}")
971
+
972
+ # Set env var for future reloads
973
+ os.environ["MODEL_PATH"] = full_path
974
+
975
+ # Trigger reload
976
+ global inference_engine
977
+ try:
978
+ # Load new model
979
+ new_engine = load_inference_engine(
980
+ model_path=full_path,
981
+ test_dataset_path=os.getenv("TEST_DATASET_PATH"),
982
+ threshold=float(os.getenv("MODEL_THRESHOLD", "0.0793")),
983
+ groq_api_key=os.getenv("GROQ_API_KEY"),
984
+ pagerank_limit=int(os.getenv("PAGERANK_LIMIT", "10000"))
985
+ )
986
+ inference_engine = new_engine
987
+ print(f"βœ… Hot-swapped to model {model_id} ({full_path})")
988
+ except Exception as e:
989
+ raise HTTPException(status_code=500, detail=f"Failed to load model into memory: {str(e)}")
990
+
991
+ return {"status": "success", "message": f"Model {model_id} activated"}
992
+
993
+
994
  # ============================================================================
995
  # ERROR HANDLERS
996
  # ============================================================================
requirements.txt CHANGED
@@ -32,4 +32,5 @@ scipy>=1.10.0
32
 
33
  # CORS support (included in FastAPI)
34
  # python-multipart for file uploads (if needed)
 
35
 
 
32
 
33
  # CORS support (included in FastAPI)
34
  # python-multipart for file uploads (if needed)
35
+ supabase>=2.0.0
36
 
training_service.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import json
4
+ import joblib
5
+ import pandas as pd
6
+ import numpy as np
7
+ import xgboost as xgb
8
+ from sklearn.pipeline import Pipeline
9
+ from sklearn.model_selection import train_test_split, RandomizedSearchCV, cross_val_predict
10
+ from sklearn.metrics import precision_recall_curve, classification_report, accuracy_score, f1_score, precision_score, recall_score
11
+ from supabase import create_client, Client
12
+ from dotenv import load_dotenv
13
+
14
+ # Import local modules
15
+ # Assuming feature_engineering.py is in the same directory
16
+ try:
17
+ from feature_engineering import FraudFeatureEngineer
18
+ except ImportError:
19
+ import sys
20
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
21
+ from feature_engineering import FraudFeatureEngineer
22
+
23
+ load_dotenv()
24
+
25
+ # Initialize Supabase client
26
+ url: str = os.environ.get("SUPABASE_URL")
27
+ key: str = os.environ.get("SUPABASE_SERVICE_ROLE_KEY") # Use Service Role Key for backend updates
28
+ supabase: Client = create_client(url, key) if url and key else None
29
+
30
+ def update_job_status(job_id: str, status: str, metrics: dict = None, file_path: str = None):
31
+ """Update job status in Supabase"""
32
+ if not supabase:
33
+ print(f"⚠️ Supabase not configured. Job {job_id} status: {status}")
34
+ return
35
+
36
+ data = {"status": status}
37
+ if metrics:
38
+ data["metrics"] = metrics
39
+ if file_path:
40
+ data["file_path"] = file_path
41
+
42
+ try:
43
+ supabase.table("model_registry").update(data).eq("id", job_id).execute()
44
+ except Exception as e:
45
+ print(f"❌ Failed to update Supabase for job {job_id}: {str(e)}")
46
+
47
+ def make_splits(df, test_frac=0.05, time_col='step', min_test_fraud=100, random_state=42):
48
+ """
49
+ Returns: X_train, X_test, y_train, y_test
50
+ """
51
+ # Temporal test split
52
+ if time_col in df.columns:
53
+ cutoff = df[time_col].quantile(1 - test_frac)
54
+ train_df = df[df[time_col] <= cutoff].reset_index(drop=True)
55
+ test_df = df[df[time_col] > cutoff].reset_index(drop=True)
56
+ else:
57
+ train_df = df.copy()
58
+ test_df = pd.DataFrame(columns=df.columns)
59
+
60
+ # Fallback to stratified split if test set is poor
61
+ if (test_df.empty) or (test_df['isFraud'].sum() < min_test_fraud):
62
+ X_temp = df.drop(columns=['isFraud'])
63
+ y_temp = df['isFraud']
64
+ X_train, X_test, y_train, y_test = train_test_split(
65
+ X_temp, y_temp, test_size=test_frac, stratify=y_temp, random_state=random_state
66
+ )
67
+ return X_train.reset_index(drop=True), X_test.reset_index(drop=True), y_train.reset_index(drop=True), y_test.reset_index(drop=True)
68
+ else:
69
+ X_train = train_df.drop(columns=['isFraud'])
70
+ y_train = train_df['isFraud']
71
+ X_test = test_df.drop(columns=['isFraud'])
72
+ y_test = test_df['isFraud']
73
+ return X_train.reset_index(drop=True), X_test.reset_index(drop=True), y_train.reset_index(drop=True), y_test.reset_index(drop=True)
74
+
75
+ async def train_model_async(job_id: str, file_path: str, params: dict):
76
+ """
77
+ Background task to train model
78
+ """
79
+ print(f"πŸš€ Starting training job {job_id} with params: {params}")
80
+ update_job_status(job_id, "training")
81
+
82
+ try:
83
+ # 1. Load Data
84
+ if not os.path.exists(file_path):
85
+ raise FileNotFoundError(f"Dataset not found at {file_path}")
86
+
87
+ df = pd.read_csv(file_path)
88
+
89
+ # Basic Preprocessing
90
+ if 'oldbalanceOrg' in df.columns:
91
+ df = df.rename(columns={'oldbalanceOrg':'oldBalanceOrig', 'newbalanceOrig':'newBalanceOrig',
92
+ 'oldbalanceDest':'oldBalanceDest', 'newbalanceDest':'newBalanceDest'})
93
+
94
+ # Filter types if needed (Notebook filters to TRANSFER/CASH_OUT)
95
+ # We'll stick to what the notebook did for consistency
96
+ if 'type' in df.columns:
97
+ df = df[df['type'].isin(['TRANSFER', 'CASH_OUT'])].reset_index(drop=True)
98
+
99
+ # 2. Split Data
100
+ test_frac = params.get('test_split', 0.05)
101
+ X_train, X_test, y_train, y_test = make_splits(df, test_frac=test_frac)
102
+
103
+ print(f"πŸ“Š Data split: Train={len(X_train)}, Test={len(X_test)}")
104
+
105
+ # 3. Feature Engineering
106
+ pagerank_limit = params.get('pagerank_limit', 10000)
107
+ fe = FraudFeatureEngineer(pagerank_limit=pagerank_limit)
108
+
109
+ print("βš™οΈ Fitting feature engineer...")
110
+ fe.fit(X_train, y_train)
111
+ X_train_trans = fe.transform(X_train)
112
+ X_test_trans = fe.transform(X_test)
113
+
114
+ # 4. Configure XGBoost
115
+ # Calculate scale_pos_weight
116
+ neg = (y_train == 0).sum()
117
+ pos = (y_train == 1).sum()
118
+ spw = int(max(1, neg / max(1, pos)))
119
+
120
+ xgb_params = {
121
+ 'objective': 'binary:logistic',
122
+ 'tree_method': 'hist',
123
+ 'random_state': 42,
124
+ 'n_jobs': 1,
125
+ 'n_estimators': int(params.get('n_estimators', 300)),
126
+ 'max_depth': int(params.get('max_depth', 6)),
127
+ 'learning_rate': float(params.get('learning_rate', 0.05)),
128
+ 'scale_pos_weight': spw
129
+ }
130
+
131
+ clf = xgb.XGBClassifier(**xgb_params)
132
+
133
+ # 5. Train
134
+ print("πŸ‹οΈ Training classifier...")
135
+ clf.fit(X_train_trans, y_train)
136
+
137
+ # 6. Evaluate
138
+ print("πŸ“ Evaluating...")
139
+ y_pred = clf.predict(X_test_trans)
140
+
141
+ metrics = {
142
+ "accuracy": float(accuracy_score(y_test, y_pred)),
143
+ "precision": float(precision_score(y_test, y_pred)),
144
+ "recall": float(recall_score(y_test, y_pred)),
145
+ "f1": float(f1_score(y_test, y_pred))
146
+ }
147
+ print(f"βœ… Metrics: {metrics}")
148
+
149
+ # 7. Save Model
150
+ # Ensure Models directory exists
151
+ models_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Models")
152
+ os.makedirs(models_dir, exist_ok=True)
153
+
154
+ model_filename = f"model_{job_id}.pkl"
155
+ model_save_path = os.path.join(models_dir, model_filename)
156
+
157
+ # Save ONLY the classifier (as per notebook strategy)
158
+ joblib.dump(clf, model_save_path)
159
+ print(f"πŸ’Ύ Model saved to {model_save_path}")
160
+
161
+ # 8. Update Registry
162
+ update_job_status(job_id, "ready", metrics=metrics, file_path=f"Models/{model_filename}")
163
+
164
+ except Exception as e:
165
+ print(f"❌ Training failed: {str(e)}")
166
+ update_job_status(job_id, "failed", metrics={"error": str(e)})