← Retour à l'épreuve

AICC (IOAI Community Contest) 2025 · Solution

Latent Model Classification

Cette page donne la méthode attendue. Lisez-la une fois que vous avez cherché.

Mis en forme par IOAI Training à partir de l'original (AI-Community-Contest/solutions, licence MIT). L'original fait foi.

Solution author: Asandei Stefan-Alexandru

import argparse
import random
from pathlib import Path

import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from sklearn.preprocessing import RobustScaler
SEED = 42
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

EM_ITERATIONS = 40
L2_REG = 1.2e-4

root_dir = Path("/home/stefan/ioai-prep/kits/aicc/round-0/latent-models")
class PenultimateNet(nn.Module):
    """The neural network architecture up to the penultimate layer."""

    def __init__(self, in_dim: int = 100, hidden: int = 64, out_dim: int = 10):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(in_dim, hidden),
            nn.ReLU(),
            nn.Linear(hidden, out_dim),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.net(x)

Data

def load_data(
    file_path: Path,
) -> tuple[pd.DataFrame, np.ndarray, np.ndarray]:
    """Loads and extracts ID, X, and Y data from the dataset CSV."""
    df = pd.read_csv(file_path)
    x = df.filter(regex=r"^x").values.astype(np.float32)
    y = df.filter(regex=r"^y").values.astype(np.float32)
    return df, x, y
df, X, Y = load_data(root_dir / "dataset.csv")
def generate_embeddings(
    model_path: Path, x_data: np.ndarray, device: torch.device
) -> np.ndarray:
    """Generates embeddings for input data using a pre-trained model."""
    model = PenultimateNet()
    model.load_state_dict(torch.load(model_path, map_location=device))
    model.to(device)
    model.eval()
    with torch.no_grad():
        x_tensor = torch.from_numpy(x_data).to(device)
        embeddings = model(x_tensor).cpu().numpy()
    return embeddings
ZA = generate_embeddings(root_dir / "modelA_penultimate.pth", X, device)
ZB = generate_embeddings(root_dir / "modelB_penultimate.pth", X, device)

Solution

def ridge_regression(
    Z: np.ndarray, Y: np.ndarray, weights: np.ndarray, l2: float
) -> tuple[np.ndarray, np.ndarray]:
    """
    Performs weighted ridge regression to find the linear head (W, b).

    Args:
        Z: Input embeddings (N, d).
        Y: Target logits (N, m).
        weights: Sample weights (N,).
        l2: L2 regularization strength.

    Returns:
        A tuple containing the weight matrix W (d, m) and bias vector b (m,).
    """
    w_col = weights.reshape(-1, 1)
    w_sum = w_col.sum()

    mu_z = (w_col * Z).sum(0) / w_sum
    mu_y = (w_col * Y).sum(0) / w_sum

    Zc = Z - mu_z
    Yc = Y - mu_y

    A = Zc.T @ (w_col * Zc) + l2 * np.eye(Z.shape[1])
    B = Zc.T @ (w_col * Yc)
    W = np.linalg.solve(A, B)
    b = mu_y - mu_z @ W
    return W, b


def fit_em_and_predict(
    ZA: np.ndarray, ZB: np.ndarray, Y: np.ndarray, n_iter: int, l2: float
) -> np.ndarray:
    """
    Fits a Gaussian Mixture Model using EM to assign labels.

    Args:
        ZA: Embeddings from model A.
        ZB: Embeddings from model B.
        Y: Observed logits.
        n_iter: Number of EM iterations.
        l2: L2 regularization for the M-step.

    Returns:
        An array of predicted labels (0 for A, 1 for B).
    """
    N, m = Y.shape

    # --- Initialization ---
    # Start by fitting linear heads to the whole dataset
    W_A, b_A = ridge_regression(ZA, Y, np.ones(N), l2)
    W_B, b_B = ridge_regression(ZB, Y, np.ones(N), l2)
    # Initial shared variance
    residuals_A = Y - (ZA @ W_A + b_A)
    sigma2 = float(np.mean(residuals_A**2))

    # --- EM Iterations ---
    for _ in range(n_iter):
        # E-step: Calculate responsibilities (gamma)
        rA = ((Y - (ZA @ W_A + b_A)) ** 2).sum(1)
        rB = ((Y - (ZB @ W_B + b_B)) ** 2).sum(1)

        # Numerically stable softmax for probabilities
        logp = np.stack([-rA / (2 * sigma2), -rB / (2 * sigma2)], axis=1)
        logp -= logp.max(axis=1, keepdims=True)
        gamma = np.exp(logp)
        gamma /= gamma.sum(axis=1, keepdims=True)

        # M-step: Update parameters using weighted regression
        W_A, b_A = ridge_regression(ZA, Y, gamma[:, 0], l2)
        W_B, b_B = ridge_regression(ZB, Y, gamma[:, 1], l2)
        rA_updated = ((Y - (ZA @ W_A + b_A)) ** 2).sum(1)
        rB_updated = ((Y - (ZB @ W_B + b_B)) ** 2).sum(1)
        sigma2 = (gamma[:, 0] @ rA_updated + gamma[:, 1] @ rB_updated) / (m * N)

    return gamma.argmax(axis=1)
labels = fit_em_and_predict(ZA, ZB, Y, EM_ITERATIONS, L2_REG)

Submission

submission = pd.DataFrame(
    {"ID": df["ID"], "Source": np.where(labels == 0, "A", "B")}
)

submission.head()
   ID Source
0   1      B
1   2      B
2   3      B
3   4      B
4   5      A
submission.to_csv("submission.csv", index=False)