File size: 9,756 Bytes
f1f4ccb
 
 
 
 
 
 
 
42bc336
 
f1f4ccb
42bc336
f1f4ccb
42bc336
f1f4ccb
 
 
 
 
 
 
 
 
 
 
 
42bc336
f1f4ccb
42bc336
 
 
 
 
 
 
 
f1f4ccb
 
 
 
 
90e38d0
42bc336
f1f4ccb
 
 
 
 
 
 
 
 
 
 
 
42bc336
f1f4ccb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42bc336
f1f4ccb
 
 
 
42bc336
 
f1f4ccb
 
 
 
 
 
 
 
 
42bc336
 
f1f4ccb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42bc336
f1f4ccb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42bc336
 
f1f4ccb
 
 
 
 
 
 
 
 
42bc336
 
 
f1f4ccb
 
 
42bc336
f1f4ccb
 
42bc336
f1f4ccb
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
from __future__ import annotations

import os
import sys
import logging
from pathlib import Path
from typing import List

import joblib
import numpy as np
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format='[%(asctime)s] %(name)s β€” %(levelname)s: %(message)s'
)
logger = logging.getLogger(__name__)

app = FastAPI(
    title="CyHub Model 4 β€” Domain Classification",
    description="Multi-class web data classifier (Normal/Adult/Betting/Malware)",
    version="1.0.0"
)

# CORS configuration
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ─────────────────────────────────────────────────────────────────
# Configuration
# ─────────────────────────────────────────────────────────────────

# Expected feature count (must match training data)
EXPECTED_FEATURES = 5

# Model file paths (support both relative and absolute)
MODEL_DIR = Path(os.getenv("MODEL_DIR", "."))
MODEL_PATH = MODEL_DIR / "trained_lightgbm_model.pkl"
ENCODER_PATH = MODEL_DIR / "label_encoder.pkl"

logger.info(f"Looking for model at: {MODEL_PATH.absolute()}")
logger.info(f"Looking for encoder at: {ENCODER_PATH.absolute()}")

# ─────────────────────────────────────────────────────────────────
# Request/Response Models
# ─────────────────────────────────────────────────────────────────

class InputData(BaseModel):
    """Input data for model prediction.

    Must be preprocessed (scaled, imputed, encoded) to match training data.
    """
    features: List[float] = Field(
        ...,
        min_items=EXPECTED_FEATURES,
        max_items=EXPECTED_FEATURES,
        description=f"Exactly {EXPECTED_FEATURES} preprocessed float values"
    )


class PredictionResponse(BaseModel):
    """Model prediction response."""
    predicted_label: str
    raw_prediction_encoded: int


# ─────────────────────────────────────────────────────────────────
# Model Loading
# ─────────────────────────────────────────────────────────────────

model = None
label_encoder = None
model_loaded = False

def load_models():
    """Load LightGBM model and label encoder."""
    global model, label_encoder, model_loaded

    try:
        # Check if files exist
        if not MODEL_PATH.exists():
            raise FileNotFoundError(f"Model file not found: {MODEL_PATH.absolute()}")
        if not ENCODER_PATH.exists():
            raise FileNotFoundError(f"Encoder file not found: {ENCODER_PATH.absolute()}")

        # Load model
        logger.info(f"Loading model from {MODEL_PATH.absolute()}...")
        model = joblib.load(str(MODEL_PATH))
        logger.info("βœ“ Model loaded successfully")

        # Load label encoder
        logger.info(f"Loading label encoder from {ENCODER_PATH.absolute()}...")
        label_encoder = joblib.load(str(ENCODER_PATH))
        logger.info("βœ“ Label encoder loaded successfully")

        # Verify encoder has classes
        if not hasattr(label_encoder, 'classes_'):
            raise ValueError("Label encoder missing 'classes_' attribute")

        logger.info(f"Label classes: {label_encoder.classes_.tolist()}")
        model_loaded = True

        return True

    except FileNotFoundError as e:
        logger.error(f"File error: {e}")
        logger.warning(f"Make sure model files are in: {MODEL_DIR.absolute()}")
        return False
    except Exception as e:
        logger.error(f"Error loading models: {e}")
        import traceback
        traceback.print_exc()
        return False


# ─────────────────────────────────────────────────────────────────
# Startup/Shutdown Events
# ─────────────────────────────────────────────────────────────────

@app.on_event("startup")
async def startup_event():
    """Initialize models on startup."""
    global model_loaded
    logger.info("Starting up CyHub Model 4 API...")

    if not load_models():
        logger.error("Failed to load models. API will return 503 until models are available.")
        model_loaded = False
    else:
        logger.info("Model 4 API ready!")


@app.on_event("shutdown")
async def shutdown_event():
    """Cleanup on shutdown."""
    logger.info("Shutting down CyHub Model 4 API...")


# ─────────────────────────────────────────────────────────────────
# API Endpoints
# ─────────────────────────────────────────────────────────────────

@app.get("/")
async def root():
    """Root endpoint β€” health check."""
    return {
        "service": "CyHub Model 4 β€” Domain Classification",
        "status": "healthy",
        "model_loaded": model_loaded,
        "version": "1.0.0"
    }


@app.get("/health")
async def health_check():
    """Health check endpoint."""
    if not model_loaded:
        raise HTTPException(
            status_code=503,
            detail="Model not loaded. Check server logs."
        )

    return {
        "status": "healthy",
        "model_status": "ready",
        "expected_features": EXPECTED_FEATURES
    }


@app.post("/predict", response_model=PredictionResponse)
async def predict(data: InputData) -> PredictionResponse:
    """Predict domain classification from preprocessed features.

    Args:
        data: InputData with exactly 5 preprocessed features

    Returns:
        PredictionResponse with predicted_label and raw_prediction_encoded

    Raises:
        HTTPException: If model not loaded or feature count incorrect
    """
    # Check if model is loaded
    if not model_loaded or model is None or label_encoder is None:
        logger.error("Model not loaded, cannot make prediction")
        raise HTTPException(
            status_code=503,
            detail="Model not loaded. Check server logs."
        )

    try:
        # Validate feature count
        if len(data.features) != EXPECTED_FEATURES:
            raise ValueError(
                f"Expected {EXPECTED_FEATURES} features, got {len(data.features)}"
            )

        # Validate all features are float/convertible
        features_array = np.array(data.features, dtype=float)

        # Check for NaN or Inf
        if not np.all(np.isfinite(features_array)):
            raise ValueError("Features contain NaN or Inf values")

        # Reshape for prediction (1 sample, 5 features)
        input_array = features_array.reshape(1, -1)

        logger.debug(f"Input features: {data.features}")

        # Make prediction
        prediction_encoded = model.predict(input_array)[0]
        logger.debug(f"Raw prediction: {prediction_encoded}")

        # Ensure prediction is valid
        if prediction_encoded not in label_encoder.classes_:
            logger.warning(f"Unexpected prediction value: {prediction_encoded}")

        # Decode prediction to label
        try:
            prediction_label = label_encoder.inverse_transform([prediction_encoded])[0]
        except Exception as e:
            logger.error(f"Error decoding prediction: {e}")
            raise ValueError(f"Could not decode prediction {prediction_encoded}")

        logger.info(f"Prediction: {prediction_label}")

        return PredictionResponse(
            predicted_label=prediction_label,
            raw_prediction_encoded=int(prediction_encoded)
        )

    except ValueError as e:
        logger.error(f"Validation error: {e}")
        raise HTTPException(
            status_code=400,
            detail=str(e)
        )
    except Exception as e:
        logger.error(f"Prediction error: {e}")
        import traceback
        traceback.print_exc()
        raise HTTPException(
            status_code=500,
            detail=f"Prediction failed: {str(e)}"
        )


# ─────────────────────────────────────────────────────────────────
# Development Entry Point
# ─────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import uvicorn

    # For development/testing
    logger.info("Starting Model 4 API in development mode...")
    uvicorn.run(
        app,
        host="0.0.0.0",
        port=7860,  # Default HuggingFace Spaces port
        log_level="info"
    )