Pular para conteúdo

1. Data

Enunciado

Exercises → Data

Como reproduzir

Todo o código está em code/. Um único script roda os três exercícios em ordem, com um único gerador rng = np.random.default_rng(42), grava as figuras em figures/ e os números em code/results.json. Todos os números deste relatório vêm desse arquivo. O Exercise 3 lê code/data/train.csv, o arquivo rotulado da competição Spaceship Titanic do Kaggle. Ele está no repositório para que o código rode num checkout limpo.

python3 -m venv env && source ./env/bin/activate
python3 -m pip install -r requirements.txt
python docs/exercises/data/code/run_all.py
Código — code/run_all.py e code/common.py
docs/exercises/data/code/run_all.py
"""Roda os três exercícios em ordem, com um único gerador aleatório, e salva os números.

Uso, a partir da raiz do repositório:

    python docs/exercises/data/code/run_all.py

As figuras vão para docs/exercises/data/figures/ e os números para code/results.json.
O Exercise 3 precisa de code/data/train.csv (Kaggle, competição Spaceship Titanic).
"""

import json

import numpy as np

import ex1_point_clouds
import ex2_nonlinearity
import ex3_spaceship
from common import CODE_DIR, SEED


def main():
    rng = np.random.default_rng(SEED)  # o mesmo gerador em todo o relatório
    results = {
        "exercise1": ex1_point_clouds.run(rng),
        "exercise2": ex2_nonlinearity.run(rng),
        "exercise3": ex3_spaceship.run(),  # não sorteia nada; o split usa random_state=SEED
    }
    (CODE_DIR / "results.json").write_text(json.dumps(results, indent=2, ensure_ascii=False) + "\n")
    print(f"Números salvos em {CODE_DIR / 'results.json'}")


if __name__ == "__main__":
    main()
docs/exercises/data/code/common.py
"""Configuração compartilhada pelos três exercícios: semente, pastas e gravação das figuras."""

from pathlib import Path

import matplotlib

matplotlib.use("Agg")  # gera os PNG sem abrir janela
import matplotlib.pyplot as plt  # noqa: E402

SEED = 42
CODE_DIR = Path(__file__).resolve().parent
FIGURES = CODE_DIR.parent / "figures"
DATA_DIR = CODE_DIR / "data"


def save(fig, name):
    """Salva a figura em figures/ e libera a memória."""
    FIGURES.mkdir(parents=True, exist_ok=True)
    fig.savefig(FIGURES / name, dpi=150, bbox_inches="tight")
    plt.close(fig)

Exercise 1

Abordagem. Cada classe é uma gaussiana 2D com covariância diagonal: amostro \(x\) e \(y\) de forma independente, com a média e o desvio do enunciado. Para estudar o espalhamento, multiplico todos os desvios por \(s\) e mantenho as médias. Meço a separação de duas formas, sem treinar nada: o separation ratio \(r_{ij}\), calculado com os parâmetros, e o mixing rate, calculado com os pontos sorteados.

Código — code/ex1_point_clouds.py
docs/exercises/data/code/ex1_point_clouds.py
"""Exercise 1 — Point clouds: geometry and spread in 2D.

Gera as 4 classes gaussianas do enunciado, repete a geração com os desvios padrão
multiplicados por s e mede o quanto as nuvens se misturam. Produz as Figuras 1, 2 e 3.
Nenhum modelo é treinado: todas as medidas são geométricas.
"""

from itertools import combinations

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.colors import ListedColormap
from matplotlib.lines import Line2D

from common import save

# Parâmetros do enunciado: média (x, y) e desvio padrão (x, y) de cada classe.
MEANS = np.array([[2.0, 3.0], [5.0, 6.0], [8.0, 1.0], [15.0, 4.0]])
STDS = np.array([[0.8, 2.5], [1.2, 1.9], [0.9, 0.9], [0.5, 2.0]])
N_PER_CLASS = 100
CLASSES = range(len(MEANS))
SCALES = [0.5, 1.0, 2.0, 4.0]
COLORS = ["tab:blue", "tab:orange", "tab:green", "tab:red"]


def generate(rng, s=1.0):
    """Amostra 100 pontos por classe. As médias ficam fixas; só o desvio muda (× s)."""
    X = np.vstack([rng.normal(MEANS[k], STDS[k] * s, size=(N_PER_CLASS, 2)) for k in CLASSES])
    y = np.repeat(np.arange(len(MEANS)), N_PER_CLASS)
    return X, y


def separation_ratios(s=1.0):
    """r_ij = ||mu_i - mu_j|| / (sigma_i + sigma_j), com sigma_k = média dos dois desvios da classe k."""
    sigma_bar = (STDS * s).mean(axis=1)
    return {
        (i, j): float(np.linalg.norm(MEANS[i] - MEANS[j]) / (sigma_bar[i] + sigma_bar[j]))
        for i, j in combinations(CLASSES, 2)
    }


def nearest_center(X):
    """Índice do centro de classe (média do enunciado) mais próximo de cada ponto."""
    # dist[n, k]: distância euclidiana do ponto n ao centro da classe k.
    dist = np.linalg.norm(X[:, None, :] - MEANS[None, :, :], axis=2)
    return dist.argmin(axis=1)


def mixing_rate(X, y):
    """Fração de pontos cujo centro mais próximo não é o da própria classe."""
    return float(np.mean(nearest_center(X) != y))


def mixing_counts(X, y):
    """counts[k, m]: pontos da classe k cujo centro mais próximo é o da classe m."""
    counts = np.zeros((len(MEANS), len(MEANS)), dtype=int)
    np.add.at(counts, (y, nearest_center(X)), 1)
    return counts


def most_likely_class(xx, yy):
    """Classe de maior densidade em cada ponto da grade, com as gaussianas verdadeiras (s = 1).

    Não é um modelo treinado: usa só os parâmetros do enunciado. Serve de esboço da
    fronteira que uma rede bem treinada tende a aproximar.
    """
    grid = np.column_stack([xx.ravel(), yy.ravel()])
    z = (grid[:, None, :] - MEANS[None]) / STDS[None]
    # log da densidade gaussiana com covariância diagonal, sem a constante comum a todas as classes.
    log_density = -0.5 * (z**2).sum(axis=2) - np.log(STDS).sum(axis=1)
    return log_density.argmax(axis=1).reshape(xx.shape)


def plot_clouds(ax, X, y, marker_size=14):
    """Desenha os pontos de cada classe e marca o centro (média) de cada nuvem."""
    for k in CLASSES:
        ax.scatter(*X[y == k].T, s=marker_size, alpha=0.65, color=COLORS[k], label=f"Classe {k}")
    ax.scatter(*MEANS.T, marker="X", s=140, color="black", edgecolors="white",
               linewidths=1.0, zorder=3, label="Centro (média)")
    ax.set_xlabel("$x_1$")
    ax.set_ylabel("$x_2$")


def figure1(X, y, name, title, boundaries=False):
    """Figura 1: as 4 nuvens com os centros. Com boundaries=True, acrescenta o esboço do item C."""
    fig, ax = plt.subplots(figsize=(9, 5.5), layout="constrained")
    xlim, ylim = (-2.0, 18.0), (-6.0, 12.0)
    handles = []
    if boundaries:
        xx, yy = np.meshgrid(np.linspace(*xlim, 800), np.linspace(*ylim, 800))
        regions = most_likely_class(xx, yy)
        ax.contourf(xx, yy, regions, levels=np.arange(-0.5, len(MEANS)),
                    cmap=ListedColormap(COLORS), alpha=0.12)
        for k in CLASSES:  # contorno de cada região: as linhas se sobrepõem na fronteira
            ax.contour(xx, yy, (regions == k).astype(float), levels=[0.5], colors="black", linewidths=1.4)
        handles.append(Line2D([], [], color="black", lw=1.4, label="Fronteira esboçada"))
    plot_clouds(ax, X, y)
    for k in CLASSES:
        ax.annotate(f"$\\mu_{k}$", MEANS[k], textcoords="offset points", xytext=(8, 8),
                    fontsize=12, fontweight="bold")
    ax.set_xlim(xlim)
    ax.set_ylim(ylim)
    ax.set_title(title)
    point_handles, _ = ax.get_legend_handles_labels()
    ax.legend(handles=point_handles + handles, loc="upper left", bbox_to_anchor=(1.01, 1.0))
    save(fig, name)


def figure2(datasets, rates):
    """Figura 2: um painel por fator de escala, todos com os mesmos limites de eixo."""
    points = np.vstack([X for X, _ in datasets.values()])
    pad = 1.0
    fig, axes = plt.subplots(2, 2, figsize=(11, 9), sharex=True, sharey=True, layout="constrained")
    for ax, s in zip(axes.flat, SCALES):
        plot_clouds(ax, *datasets[s], marker_size=8)
        ax.set_title(f"s = {s}  —  mixing rate = {100 * rates[s]:.2f}%")
        ax.set_xlim(points[:, 0].min() - pad, points[:, 0].max() + pad)
        ax.set_ylim(points[:, 1].min() - pad, points[:, 1].max() + pad)
    handles, labels = axes.flat[0].get_legend_handles_labels()
    fig.legend(handles, labels, loc="outside lower center", ncol=5)
    fig.suptitle("Figura 2 — As mesmas 4 classes em 4 fatores de escala (eixos compartilhados)")
    save(fig, "fig2_scales.png")


def figure3(rates, r_min, pair):
    """Figura 3: mixing rate × s, com o menor r_ij (que cai como 1/s) no eixo da direita."""
    fig, ax = plt.subplots(figsize=(8.5, 5), layout="constrained")
    values = [100 * rates[s] for s in SCALES]
    ax.plot(SCALES, values, "o-", color="tab:purple", lw=2, label="Mixing rate (%)")
    for s, v in zip(SCALES, values):
        ax.annotate(f"{v:.2f}%", (s, v), textcoords="offset points", xytext=(0, 9), ha="center")
    ax.set_ylim(-3, max(values) + 8)  # espaço para os rótulos acima dos pontos
    ax.set_xscale("log", base=2)
    ax.set_xticks(SCALES, [str(s) for s in SCALES])
    ax.set_xlabel("Fator de escala s (escala log)")
    ax.set_ylabel("Mixing rate (%)")

    ax2 = ax.twinx()
    ax2.plot(SCALES, [r_min / s for s in SCALES], "s--", color="gray",
             label=f"Menor $r_{{ij}}$ (par {pair[0]}–{pair[1]}) = {r_min:.2f} / s")
    ax2.axhline(1.0, color="gray", lw=0.8, ls=":", label="$r_{ij} = 1$")
    ax2.set_ylabel("Menor $r_{ij}$")

    h1, l1 = ax.get_legend_handles_labels()
    h2, l2 = ax2.get_legend_handles_labels()
    ax.legend(h1 + h2, l1 + l2, loc="center left")
    ax.set_title("Figura 3 — Mixing rate × fator de escala s")
    save(fig, "fig3_mixing_rate.png")


def run(rng):
    # A — gera as nuvens originais (s = 1) e desenha a Figura 1.
    X, y = generate(rng, 1.0)
    figure1(X, y, "fig1_point_clouds.png", "Figura 1 — Nuvens de pontos das 4 classes (s = 1)")

    # B — as mesmas 4 classes em 4 escalas. O dataset de s = 1 é o próprio dataset do item A.
    datasets = {s: (X, y) if s == 1.0 else generate(rng, s) for s in SCALES}
    rates = {s: mixing_rate(*datasets[s]) for s in SCALES}
    ratios = separation_ratios(1.0)
    pair, r_min = min(ratios.items(), key=lambda item: item[1])
    figure2(datasets, rates)
    figure3(rates, r_min, pair)

    # C — esboço das fronteiras sobre a Figura 1.
    figure1(X, y, "fig1_boundaries.png",
            "Figura 1 (item C) — Esboço das fronteiras de decisão sobre as nuvens (s = 1)",
            boundaries=True)

    print("Exercise 1")
    for (i, j), r in ratios.items():
        print(f"  r_{i}{j} = {r:.3f}")
    for s in SCALES:
        print(f"  s = {s}: mixing rate = {100 * rates[s]:.2f}%")

    return {
        "separation_ratios_s1": {f"{i}-{j}": r for (i, j), r in ratios.items()},
        "smallest_ratio_pair": f"{pair[0]}-{pair[1]}",
        "smallest_ratio_s1": r_min,
        "smallest_ratio_s2": r_min / 2,
        "smallest_ratio_s2_direct": separation_ratios(2.0)[pair],  # confere que r escala com 1/s
        "mixing_rate": {str(s): rates[s] for s in SCALES},
        "mixing_counts": {str(s): mixing_counts(*datasets[s]).tolist() for s in SCALES},
    }

A — Generate the clouds

A função generate(rng, s=1.0) sorteia 100 pontos por classe com rng.normal(média, desvio × s). São 400 pontos, 4 classes e 100 amostras por classe.

Figura 1

Figura 1 — As 4 nuvens de pontos com \(s = 1\). O X preto marca o centro (média) de cada classe.

B — More or less spread out

Gero as mesmas 4 classes com \(s \in \{0.5,\ 1.0,\ 2.0,\ 4.0\}\). São 4 datasets, cada um com as 4 classes. O dataset de \(s = 1\) é o próprio dataset do item A. Assim, a Figura 1 e o painel \(s = 1\) da Figura 2 mostram os mesmos pontos.

Figura 2

Figura 2 — As mesmas 4 classes nos 4 fatores de escala. Os 4 painéis têm os mesmos limites de eixo.

Separation ratio em \(s = 1\). Com \(\bar\sigma_0 = 1.65\), \(\bar\sigma_1 = 1.55\), \(\bar\sigma_2 = 0.90\) e \(\bar\sigma_3 = 1.25\):

Par \((i, j)\) \(\lVert \mu_i - \mu_j \rVert\) \(\bar\sigma_i + \bar\sigma_j\) \(r_{ij}\)
(0, 1) 4.243 3.20 1.326
(0, 2) 6.325 2.55 2.480
(0, 3) 13.038 2.90 4.496
(1, 2) 5.831 2.45 2.380
(1, 3) 10.198 2.80 3.642
(2, 3) 7.616 2.15 3.542

O menor valor é \(r_{01} = 1.33\), do par de classes 0 e 1. As médias não mudam e os desvios são multiplicados por \(s\), então \(r_{ij}\) é dividido por \(s\). Em \(s = 2\), o menor valor passa a ser \(r_{01} = 1.33 / 2 = 0.66\). O script confere esse valor recalculando a fórmula com os desvios dobrados.

Mixing rate. Para cada ponto, calculo a distância até os 4 centros do enunciado. Um ponto "mistura" quando o centro mais próximo não é o da sua classe.

\(s\) Mixing rate Pontos misturados Menor \(r_{ij}\)
0.5 0.0% 0 / 400 2.65
1.0 5.0% 20 / 400 1.33
2.0 19.25% 77 / 400 0.66
4.0 48.25% 193 / 400 0.33

Figura 3

Figura 3 — Mixing rate em função de \(s\) (esquerda) e menor \(r_{ij}\), que cai como \(1/s\) (direita).

A partir de qual \(s\) as nuvens deixam de ser separáveis por retas? A partir de \(s = 1.0\). A regra "centro mais próximo" já é um conjunto de retas: a fronteira entre dois centros é a mediatriz do segmento que os une. Em \(s = 0.5\), essas retas separam os 400 pontos sem erro. Em \(s = 1.0\), 20 pontos ficam do lado errado: 8 pontos da classe 0 estão mais perto de \(\mu_1\), 6 da classe 1 estão mais perto de \(\mu_0\) e outros 6 estão mais perto de \(\mu_2\). Nesse ponto, o menor \(r_{ij}\) vale 1.33: a distância entre \(\mu_0\) e \(\mu_1\) é só 1.33 vez a soma dos desvios médios, e as caudas das duas nuvens já se cruzam. Quando \(s\) passa de \(1.33\), \(r_{01}\) fica abaixo de 1. Em \(s = 2\) (\(r_{01} = 0.66\)), a mistura sobe para 19.25%.

C — Analysis

Sobreposição em \(s = 1\). A mistura fica quase toda no par 0–1, o de menor \(r_{ij}\) (1.33). Das 20 trocas, 14 são entre essas duas classes. A classe 0 é alta (\(\sigma_y = 2.5\)) e invade a região da classe 1. A classe 2 é compacta (\(\sigma = 0.9\)): nenhum ponto dela mistura, mas 6 pontos da classe 1 caem mais perto de \(\mu_2\). A classe 3 fica isolada à direita (\(r_{03} = 4.50\), \(r_{13} = 3.64\), \(r_{23} = 3.54\)), sem nenhum ponto misturado.

Uma única fronteira linear separa as 4 classes? Não. Uma reta divide o plano em só duas regiões, e há 4 classes. Uma reta separa bem só uma classe do resto: a classe 3, com uma reta vertical perto de \(x_1 \approx 12\).

E um conjunto de fronteiras lineares? Separa quase tudo. As retas do "centro mais próximo" já acertam 95% dos pontos. Os 5% restantes são pontos que entraram na nuvem vizinha. Para acertá-los, as fronteiras teriam de contornar ponto a ponto. Isso decora o ruído desta amostra e não vale para novos pontos.

Figura 1 com fronteiras

Figura 1 (item C) — Esboço das fronteiras que uma rede treinada tenderia a aprender, sobre a Figura 1.

Como o esboço foi feito. Para cada ponto do plano, pintei a classe de maior densidade, usando as gaussianas do enunciado. Nenhum modelo foi treinado: é a fronteira ideal para esses parâmetros, e uma rede bem treinada tende a se aproximar dela. O esboço mostra três coisas:

  • a fronteira 3 × resto é quase uma reta vertical em \(x_1 \approx 12\);
  • a fronteira 0 × 1 é uma curva suave na diagonal, entre as duas nuvens;
  • a classe 2 fica dentro de uma região fechada e curva. Ela é mais compacta que as vizinhas, e por isso a densidade dela só vence perto do centro.

Uma rede com camadas ocultas (tanh, por exemplo) desenha curvas assim. Um perceptron sozinho desenha só uma reta.

Relação com o item B. Mais espalhamento aumenta a região em que as classes se sobrepõem. Nessa região, um mesmo lugar do plano tem pontos de duas classes, e nenhuma fronteira acerta os dois. A rede pode só escolher o melhor lado, e o erro dessa região não cai com mais treinamento. Os números mostram esse crescimento: o mixing rate vai de 0% para 5%, 19.25% e 48.25%, e o menor \(r_{ij}\) cai de 2.65 para 1.33, 0.66 e 0.33. Em \(s = 4\), quase metade dos pontos está mais perto do centro errado.

Exercise 2

Abordagem. Gero dois datasets em 5D, cada um com 500 pontos por classe. O Dataset I tem duas gaussianas com centros diferentes. O Dataset II tem um núcleo dentro de uma casca, com o mesmo centro. Comparo os dois de três formas: projeção PCA em 2D, distância entre os centros das classes e raio \(\lVert x \rVert\) de cada ponto. As duas últimas medidas usam os dados 5D originais.

Código — code/ex2_nonlinearity.py
docs/exercises/data/code/ex2_nonlinearity.py
"""Exercise 2 — Non-linearity in higher dimensions.

Dataset I: duas gaussianas 5D deslocadas. Dataset II: núcleo e casca concêntricos em 5D.
Compara os dois com PCA (Figura 4), distância entre centros e histograma do raio (Figura 5).
"""

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

from common import save

N_PER_CLASS = 500
DIM = 5

MU_A = np.zeros(DIM)
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(DIM, 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],
])

# Raio das cascas: rho ~ N(média, 0.4), com 0.4 lido como desvio padrão (convenção do NumPy).
RADIUS_C, RADIUS_D, RADIUS_STD = 2.0, 5.0, 0.4
THRESHOLD = (RADIUS_C + RADIUS_D) / 2  # raio no meio do caminho entre núcleo e casca: 3.5

CLASS_COLORS = ["tab:blue", "tab:red"]


def check_covariance(cov):
    """Uma covariância válida é simétrica e positiva definida (todos os autovalores > 0)."""
    eigenvalues = np.linalg.eigvalsh(cov)
    if not np.allclose(cov, cov.T) or eigenvalues.min() <= 0:
        raise ValueError(f"covariância inválida, autovalores = {eigenvalues}")
    return float(eigenvalues.min())


def dataset_gaussians(rng):
    """Dataset I: classe A ~ N(mu_A, Sigma_A) e classe B ~ N(mu_B, Sigma_B), 500 pontos cada."""
    # method="cholesky": a decomposição de Cholesky é única, então o resultado não muda de máquina.
    Xa = rng.multivariate_normal(MU_A, SIGMA_A, size=N_PER_CLASS, method="cholesky")
    Xb = rng.multivariate_normal(MU_B, SIGMA_B, size=N_PER_CLASS, method="cholesky")
    return np.vstack([Xa, Xb]), np.repeat([0, 1], N_PER_CLASS)


def shell(rng, radius_mean):
    """Pontos x = rho * u, com u uniforme na esfera unitária de R^5."""
    v = rng.standard_normal((N_PER_CLASS, DIM))
    u = v / np.linalg.norm(v, axis=1, keepdims=True)  # cada linha passa a ter norma 1
    rho = rng.normal(radius_mean, RADIUS_STD, size=N_PER_CLASS)
    return rho[:, None] * u


def dataset_shells(rng):
    """Dataset II: classe C (núcleo, raio ~2) e classe D (casca, raio ~5), 500 pontos cada."""
    return np.vstack([shell(rng, RADIUS_C), shell(rng, RADIUS_D)]), np.repeat([0, 1], N_PER_CLASS)


def radial_rule(X):
    """f(x) = ||x||^2 - 3.5^2. f > 0 prevê casca (D); f < 0 prevê núcleo (C)."""
    return (X**2).sum(axis=1) - THRESHOLD**2


def figure4(datasets):
    """Figura 4: PCA em 2D de cada dataset, lado a lado. Devolve a variância explicada."""
    fig, axes = plt.subplots(1, 2, figsize=(12, 5.4), layout="constrained")
    explained = {}
    for ax, (key, (title, X, y, names)) in zip(axes, datasets.items()):
        pca = PCA(n_components=2, svd_solver="full").fit(X)  # o PCA centraliza os dados sozinho
        Z = pca.transform(X)
        ratio = pca.explained_variance_ratio_
        explained[key] = ratio.tolist()
        for c in (0, 1):
            ax.scatter(*Z[y == c].T, s=10, alpha=0.55, color=CLASS_COLORS[c], label=names[c])
        ax.set_title(f"{title}: PC1 + PC2 = {100 * ratio.sum():.1f}% da variância")
        ax.set_xlabel(f"PC1 ({100 * ratio[0]:.1f}% da variância)")
        ax.set_ylabel(f"PC2 ({100 * ratio[1]:.1f}% da variância)")
        ax.set_aspect("equal", adjustable="datalim")
        ax.legend(loc="upper right")
    fig.suptitle("Figura 4 — Projeção PCA em 2D dos dois datasets 5D")
    save(fig, "fig4_pca.png")
    return explained


def figure5(datasets, radii):
    """Figura 5: histograma do raio ||x|| em 5D, com as duas classes no mesmo eixo."""
    fig, axes = plt.subplots(1, 2, figsize=(12, 4.6), layout="constrained")
    for ax, (key, (title, _, y, names)) in zip(axes, datasets.items()):
        bins = np.histogram_bin_edges(radii[key], bins=40)
        for c in (0, 1):
            ax.hist(radii[key][y == c], bins=bins, alpha=0.6, color=CLASS_COLORS[c], label=names[c])
        ax.set_title(title)
        ax.set_xlabel("Raio $\\|x\\|$ (calculado em 5D)")
        ax.set_ylabel("Número de pontos")
        ax.legend(loc="best")
    axes[1].axvline(THRESHOLD, color="black", ls="--", lw=1.2)
    axes[1].annotate(f"$\\|x\\| = {THRESHOLD}$", (THRESHOLD, axes[1].get_ylim()[1] * 0.9),
                     xytext=(6, 0), textcoords="offset points")
    fig.suptitle("Figura 5 — Histograma do raio de cada ponto, por classe")
    save(fig, "fig5_radius.png")


def run(rng):
    # A — Dataset I. Antes de amostrar, confere se as covariâncias do enunciado são válidas.
    min_eigenvalues = {"Sigma_A": check_covariance(SIGMA_A), "Sigma_B": check_covariance(SIGMA_B)}
    X1, y1 = dataset_gaussians(rng)

    # B — Dataset II.
    X2, y2 = dataset_shells(rng)

    datasets = {
        "dataset_I": ("Dataset I (gaussianas)", X1, y1, ("Classe A", "Classe B")),
        "dataset_II": ("Dataset II (cascas)", X2, y2, ("Classe C (núcleo)", "Classe D (casca)")),
    }

    # C — PCA e medidas geométricas, sempre nos dados 5D originais.
    explained = figure4(datasets)
    center_distance = {
        key: float(np.linalg.norm(X[y == 0].mean(axis=0) - X[y == 1].mean(axis=0)))
        for key, (_, X, y, _) in datasets.items()
    }
    radii = {key: np.linalg.norm(X, axis=1) for key, (_, X, _, _) in datasets.items()}
    figure5(datasets, radii)

    # D — uma função das entradas que separa o Dataset II.
    predicted_shell = radial_rule(X2) > 0
    rule_accuracy = float(np.mean(predicted_shell == (y2 == 1)))

    print("Exercise 2")
    for key in datasets:
        print(f"  {key}: distância entre centros = {center_distance[key]:.3f}, "
              f"PC1+PC2 = {100 * sum(explained[key]):.1f}%")
    print(f"  regra ||x||^2 > {THRESHOLD ** 2}: acurácia no Dataset II = {100 * rule_accuracy:.1f}%")

    radius_stats = {
        key: {
            str(c): {"mean": float(r[y == c].mean()), "min": float(r[y == c].min()), "max": float(r[y == c].max())}
            for c in (0, 1)
        }
        for key, (_, _, y, _) in datasets.items()
        for r in [radii[key]]
    }
    return {
        "min_eigenvalues": min_eigenvalues,
        "explained_variance": explained,
        "explained_variance_pc1_pc2": {key: float(sum(v)) for key, v in explained.items()},
        "center_distance_5d": center_distance,
        "center_distance_theory_dataset_I": float(np.linalg.norm(MU_B - MU_A)),
        "radius_stats": radius_stats,
        "radial_rule_threshold_squared": THRESHOLD**2,
        "radial_rule_accuracy_dataset_II": rule_accuracy,
    }

A — Dataset I: shifted Gaussians

Antes de amostrar, confiro se as duas matrizes do enunciado são covariâncias válidas (simétricas e positivas definidas). O menor autovalor é 0.158 para \(\Sigma_A\) e 0.498 para \(\Sigma_B\). Os dois são positivos, então as matrizes são válidas. Amostro com rng.multivariate_normal(mu, Sigma, size=500, method="cholesky"). A decomposição de Cholesky é única, então o resultado não depende da biblioteca de álgebra linear da máquina.

B — Dataset II: concentric shells

Cada ponto é \(x = \rho \cdot u\):

  • Direção \(u\): sorteio \(v \sim \mathcal{N}(0, I_5)\) e normalizo, \(u = v / \lVert v \rVert\). A gaussiana \(\mathcal{N}(0, I_5)\) tem a mesma forma em todas as direções. Por isso a direção normalizada é uniforme na esfera unitária.
  • Raio \(\rho\): \(\mathcal{N}(2.0,\ 0.4)\) na classe C (núcleo) e \(\mathcal{N}(5.0,\ 0.4)\) na classe D (casca). Leio 0.4 como desvio padrão, a convenção de rng.normal. Um raio negativo exigiria um desvio de 5σ abaixo da média, o que não acontece na prática.

C — Visualize and compare

Figura 4

Figura 4 — Projeção PCA em 2D de cada dataset. Cada PCA é ajustado no próprio dataset.

Medida Dataset I (gaussianas) Dataset II (cascas)
Variância explicada — PC1 50.5% 21.4%
Variância explicada — PC2 15.7% 20.9%
Variância explicada — PC1 + PC2 66.2% 42.3%
Distância entre os centros das classes (5D) 3.399 0.222
Distância teórica entre os centros \(\lVert \mu_B - \mu_A \rVert = 1.5\sqrt{5} = 3.354\) 0
Raio médio — 1ª classe (A / C) 2.07 2.00
Raio médio — 2ª classe (B / D) 4.21 5.02

Qual projeção preserva melhor a informação para classificar? A do Dataset I. A diferença entre as classes é o deslocamento \(\mu_B - \mu_A = 1.5 \cdot (1,1,1,1,1)\), uma direção fixa. O PC1 captura essa direção (50.5% da variância), e as classes aparecem lado a lado ao longo dele. No Dataset II não existe direção preferida. Os 5 componentes explicam cerca de 20% cada, e o valor 42.3% está perto de \(2/5 = 40\%\). A projeção joga fora 57.7% da variância. Um ponto da casca cuja direção está nos componentes descartados cai perto do centro da projeção, em cima do núcleo.

Distância entre os centros (5D). No Dataset I, os centros ficam a 3.40 um do outro (teoria: 3.35). No Dataset II, a distância é 0.22, quase zero: as duas classes têm o mesmo centro, a origem. O pequeno desvio vem da amostra finita.

Figura 5

Figura 5 — Histograma do raio \(\lVert x \rVert\) (calculado em 5D), com as duas classes no mesmo eixo.

No Dataset I, os raios se sobrepõem muito: a classe A vai até 4.97 e a classe B começa em 0.67. No Dataset II, os raios não se sobrepõem: o núcleo vai de 0.75 a 3.28, e a casca vai de 3.44 a 6.24.

D — Analysis

Centros coincidentes e raios separados: o que isso diz sobre um hiperplano? As classes do Dataset II não diferem na posição do centro. Elas diferem na distância até a origem. Um hiperplano \(w^\top x + b = 0\) separa o espaço em dois lados. A regra de decisão dele depende de uma direção. A casca, porém, envolve o núcleo em todas as direções. Por isso nenhum hiperplano deixa o núcleo de um lado e a casca inteira do outro.

Por que nenhuma quantidade de dados resolve isso? Tome qualquer hiperplano, com \(\lVert w \rVert = 1\), e suponha que o núcleo fique no lado \(w^\top x + b < 0\):

  • o núcleo tem pontos na direção \(+w\), com raio perto de 2: \(x = 2w\). Então \(2 + b < 0\), ou seja, \(b < -2\);
  • a casca tem pontos na direção \(-w\), com raio perto de 5: \(x = -5w\). Para ficarem no outro lado, \(-5 + b > 0\), ou seja, \(b > 5\).

Não existe \(b\) que seja menor que \(-2\) e maior que \(5\) ao mesmo tempo. O problema está na geometria, e não na falta de amostras. Mais dados só preenchem melhor as direções \(+w\) e \(-w\) e tornam a contradição ainda mais certa.

Uma projeção 2D misturada prova que as classes são inseparáveis? Não. O PCA é linear: ele só gira os dados e descarta eixos. Se as classes se misturam nos 2 eixos que sobraram, a diferença pode estar nos eixos descartados ou numa função não linear dos eixos. Meus resultados mostram isso. Na Figura 4, o Dataset II parece misturado, com só 42.3% da variância. Mesmo assim, em 5D, o raio separa as classes sem sobreposição (Figura 5), e a distância entre centros é só 0.22, o que confirma que a informação não está na posição.

Uma função simples que separa o Dataset II:

\[ f(x) = \lVert x \rVert^2 - 3.5^2 = \sum_{i=1}^{5} x_i^2 - 12.25, \qquad \text{classe} = \begin{cases} \text{D (casca)} & \text{se } f(x) > 0 \\ \text{C (núcleo)} & \text{se } f(x) < 0 \end{cases} \]

O limiar \(3.5\) é o ponto médio dos raios do enunciado, \((2 + 5)/2\). Não o ajustei aos dados. Essa regra classifica corretamente 99.9% dos 1000 pontos. O único erro é o ponto da casca com o menor raio, 3.44, que fica abaixo de 3.5. Com a nova feature \(z = \lVert x \rVert^2\), a fronteira passa a ser linear: um único limiar em \(z\). Uma camada oculta faz esse tipo de transformação: ela cria features não lineares nas quais o problema fica linear.

Exercise 3

Abordagem. Primeiro, descrevo o train.csv inteiro. Depois, separo treino e teste antes de calcular qualquer estatística de transformação. A classe TanhPreprocessor concentra o pré-processamento: o método fit aprende tudo só no treino (medianas, modas, categorias, média e desvio), e o método transform aplica exatamente os mesmos valores ao treino e ao teste. No fim, verifico se sobrou NaN, qual é o formato final e qual é a faixa de valores.

Código — code/ex3_spaceship.py
docs/exercises/data/code/ex3_spaceship.py
"""Exercise 3 — Preparing real-world data for a neural network (Spaceship Titanic).

Descreve o train.csv, separa treino e teste ANTES de qualquer estatística e prepara as
features para uma rede com ativação tanh. Produz a figura auxiliar do item C e a Figura 6.
"""

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

from common import DATA_DIR, SEED, save

TARGET = "Transported"
SPEND = ["RoomService", "FoodCourt", "ShoppingMall", "Spa", "VRDeck"]
NUMERIC = ["Age", *SPEND]
CATEGORICAL = ["HomePlanet", "CryoSleep", "Destination", "VIP"]
DROPPED = ["Cabin", "Name", "PassengerId"]
LOG_COLUMNS = [*SPEND, "TotalSpend"]
SCALED = ["Age", *LOG_COLUMNS]

CLASS_NAMES = {0: "Transported = False", 1: "Transported = True"}
CLASS_COLORS = {0: "tab:blue", 1: "tab:orange"}


def describe(df):
    """A — balanço do alvo, valores ausentes e estatísticas dos gastos (arquivo train.csv inteiro)."""
    missing = df.isna().sum()
    return {
        "rows": len(df),
        "columns": list(df.columns),
        "balance": {str(k): float(v) for k, v in df[TARGET].value_counts(normalize=True).items()},
        "missing": {
            col: {"count": int(missing[col]), "percent": float(100 * missing[col] / len(df))}
            for col in df.columns
        },
        "spend_stats": {
            col: {"mean": float(df[col].mean()), "median": float(df[col].median()), "max": float(df[col].max())}
            for col in SPEND
        },
    }


class TanhPreprocessor:
    """Aprende todas as estatísticas no treino (fit) e aplica as mesmas ao teste (transform)."""

    def fit(self, X):
        X = X.drop(columns=DROPPED)
        # Numéricas: mediana do treino. Os gastos têm cauda longa; a média seria puxada pelos extremos.
        self.num_imputer = SimpleImputer(strategy="median").fit(X[NUMERIC])
        # Categóricas: categoria mais frequente do treino.
        self.cat_imputer = SimpleImputer(strategy="most_frequent").fit(X[CATEGORICAL].astype(object))
        X = self._impute_and_engineer(X)
        self.scaler = StandardScaler().fit(X[SCALED])
        # handle_unknown="ignore": categoria nova no teste vira um vetor só de zeros, sem erro.
        self.encoder = OneHotEncoder(handle_unknown="ignore", sparse_output=False).fit(X[CATEGORICAL])
        return self

    def _impute_and_engineer(self, X):
        """Imputação, TotalSpend e log(1 + x). Usa só objetos já ajustados no treino."""
        X = X.copy()
        X[NUMERIC] = self.num_imputer.transform(X[NUMERIC])
        X[CATEGORICAL] = self.cat_imputer.transform(X[CATEGORICAL].astype(object)).astype(str)
        X["TotalSpend"] = X[SPEND].sum(axis=1)  # soma depois da imputação, para não somar NaN
        X[LOG_COLUMNS] = np.log1p(X[LOG_COLUMNS])  # log(1 + x) comprime a cauda longa dos gastos
        return X

    def transform(self, X):
        X = self._impute_and_engineer(X.drop(columns=DROPPED))
        scaled = self.scaler.transform(X[SCALED])  # média 0 e desvio 1, com a média e o desvio do treino
        onehot = self.encoder.transform(X[CATEGORICAL])
        columns = [*SCALED, *self.encoder.get_feature_names_out(CATEGORICAL)]
        return pd.DataFrame(np.hstack([scaled, onehot]), columns=columns, index=X.index)


def hist_by_class(ax, values, y, bins):
    """Histograma sobreposto das duas classes do alvo."""
    for c in (0, 1):
        ax.hist(values[y == c].dropna(), bins=bins, alpha=0.6, color=CLASS_COLORS[c], label=CLASS_NAMES[c])
    ax.set_ylabel("Número de passageiros")
    ax.legend(loc="upper right")


def figure_before_after(before, after, y, name, title, after_label):
    fig, axes = plt.subplots(1, 2, figsize=(12, 4.6), layout="constrained")
    for ax, values, label in [(axes[0], before, "valor original"), (axes[1], after, after_label)]:
        hist_by_class(ax, values, y, np.histogram_bin_edges(values.dropna(), bins=50))
        ax.set_xlabel(f"{values.name} — {label}")
    axes[0].set_title("Antes")
    axes[1].set_title("Depois")
    fig.suptitle(title)
    save(fig, name)


def run():
    df = pd.read_csv(DATA_DIR / "train.csv")

    # A — conhecer os dados.
    description = describe(df)

    # B — separar ANTES de calcular qualquer estatística de transformação.
    X = df.drop(columns=[TARGET])
    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
    )

    # C — pré-processar: fit só no treino, transform no treino e no teste.
    prep = TanhPreprocessor().fit(X_train)
    Xtr = prep.transform(X_train)
    Xte = prep.transform(X_test)

    # Categoria que não existe no treino: HomePlanet = "Pluto".
    unknown = X_train.iloc[[0]].copy()
    unknown["HomePlanet"] = "Pluto"
    unknown_encoded = prep.transform(unknown)
    unknown_onehot = {c: float(unknown_encoded[c].iloc[0]) for c in unknown_encoded if c.startswith("HomePlanet_")}

    log_example = np.log1p(X_train["RoomService"]).rename("RoomService")
    figure_before_after(X_train["RoomService"], log_example, y_train, "fig_c_log1p_roomservice.png",
                        "Figura auxiliar (item C) — RoomService antes e depois de log(1 + x), treino",
                        "log(1 + x)")

    # D — verificar e visualizar.
    figure_before_after(X_train["FoodCourt"], Xtr["FoodCourt"], y_train, "fig6_foodcourt.png",
                        "Figura 6 — FoodCourt antes e depois do pré-processamento, treino",
                        "imputado, log(1 + x) e padronizado")

    # Comparação: gastos imputados e padronizados SEM log(1 + x). Só mede o efeito do log;
    # não entra na matriz final.
    medians = dict(zip(NUMERIC, prep.num_imputer.statistics_))
    raw_spend = X_train[SPEND].fillna({col: medians[col] for col in SPEND})
    z_without_log = (raw_spend - raw_spend.mean()) / raw_spend.std(ddof=0)

    checks = {
        "max_z_without_log_train": {c: float(z_without_log[c].max()) for c in SPEND},
        "share_abs_above_3_without_log_train": float((z_without_log.abs() > 3).to_numpy().mean()),
        "nan_train": int(Xtr.isna().sum().sum()),
        "nan_test": int(Xte.isna().sum().sum()),
        "shape_train": list(Xtr.shape),
        "shape_test": list(Xte.shape),
        "min_train": float(Xtr.to_numpy().min()),
        "max_train": float(Xtr.to_numpy().max()),
        "min_test": float(Xte.to_numpy().min()),
        "max_test": float(Xte.to_numpy().max()),
        "scaled_mean_train": {c: float(Xtr[c].mean()) for c in SCALED},
        "scaled_std_train": {c: float(Xtr[c].std(ddof=0)) for c in SCALED},
        "scaled_min_max_train": {c: [float(Xtr[c].min()), float(Xtr[c].max())] for c in SCALED},
        "scaled_min_max_test": {c: [float(Xte[c].min()), float(Xte[c].max())] for c in SCALED},
        "share_abs_above_3_train": float((Xtr[SCALED].abs() > 3).to_numpy().mean()),
    }

    print("Exercise 3")
    print(f"  treino {Xtr.shape}, teste {Xte.shape}, NaN = {checks['nan_train']} / {checks['nan_test']}")
    print(f"  faixa treino [{checks['min_train']:.3f}, {checks['max_train']:.3f}], "
          f"teste [{checks['min_test']:.3f}, {checks['max_test']:.3f}]")

    return {
        "description": description,
        "split": {
            "train_rows": len(X_train),
            "test_rows": len(X_test),
            "positive_share_train": float(y_train.mean()),
            "positive_share_test": float(y_test.mean()),
        },
        "foodcourt_train_before": {
            "mean": float(X_train["FoodCourt"].mean()),
            "median": float(X_train["FoodCourt"].median()),
        },
        "imputation_values": {
            "numeric_median": dict(zip(NUMERIC, prep.num_imputer.statistics_.tolist())),
            "categorical_mode": dict(zip(CATEGORICAL, map(str, prep.cat_imputer.statistics_))),
        },
        "features": list(Xtr.columns),
        "unknown_category_onehot": unknown_onehot,
        "checks": checks,
    }

A — Get to know the data

Objetivo do dataset. No cenário da competição, a nave Spaceship Titanic bate numa anomalia do espaço-tempo, e parte dos passageiros vai parar em outra dimensão. A coluna Transported é o alvo: True quando o passageiro foi transportado para outra dimensão e False quando não foi. É um problema de classificação binária.

Balanço das classes. O arquivo tem 8693 passageiros e 14 colunas. True são 4378 (50.36%) e False são 4315 (49.64%). As classes estão praticamente equilibradas, então não é preciso reamostrar nem dar pesos diferentes às classes.

Features.

Tipo Colunas
Numéricas Age, RoomService, FoodCourt, ShoppingMall, Spa, VRDeck
Categóricas HomePlanet (3 planetas), CryoSleep (booleana), Destination (3 destinos), VIP (booleana), Cabin (texto deck/número/lado)
Identificadores PassengerId (grupo_número), Name
Alvo Transported (booleana)

Valores ausentes por coluna (arquivo inteiro, 8693 linhas):

Coluna Ausentes %
PassengerId 0 0.00%
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%
Transported 0 0.00%

Todas as features têm entre 2% e 2.5% de ausentes. Só o identificador e o alvo estão completos.

Colunas de gasto (arquivo inteiro, sem os NaN):

Coluna Média Mediana Máximo
RoomService 224.69 0.00 14327
FoodCourt 458.08 0.00 29813
ShoppingMall 173.73 0.00 23492
Spa 311.14 0.00 22408
VRDeck 304.85 0.00 24133

Média × mediana. A mediana é 0 nas cinco colunas: mais da metade dos passageiros não gastou nada em cada serviço. A média, porém, fica entre 174 e 458, e o máximo chega a dezenas de milhares. Poucos passageiros gastam muito e puxam a média para cima. Então as distribuições têm assimetria forte à direita (cauda longa): quase todos os valores estão em 0, e o espalhamento vem de uma minoria com gastos extremos.

B — Split before you transform

Uso train_test_split(X, y, test_size=0.2, stratify=y, random_state=42). O treino fica com 6954 linhas e o teste com 1739. A estratificação mantém o balanço: True é 50.36% no treino e 50.37% no teste.

Por que o split vem antes da imputação e do escalonamento? O teste simula dados que o modelo nunca viu. Se a mediana da imputação ou a média e o desvio do escalonamento usarem linhas do teste, a informação do teste entra no treino (data leakage), e a nota no teste fica otimista. Por isso separo primeiro, calculo todas as estatísticas só no treino e transformo o teste com elas, como aconteceria com um dado novo.

C — Preprocess

A ordem dos passos é: imputação → TotalSpend → \(\log(1 + x)\) → padronização, com o one-hot em paralelo. Todos os objetos do scikit-learn recebem fit só com X_train.

Dados ausentes.

  • Numéricas → mediana do treino (SimpleImputer(strategy="median")). Os valores aprendidos são Age = 27 e 0 para os cinco gastos. Os gastos têm cauda longa: a média do FoodCourt no treino é 452.61, mas a mediana é 0. A média colocaria um gasto alto e raro em quem não tem registro. A mediana é robusta a esses extremos e, nos gastos, coincide com o comportamento mais comum, "não gastou".
  • Categóricas → moda do treino (SimpleImputer(strategy="most_frequent")). Os valores aprendidos são HomePlanet = Earth, CryoSleep = False, Destination = TRAPPIST-1e e VIP = False. Só cerca de 2% dos valores faltam em cada coluna. A moda preenche esses poucos casos com uma categoria que já existe e não cria uma coluna extra, quase vazia, no one-hot.

Categóricas → one-hot. OneHotEncoder(handle_unknown="ignore") aprende as categorias do treino e cria 10 colunas: 3 de HomePlanet, 2 de CryoSleep, 3 de Destination e 2 de VIP.

Categoria nova no teste. Com handle_unknown="ignore", uma categoria que não existe no treino não gera erro: ela vira um vetor só de zeros nas colunas daquela feature. Testei isso no código: uma linha com HomePlanet = "Pluto" saiu com HomePlanet_Earth = HomePlanet_Europa = HomePlanet_Mars = 0. Para a rede, isso quer dizer "nenhum dos planetas conhecidos". Um NaN nunca chega ao encoder, porque a imputação vem antes.

Feature engineering. TotalSpend é a soma das cinco colunas de gasto, calculada depois da imputação para não somar NaN. Removo Cabin, Name e PassengerId.

Cauda longa → \(\log(1 + x)\). Aplico np.log1p às cinco colunas de gasto e ao TotalSpend. O \(+1\) mantém o zero em zero: \(\log(1 + 0) = 0\).

Figura auxiliar do item C

Figura auxiliar (item C) — RoomService no treino, antes e depois de \(\log(1 + x)\).

Por que isso ajuda uma rede com tanh? A tanh só é sensível perto de zero. A derivada é \(1 - \tanh^2(z)\): vale 1 em \(z = 0\), 0.07 em \(z = 2\) e 0.0099 em \(z = 3\). Sem o log, os gastos padronizados chegam a 18.26 a 22.27 desvios padrão acima da média no treino, e 1.93% dos valores ficam fora de \([-3, 3]\). Esses passageiros saturariam os neurônios (tanh ≈ 1, gradiente ≈ 0) e dominariam o ajuste dos pesos. O log comprime a cauda: um gasto de 14327 vira 9.57. Depois do log e da padronização, o máximo de cada gasto fica perto de 3, e só 0.06% dos valores numéricos passam de 3 em módulo.

Escalonamento → padronização (StandardScaler, média 0 e desvio 1), aplicada às 7 colunas numéricas: Age, os 5 gastos e TotalSpend. As colunas one-hot ficam em {0, 1}. Motivos da escolha:

  • a tanh é centrada em zero, e entradas com média 0 mantêm as pré-ativações na região em que o gradiente é grande;
  • todas as features ficam com a mesma escala (desvio 1), e nenhuma domina os pesos só pelo tamanho;
  • depois do log não sobram extremos fortes, que eram a principal fraqueza da padronização.

A normalização para \([-1, 1]\) usaria o mínimo e o máximo do treino, que são justamente os valores mais extremos. Ela também não garante a faixa no teste.

Mínimo e máximo depois do escalonamento (matriz completa, com o one-hot):

Conjunto Mínimo Máximo
Treino −1.996 3.508
Teste −1.996 3.369

Os dois extremos vêm de Age: 0 anos (−1.996) e 79 anos (3.508). No treino, cada coluna padronizada tem média 0.000 e desvio 1.000.

D — Verify and visualize

Figura 6

Figura 6 — FoodCourt no treino: valor original (esquerda) e depois de imputação, \(\log(1 + x)\) e padronização (direita).

Antes, quase todo o histograma é uma barra em 0, e a cauda vai até 29813. Depois, os valores de quem gastou ficam espalhados até 2.88. A barra dos que não gastaram continua lá, agora em −0.64, porque o log não muda a quantidade de zeros: ele só aproxima os valores altos.

Verificações finais:

Verificação Treino Teste
NaN restantes 0 0
Formato da matriz de features (6954, 17) (1739, 17)
Faixa de valores [−1.996, 3.508] [−1.996, 3.369]
Valores numéricos com \(\lvert z \rvert > 3\) 0.06% —

As 17 features são 7 numéricas padronizadas e 10 colunas one-hot. A faixa é compatível com a tanh: todos os valores estão entre −2 e 3.5, centrados em 0, e quase nenhum fica na região em que a tanh satura.

Qual decisão mais afeta o treinamento da rede? O \(\log(1 + x)\) nos gastos. Seis das sete features numéricas são gastos, e todos têm mediana 0 e máximo na casa das dezenas de milhares. Sem o log, a padronização deixaria alguns passageiros a cerca de 20 desvios padrão da média (máximo de 18.26 a 22.27). Esses valores saturam a tanh, zeram o gradiente dos neurônios que os recebem e fazem os pesos se ajustarem a poucos casos extremos. A imputação afeta pouco, porque só cerca de 2% dos valores faltam em cada coluna. A escolha entre padronização e normalização também pesa menos, desde que o log venha antes.

Results summary

# Item Your value
1 Mixing rate at \(s = 0.5\) 0.0% (0 / 400)
2 Mixing rate at \(s = 1.0\) 5.0% (20 / 400)
3 Mixing rate at \(s = 2.0\) 19.25% (77 / 400)
4 Mixing rate at \(s = 4.0\) 48.25% (193 / 400)
5 Smallest \(r_{ij}\) at \(s = 1.0\), and which pair \(r_{01} = 1.33\), par (0, 1)
6 Distance between centers — Dataset I 3.399
7 Distance between centers — Dataset II 0.222
8 Explained variance PC1 + PC2 — Dataset I 66.2%
9 Explained variance PC1 + PC2 — Dataset II 42.3%
10 Share of the positive class in Transported 50.36% (4378 / 8693)
11 Mean and median of FoodCourt on the training set, before transforming média 452.61; mediana 0.00
12 Final shape of the training feature matrix (6954, 17)
13 Minimum and maximum of the training and test sets after scaling treino: −1.996 / 3.508; teste: −1.996 / 3.369