Concepts 🚀 Project 🧮 Labs
🚀 Learn with Project / Stage 11 — Add a /model-info page — show metrics + feature importance
🚀 Stage 11 of 13
← Previous — Stage 10
Add CSS — cards layout, hover effects
←

Add a /model-info page — show metrics + feature importance

The model is a black box to users. Surface the R^2, MAE, and which features matter most. Trust comes from transparency.

🧠 The intuition If a student gets predicted 76 and asks 'why?', you need an answer. Two answers, actually: 1. 'The model is off by ~4 points on average' (MAE) 2. 'previous_score matters most, then attendance, then study_hours' (feature importance) LinearRegression exposes coefficients (positive/negative weights per feature). RandomForest exposes feature_importances_ (0-1 share per feature). Both let you say 'which feature matters most'.
✏️ Tiny task — write this yourself Add a /model-info route that renders a template showing: model type, training R^2, MAE, and a sorted list of feature importances. Use the saved model — don't retrain.
✅ Check — what you should see Visit http://localhost:8000/model-info — you should see: 'Model: RandomForestRegressor | R^2: 0.83 | MAE: 4.1 points' and a list of features sorted by importance, with previous_score at the top.

🧮 Code files for this stage

📄 app/main.py (add /model-info route) python
# Add to app/main.py:

@app.get('/model-info', response_class=HTMLResponse)
async def model_info(request: Request):
    # Inspect the loaded model — no retraining
    model_type = type(model).__name__

    # Feature importances (RandomForest) or coefficients (LinearRegression)
    if hasattr(model, 'feature_importances_'):
        importances = list(zip(FEATURES, model.feature_importances_))
        importances.sort(key=lambda x: x[1], reverse=True)
        importance_type = 'feature_importances_'
    elif hasattr(model, 'coef_'):
        importances = list(zip(FEATURES, model.coef_))
        importances.sort(key=lambda x: abs(x[1]), reverse=True)
        importance_type = 'coefficient'
    else:
        importances = []
        importance_type = 'unknown'

    # Re-compute test metrics by loading the data + splitting the same way
    import pandas as pd
    from sklearn.model_selection import train_test_split
    from sklearn.metrics import mean_absolute_error, r2_score
    df = pd.read_csv('data/students.csv').drop(columns=['student_id'])
    X = df.drop(columns=['final_score'])
    y = df['final_score']
    _, X_test, _, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
    y_pred = model.predict(X_test)
    metrics = {
        'r2': round(r2_score(y_test, y_pred), 3),
        'mae': round(mean_absolute_error(y_test, y_pred), 2),
    }

    return templates.TemplateResponse('model.html', {
        'request': request,
        'model_type': model_type,
        'metrics': metrics,
        'importances': importances,
        'importance_type': importance_type,
    })
📄 app/templates/model.html html
{% extends 'base.html' %}
{% block title %}Model info{% endblock %}

{% block content %}
  <div class='card'>
    <h1>Model Info</h1>
    <p><strong>Type:</strong> {{ model_type }}</p>
    <p><strong>R^2:</strong> {{ metrics.r2 }}  (1.0 is perfect, 0 is guessing)</p>
    <p><strong>MAE:</strong> {{ metrics.mae }} points  (average error)</p>
  </div>

  <div class='card' style='margin-top: 1rem;'>
    <h2>Feature importance ({{ importance_type }})</h2>
    <p>Which inputs matter most to the model's prediction.</p>
    {% for feature, weight in importances %}
      <div class='imp-row'>
        <span class='imp-name'>{{ feature }}</span>
        <div class='imp-bar-bg'>
          <div class='imp-bar' style='width: {{ (weight * 100 if weight < 1 else weight) | round(1) }}%;'></div>
        </div>
        <span class='imp-val'>{{ weight | round(3) }}</span>
      </div>
    {% endfor %}
  </div>
{% endblock %}
📄 app/static/style.css (add to bottom) css
/* Feature importance bars */
.imp-row { display: flex; align-items: center; gap: 0.5rem; margin: 0.4rem 0; }
.imp-name { width: 200px; font-family: monospace; font-size: 0.9rem; }
.imp-bar-bg { flex: 1; height: 18px; background: #eee; border-radius: 9px; overflow: hidden; }
.imp-bar { height: 100%; background: #a0c000; transition: width 0.3s; }
.imp-val { width: 60px; text-align: right; font-family: monospace; font-size: 0.85rem; color: #555; }

How it works

hasattr(model, 'feature_importances_') is the trick — RandomForest and most tree-based models expose feature_importances_ (a 0-1 array summing to 1, where bigger = more important). LinearRegression exposes coef_ instead (signed weights — positive means more of that feature INCREASES the prediction, negative means DECREASES). The same /model-info route handles both cases by checking which attribute exists. This is duck typing — we don't care WHAT the model is, we care what it CAN DO.

🚧 Routes — what's wired up at this stage ROUTES INTRODUCED HERE: GET /model-info -> renders model.html with metrics + feature importance Total project routes: 4 (/, /predict, /predict-form, /model-info). That's the whole app. No more routes needed.
🧮 Is a response model necessary here? NO — use response_class=HTMLResponse

/model-info returns HTML, not JSON. Use response_class=HTMLResponse. If you also want a JSON version (for dashboards), add a parallel GET /api/model-info with response_model=ModelInfoResponse — but for this project, HTML is enough.

⚠️ Common mistakes to avoid
  • Retraining the model on every /model-info request — load once at startup, just read its attributes here.
  • Showing coefficients without absolute value — a -5 coefficient is MORE important than a +1, but sorting by raw value puts it last.
  • Comparing RandomForest importances with LinearRegression coefficients directly — they're on different scales. Compare within one model type.
👀 What's next Final: Stage 12 wraps up — folder structure recap, how to run it, how to deploy to Render, and what to learn next.
Next — Stage 12
Wrap up — folder structure, deploy, what's next
→