openclassrooms_projet5 / src /create_db.py
github-actions[bot]
Deploy snapshot 73053884eaccbd2ab4535c17ecba37a677a3fc0d
be9501c
Raw
History Blame Contribute Delete
12.1 kB
from pathlib import Path
import json
import os
import re
import uuid
import pandas as pd
from dotenv import load_dotenv
from sqlalchemy import (
Column,
DateTime,
Float,
ForeignKey,
Integer,
MetaData,
String,
Table,
create_engine,
)
from sqlalchemy import inspect
from sqlalchemy.engine import URL
from sqlalchemy.sql import func
from sqlalchemy.dialects.postgresql import JSONB
ROOT_DIR = Path(__file__).resolve().parents[1]
DATA_DIR = ROOT_DIR / "data"
ARTIFACTS_DIR = ROOT_DIR / "artifacts"
SCHEMA_PATH = ARTIFACTS_DIR / "input_schema.json"
def _load_feature_specs(schema_path: Path = SCHEMA_PATH) -> dict[str, dict]:
raw_schema = json.loads(schema_path.read_text(encoding="utf-8"))
features_raw = raw_schema.get("features", {})
if not isinstance(features_raw, dict):
return {}
normalized_features: dict[str, dict] = {}
for feature_name, spec in features_raw.items():
clean_name = str(feature_name).strip()
normalized_features[clean_name] = spec if isinstance(spec, dict) else {}
return normalized_features
FEATURE_SPECS = _load_feature_specs()
def _build_request_feature_columns() -> list[Column]:
columns: list[Column] = []
for feature_name, spec in FEATURE_SPECS.items():
feature_type = str(spec.get("type", "")).strip().lower()
if feature_type == "number":
columns.append(Column(feature_name, Float, nullable=True))
else:
columns.append(Column(feature_name, String, nullable=True))
return columns
REQUEST_FEATURE_COLUMNS = _build_request_feature_columns()
def _resolve_env_file_path() -> Path | None:
root_env = ROOT_DIR / ".env"
if root_env.exists():
return root_env
confs_dir = ROOT_DIR / "confs"
if confs_dir.exists():
env_candidates = sorted(confs_dir.rglob(".env"))
if env_candidates:
return env_candidates[0]
env_pattern_candidates = sorted(confs_dir.rglob(".env.*"))
if env_pattern_candidates:
return env_pattern_candidates[0]
return None
def _build_merged_source_dataset(data_dir: Path = DATA_DIR) -> pd.DataFrame:
eval_df = pd.read_csv(data_dir / "extrait_eval.csv")
sirh_df = pd.read_csv(data_dir / "extrait_sirh.csv")
sondage_df = pd.read_csv(data_dir / "extrait_sondage.csv")
eval_df["eval_number"] = eval_df["eval_number"].str.replace("E_", "", regex=False).astype(int)
merge_df = (
eval_df.merge(sirh_df, left_on="eval_number", right_on="id_employee", how="inner")
.merge(sondage_df, left_on="eval_number", right_on="code_sondage", how="inner")
)
merge_df.drop(columns=["eval_number", "id_employee", "code_sondage"], inplace=True)
return merge_df
def build_dataset(data_dir: Path = DATA_DIR) -> pd.DataFrame:
merge_df = _build_merged_source_dataset(data_dir=data_dir)
merge_df["augementation_salaire_precedente"] = (
merge_df["augementation_salaire_precedente"].str.replace("%", "", regex=False).astype(int)
)
col_to_drop = [
"nombre_heures_travailless",
"nombre_employee_sous_responsabilite",
"ayant_enfants",
"satisfaction_employee_environnement",
"satisfaction_employee_nature_travail",
"satisfaction_employee_equipe",
"satisfaction_employee_equilibre_pro_perso",
"note_evaluation_actuelle",
]
merge_df.drop(columns=col_to_drop, inplace=True)
salaire_moyen_par_niveau = merge_df.groupby("niveau_hierarchique_poste")["revenu_mensuel"].mean()
merge_df["salaire_moyen_niveau"] = merge_df["niveau_hierarchique_poste"].map(salaire_moyen_par_niveau)
merge_df["diff_salaire_vs_niveau"] = merge_df["revenu_mensuel"] - merge_df["salaire_moyen_niveau"]
merge_df["diff_salaire_vs_niveau_pct"] = merge_df["diff_salaire_vs_niveau"] / merge_df["salaire_moyen_niveau"]
merge_df.drop(columns=["salaire_moyen_niveau", "diff_salaire_vs_niveau"], inplace=True)
merge_df["annee_experience_totale"] = merge_df["annee_experience_totale"] + 1
merge_df["ratio_salaire_anciennete"] = merge_df["revenu_mensuel"] / merge_df["annee_experience_totale"]
col_to_drop_after = [
"annees_dans_l_entreprise",
"annee_experience_totale",
"annes_sous_responsable_actuel",
"niveau_hierarchique_poste",
]
merge_df.drop(columns=col_to_drop_after, inplace=True)
return merge_df
def build_features_engineering_variables(data_dir: Path = DATA_DIR) -> pd.DataFrame:
merge_df = _build_merged_source_dataset(data_dir=data_dir)
return merge_df[["niveau_hierarchique_poste", "annee_experience_totale"]].copy()
def get_engine_from_env():
dotenv_path = _resolve_env_file_path()
if dotenv_path is not None:
load_dotenv(dotenv_path=dotenv_path, override=False)
url = URL.create(
drivername="postgresql+psycopg2",
username=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD"),
host=os.getenv("DB_HOST") or "127.0.0.1",
port=int(os.getenv("DB_PORT") or 5432),
database=os.getenv("DB_NAME"),
)
return create_engine(url)
# ---------------------------------------------------------------------
# API logging (requêtes + prédictions)
# ---------------------------------------------------------------------
_metadata = MetaData()
api_requests = Table(
"api_requests",
_metadata,
Column("id", String, primary_key=True),
Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False),
Column("endpoint", String, nullable=False),
*REQUEST_FEATURE_COLUMNS,
)
api_predictions = Table(
"api_predictions",
_metadata,
Column("id", String, primary_key=True),
Column("request_id", String, ForeignKey("api_requests.id"), nullable=False),
Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False),
Column("prediction_index", Integer, nullable=False),
Column("proba_leave", Float, nullable=False),
Column("label", Integer, nullable=False),
)
def init_api_logging_tables(engine) -> None:
"""Crée les tables de logging si elles n'existent pas."""
_metadata.create_all(engine)
def log_request_and_prediction(
engine,
*,
endpoint: str,
payload: dict,
proba_leave: list[float],
label: list[int],) -> tuple[str, str]:
request_id = str(uuid.uuid4())
if len(proba_leave) != len(label):
raise ValueError("proba_leave et label doivent avoir la même longueur")
prediction_ids: list[str] = []
request_insert_values = {
"id": request_id,
"endpoint": endpoint,
}
records = payload.get("records", []) if isinstance(payload, dict) else []
first_record = records[0] if records and isinstance(records[0], dict) else {}
for feature_name, spec in FEATURE_SPECS.items():
raw_value = first_record.get(feature_name)
if raw_value is None:
request_insert_values[feature_name] = None
continue
feature_type = str(spec.get("type", "")).strip().lower()
if feature_type == "number":
request_insert_values[feature_name] = float(raw_value)
else:
request_insert_values[feature_name] = str(raw_value)
with engine.begin() as conn:
conn.execute(
api_requests.insert().values(
**request_insert_values,
)
)
for idx, (proba_value, label_value) in enumerate(zip(proba_leave, label)):
prediction_id = str(uuid.uuid4())
prediction_ids.append(prediction_id)
conn.execute(
api_predictions.insert().values(
id=prediction_id,
request_id=request_id,
prediction_index=idx,
proba_leave=float(proba_value),
label=int(label_value),
)
)
first_prediction_id = prediction_ids[0] if prediction_ids else ""
return request_id, first_prediction_id
def full_dataset_to_bdd(data_dir: Path = DATA_DIR, table_name: str = "dataset_final") -> pd.DataFrame:
merge_df = build_dataset(data_dir=data_dir)
engine = get_engine_from_env()
merge_df.to_sql(name=table_name, con=engine, if_exists="replace", index=False)
print(f"Dataset envoyé avec succès dans la table '{table_name}'")
return merge_df
def features_engineering_variables_to_bdd(
data_dir: Path = DATA_DIR,
table_name: str = "features_engineering_variable",
) -> pd.DataFrame:
features_df = build_features_engineering_variables(data_dir=data_dir)
engine = get_engine_from_env()
features_df.to_sql(name=table_name, con=engine, if_exists="replace", index=False)
print(f"Features engineering envoyées avec succès dans la table '{table_name}'")
return features_df
def init_feature_tables_if_missing(
engine,
data_dir: Path = DATA_DIR,
dataset_table_name: str = "dataset_final",
features_table_name: str = "features_engineering_variable",
) -> None:
inspector = inspect(engine)
if not inspector.has_table(dataset_table_name):
build_dataset(data_dir=data_dir).to_sql(
name=dataset_table_name,
con=engine,
if_exists="replace",
index=False,
)
if not inspector.has_table(features_table_name):
build_features_engineering_variables(data_dir=data_dir).to_sql(
name=features_table_name,
con=engine,
if_exists="replace",
index=False,
)
def build_payload_from_bdd_row(
row_number: int,
table_name: str = "dataset_final",
features_table_name: str = "features_engineering_variable",
engine=None,
) -> dict:
if row_number < 1:
raise ValueError("row_number doit être >= 1")
if not re.fullmatch(r"[A-Za-z0-9_]+", table_name):
raise ValueError("table_name invalide")
if not re.fullmatch(r"[A-Za-z0-9_]+", features_table_name):
raise ValueError("features_table_name invalide")
db_engine = engine if engine is not None else get_engine_from_env()
offset = row_number - 1
dataset_query = f'SELECT * FROM "{table_name}" LIMIT 1 OFFSET {offset}'
features_query = f'SELECT * FROM "{features_table_name}" LIMIT 1 OFFSET {offset}'
dataset_row_df = pd.read_sql_query(dataset_query, con=db_engine)
features_row_df = pd.read_sql_query(features_query, con=db_engine)
if dataset_row_df.empty:
raise IndexError(
f"Aucune ligne trouvée pour row_number={row_number} dans la table '{table_name}'"
)
if features_row_df.empty:
raise IndexError(
f"Aucune ligne trouvée pour row_number={row_number} dans la table '{features_table_name}'"
)
dataset_row_df = dataset_row_df.iloc[:, :-2]
dataset_row = dataset_row_df.iloc[0]
features_row = features_row_df.iloc[0]
record: dict[str, float | str | None] = {}
required_feature_columns = ["niveau_hierarchique_poste", "annee_experience_totale"]
for col in required_feature_columns:
if col not in features_row.index:
raise KeyError(
f"La colonne '{col}' est absente de la table '{features_table_name}'"
)
for feature_name, spec in FEATURE_SPECS.items():
if feature_name in dataset_row.index:
value = dataset_row[feature_name]
elif feature_name in required_feature_columns:
value = features_row[feature_name]
else:
raise KeyError(
f"La colonne '{feature_name}' est absente des tables '{table_name}' et '{features_table_name}'"
)
if pd.isna(value):
record[feature_name] = None
continue
feature_type = str(spec.get("type", "")).strip().lower()
if feature_type == "number":
record[feature_name] = float(value)
else:
record[feature_name] = str(value)
return {"records": [record]}