File size: 10,467 Bytes
422809f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Docker Space: FastAPI REST service plus Gradio web UI, powered by trained pipelines."""
from __future__ import annotations
import json
import tempfile
from pathlib import Path

import gradio as gr
import joblib
import numpy as np
import pandas as pd
from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field

ROOT = Path(__file__).resolve().parent
META = json.loads((ROOT / 'model_metadata.json').read_text(encoding='utf-8'))['datasets']
MODELS = {key: joblib.load(ROOT / 'models' / f'{key}_churn.joblib') for key in META}

class SinglePayload(BaseModel):
    data: dict

class BatchPayload(BaseModel):
    records: list[dict] = Field(min_length=1, max_length=100)

def validate_input(key, records):
    if key not in MODELS:
        raise ValueError(f'Unknown segment {key!r}; choose {list(MODELS)}')
    if not records or len(records) > 100:
        raise ValueError('Provide 1–100 records per request')
    info = META[key]
    required = info['features']
    X = pd.DataFrame(records)
    absent = [c for c in required if c not in X.columns]
    if absent:
        raise ValueError(f'Missing required features for {key}: {absent}')
    X = X[required].copy()  # Ignore arbitrary PII/extraneous uploaded columns.
    for c in info['numeric']:
        vals = pd.to_numeric(X[c], errors='coerce')
        if vals.isna().any() or not np.isfinite(vals.to_numpy(dtype=float)).all():
            raise ValueError(f'{c} must contain valid finite numeric values; empty inputs are not accepted')
        X[c] = vals
    for c in info['categorical']:
        allowed = info['categorical_choices'][c]
        vals = X[c].astype('string').str.strip()
        if vals.isna().any() or (~vals.isin(allowed)).any():
            raise ValueError(f'{c} must be one of {allowed}')
        X[c] = vals.astype(str)
    return X

def predict_records(key, records):
    frame = validate_input(key, records)
    probs = MODELS[key]['pipeline'].predict_proba(frame)[:, 1]
    cut = float(MODELS[key]['threshold'])
    low_signal = META[key]['deployment_status'] != 'demonstration'
    return [{'row_number': i+1, 'churn_score': round(float(p), 5),
             'prediction': ('Exploratory only — unreliable classifier' if low_signal
                           else ('Likely to churn' if p >= cut else 'Likely to stay')),
             'deployment_status': META[key]['deployment_status'],
             'threshold': cut} for i, p in enumerate(probs)]

api = FastAPI(title='Churn Intelligence Lab API', version='1.0.0',
              description='Demo-only bank and telecom churn inference. Do not submit real customer PII.')

@api.get('/', include_in_schema=False)
def home():
    return RedirectResponse('/ui/')

@api.get('/health')
def health():
    return {'status': 'ok', 'trained_models': list(MODELS), 'version': '1.0.0'}

@api.get('/api/metadata')
def metadata():
    return {name: {'features': info['features'], 'threshold': info['threshold'],
            'selected_model': info['selected_model'], 'test_metrics': info['test_metrics']}
            for name, info in META.items()}

@api.post('/api/predict/{segment}')
def predict_one(segment: str, body: SinglePayload):
    try:
        return predict_records(segment, [body.data])[0]
    except ValueError as error:
        raise HTTPException(status_code=422, detail=str(error)) from error

@api.post('/api/predict-batch/{segment}')
def predict_many(segment: str, body: BatchPayload):
    try:
        return {'results': predict_records(segment, body.records)}
    except ValueError as error:
        raise HTTPException(status_code=422, detail=str(error)) from error

def format_prediction(segment, *values):
    fields = META[segment]['features']
    response = predict_records(segment, [dict(zip(fields, values))])[0]
    return (f'### {response["prediction"]}\n\n'
            f'**Churn model score:** {response["churn_score"]:.1%}  '\
            f'\n\n**Decision threshold:** {response["threshold"]:.1%}  '
            f'\n\n*Uncalibrated model output; screening aid, not a guarantee of churn.*'), response

def run_batch(segment, filepath):
    if not filepath:
        raise gr.Error('Upload a CSV file before requesting batch predictions.')
    try:
        raw = pd.read_csv(filepath)
        if len(raw) == 0 or len(raw) > 100:
            raise ValueError('CSV must contain between 1 and 100 rows')
        output = predict_records(segment, raw.to_dict(orient='records'))
        result_path = Path(tempfile.mkdtemp(prefix='churn_demo_')) / f'{segment}_prediction_results.csv'
        pd.DataFrame(output).to_csv(result_path, index=False)
        return pd.DataFrame(output), str(result_path)
    except (ValueError, pd.errors.ParserError) as error:
        raise gr.Error(str(error)) from error

SITE_CSS = '''
.gradio-container {max-width: 1240px !important; margin: auto !important;}
#hero {background:linear-gradient(120deg,#0a2342,#123a68 65%,#176a89);border-radius:20px;
       padding:28px 34px;color:white; box-shadow:0 12px 40px rgba(5,22,45,.15);}
#hero h1,#hero p {color:white !important;}
#hero h1 {font-weight:800;letter-spacing:-.03em;font-size:2.2rem;}
#metricline {padding:10px 16px;border:1px solid #dce6ef;border-radius:12px;}
#caution {background:#fff7ed;border:1px solid #fed7aa;padding:12px 16px;border-radius:12px;}
#resultbox {border-left:4px solid #0e7490;padding:8px 16px;min-height:90px;}
'''

with gr.Blocks(title='Churn Intelligence Lab | AI Customer Retention', css=SITE_CSS,
               theme=gr.themes.Soft(primary_hue='blue', neutral_hue='slate')) as demo:
    gr.Markdown('''<div id="hero"><p style="opacity:.8;font-weight:700;letter-spacing:.13em">AI • MACHINE LEARNING • MLOPS</p>
    <h1>Churn Intelligence Lab</h1>
    <p>Explore AI-assisted customer attrition insights across banking and telecom. Compare model evidence, run individual assessments, and export anonymized batch results.</p>
    <p style="font-size:.86em;opacity:.85">Portfolio research demonstration • Do not enter real customer identifiers or sensitive information.</p></div>''')
    with gr.Row():
        gr.Markdown('**Two independently evaluated datasets**  \nTrain/test separation · Reproducible preprocessing · Saved production-style pipelines')
        gr.Markdown('**Developer access**  \n[REST API docs](/docs) · [Model metadata](/api/metadata) · [Service health](/health)')
    for key, info in META.items():
        with gr.Tab('🏦  Banking attrition' if key == 'bank' else '📶  Telecom attrition'):
            met = info['test_metrics']
            with gr.Row():
                gr.Markdown(f'''### {"Banking" if key == "bank" else "Telecommunications"} model
                <div id="metricline"> <b>Selected:</b> {info["selected_model"]} &nbsp; | &nbsp;
                <b>Test ROC AUC:</b> {met["roc_auc"]:.3f} &nbsp; | &nbsp;
                <b>PR AUC:</b> {met["pr_auc"]:.3f} &nbsp; | &nbsp;
                <b>Recall:</b> {met["recall"]:.3f}</div>''')
            if info['deployment_status'] != 'demonstration':
                gr.Markdown('''<div id="caution"><b>⚠ Experimental — low predictive reliability.</b>
                The held-out evaluation is near chance. Scores are exploratory; this model is not suitable for business churn decisions.</div>''')
            else:
                gr.Markdown('**Customer assessment** — review sample defaults, modify customer attributes, and run a prediction.')
            inputs=[]
            with gr.Accordion('Customer profile inputs', open=True):
                with gr.Row():
                    with gr.Column():
                        first=info['features'][::2]
                        for col in first:
                            if col in info['numeric']:
                                component=gr.Number(label=col, value=info['defaults'][col], precision=2)
                            else:
                                component=gr.Dropdown(label=col, choices=info['categorical_choices'][col],value=info['defaults'][col])
                            inputs.append((col,component))
                    with gr.Column():
                        for col in info['features'][1::2]:
                            if col in info['numeric']:
                                component=gr.Number(label=col, value=info['defaults'][col], precision=2)
                            else:
                                component=gr.Dropdown(label=col, choices=info['categorical_choices'][col],value=info['defaults'][col])
                            inputs.append((col,component))
            ordered=[dict(inputs)[col] for col in info['features']]
            run=gr.Button('Analyze customer churn risk',variant='primary',size='lg')
            with gr.Row():
                with gr.Column(scale=2):
                    textout=gr.Markdown('Ready to analyze a synthetic customer profile.',elem_id='resultbox')
                with gr.Column(scale=1):
                    jsonout=gr.JSON(label='Prediction details')
            run.click(fn=lambda *values,segment=key: format_prediction(segment,*values),
                      inputs=ordered,outputs=[textout,jsonout],api_name=False)
            gr.Markdown('### Batch screening (CSV)\nUpload 1–100 rows with the required feature columns. Only anonymized or synthetic data is permitted.')
            with gr.Row():
                csvfile=gr.File(label='Anonymized feature CSV',file_types=['.csv'],type='filepath')
                batch_button=gr.Button('Process CSV batch')
            with gr.Row():
                batch_table=gr.Dataframe(label='Prediction results',interactive=False)
                batch_download=gr.File(label='Download results CSV')
            batch_button.click(fn=lambda f,segment=key: run_batch(segment,f),
                               inputs=csvfile,outputs=[batch_table,batch_download],api_name=False)
    gr.Markdown('''---
    **How to interpret scores:** estimated model probabilities are not calibrated. Thresholds are validation-derived; outcomes are uncertain.
    Telecom is research-only because of weak signal. Evaluate fairness, drift, and real-world fit before any operational use.

    **Architecture:** Python · Scikit-learn · XGBoost · Gradio · FastAPI · Docker · Hugging Face Spaces''')

app = gr.mount_gradio_app(api, demo, path='/ui')

if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app, host='0.0.0.0', port=7860)