| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from math import sqrt |
| from pathlib import Path |
| from typing import Any |
|
|
| import joblib |
| import pandas as pd |
| from sklearn.compose import ColumnTransformer |
| from sklearn.ensemble import RandomForestRegressor |
| from sklearn.impute import SimpleImputer |
| from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score |
| from sklearn.model_selection import train_test_split |
| from sklearn.pipeline import Pipeline |
| from sklearn.preprocessing import OneHotEncoder |
|
|
| from src.data import DEFAULT_FEATURES, TARGET_COLUMN, select_model_frame |
|
|
|
|
| NUMERIC_FEATURES = [ |
| "OverallQual", |
| "GrLivArea", |
| "GarageCars", |
| "TotalBsmtSF", |
| "FullBath", |
| "YearBuilt", |
| ] |
| CATEGORICAL_FEATURES = ["Neighborhood", "HouseStyle"] |
| DEFAULT_MODEL_PATH = Path("models") / "house_price_model.joblib" |
|
|
|
|
| @dataclass(frozen=True) |
| class TrainedModel: |
| pipeline: Pipeline |
| feature_names: list[str] |
| metrics: dict[str, float] |
|
|
|
|
| class HousePriceModel: |
| def __init__(self, trained: TrainedModel): |
| self._trained = trained |
|
|
| @property |
| def metrics(self) -> dict[str, float]: |
| return self._trained.metrics |
|
|
| @property |
| def feature_names(self) -> list[str]: |
| return self._trained.feature_names |
|
|
| @classmethod |
| def load(cls, path: str | Path = DEFAULT_MODEL_PATH) -> "HousePriceModel": |
| payload = joblib.load(path) |
| return cls( |
| TrainedModel( |
| pipeline=payload["pipeline"], |
| feature_names=list(payload["feature_names"]), |
| metrics=dict(payload["metrics"]), |
| ) |
| ) |
|
|
| def predict(self, features: dict[str, Any]) -> float: |
| frame = pd.DataFrame([{name: features.get(name) for name in self.feature_names}]) |
| prediction = self._trained.pipeline.predict(frame)[0] |
| return round(float(max(prediction, 0.0)), 2) |
|
|
|
|
| def build_pipeline() -> Pipeline: |
| numeric_pipeline = Pipeline( |
| steps=[ |
| ("imputer", SimpleImputer(strategy="median")), |
| ] |
| ) |
| categorical_pipeline = Pipeline( |
| steps=[ |
| ("imputer", SimpleImputer(strategy="most_frequent")), |
| ("encoder", OneHotEncoder(handle_unknown="ignore", sparse_output=False)), |
| ] |
| ) |
| preprocessor = ColumnTransformer( |
| transformers=[ |
| ("num", numeric_pipeline, NUMERIC_FEATURES), |
| ("cat", categorical_pipeline, CATEGORICAL_FEATURES), |
| ] |
| ) |
| regressor = RandomForestRegressor(n_estimators=250, random_state=42, min_samples_leaf=2) |
| return Pipeline(steps=[("preprocessor", preprocessor), ("regressor", regressor)]) |
|
|
|
|
| def train_model(frame: pd.DataFrame, artifact_path: str | Path = DEFAULT_MODEL_PATH) -> TrainedModel: |
| model_frame = select_model_frame(frame).dropna(subset=[TARGET_COLUMN]) |
| features = model_frame[DEFAULT_FEATURES] |
| target = model_frame[TARGET_COLUMN] |
|
|
| if len(model_frame) >= 10: |
| x_train, x_test, y_train, y_test = train_test_split( |
| features, target, test_size=0.2, random_state=42 |
| ) |
| else: |
| x_train, x_test, y_train, y_test = features, features, target, target |
|
|
| pipeline = build_pipeline() |
| pipeline.fit(x_train, y_train) |
| predictions = pipeline.predict(x_test) |
| metrics = { |
| "rmse": round(sqrt(mean_squared_error(y_test, predictions)), 2), |
| "mae": round(mean_absolute_error(y_test, predictions), 2), |
| "r2": round(r2_score(y_test, predictions), 4), |
| } |
| trained = TrainedModel(pipeline=pipeline, feature_names=list(DEFAULT_FEATURES), metrics=metrics) |
|
|
| artifact_path = Path(artifact_path) |
| artifact_path.parent.mkdir(parents=True, exist_ok=True) |
| joblib.dump( |
| { |
| "pipeline": trained.pipeline, |
| "feature_names": trained.feature_names, |
| "metrics": trained.metrics, |
| }, |
| artifact_path, |
| ) |
| return trained |
|
|