Notebook Bab 4 - Representasi Fitur Kategorikal

Open In Colab

Notebook Bab 4 ini punya dua bagian. Bagian Demo tinggal Anda jalankan lalu amati keluarannya; bagian Mini Project berisi soal dan data yang Anda kerjakan sendiri.

Fitur kategorikal harus diubah menjadi angka. Pilihan encoding menentukan hasil, terutama pada kategori berkardinalitas tinggi.

Persiapan

import json
from pathlib import Path

import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, StandardScaler, TargetEncoder

RANDOM_STATE = 42
rng = np.random.default_rng(RANDOM_STATE)
print('Setup selesai.')


from pathlib import Path
import json
import urllib.request
import urllib.parse

DATA_BASE_URL = 'https://raw.githubusercontent.com/muhammad-zainal-muttaqin/NulisBuku/main/website/notebooks/data/section1'


def section_data_dir(name):
    """Folder data Bagian 1: pakai salinan lokal bila ada; jika tidak (mis. di
    Google Colab), unduh berkas dari repo GitHub sesuai manifest."""
    for base in (Path('data/section1'), Path('../data/section1')):
        if (base / name).exists():
            return base / name
    cache = Path('_nb_data') / name
    if not (cache / 'manifest.json').exists():
        cache.mkdir(parents=True, exist_ok=True)
        base_url = DATA_BASE_URL + '/' + name
        manifest = json.loads(urllib.request.urlopen(base_url + '/manifest.json').read().decode('utf-8'))
        for rel in manifest:
            dest = cache / rel
            dest.parent.mkdir(parents=True, exist_ok=True)
            if not dest.exists():
                url = base_url + '/' + '/'.join(urllib.parse.quote(seg) for seg in rel.split('/'))
                urllib.request.urlretrieve(url, dest)
        (cache / 'manifest.json').write_text(json.dumps(manifest), encoding='utf-8')
    return cache
Setup selesai.

Section 1 - Demo: Encoding Kategorikal pada Census-Income KDD

Data nyata: kategori demografis, pekerjaan, pendidikan, dan negara lahir

DATA_DIR = section_data_dir('ch04_census_income_kdd')
df = pd.read_parquet(DATA_DIR / 'census_income_combined.parquet')
stats = json.loads((DATA_DIR / 'verified_stats.json').read_text(encoding='utf-8'))

print('Snapshot:', DATA_DIR / 'census_income_combined.parquet')
print(f"Baris total: {stats['total_rows']:,} | target >50K: {stats['positive_rate_total']:.2%}")
print('Kardinalitas terpilih:')
for col, n in stats['selected_cardinality'].items():
    print(f'  {col:35s} {n:>5}')

unseen = stats['unseen_levels_year_95_vs_94']
print('\nLevel baru pada year=95 dibanding year=94 (ringkas):')
for col in ['country_of_birth_father', 'country_of_birth_mother', 'country_of_birth_self', 'employment_status']:
    vals = unseen.get(col, [])
    print(f'  {col:35s} {vals[:5]}')

encoder_cols = [
    'education', 'race', 'sex', 'major_industry_code',
    'detailed_occupation_recode', 'country_of_birth_self'
]
numeric_cols = ['age', 'wage_per_hour', 'capital_gains', 'weeks_worked_in_year']
work = df[encoder_cols + numeric_cols + ['income_gt_50k']].sample(n=80000, random_state=RANDOM_STATE)
X = work[encoder_cols + numeric_cols].copy()
X[encoder_cols] = X[encoder_cols].fillna('<missing>').astype(str)
y = work['income_gt_50k']
print(f"\nDemo sample: {len(work):,} baris | positif: {y.mean():.2%}")
print("Missing kategori diperlakukan eksplisit sebagai token '<missing>' sebelum encoder.")
Snapshot: data\section1\ch04_census_income_kdd\census_income_combined.parquet
Baris total: 299,285 | target >50K: 6.20%
Kardinalitas terpilih:
  sex                                     2
  race                                    5
  education                              17
  state_of_previous_residence            51
  country_of_birth_father                43
  country_of_birth_mother                43
  country_of_birth_self                  43
  detailed_household_and_family_stat     38
  detailed_industry_recode               52
  detailed_occupation_recode             47
  major_industry_code                    24
  major_occupation_code                  15
  year                                    2

Level baru pada year=95 dibanding year=94 (ringkas):
  country_of_birth_father             ['Holand-Netherlands', 'Panama']
  country_of_birth_mother             ['Holand-Netherlands', 'Panama']
  country_of_birth_self               ['Panama']
  employment_status                   ['Full-time schedules', 'Not in labor force', 'PT for econ reasons usually FT', 'PT for econ reasons usually PT', 'PT for non-econ reasons usually FT']

Demo sample: 80,000 baris | positif: 6.13%
Missing kategori diperlakukan eksplisit sebagai token '<missing>' sebelum encoder.

Bandingkan encoder dengan model yang sama

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=RANDOM_STATE)

logreg = LogisticRegression(max_iter=1000, solver='liblinear', random_state=RANDOM_STATE)

def make_pipe(encoder):
    ct = ColumnTransformer([
        ('cat', encoder, encoder_cols),
        ('num', StandardScaler(), numeric_cols),
    ])
    return Pipeline([('prep', ct), ('model', logreg)])

pipes = {
    'OneHotEncoder': make_pipe(OneHotEncoder(handle_unknown='ignore', min_frequency=20)),
    'OrdinalEncoder': make_pipe(OrdinalEncoder(handle_unknown='use_encoded_value', unknown_value=-1)),
    'TargetEncoder cross-fit': make_pipe(TargetEncoder(random_state=RANDOM_STATE, cv=5, smooth=20)),
}
rows = []
for name, pipe in pipes.items():
    scores = cross_val_score(pipe, X, y, cv=cv, scoring='roc_auc', n_jobs=None)
    rows.append({'encoder': name, 'ROC_AUC_mean': scores.mean(), 'ROC_AUC_std': scores.std()})
print(pd.DataFrame(rows).round(4).to_string(index=False))

# Leakage demo: a derived high-cardinality category from real columns.
leak_cols = ['education', 'detailed_occupation_recode', 'state_of_previous_residence', 'country_of_birth_self']
leak_work = df[leak_cols + numeric_cols + ['income_gt_50k']].sample(n=80000, random_state=RANDOM_STATE + 1).copy()
leak_work[leak_cols] = leak_work[leak_cols].fillna('<missing>').astype(str)
combo = leak_work[leak_cols[0]].astype(str)
for col in leak_cols[1:]:
    combo = combo + '|' + leak_work[col].astype(str)
leak_work['category_combo'] = combo
X_combo = leak_work[['category_combo'] + numeric_cols]
y_combo = leak_work['income_gt_50k']
print(f"\nDerived category_combo cardinality: {leak_work['category_combo'].nunique():,}")

# Wrong on purpose: target means computed once on the full sample before CV.
global_mean = y_combo.mean()
full_means = leak_work.groupby('category_combo')['income_gt_50k'].mean()
X_naive = pd.DataFrame({
    'category_combo_te': leak_work['category_combo'].map(full_means).fillna(global_mean),
})
for col in numeric_cols:
    X_naive[col] = leak_work[col].to_numpy()
naive_pipe = Pipeline([('scaler', StandardScaler()), ('model', logreg)])
naive_scores = cross_val_score(naive_pipe, X_naive, y_combo, cv=cv, scoring='roc_auc')

safe_pipe = Pipeline([
    ('prep', ColumnTransformer([
        ('te', TargetEncoder(random_state=RANDOM_STATE, cv=5, smooth=20), ['category_combo']),
        ('num', StandardScaler(), numeric_cols),
    ])),
    ('model', logreg),
])
safe_scores = cross_val_score(safe_pipe, X_combo, y_combo, cv=cv, scoring='roc_auc')
print('\nTarget encoding leakage demo (ROC-AUC):')
print(f"  naive pre-CV target mean : {naive_scores.mean():.4f} +/- {naive_scores.std():.4f}")
print(f"  cross-fitted pipeline    : {safe_scores.mean():.4f} +/- {safe_scores.std():.4f}")

# Unseen-category behavior from year 94 -> 95.
train94 = df[df['year'] == 94].copy()
test95 = df[df['year'] == 95].copy()
train94['country_of_birth_self'] = train94['country_of_birth_self'].fillna('<missing>').astype(str)
test95['country_of_birth_self'] = test95['country_of_birth_self'].fillna('<missing>').astype(str)
ohe = OneHotEncoder(handle_unknown='ignore', sparse_output=False).fit(train94[['country_of_birth_self']])
new_country = sorted(set(test95['country_of_birth_self']) - set(train94['country_of_birth_self']))
print('\nUnseen country_of_birth_self in 1995:', new_country)
if new_country:
    examples = test95[test95['country_of_birth_self'].astype(str).isin(new_country)][['country_of_birth_self']].head(3)
    encoded = ohe.transform(examples)
    print('Jumlah kolom aktif setelah OneHotEncoder(handle_unknown="ignore") untuk contoh unseen:', encoded.sum(axis=1).astype(int).tolist())
                encoder  ROC_AUC_mean  ROC_AUC_std
          OneHotEncoder        0.9320       0.0019
         OrdinalEncoder        0.8859       0.0038
TargetEncoder cross-fit        0.9267       0.0014

Derived category_combo cardinality: 6,613

Target encoding leakage demo (ROC-AUC):
  naive pre-CV target mean : 0.9348 +/- 0.0031
  cross-fitted pipeline    : 0.9058 +/- 0.0034

Unseen country_of_birth_self in 1995: ['Panama']
Jumlah kolom aktif setelah OneHotEncoder(handle_unknown="ignore") untuk contoh unseen: [0, 0, 0]

🔎 Amati. Dataset ini memuat kategori rendah, menengah, ordinal-like, dan puluhan level dalam satu tabel. Ketika target encoding dihitung sebelum CV, skor tampak naik karena label validasi ikut masuk ke rata-rata kategori; ketika encoder berada di dalam pipeline, cross-fitting menahan kebocoran itu. Demo year=94 ke year=95 juga menunjukkan bahwa kategori baru perlu kebijakan eksplisit, bukan asumsi bahwa semua level sudah pernah terlihat.

Section 2 - Mini Project

Soal

Anda diberi data dengan kolom kategorikal berkardinalitas tinggi (merek_produk) dan satu kolom numerik (harga). Targetnya terjual (1/0).

Tugas:

  1. Bandingkan minimal dua strategi encoding pada merek_produk (misalnya one-hot dan target encoding), semuanya di dalam pipeline.
  2. Tangani kategori yang belum pernah muncul saat inferensi (unseen category).
  3. Laporkan akurasi CV tiap strategi.

Luaran: kode pipeline, tabel akurasi, dan 2-3 kalimat kesimpulan pilihan encoding.

Kriteria penilaian: (a) encoder di dalam pipeline (tidak di-fit pada seluruh data); (b) penanganan unseen category eksplisit; (c) perbandingan adil (model sama).

# DATA AWAL (jangan diubah) - merek berkardinalitas tinggi.
merek_ids = [f'merek_{i}' for i in range(300)]
efek_merek = dict(zip(merek_ids, rng.normal(0, 2.2, len(merek_ids))))
merek = rng.choice(merek_ids, 3000)
harga = rng.gamma(2.0, 50000, 3000)
logit = np.array([efek_merek[m] for m in merek]) - 0.000004 * harga
terjual = (rng.random(3000) < 1 / (1 + np.exp(-logit))).astype(int)
produk = pd.DataFrame({'merek_produk': merek, 'harga': harga, 'terjual': terjual})
print('Data:', produk.shape, '| kardinalitas merek:', produk['merek_produk'].nunique())
produk.head()
Data: (3000, 3) | kardinalitas merek: 300
# Kerjakan di sini.
# Petunjuk: ColumnTransformer + TargetEncoder / OneHotEncoder(handle_unknown='ignore').