Pular para conteúdo

1. Data

Enunciado

Exercises → Data

Exercise 1

Abordagem

O item A gera as 4 classes gaussianas em 2D com os parâmetros exatos do enunciado (médias e desvios por classe), 100 pontos por classe, usando rng = np.random.default_rng(42). Para o item B, um único ruído padrão \(N(0, I)\) é sorteado uma vez por classe e reaproveitado em todas as escalas s, de forma que as quatro versões sejam literalmente as mesmas nuvens mais ou menos espalhadas, e não sorteios independentes. A mesma instância RNG é usada do início ao fim do script, o que garante que os números abaixo sejam reproduzíveis.

Código

O script vive em code/exercise1_point_clouds.py e é incluído aqui pelo próprio arquivo.

docs/exercises/data/code/exercise1_point_clouds.py
"""Exercise 1: Point Clouds, Geometry and Spread in 2D.

Gera as 4 classes gaussianas do enunciado (item A), as versões reescalonadas
(item B) e a visualização de fronteiras por centróide mais próximo (item C).
Salva as figuras em ``figures/`` e imprime os números que alimentam o
relatório e a tabela *Results summary*.

Uso (a partir da raiz do repositório):

    python docs/exercises/data/code/exercise1_point_clouds.py
"""

from itertools import combinations
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np

FIGURES = Path(__file__).resolve().parents[1] / "figures"

CLASSES = {
    0: {"mean": np.array([2.0, 3.0]), "std": np.array([0.8, 2.5])},
    1: {"mean": np.array([5.0, 6.0]), "std": np.array([1.2, 1.9])},
    2: {"mean": np.array([8.0, 1.0]), "std": np.array([0.9, 0.9])},
    3: {"mean": np.array([15.0, 4.0]), "std": np.array([0.5, 2.0])},
}
N_PER_CLASS = 100
SCALES = (0.5, 1.0, 2.0, 4.0)
COLORS = {0: "tab:blue", 1: "tab:orange", 2: "tab:green", 3: "tab:red"}

RNG = np.random.default_rng(42)  # (1)!

# Ruído-base padrão N(0, I), sorteado uma única vez e reaproveitado em todas
# as escalas: as 4 versões do item B são literalmente as mesmas nuvens mais
# ou menos espalhadas, não sorteios independentes.
BASE_Z = {c: RNG.standard_normal(size=(N_PER_CLASS, 2)) for c in CLASSES}


def generate(scale: float = 1.0) -> tuple[np.ndarray, np.ndarray]:
    """Amostra as 4 classes com os desvios multiplicados por ``scale``."""
    xs, ys = [], []
    for c, params in CLASSES.items():
        xs.append(params["mean"] + scale * params["std"] * BASE_Z[c])
        ys.append(np.full(N_PER_CLASS, c))
    return np.vstack(xs), np.concatenate(ys)


def sigma_bar(c: int) -> float:
    """sigma_bar_k = (sigma_kx + sigma_ky) / 2, a partir dos parâmetros da classe."""
    return float(CLASSES[c]["std"].mean())


def separation_ratios(scale: float = 1.0) -> dict[tuple[int, int], float]:
    """r_ij = ||mu_i - mu_j|| / (sigma_bar_i + sigma_bar_j) para cada par de classes."""
    ratios = {}
    for i, j in combinations(CLASSES, 2):
        dist = np.linalg.norm(CLASSES[i]["mean"] - CLASSES[j]["mean"])
        denom = scale * (sigma_bar(i) + sigma_bar(j))
        ratios[(i, j)] = float(dist / denom)
    return ratios


def mixing_rate(scale: float) -> float:
    """Fração de pontos cujo centro de classe mais próximo não é o da própria classe."""
    X, y = generate(scale)
    means = np.stack([CLASSES[c]["mean"] for c in CLASSES])
    dists = np.linalg.norm(X[:, None, :] - means[None, :, :], axis=2)
    nearest = dists.argmin(axis=1)
    return float(np.mean(nearest != y))


def plot_clouds(ax, X, y, means, title):
    for c in CLASSES:
        ax.scatter(*X[y == c].T, s=14, alpha=0.75, color=COLORS[c], label=f"Classe {c}")
    for c in CLASSES:
        ax.scatter(*means[c], marker="X", s=140, color=COLORS[c], edgecolor="black", linewidth=1.2, zorder=5)
    ax.set_xlabel("$x_1$")
    ax.set_ylabel("$x_2$")
    ax.set_title(title)


def figure_1() -> None:
    """Figura 1: dispersão das 4 classes em scale=1.0, com centros marcados."""
    X, y = generate(1.0)
    means = np.stack([CLASSES[c]["mean"] for c in CLASSES])
    fig, ax = plt.subplots(figsize=(7, 5))
    plot_clouds(ax, X, y, means, "Nuvens de pontos gaussianas (scale = 1.0)")
    ax.set_axisbelow(True)
    ax.grid(True, linestyle="--", alpha=0.4)
    ax.legend(loc="upper left")
    fig.tight_layout()
    fig.savefig(FIGURES / "fig01-point-clouds.png", dpi=160)
    plt.close(fig)  # (2)!


def figure_1b() -> None:
    """Figura 1b: mesma dispersão com as regiões de centróide mais próximo."""
    X, y = generate(1.0)
    means = np.stack([CLASSES[c]["mean"] for c in CLASSES])

    margin = 1.5
    x_min, x_max = X[:, 0].min() - margin, X[:, 0].max() + margin
    y_min, y_max = X[:, 1].min() - margin, X[:, 1].max() + margin
    xx, yy = np.meshgrid(np.linspace(x_min, x_max, 400), np.linspace(y_min, y_max, 400))
    grid = np.stack([xx.ravel(), yy.ravel()], axis=1)
    grid_dists = np.linalg.norm(grid[:, None, :] - means[None, :, :], axis=2)
    region = grid_dists.argmin(axis=1).reshape(xx.shape)

    fig, ax = plt.subplots(figsize=(7, 5))
    cmap = plt.matplotlib.colors.ListedColormap([COLORS[c] for c in CLASSES])
    ax.contourf(xx, yy, region, levels=np.arange(-0.5, 4.5, 1), cmap=cmap, alpha=0.15)
    ax.contour(xx, yy, region, levels=np.arange(0.5, 4, 1), colors="black", linewidths=1.0, linestyles="--")
    plot_clouds(ax, X, y, means, "Fronteiras por centróide mais próximo (scale = 1.0)")
    ax.legend(loc="upper left")
    fig.tight_layout()
    fig.savefig(FIGURES / "fig01b-decision-boundaries.png", dpi=160)
    plt.close(fig)


def figure_2() -> None:
    """Figura 2: 4 subplots, um por valor de s, com eixos compartilhados."""
    datasets = {s: generate(s) for s in SCALES}
    means = np.stack([CLASSES[c]["mean"] for c in CLASSES])

    max_std = max(CLASSES[c]["std"].max() for c in CLASSES) * max(SCALES)
    margin = 3 * max_std
    x_min = min(CLASSES[c]["mean"][0] for c in CLASSES) - margin
    x_max = max(CLASSES[c]["mean"][0] for c in CLASSES) + margin
    y_min = min(CLASSES[c]["mean"][1] for c in CLASSES) - margin
    y_max = max(CLASSES[c]["mean"][1] for c in CLASSES) + margin

    fig, axes = plt.subplots(2, 2, figsize=(11, 9), sharex=True, sharey=True)
    for ax, s in zip(axes.ravel(), SCALES):
        X, y = datasets[s]
        plot_clouds(ax, X, y, means, f"s = {s}")
        ax.set_xlim(x_min, x_max)
        ax.set_ylim(y_min, y_max)
        ax.set_axisbelow(True)
        ax.grid(True, linestyle="--", alpha=0.4)
    axes.ravel()[0].legend(loc="upper left", fontsize=8)
    fig.suptitle("Nuvens de pontos para diferentes fatores de escala do desvio padrão")
    fig.tight_layout()
    fig.savefig(FIGURES / "fig02-scaled-clouds.png", dpi=160)
    plt.close(fig)


def figure_3(rates: dict[float, float]) -> None:
    """Figura 3: taxa de mistura em função de s."""
    fig, ax = plt.subplots(figsize=(7, 5))
    ax.plot(SCALES, [rates[s] for s in SCALES], marker="o", color="tab:purple")
    ax.set_axisbelow(True)
    ax.grid(True, linestyle="--", alpha=0.4)
    ax.set_xlabel("Fator de escala $s$")
    ax.set_ylabel("Taxa de mistura")
    ax.set_title("Taxa de mistura em função do fator de escala do desvio padrão")
    fig.tight_layout()
    fig.savefig(FIGURES / "fig03-mixing-rate.png", dpi=160)
    plt.close(fig)


def main() -> None:
    FIGURES.mkdir(parents=True, exist_ok=True)

    figure_1()
    figure_1b()
    figure_2()

    ratios_s1 = separation_ratios(1.0)
    print("Separation ratio r_ij em s = 1.0:")
    for (i, j), r in ratios_s1.items():
        print(f"  r_{i}{j} = {r:.4f}")
    smallest_pair, smallest_r = min(ratios_s1.items(), key=lambda kv: kv[1])
    print(f"Menor r_ij em s = 1.0: par {smallest_pair} = {smallest_r:.4f}")
    print(f"Mesmo par em s = 2.0 (sem regenerar dados): {smallest_r / 2:.4f}")

    rates = {s: mixing_rate(s) for s in SCALES}
    for s in SCALES:
        print(f"Taxa de mistura em s = {s}: {rates[s]:.4f} ({rates[s] * 100:.2f}%)")

    figure_3(rates)


if __name__ == "__main__":
    main()
  1. Semente fixa, criada uma única vez e reutilizada em todo o script: sem ela, os números da tabela de resultados mudam a cada execução e a correção não consegue reproduzir o relatório.
  2. plt.close(fig) evita o vazamento de figuras quando o script gera várias em sequência.

A: Generate the clouds

Nuvens de pontos das quatro classes gaussianas, com os centros marcados

Figura 1. Dispersão das quatro classes no plano \((x_1, x_2)\) com scale = 1.0; os X marcam os centros (médias) de cada classe.

B: More or less spread out

Nuvens de pontos para os quatro fatores de escala, com eixos compartilhados

Figura 2. As mesmas quatro nuvens com os desvios padrão multiplicados por \(s \in \{0.5, 1.0, 2.0, 4.0\}\), em eixos compartilhados.

A razão de separação \(r_{ij} = \|\mu_i - \mu_j\| / (\bar\sigma_i + \bar\sigma_j)\), com \(\bar\sigma_k = (\sigma_{k,x} + \sigma_{k,y})/2\), calculada a partir dos parâmetros do enunciado em s = 1.0, para os 6 pares de classes:

Par \((i, j)\) \(r_{ij}\)
(0, 1) 1.3258
(0, 2) 2.4802
(0, 3) 4.4960
(1, 2) 2.3800
(1, 3) 3.6422
(2, 3) 3.5422

O menor valor é \(r_{01} = 1.3258\) (classes 0 e 1, os "vizinhos" mais próximos no plano). Como apenas o denominador escala com s (as médias não mudam), \(r_{ij}(s) = r_{ij}(1)/s\): sem regenerar os dados, \(r_{01}(2) = 1.3258 / 2 = 0.6629\).

A taxa de mistura (fração de pontos cujo centro de classe mais próximo não é o da própria classe) para cada escala:

\(s\) Taxa de mistura
0.5 0.25%
1.0 5.00%
2.0 20.25%
4.0 43.00%

Taxa de mistura em função do fator de escala

Figura 3. Taxa de mistura × fator de escala \(s\).

A taxa de mistura cresce de forma aproximadamente linear-acelerada com s: ela mais que quadruplica entre s = 1.0 e s = 2.0 (de 5.00% para 20.25%) e volta a mais que dobrar até s = 4.0 (43.00%). Esse salto entre s = 1.0 e s = 2.0 coincide com o ponto em que o menor \(r_{ij}\) cruza 1: \(r_{01}(1) = 1.33 > 1\) (centros ainda mais afastados que a dispersão média somada), mas \(r_{01}(2) = 0.66 < 1\) (dispersão já maior que a distância entre centros). É razoável apontar s = 2.0 como o fator em que a separação linear deixa de ser confiável para o par (0, 1): com quase 1 em cada 5 pontos mais perto do centro errado, nenhuma fronteira linear única consegue mais isolar as duas classes sem um erro substancial.

C: Analysis

Fronteiras por centróide mais próximo, sobrepostas à mesma dispersão da Figura 1

Figura 1 (variante). Regiões de centróide mais próximo (fronteiras lineares por partes) sobrepostas à Figura 1, como esboço do que uma rede treinada tenderia a aprender.

  1. Em s = 1.0, as classes 2 e 3 estão bem isoladas (\(r_{02}, r_{03}, r_{12}, r_{13}, r_{23}\) todos acima de 2.3), mas as classes 0 e 1 se tocam na região central (\(r_{01} = 1.33\), taxa de mistura de 5.00%). Um único separador linear não pode isolar as 4 classes, pois uma reta divide o plano em apenas 2 regiões e há 4 classes; mas múltiplos separadores lineares (uma fronteira por par de classes vizinhas) conseguem uma separação quase completa, com um pequeno erro residual justamente entre as classes 0 e 1.
  2. A Figura 1 (variante) mostra o esboço: as fronteiras são os segmentos que dividem o plano em 4 regiões convexas, aproximadamente as bissetrizes perpendiculares entre cada par de centros vizinhos (0-1, 1-2, 1-3 e 2-3). É exatamente esse tipo de fronteira linear por partes que uma rede rasa treinada nesses dados tende a aproximar.
  3. Conforme as nuvens se espalham (item B), a região de sobreposição ao redor de cada fronteira cresce, e é nela que a rede é forçada a errar: a taxa de mistura sobe de 0.25% em s = 0.5 para 43.00% em s = 4.0. Isso reflete um erro de Bayes crescente, que não é mais uma limitação do classificador, mas da própria distribuição dos dados, que passam a se sobrepor de verdade.

Exercise 2

Abordagem

O Dataset I usa rng.multivariate_normal com as médias e as matrizes de covariância 5×5 do enunciado, 500 amostras por classe. O Dataset II sorteia direções uniformes na esfera unitária de \(\mathbb{R}^5\) (vetor gaussiano padrão normalizado) e um raio gaussiano por classe (núcleo \(\rho \sim N(2.0, 0.4)\), casca \(\rho \sim N(5.0, 0.4)\), interpretando 0.4 como desvio padrão). PCA (scikit-learn, só para projeção e variância explicada) reduz cada dataset a 2D exclusivamente para visualização; nenhum modelo é treinado. A mesma semente 42 é reaproveitada do início ao fim do script.

Código

O script vive em code/exercise2_high_dim.py e é incluído aqui pelo próprio arquivo.

docs/exercises/data/code/exercise2_high_dim.py
"""Exercise 2: Non-Linearity in Higher Dimensions.

Gera o Dataset I (gaussianas deslocadas em 5D, item A) e o Dataset II (cascas
concêntricas em 5D, item B), projeta ambos com PCA (item C) e avalia uma
função de separação não linear para o Dataset II (item D3). Salva as figuras
em ``figures/`` e imprime os números que alimentam o relatório e a tabela
*Results summary*.

Uso (a partir da raiz do repositório):

    python docs/exercises/data/code/exercise2_high_dim.py
"""

from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
from sklearn.decomposition import PCA

FIGURES = Path(__file__).resolve().parents[1] / "figures"

RNG = np.random.default_rng(42)  # (1)!

N_PER_CLASS = 500

MU_A = np.zeros(5)
SIGMA_A = np.array([
    [1.0, 0.8, 0.1, 0.0, 0.0],
    [0.8, 1.0, 0.3, 0.0, 0.0],
    [0.1, 0.3, 1.0, 0.5, 0.0],
    [0.0, 0.0, 0.5, 1.0, 0.2],
    [0.0, 0.0, 0.0, 0.2, 1.0],
])
MU_B = np.full(5, 1.5)
SIGMA_B = np.array([
    [1.5, -0.7, 0.2, 0.0, 0.0],
    [-0.7, 1.5, 0.4, 0.0, 0.0],
    [0.2, 0.4, 1.5, 0.6, 0.0],
    [0.0, 0.0, 0.6, 1.5, 0.3],
    [0.0, 0.0, 0.0, 0.3, 1.5],
])

RADIUS_C = (2.0, 0.4)  # (media, desvio padrao), classe núcleo
RADIUS_D = (5.0, 0.4)  # (media, desvio padrao), classe casca


def dataset_i() -> tuple[np.ndarray, np.ndarray]:
    """Dataset I: duas gaussianas 5D deslocadas (classes A=0, B=1)."""
    XA = RNG.multivariate_normal(MU_A, SIGMA_A, size=N_PER_CLASS)
    XB = RNG.multivariate_normal(MU_B, SIGMA_B, size=N_PER_CLASS)
    X = np.vstack([XA, XB])
    y = np.concatenate([np.zeros(N_PER_CLASS), np.ones(N_PER_CLASS)])
    return X, y


def random_unit_vectors(n: int, dim: int = 5) -> np.ndarray:
    """Direções uniformes na esfera unitária de R^dim (item B1)."""
    v = RNG.standard_normal(size=(n, dim))
    return v / np.linalg.norm(v, axis=1, keepdims=True)


def dataset_ii() -> tuple[np.ndarray, np.ndarray]:
    """Dataset II: cascas concêntricas 5D (classe C=núcleo=0, D=casca=1)."""
    u_c = random_unit_vectors(N_PER_CLASS)
    rho_c = RNG.normal(RADIUS_C[0], RADIUS_C[1], size=N_PER_CLASS)
    XC = rho_c[:, None] * u_c

    u_d = random_unit_vectors(N_PER_CLASS)
    rho_d = RNG.normal(RADIUS_D[0], RADIUS_D[1], size=N_PER_CLASS)
    XD = rho_d[:, None] * u_d

    X = np.vstack([XC, XD])
    y = np.concatenate([np.zeros(N_PER_CLASS), np.ones(N_PER_CLASS)])
    return X, y


def figure_4(X1, y1, X2, y2, ev1, ev2) -> None:
    """Figura 4: projeção PCA 2D dos dois datasets, lado a lado."""
    fig, axes = plt.subplots(1, 2, figsize=(12, 5))

    for c, label, color in ((0, "Classe A", "tab:blue"), (1, "Classe B", "tab:orange")):
        axes[0].scatter(*X1[y1 == c].T, s=10, alpha=0.6, color=color, label=label)
    axes[0].set_title(f"Dataset I (PC1+PC2 = {ev1[:2].sum() * 100:.1f}% da variância)")
    axes[0].set_xlabel("PC1")
    axes[0].set_ylabel("PC2")
    axes[0].legend()

    for c, label, color in ((0, "Classe C (núcleo)", "tab:green"), (1, "Classe D (casca)", "tab:red")):
        axes[1].scatter(*X2[y2 == c].T, s=10, alpha=0.6, color=color, label=label)
    axes[1].set_title(f"Dataset II (PC1+PC2 = {ev2[:2].sum() * 100:.1f}% da variância)")
    axes[1].set_xlabel("PC1")
    axes[1].set_ylabel("PC2")
    axes[1].legend()

    fig.tight_layout()
    fig.savefig(FIGURES / "fig04-pca-projections.png", dpi=160)
    plt.close(fig)


def figure_5(X1, y1, X2, y2) -> None:
    """Figura 5: histograma do raio ||x|| em 5D, com as duas classes sobrepostas."""
    r1 = np.linalg.norm(X1, axis=1)
    r2 = np.linalg.norm(X2, axis=1)

    fig, axes = plt.subplots(1, 2, figsize=(12, 5))

    axes[0].hist(r1[y1 == 0], bins=30, alpha=0.6, color="tab:blue", label="Classe A")
    axes[0].hist(r1[y1 == 1], bins=30, alpha=0.6, color="tab:orange", label="Classe B")
    axes[0].set_title("Dataset I: raio $\\|x\\|$ em 5D")
    axes[0].set_xlabel("$\\|x\\|$")
    axes[0].set_ylabel("Contagem")
    axes[0].legend()

    axes[1].hist(r2[y2 == 0], bins=30, alpha=0.6, color="tab:green", label="Classe C (núcleo)")
    axes[1].hist(r2[y2 == 1], bins=30, alpha=0.6, color="tab:red", label="Classe D (casca)")
    axes[1].set_title("Dataset II: raio $\\|x\\|$ em 5D")
    axes[1].set_xlabel("$\\|x\\|$")
    axes[1].set_ylabel("Contagem")
    axes[1].legend()

    fig.tight_layout()
    fig.savefig(FIGURES / "fig05-radius-histograms.png", dpi=160)
    plt.close(fig)


def main() -> None:
    FIGURES.mkdir(parents=True, exist_ok=True)

    X1, y1 = dataset_i()
    X2, y2 = dataset_ii()

    pca1 = PCA(n_components=2, random_state=42).fit(X1)
    pca2 = PCA(n_components=2, random_state=42).fit(X2)
    P1 = pca1.transform(X1)
    P2 = pca2.transform(X2)
    ev1 = pca1.explained_variance_ratio_
    ev2 = pca2.explained_variance_ratio_

    figure_4(P1, y1, P2, y2, ev1, ev2)
    figure_5(X1, y1, X2, y2)

    print(f"Dataset I : variancia explicada PC1={ev1[0]:.4f} PC2={ev1[1]:.4f} soma={ev1[:2].sum():.4f}")
    print(f"Dataset II: variancia explicada PC1={ev2[0]:.4f} PC2={ev2[1]:.4f} soma={ev2[:2].sum():.4f}")

    centerA, centerB = X1[y1 == 0].mean(axis=0), X1[y1 == 1].mean(axis=0)
    centerC, centerD = X2[y2 == 0].mean(axis=0), X2[y2 == 1].mean(axis=0)
    dist1 = float(np.linalg.norm(centerA - centerB))
    dist2 = float(np.linalg.norm(centerC - centerD))
    print(f"Distancia entre centros, Dataset I: {dist1:.4f}")
    print(f"Distancia entre centros, Dataset II: {dist2:.4f}")

    r2 = np.linalg.norm(X2, axis=1)
    mean_radius_c = float(r2[y2 == 0].mean())
    mean_radius_d = float(r2[y2 == 1].mean())
    print(f"Raio medio, casca interna (C): {mean_radius_c:.4f}")
    print(f"Raio medio, casca externa (D): {mean_radius_d:.4f}")

    # Item D3: proposta de funcao separadora nao linear para o Dataset II,
    # baseada em ||x||^2 = soma dos xi^2 (nunca treinada, so avaliada).
    threshold = ((RADIUS_C[0] + RADIUS_D[0]) / 2) ** 2  # (2)!
    f_x = np.sum(X2 ** 2, axis=1)
    pred = (f_x > threshold).astype(float)
    accuracy = float(np.mean(pred == y2))
    print(f"f(x) = ||x||^2 com limiar {threshold:.4f} separa o Dataset II com acuracia {accuracy:.4f}")


if __name__ == "__main__":
    main()
  1. Mesma semente única do Exercício 1, reaproveitada em todo o script.
  2. Limiar da função de separação proposta no item D3: o ponto médio entre os raios das duas classes (\((2.0 + 5.0)/2 = 3.5\)), elevado ao quadrado porque a função testada é \(\|x\|^2\), não \(\|x\|\).

A: Dataset I: shifted Gaussians

Classes A (\(\mu_A = [0,0,0,0,0]\)) e B (\(\mu_B = [1.5,1.5,1.5,1.5,1.5]\)), 500 amostras cada, com as matrizes de covariância do enunciado (B com variâncias maiores e correlação negativa no primeiro par de features, A com correlação positiva).

B: Dataset II: concentric shells

Classes C (núcleo, \(\rho \sim N(2.0, 0.4)\)) e D (casca, \(\rho \sim N(5.0, 0.4)\)), 500 amostras cada, construídas como \(x = \rho \cdot u\) com \(u\) uma direção uniforme na esfera unitária de \(\mathbb{R}^5\).

C: Visualize and compare

Projeção PCA 2D dos dois datasets, lado a lado

Figura 4. Projeção PCA (2 componentes) do Dataset I e do Dataset II, coloridas por classe.

A variância explicada pelos dois primeiros componentes principais:

Dataset PC1 PC2 PC1 + PC2
I (gaussianas deslocadas) 51.27% 15.77% 67.04%
II (cascas concêntricas) 21.59% 21.32% 42.91%

A projeção do Dataset I preserva muito mais informação relevante para a classificação: as duas classes já aparecem visualmente bem separadas ao longo de PC1 na Figura 4, e os dois componentes juntos capturam 67.04% da variância total. No Dataset II, PC1+PC2 capturam apenas 42.91% da variância e as classes ficam completamente misturadas na projeção; como veremos no item D, isso é uma limitação da projeção linear, não da separabilidade real dos dados.

Distância entre os centros de classe, calculada em \(\mathbb{R}^5\) (sem passar por PCA):

  • Dataset I: \(\|\mu_A - \mu_B\| = 3.2643\)
  • Dataset II: \(\|\mu_C - \mu_D\| = 0.2662\)

Histograma do raio dos pontos em 5D, com as duas classes sobrepostas

Figura 5. Histograma de \(\|x\|\) em \(\mathbb{R}^5\), classes sobrepostas, um painel por dataset.

D: Analysis

  1. No Dataset II os centros estão quase no mesmo ponto (distância de apenas 0.2662), mas os histogramas de raio são bem separados (núcleo com raio médio 1.9718, casca com raio médio 5.0047, praticamente sem sobreposição na Figura 5). Isso mostra que a diferença entre as classes não está em onde a nuvem está centrada, mas na sua estrutura radial: exatamente o tipo de padrão que um hiperplano não consegue capturar, já que um hiperplano separa o espaço com base numa combinação linear das coordenadas, e não com base na distância a um centro comum.
  2. As duas classes são cascas esféricas concêntricas em torno da mesma origem aproximada. Por simetria, qualquer hiperplano \(w^\top x + b = 0\) corta as duas cascas em pontos de ambas as classes em praticamente todas as direções, e não existe um lado do hiperplano que fique predominantemente com uma classe. Mais dados não mudam essa geometria: eles apenas descrevem com mais precisão a mesma distribuição sobreposta em qualquer projeção linear, o problema é estrutural, não estatístico.
  3. Não. PCA é uma transformação linear, então uma projeção 2D misturada só prova que as duas direções de maior variância não coincidem com a direção que separa as classes, não que as classes sejam inseparáveis no espaço original. A evidência está nos próprios resultados: mesmo com a Figura 4 mostrando as classes completamente sobrepostas (PC1+PC2 = 42.91% da variância), a função simples $$ f(\mathbf{x}) = |\mathbf{x}|^2 = \sum_i x_i^2, $$ com limiar \(((2.0 + 5.0)/2)^2 = 12.25\), separa as duas classes do Dataset II com 100.00% de acurácia (script exercise2_high_dim.py, item D3): uma função não linear das coordenadas originais resolve exatamente o que a projeção linear escondia.

Exercise 3

Abordagem

O train.csv do Spaceship Titanic (baixado manualmente do Kaggle) é dividido 80/20 de forma estratificada com semente fixa antes de qualquer imputação, codificação ou escalonamento. Os transformadores (mediana, moda, one-hot, MinMaxScaler) são ajustados exclusivamente no conjunto de treino e depois aplicados ao teste, para que nenhuma estatística do teste vaze para o pré-processamento.

Código

O script vive em code/exercise3_spaceship_titanic.py e é incluído aqui pelo próprio arquivo. Ele espera o dataset em docs/exercises/data/code/data/train.csv (não versionado, veja .gitignore).

docs/exercises/data/code/exercise3_spaceship_titanic.py
"""Exercise 3: Preparing Real-World Data for a Neural Network.

Carrega o Spaceship Titanic (``train.csv``), descreve o dataset bruto (item
A), faz o split treino/teste (item B), aplica o pipeline de pré-processamento
(imputação, one-hot, TotalSpend, log1p, escalonamento, ajustado só no
treino) no item C e reporta as verificações finais (item D). Salva a figura em
``figures/`` e imprime os números que alimentam o relatório e a tabela
*Results summary*.

Uso (a partir da raiz do repositório), com o dataset já baixado do Kaggle
(https://www.kaggle.com/competitions/spaceship-titanic/data) em
``docs/exercises/data/code/data/train.csv``:

    python docs/exercises/data/code/exercise3_spaceship_titanic.py
"""

from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import MinMaxScaler, OneHotEncoder

FIGURES = Path(__file__).resolve().parents[1] / "figures"
DATA_PATH = Path(__file__).resolve().parent / "data" / "train.csv"

SEED = 42
TARGET = "Transported"
SPEND_COLS = ["RoomService", "FoodCourt", "ShoppingMall", "Spa", "VRDeck"]
NUMERIC_COLS = ["Age"] + SPEND_COLS
CATEGORICAL_COLS = ["HomePlanet", "CryoSleep", "Destination", "VIP"]
DROP_COLS = ["Cabin", "Name", "PassengerId"]


def load_data() -> pd.DataFrame:
    if not DATA_PATH.exists():
        raise FileNotFoundError(
            "Dataset nao encontrado. Baixe train.csv em "
            "https://www.kaggle.com/competitions/spaceship-titanic/data "
            f"e salve em {DATA_PATH}"
        )
    return pd.read_csv(DATA_PATH)


def describe_raw(df: pd.DataFrame) -> None:
    """Item A: descrição do dataset bruto, antes de qualquer split ou transformação."""
    balance = df[TARGET].value_counts(normalize=True)
    print("Balanco de classes (Transported):")
    print(balance.to_string())

    missing = df.isna().sum()
    missing_pct = (missing / len(df) * 100).round(2)
    print("\nValores ausentes por coluna (contagem e %):")
    for col in df.columns:
        if missing[col] > 0:
            print(f"  {col}: {missing[col]} ({missing_pct[col]}%)")
    print(f"Total de colunas com valores ausentes: {int((missing > 0).sum())}")

    print("\nEstatisticas das colunas de gasto (dados brutos, antes do split):")
    print(df[SPEND_COLS].agg(["mean", "median", "max"]).T.to_string())


def figure_6(before: pd.Series, after: pd.Series) -> None:
    """Figura 6: FoodCourt antes/depois de log1p, no treino."""
    fig, axes = plt.subplots(1, 2, figsize=(12, 5))

    axes[0].hist(before, bins=40, color="tab:blue", alpha=0.8)
    axes[0].set_title("FoodCourt, antes (treino, apos imputacao)")
    axes[0].set_xlabel("FoodCourt")
    axes[0].set_ylabel("Contagem")

    axes[1].hist(after, bins=40, color="tab:orange", alpha=0.8)
    axes[1].set_title("FoodCourt, depois de log1p (treino)")
    axes[1].set_xlabel("log1p(FoodCourt)")
    axes[1].set_ylabel("Contagem")

    fig.tight_layout()
    fig.savefig(FIGURES / "fig06-foodcourt-before-after.png", dpi=160)
    plt.close(fig)  # (2)!


def main() -> None:
    FIGURES.mkdir(parents=True, exist_ok=True)
    df = load_data()

    print(f"Total de passageiros: {len(df)}")
    describe_raw(df)

    # B: Split ANTES de qualquer imputacao/encoding/escalonamento.
    X = df.drop(columns=[TARGET] + DROP_COLS)
    y = df[TARGET].astype(int)
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.2, stratify=y, random_state=SEED
    )  # (1)!
    X_train, X_test = X_train.copy(), X_test.copy()
    print(f"\nAmostras de treino: {len(X_train)} | Amostras de teste: {len(X_test)}")

    # C1: Imputacao, mediana (numericas) e moda (categoricas), ajustadas so no treino.
    medians = X_train[NUMERIC_COLS].median()
    for col in NUMERIC_COLS:
        X_train[col] = X_train[col].fillna(medians[col])
        X_test[col] = X_test[col].fillna(medians[col])

    modes = {col: X_train[col].mode(dropna=True).iloc[0] for col in CATEGORICAL_COLS}
    for col in CATEGORICAL_COLS:
        X_train[col] = X_train[col].fillna(modes[col])
        X_test[col] = X_test[col].fillna(modes[col])

    print(
        f"\nFoodCourt no treino, apos imputacao e antes de transformar: "
        f"media={X_train['FoodCourt'].mean():.4f}, mediana={X_train['FoodCourt'].median():.4f}"
    )

    # C3: Feature engineering, TotalSpend = soma das 5 colunas de gasto.
    X_train["TotalSpend"] = X_train[SPEND_COLS].sum(axis=1)
    X_test["TotalSpend"] = X_test[SPEND_COLS].sum(axis=1)
    spend_and_total = SPEND_COLS + ["TotalSpend"]

    # C4: Cauda pesada, log1p nas colunas de gasto e no TotalSpend.
    foodcourt_before = X_train["FoodCourt"].copy()
    for col in spend_and_total:
        X_train[col] = np.log1p(X_train[col])
        X_test[col] = np.log1p(X_test[col])
    foodcourt_after = X_train["FoodCourt"].copy()
    figure_6(foodcourt_before, foodcourt_after)

    # C2: One-hot ajustado so no treino; categorias novas no teste viram tudo-zero.
    encoder = OneHotEncoder(handle_unknown="ignore", sparse_output=False)
    encoder.fit(X_train[CATEGORICAL_COLS])
    cat_train = encoder.transform(X_train[CATEGORICAL_COLS])
    cat_test = encoder.transform(X_test[CATEGORICAL_COLS])
    cat_names = encoder.get_feature_names_out(CATEGORICAL_COLS)

    # C5: Escalonamento para [-1, 1], ajustado so no treino.
    numeric_final = NUMERIC_COLS + ["TotalSpend"]
    scaler = MinMaxScaler(feature_range=(-1, 1))
    scaler.fit(X_train[numeric_final])
    num_train = scaler.transform(X_train[numeric_final])
    num_test = scaler.transform(X_test[numeric_final])

    X_train_final = np.hstack([num_train, cat_train])
    X_test_final = np.hstack([num_test, cat_test])
    feature_names = list(numeric_final) + list(cat_names)

    # D2: Verificacoes finais.
    print(f"\nFeatures apos encoding: {len(feature_names)}")
    print(f"NaN restantes, treino: {int(np.isnan(X_train_final).sum())}, "
          f"teste: {int(np.isnan(X_test_final).sum())}")
    print(f"Formato final da matriz de treino: {X_train_final.shape}")
    print(f"Formato final da matriz de teste: {X_test_final.shape}")
    print(f"Treino: min={X_train_final.min():.4f}, max={X_train_final.max():.4f}")
    print(f"Teste : min={X_test_final.min():.4f}, max={X_test_final.max():.4f}")


if __name__ == "__main__":
    main()
  1. O split estratificado com semente fixa acontece antes de qualquer fit de imputador, codificador ou escalador: é o que evita vazamento de dados do teste para o treino.
  2. plt.close(fig) evita o vazamento de figuras quando o script gera várias em sequência.

A: Get to know the data

O dataset descreve passageiros de uma nave interestelar que colidiu com uma anomalia espaço-temporal; a coluna Transported indica se o passageiro foi transportado para uma dimensão alternativa (True) ou não (False). O balanço de classes é quase perfeito: 50.36% True e 49.64% False, num total de 8693 passageiros.

Features numéricas: Age, RoomService, FoodCourt, ShoppingMall, Spa, VRDeck. Features categóricas: HomePlanet, CryoSleep, Destination, VIP (mais Cabin, que é uma string composta e é descartada no item C3 junto com Name e PassengerId, ambos identificadores sem valor preditivo).

Valores ausentes por coluna (das 14 colunas originais, 12 têm algum valor ausente; só PassengerId e Transported estão sempre preenchidas):

Coluna Ausentes %
HomePlanet 201 2.31%
CryoSleep 217 2.50%
Cabin 199 2.29%
Destination 182 2.09%
Age 179 2.06%
VIP 203 2.34%
RoomService 181 2.08%
FoodCourt 183 2.11%
ShoppingMall 208 2.39%
Spa 183 2.11%
VRDeck 188 2.16%
Name 200 2.30%

Estatísticas das 5 colunas de gasto, nos dados brutos (antes do split):

Coluna Média Mediana Máximo
RoomService 224.69 0.00 14327.00
FoodCourt 458.08 0.00 29813.00
ShoppingMall 173.73 0.00 23492.00
Spa 311.14 0.00 22408.00
VRDeck 304.85 0.00 24133.00

A mediana é 0 em todas as 5 colunas, enquanto a média fica na casa das centenas: a maioria dos passageiros não gasta nada em nenhuma categoria, e uma minoria gasta muito (o máximo chega a quase 30 mil em FoodCourt), puxando a média para cima. Essa diferença grande entre média e mediana é a assinatura de uma distribuição fortemente assimétrica à direita (cauda pesada), o que motiva a transformação logarítmica do item C4.

B: Split before you transform

train_test_split com test_size=0.2, stratify=Transported e random_state=42, aplicado antes de qualquer imputação, encoding ou escalonamento: 6954 amostras de treino e 1739 de teste.

O split precisa vir antes de qualquer transformação porque imputadores, codificadores e escaladores aprendem estatísticas dos dados (mediana, moda, mínimo/máximo) que, se calculadas sobre o conjunto inteiro, vazariam informação do teste para o treino. Isso infla artificialmente o desempenho medido no teste, porque o modelo estaria sendo avaliado em dados cujas estatísticas ele já "viu" de alguma forma durante o pré-processamento.

C: Preprocess

Dados ausentes (C1): colunas numéricas recebem imputação pela mediana, ajustada só no treino, mais robusta que a média diante da cauda pesada já observada em A. Colunas categóricas recebem imputação pela moda (categoria mais frequente), também ajustada só no treino; como a proporção de ausentes é pequena (~2% em cada coluna) e não há indício de um padrão sistemático de ausência, a moda é uma escolha simples e adequada.

Features categóricas (C2): HomePlanet, CryoSleep, Destination e VIP são codificadas com OneHotEncoder(handle_unknown="ignore"), ajustado só no treino. Categorias do teste que não aparecem no treino (nenhuma ocorreu neste split, mas o mecanismo cobre o caso) são codificadas como uma linha de zeros em todas as colunas dummy daquela feature, em vez de gerar erro ou uma coluna nova.

Feature engineering (C3): TotalSpend é criada como a soma das 5 colunas de gasto; Cabin, Name e PassengerId são descartadas (identificadores ou texto composto sem valor preditivo direto).

Cauda pesada (C4): log1p é aplicado às 5 colunas de gasto e a TotalSpend.

FoodCourt no treino antes e depois de log1p

Figura 6. Histograma de FoodCourt no treino, antes (bruto, após imputação) e depois de log1p.

tanh satura (gradiente perto de zero) para entradas de magnitude grande; com valores brutos chegando a dezenas de milhares, a soma ponderada de entrada da rede ficaria na região saturada da tanh para a maioria das unidades. log1p comprime essa cauda longa para uma faixa muito mais estreita (a Figura 6 mostra o eixo indo de ~30000 para ~10), mantendo mais da distribuição na região sensível da ativação.

Escalonamento (C5): MinMaxScaler(feature_range=(-1, 1)), ajustado só no treino, aplicado às colunas numéricas (Age, as 5 colunas de gasto já transformadas por log1p, e TotalSpend). A escolha por [-1, 1] em vez de padronização (média 0, desvio 1) casa diretamente com a imagem de saída da tanh. Como o escalador é ajustado só no treino, o próprio treino fica exatamente em [-1.0000, 1.0000] por construção, enquanto o teste fica em [-1.0000, 1.1383]. O valor acima de 1 é esperado, não um erro: existe pelo menos um valor no teste maior que o máximo visto no treino para alguma feature, e é exatamente esse tipo de diferença que o split antes do escalonamento deve preservar.

D: Verify and visualize

Figura 6 apresentada no item C4 acima.

Verificações finais:

  • NaN restantes: 0 no treino e 0 no teste.
  • Formato final da matriz de treino: (6954, 17); da matriz de teste: (1739, 17).
  • Faixa de valores: treino em [-1.0000, 1.0000] (dentro da imagem de tanh por construção); teste em [-1.0000, 1.1383] (compatível na prática, já que tanh continua suave e bem-comportada para entradas ligeiramente acima de 1).

A decisão de pré-processamento com maior efeito no treinamento é a transformação log1p das colunas de gasto. Sem ela, a razão entre o máximo e a mediana chega à casa dos milhares (ex.: FoodCourt, máximo 29813 contra mediana 0), e o MinMaxScaler comprimiria a enorme maioria dos passageiros que gastam pouco ou nada para um intervalo minúsculo colado em -1, jogando os poucos grandes gastadores para a região saturada da tanh e destruindo a capacidade da rede de distinguir níveis intermediários de gasto. A imputação e o one-hot, em comparação, afetam uma fração pequena das linhas ou têm efeito mais localizado.

Results summary

# Item Valor
1 Taxa de mistura em s = 0.5 0.25%
2 Taxa de mistura em s = 1.0 5.00%
3 Taxa de mistura em s = 2.0 20.25%
4 Taxa de mistura em s = 4.0 43.00%
5 Menor \(r_{ij}\) em s = 1.0, e qual par 1.3258 (par 0-1)
6 Distância entre centros (Dataset I) 3.2643
7 Distância entre centros (Dataset II) 0.2662
8 Variância explicada PC1 + PC2 (Dataset I) 67.04%
9 Variância explicada PC1 + PC2 (Dataset II) 42.91%
10 Proporção da classe positiva em Transported 50.36%
11 Média e mediana de FoodCourt no treino, antes de transformar média 442.59 / mediana 0.00
12 Formato final da matriz de treino (6954, 17)
13 Mínimo e máximo dos conjuntos de treino e teste após o escalonamento treino [-1.0000, 1.0000]; teste [-1.0000, 1.1383]

Discussão

Um ponto que exigiu cuidado foi o item B do Exercício 1: sortear as quatro escalas de forma independente contaminaria a comparação entre elas com ruído extra, então reaproveitei o mesmo ruído-base nas quatro. Na análise do Dataset II, com os centros quase coincidentes eu esperava dados misturados, mas o histograma do raio mostra que qualquer hiperplano corta as duas cascas de forma parecida, não importa a orientação. Se fosse refazer, tentaria reportar uma faixa em vez de um número único para a taxa de mistura, já que com 400 pontos por rodada esse valor deve variar de seed para seed.

Conclusão

Os três exercícios mostram a mesma ideia sob ângulos diferentes: a complexidade da fronteira que uma rede precisa aprender é ditada pela geometria da distribuição dos dados, não por uma escolha de arquitetura. No Exercício 1, uma métrica simples baseada nas médias e desvios (\(r_{ij}\)) já prevê quando a separação linear degrada, e a taxa de mistura torna esse limite mensurável. No Exercício 2, o Dataset II é o contraponto: duas classes com centros quase idênticos, perfeitamente separáveis por uma função não linear das coordenadas, mas invisíveis para qualquer hiperplano ou projeção linear, e nenhuma quantidade de dados lineares resolveria isso, só uma mudança na forma da fronteira. O Exercício 3 mostra o mesmo princípio do lado do pré-processamento: a escala e a forma da distribuição de entrada (cauda pesada das colunas de gasto) interagem diretamente com a região de operação da função de ativação, então preparar os dados é, na prática, parte de preparar a rede para conseguir aprender a fronteira certa.