Загрузка данных


# ML: uplift по продуктам (T-learner), политика по ожидаемой инкрементальной прибыли
def load_data(data_dir: Path) -> dict[str, pd.DataFrame]:
    return {
        "history_clients": pd.read_csv(data_dir / "history_clients.csv"),
        "campaign_history": pd.read_csv(data_dir / "campaign_history.csv"),
        "products": pd.read_csv(data_dir / "products.csv", encoding="utf-8-sig"),
        "region_profiles": pd.read_csv(data_dir / "region_profiles.csv"),
    }


def feature_columns(history_clients: pd.DataFrame, public_clients: pd.DataFrame) -> list[str]:
    common = [c for c in history_clients.columns if c in public_clients.columns]
    drop = {"client_id", "evaluation_month"} | {f"eligible_p{j}" for j in PRODUCT_IDS}
    return [c for c in common if c not in drop]


def make_model() -> HistGradientBoostingClassifier:
    return HistGradientBoostingClassifier(
        max_iter=200,
        learning_rate=0.03,
        max_leaf_nodes=15,
        min_samples_leaf=1000,
        l2_regularization=1.0,
        random_state=SEED,
    )


def fit_uplift_models(
    history: pd.DataFrame,
    campaigns: pd.DataFrame,
    features: list[str],
    product_ids: list[int],
) -> dict[int, tuple[HistGradientBoostingClassifier, HistGradientBoostingClassifier]]:
    models = {}
    for j in product_ids:
        table = build_product_table(history, campaigns, j)
        table = table[table[f"eligible_p{j}"] == 1]
        treated = table[table["offered"] == 1]
        control = table[table["offered"] == 0]
        m1 = make_model().fit(treated[features], treated["purchased"])
        m0 = make_model().fit(control[features], control["purchased"])
        models[j] = (m1, m0)
    return models


def score_candidates(
    candidates: pd.DataFrame,
    clients: pd.DataFrame,
    features: list[str],
    models: dict,
    products: pd.DataFrame,
) -> pd.DataFrame:
    margin = dict(zip(products["product_id"], products["margin_rub"]))
    cost = dict(zip(products["product_id"], products["contact_cost_rub"]))
    feats = clients.set_index("client_id")[features]
    scored = []
    for j, (m1, m0) in models.items():
        cand = candidates[candidates["product_id"] == j]
        if cand.empty:
            continue
        x = feats.loc[cand["client_id"].to_numpy()]
        tau = m1.predict_proba(x)[:, 1] - m0.predict_proba(x)[:, 1]
        part = cand.copy()
        part["score"] = tau * margin[j] - cost[j]
        scored.append(part)
    return pd.concat(scored, ignore_index=True)


def select_offers(scored: pd.DataFrame, n_clients: int, contact_fraction: float) -> pd.DataFrame:
    best = scored.sort_values(["score", "client_id"], ascending=[False, True])
    best = best.drop_duplicates("client_id", keep="first")
    best = best[best["score"] > 0]
    max_contacts = int(math.floor(contact_fraction * n_clients))
    best = best.head(max_contacts)
    return pd.DataFrame(
        {"client_id": best["client_id"].to_numpy(), "offer_id": best["product_id"].astype(int).to_numpy()}
    )


def build_policy(
    public_clients: pd.DataFrame,
    data_dir: Path,
) -> pd.DataFrame:
    np.random.seed(SEED)
    data = load_data(data_dir)
    history = add_region_features(data["history_clients"], data["region_profiles"])
    public = add_region_features(public_clients, data["region_profiles"])
    features = feature_columns(history, public)
    product_ids = [j for j in PRODUCT_IDS if j not in EXCLUDED_PRODUCTS]
    models = fit_uplift_models(history, data["campaign_history"], features, product_ids)
    candidates = make_product_candidates(public, data["products"])
    candidates = candidates[candidates["product_id"].isin(product_ids)]
    scored = score_candidates(candidates, public, features, models, data["products"])
    return select_offers(scored, len(public_clients), CONTACT_FRACTION)


def run(data_dir: Path, output_path: Path) -> None:
    np.random.seed(SEED)

    public_clients_path = data_dir / "public_clients.csv"
    if not public_clients_path.exists():
        raise FileNotFoundError(f"File not found: {public_clients_path}")

    public_clients = pd.read_csv(public_clients_path)

    offers = build_policy(
        public_clients=public_clients,
        data_dir=data_dir,
    )

    submission = make_submission(
        clients=public_clients,
        offers=offers,
    )

    diagnostics = validate_submission_frame(
        submission=submission,
        clients=public_clients,
        contact_fraction=CONTACT_FRACTION,
    )

    if not diagnostics["is_valid"]:
        raise ValueError(f"Generated submission is invalid: {diagnostics}")

    output_path.parent.mkdir(parents=True, exist_ok=True)
    submission[["client_id", "offer_id"]].to_csv(output_path, index=False)

    print(f"Submission saved to: {output_path}")
    print(f"Validation: {diagnostics}")
    print(f"Contacts: {contact_statistics(submission)}")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--data-dir",
        type=Path,
        required=True,
        help="Directory containing competition CSV files",
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=Path("submission.csv"),
        help="Output submission path",
    )
    return parser.parse_args()


if __name__ == "__main__":
    args = parse_args()
    run(
        data_dir=args.data_dir,
        output_path=args.output,
    )