Hybrid Recommender¶
Ketika pengguna baru selesai membaca sebuah buku dan bertanya, “selanjutnya baca apa ya?” Secara bisnis, sistem perlu mengisi rak rekomendasi top-N dengan buku yang cukup relevan untuk mendorong user meng- klik, simpan, atau mulai membaca dengan pengalaman yang baik, tanpa terus disuguhi judul populer dan tanpa menampilkan beberapa edisi dari karya yang sama yang barangkali membuat pengalaman membaca berkurang.
Notebook ini mengikuti perjalanan keputusan tersebut:
- popularity based dipakai sebagai baseline yang selalu tersedia.
- mencoba collaborative filtering untuk personalisasi.
- menambahkan content-based filtering ketika pola co-rating lemah.
- menggabungkan sinyal tersebut dalam hybrid recommender.
- memisahkan demo, diagnostic validation, dan benchmark formal.
- menguji apakah katalog lebih besar memberi reach yang layak terhadap biaya serving.
- memverifikasi bahwa keputusan eksperimen terhubung ke artifact serving.
0. Batasan¶
Dataset Book-Crossing menyediakan rating eksplisit, tetapi tidak menyediakan impression, click, save, purchase, inventory, maupun timestamp interaksi. Karena itu, ranking metric di sini adalah offline proxy. Kita dapat menguji apakah model menemukan kembali karya yang ditahan dari histori pengguna, tetapi belum dapat mengklaim peningkatan penjualan atau engagement tanpa eksperimen online.
Target keputusan offline:
- relevansi: HitRate, MAP/MRR, dan NDCG@K.
- reach: candidate dan personalization availability.
- discovery: catalog coverage, novelty, dan diversity.
- risiko: popularity concentration, cold-start, dan ketidakpastian antar-split.
- biaya sistem: fit time, latency warm inference, dan ukuran artifact/model proxy.
from __future__ import annotations
import os
# Batasi native thread sebelum NumPy/sklearn diimpor. Ini membuat notebook lebih
# stabil pada laptop dan runner CI dengan resource kecil.
for variable in (
"OMP_NUM_THREADS",
"OPENBLAS_NUM_THREADS",
"MKL_NUM_THREADS",
"NUMEXPR_NUM_THREADS",
):
os.environ.setdefault(variable, "1")
import gc
import json
from inspect import Parameter, signature
from pathlib import Path
from time import perf_counter
import matplotlib
try:
get_ipython
except NameError:
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from threadpoolctl import threadpool_limits
from book_recommender.artifacts import fingerprint_dataset
from book_recommender.catalog_benchmark import read_catalog_benchmark_summary
from book_recommender.cli import evaluation_algorithm_contract, read_benchmark_metrics
from book_recommender.collaborative import ItemBasedCollaborativeRecommender
from book_recommender.content_based import ContentBasedRecommender, book_work_key
from book_recommender.data import (
BookDataset,
clean_dataset as clean_book_dataset,
dataset_summary,
load_raw_data,
prepare_interaction_dataset,
select_content_catalog,
validate_data_dir,
)
from book_recommender.evaluation import (
prepare_validation_test_evaluation,
ranking_metrics_at_k,
)
from book_recommender.hybrid import HybridRecommender
from book_recommender.service import RecommenderService
try:
from IPython.display import Markdown, display
except ImportError:
Markdown = str
display = print
sns.set_theme(style="whitegrid", context="notebook")
pd.set_option("display.max_columns", 40)
pd.set_option("display.max_colwidth", 90)
def find_project_root(start: Path) -> Path:
"""Find the checkout root without assuming where Jupyter was launched."""
for candidate in (start.resolve(), *start.resolve().parents):
if (candidate / "pyproject.toml").exists():
return candidate
raise FileNotFoundError("Cannot find project root containing pyproject.toml.")
def configured_path(name: str, default: Path, project_root: Path) -> Path:
value = Path(os.environ.get(name, str(default)))
return value if value.is_absolute() else project_root / value
def display_path(path: Path, project_root: Path) -> str:
try:
return path.resolve().relative_to(project_root.resolve()).as_posix()
except ValueError:
return f"<external>/{path.name}"
def work_key_map(books: pd.DataFrame) -> dict[str, str]:
return {
str(row.isbn): book_work_key(row.book_title, row.book_author, row.isbn)
for row in books.itertuples(index=False)
}
def recommendation_work_keys(
recommendations: pd.DataFrame,
isbn_to_work: dict[str, str],
) -> list[str]:
return list(
dict.fromkeys(
isbn_to_work[str(isbn)]
for isbn in recommendations["isbn"].astype(str)
if str(isbn) in isbn_to_work
)
)
def weight_label(record: dict[str, object]) -> str:
return (
f"C={float(record['collaborative_weight']):.2f}, "
f"T={float(record['content_weight']):.2f}, "
f"P={float(record['popularity_weight']):.2f}"
)
def catalog_label(record: dict[str, object]) -> str:
return (
f"{int(record['max_interaction_books']):,} / "
f"{int(record['max_content_books']):,}"
)
def parity_value_matches(left: object, right: object) -> bool:
"""Compare scalar config values while treating two missing values as equal."""
if pd.isna(left) or pd.isna(right):
return bool(pd.isna(left) and pd.isna(right))
return bool(left == right)
MISSING_CONFIG_VALUE = "<missing>"
def required_config_value(config: dict[str, object], key: str) -> object:
"""Expose missing manifest fields instead of silently replacing them with None."""
return config[key] if key in config else MISSING_CONFIG_VALUE
1. Konfigurasi dan Reproducibility¶
Default notebook diselaraskan dengan bundle serving: 1.000 interaction books, 3.000 content books, 50 neighbors, rating positif minimal 8, dan Top-10. Nilai dapat dioverride melalui environment variable untuk smoke test CI tanpa membuat cabang logika khusus di notebook.
PROJECT_ROOT = find_project_root(Path.cwd())
DATA_DIR = configured_path(
"BOOK_RECOMMENDER_DATA_DIR",
PROJECT_ROOT / "data" / "raw",
PROJECT_ROOT,
)
MODEL_ARTIFACT = configured_path(
"BOOK_RECOMMENDER_MODEL_ARTIFACT",
PROJECT_ROOT / "models" / "hybrid.joblib",
PROJECT_ROOT,
)
BENCHMARK_ARTIFACT = configured_path(
"BOOK_RECOMMENDER_BENCHMARK_ARTIFACT",
PROJECT_ROOT / "artifacts" / "evaluation" / "repeated_split_hybrid_benchmark.json",
PROJECT_ROOT,
)
CATALOG_BENCHMARK_SUMMARY = configured_path(
"BOOK_RECOMMENDER_CATALOG_BENCHMARK_SUMMARY",
PROJECT_ROOT / "docs" / "evidence" / "catalog_size_benchmark_summary.json",
PROJECT_ROOT,
)
MAX_BOOKS = int(os.environ.get("BOOK_RECOMMENDER_NOTEBOOK_MAX_BOOKS", "1000"))
MAX_CONTENT_BOOKS = int(
os.environ.get("BOOK_RECOMMENDER_NOTEBOOK_MAX_CONTENT_BOOKS", "3000")
)
MIN_USER_RATINGS = 2
MIN_BOOK_RATINGS = 2
N_NEIGHBORS = 50
POSITIVE_RATING_THRESHOLD = 8.0
TOP_K = 10
EVALUATION_SAMPLE_USERS = int(
os.environ.get("BOOK_RECOMMENDER_NOTEBOOK_EVAL_USERS", "40")
)
RANDOM_STATE = 42
validate_data_dir(DATA_DIR)
configuration = pd.DataFrame(
{
"parameter": [
"data_dir",
"catalog_benchmark_summary",
"max_interaction_books",
"max_content_books",
"neighbors",
"positive_rating_threshold",
"top_k",
"diagnostic_sample_users",
"random_state",
],
"value": [
display_path(DATA_DIR, PROJECT_ROOT),
display_path(CATALOG_BENCHMARK_SUMMARY, PROJECT_ROOT),
MAX_BOOKS,
MAX_CONTENT_BOOKS,
N_NEIGHBORS,
POSITIVE_RATING_THRESHOLD,
TOP_K,
EVALUATION_SAMPLE_USERS,
RANDOM_STATE,
],
}
)
display(configuration)
| parameter | value | |
|---|---|---|
| 0 | data_dir | data/raw |
| 1 | catalog_benchmark_summary | docs/evidence/catalog_size_benchmark_summary.json |
| 2 | max_interaction_books | 1000 |
| 3 | max_content_books | 3000 |
| 4 | neighbors | 50 |
| 5 | positive_rating_threshold | 8.0 |
| 6 | top_k | 10 |
| 7 | diagnostic_sample_users | 40 |
| 8 | random_state | 42 |
2. Data Understanding¶
Dataset terdiri dari katalog buku, rating user ISBN, dan metadata user. ISBN merepresentasikan edisi (tidak selalu karya unik) . Nanti, unit output dan unit relevance evaluasi akan menggunakan canonical work berbasis judul–penulis.
raw = load_raw_data(DATA_DIR)
raw_shapes = pd.DataFrame(
{
"dataset": ["Books", "Ratings", "Users"],
"rows": [len(raw.books), len(raw.ratings), len(raw.users)],
"columns": [raw.books.shape[1], raw.ratings.shape[1], raw.users.shape[1]],
}
)
display(raw_shapes)
display(raw.books.head(3))
display(raw.ratings.head(3))
display(raw.users.head(3))
| dataset | rows | columns | |
|---|---|---|---|
| 0 | Books | 271360 | 8 |
| 1 | Ratings | 1149780 | 3 |
| 2 | Users | 278858 | 3 |
| ISBN | Book-Title | Book-Author | Year-Of-Publication | Publisher | Image-URL-S | Image-URL-M | Image-URL-L | |
|---|---|---|---|---|---|---|---|---|
| 0 | 0195153448 | Classical Mythology | Mark P. O. Morford | 2002 | Oxford University Press | http://images.amazon.com/images/P/0195153448.01.THUMBZZZ.jpg | http://images.amazon.com/images/P/0195153448.01.MZZZZZZZ.jpg | http://images.amazon.com/images/P/0195153448.01.LZZZZZZZ.jpg |
| 1 | 0002005018 | Clara Callan | Richard Bruce Wright | 2001 | HarperFlamingo Canada | http://images.amazon.com/images/P/0002005018.01.THUMBZZZ.jpg | http://images.amazon.com/images/P/0002005018.01.MZZZZZZZ.jpg | http://images.amazon.com/images/P/0002005018.01.LZZZZZZZ.jpg |
| 2 | 0060973129 | Decision in Normandy | Carlo D'Este | 1991 | HarperPerennial | http://images.amazon.com/images/P/0060973129.01.THUMBZZZ.jpg | http://images.amazon.com/images/P/0060973129.01.MZZZZZZZ.jpg | http://images.amazon.com/images/P/0060973129.01.LZZZZZZZ.jpg |
| User-ID | ISBN | Book-Rating | |
|---|---|---|---|
| 0 | 276725 | 034545104X | 0 |
| 1 | 276726 | 0155061224 | 5 |
| 2 | 276727 | 0446520802 | 0 |
| User-ID | Location | Age | |
|---|---|---|---|
| 0 | 1 | nyc, new york, usa | NaN |
| 1 | 2 | stockton, california, usa | 18.0 |
| 2 | 3 | moscow, yukon territory, russia | NaN |
Missing Value dan Semantik Rating 0¶
Rating 0 pada Book-Crossing berarti tidak ada rating eksplisit. Nilai itu
tidak boleh diperlakukan sebagai ketidaksukaan, sebab itu akan mengubah arti
label. Kolom umur memiliki banyak missing value, informasi ini menjadi pertimbangan sehingga variabel itu tidak dipakai model.
missing_summary = pd.concat(
{
"books": raw.books.isna().sum(),
"ratings": raw.ratings.isna().sum(),
"users": raw.users.isna().sum(),
},
axis=1,
).fillna(0).astype("int64")
display(missing_summary)
rating_counts = raw.ratings["Book-Rating"].value_counts().sort_index()
implicit_rows = int((raw.ratings["Book-Rating"] == 0).sum())
explicit_raw = raw.ratings[raw.ratings["Book-Rating"] > 0].copy()
fig, ax = plt.subplots(figsize=(9, 4))
sns.barplot(x=rating_counts.index.astype(str), y=rating_counts.values, color="#4C78A8", ax=ax)
ax.set(title="Distribusi Rating: Nilai 0 Mendominasi", xlabel="Rating", ylabel="Interaksi")
plt.tight_layout()
plt.show()
display(
pd.DataFrame(
{
"metric": ["implicit/no-rating rows", "explicit rating rows", "implicit share"],
"value": [
implicit_rows,
len(explicit_raw),
implicit_rows / len(raw.ratings),
],
}
)
)
| books | ratings | users | |
|---|---|---|---|
| ISBN | 0 | 0 | 0 |
| Book-Title | 0 | 0 | 0 |
| Book-Author | 2 | 0 | 0 |
| Year-Of-Publication | 0 | 0 | 0 |
| Publisher | 2 | 0 | 0 |
| Image-URL-S | 0 | 0 | 0 |
| Image-URL-M | 0 | 0 | 0 |
| Image-URL-L | 3 | 0 | 0 |
| User-ID | 0 | 0 | 0 |
| Book-Rating | 0 | 0 | 0 |
| Location | 0 | 0 | 0 |
| Age | 0 | 0 | 110762 |
| metric | value | |
|---|---|---|
| 0 | implicit/no-rating rows | 716109.000000 |
| 1 | explicit rating rows | 433671.000000 |
| 2 | implicit share | 0.622823 |
Sparsity dan Long Tail¶
Collaborative filtering belajar dari pola rating bersama. Jika dua buku sering mendapat pola penilaian yang mirip dari pengguna yang sama, keduanya dianggap saling berkaitan. Masalahnya, tidak semua pengguna memberi rating ke semua buku. Ketika sebagian besar pasangan pengguna buku tidak mempunyai rating disebut sparsity.
Rating yang tersedia juga tidak tersebar merata. Sedikit buku mendapat banyak rating, sedangkan banyak buku lainnya hanya mendapat sedikit rating. Pola ini disebut long tail. Keduanya membuat pola kesamaan lebih sulit ditemukan, terutama untuk pengguna dan buku yang hanya mempunyai sedikit riwayat rating. Pada bagian ini, satu interaksi berarti satu rating pengguna terhadap sebuah buku.
raw_user_counts = explicit_raw["User-ID"].value_counts()
raw_book_counts = explicit_raw["ISBN"].astype(str).value_counts()
matrix_density = len(explicit_raw) / (
explicit_raw["User-ID"].nunique() * explicit_raw["ISBN"].nunique()
)
ranked_book_counts = raw_book_counts.sort_values(ascending=False).reset_index(drop=True)
cumulative_share = ranked_book_counts.cumsum() / ranked_book_counts.sum()
top_share_rows = []
for fraction in (0.01, 0.05, 0.10):
cutoff = max(1, int(np.ceil(len(ranked_book_counts) * fraction)))
top_share_rows.append(
{
"top_catalog_fraction": fraction,
"interaction_share": float(ranked_book_counts.head(cutoff).sum() / ranked_book_counts.sum()),
}
)
fig, axes = plt.subplots(1, 3, figsize=(16, 4))
axes[0].hist(raw_user_counts.clip(upper=raw_user_counts.quantile(0.99)), bins=40, color="#4C78A8")
axes[0].set(title="Rating per User (hingga P99)", xlabel="Jumlah rating", ylabel="User")
axes[1].loglog(np.arange(1, len(ranked_book_counts) + 1), ranked_book_counts, color="#F58518")
axes[1].set(title="Long Tail Buku", xlabel="Rank popularitas", ylabel="Jumlah rating")
axes[2].plot(
np.arange(1, len(cumulative_share) + 1) / len(cumulative_share),
cumulative_share,
color="#54A24B",
)
axes[2].axhline(0.80, color="black", linestyle="--", linewidth=1)
axes[2].set(
title="Kumulatif Interaksi",
xlabel="Proporsi katalog",
ylabel="Proporsi interaksi",
)
plt.tight_layout()
plt.show()
display(
pd.DataFrame(
{
"metric": ["explicit matrix density", "unique explicit users", "rated ISBN"],
"value": [matrix_density, len(raw_user_counts), len(raw_book_counts)],
}
)
)
display(pd.DataFrame(top_share_rows))
| metric | value | |
|---|---|---|
| 0 | explicit matrix density | 0.00003 |
| 1 | unique explicit users | 77805.00000 |
| 2 | rated ISBN | 185973.00000 |
| top_catalog_fraction | interaction_share | |
|---|---|---|
| 0 | 0.01 | 0.209415 |
| 1 | 0.05 | 0.395083 |
| 2 | 0.10 | 0.497449 |
Grafik kiri menunjukkan jumlah rating per pengguna. Sebaran dibatasi sampai P99 agar beberapa pengguna yang sangat aktif tidak membuat pola mayoritas sulit terlihat. Penumpukan di jumlah rating yang rendah menunjukkan bahwa banyak pengguna hanya memberikan sedikit rating.
Grafik tengah mengurutkan buku dari yang paling banyak hingga paling sedikit mendapat rating. Garis yang turun tajam lalu memanjang menunjukkan long tail: sedikit buku sangat populer, sementara banyak buku lainnya hanya mempunyai sedikit rating.
Grafik kanan menunjukkan seberapa besar rating terkumpul pada sebagian kecil katalog. Sebanyak 1% buku menerima 20,9% dari seluruh rating, 5% buku menerima 39,5%, dan 10% buku menerima 49,7%. Garis putus-putus menandai 80% dari seluruh rating.
Density matriks hanya sekitar 0,003%. Artinya, dari seluruh pasangan pengguna buku yang mungkin terbentuk, hampir semuanya tidak memiliki rating. Jadi, sparsity menunjukkan bahwa matriks rating sangat kosong, sedangkan long tail menunjukkan bahwa rating yang tersedia lebih banyak terkumpul pada buku populer.
3. Data Preparation dan Kontrak¶
Pipeline membersihkan identifier, memvalidasi rating, mengagregasi pasangan user–ISBN duplikat secara deterministik, menghapus orphan rating, menjalankan k-core sampai konvergen, lalu memisahkan interaction catalog dari content catalog. Buku tanpa rating masih dapat dipertahankan untuk pencarian berbasis metadata.
# Bersihkan dan validasi data buku, rating, serta pengguna
raw_rating_rows = len(raw.ratings)
raw_explicit_rows = len(explicit_raw)
cleaned = clean_book_dataset(raw)
del raw, explicit_raw
gc.collect()
# Bentuk data rating untuk collaborative filtering
interaction_dataset = prepare_interaction_dataset(
books=cleaned.books,
ratings=cleaned.ratings,
users=cleaned.users,
min_user_ratings=MIN_USER_RATINGS,
min_book_ratings=MIN_BOOK_RATINGS,
max_books=MAX_BOOKS,
random_state=RANDOM_STATE,
)
# Tambahkan buku untuk pencarian berbasis metadata
content_books = select_content_catalog(
cleaned.books,
required_books=interaction_dataset.books,
max_content_books=MAX_CONTENT_BOOKS,
)
dataset = BookDataset(
books=content_books,
ratings=interaction_dataset.ratings,
users=cleaned.users,
)
# Ringkas perubahan jumlah data pada setiap tahap
funnel = pd.DataFrame(
{
"stage": [
"raw rating rows",
"explicit ratings",
"cleaned + deduplicated",
"interaction core",
],
"rows": [
raw_rating_rows,
raw_explicit_rows,
len(cleaned.ratings),
len(dataset.ratings),
],
}
)
fig, ax = plt.subplots(figsize=(10, 4))
sns.barplot(data=funnel, x="rows", y="stage", color="#4C78A8", ax=ax)
ax.set(title="Data Preparation Funnel", xlabel="Jumlah baris rating", ylabel="")
for container in ax.containers:
ax.bar_label(container, fmt="{:,.0f}", padding=4)
plt.tight_layout()
plt.show()
display(funnel)
display(pd.Series(dataset_summary(dataset), name="value").to_frame())
| stage | rows | |
|---|---|---|
| 0 | raw rating rows | 1149780 |
| 1 | explicit ratings | 433671 |
| 2 | cleaned + deduplicated | 383843 |
| 3 | interaction core | 52109 |
| value | |
|---|---|
| books | 3000 |
| ratings | 52109 |
| users | 278858 |
| rated_books | 1000 |
| rating_users | 9352 |
Dari 1.149.780 baris rating mentah, hanya 433.671 yang berupa rating eksplisit 1–10. Setelah dibersihkan dan disaring, collaborative filtering menggunakan 52.109 rating dari 9.352 pengguna pada 1.000 buku, sedangkan katalog content-based diperluas menjadi 3.000 buku.
Angka 278.858 menunjukkan seluruh metadata pengguna yang tetap disimpan. Hanya 9.352 pengguna di antaranya yang masuk ke data rating collaborative.
# Hitung jumlah rating setiap pengguna dan buku
user_degree = dataset.ratings.groupby("user_id").size()
book_degree = dataset.ratings.groupby("isbn").size()
orphan_ratings = ~dataset.ratings["isbn"].isin(dataset.books["isbn"])
duplicate_pairs = dataset.ratings.duplicated(["user_id", "isbn"])
# Pastikan data memenuhi kontrak sebelum digunakan model
contract_checks = pd.DataFrame(
{
"contract": [
"rating finite and in [1, 10]",
"no duplicate user–ISBN",
"every rating has book metadata",
f"minimum user degree >= {MIN_USER_RATINGS}",
f"minimum item degree >= {MIN_BOOK_RATINGS}",
"interaction catalog is contained in content catalog",
],
"passed": [
bool(
np.isfinite(dataset.ratings["book_rating"]).all()
and dataset.ratings["book_rating"].between(1, 10).all()
),
not bool(duplicate_pairs.any()),
not bool(orphan_ratings.any()),
bool(user_degree.min() >= MIN_USER_RATINGS),
bool(book_degree.min() >= MIN_BOOK_RATINGS),
set(interaction_dataset.books["isbn"]).issubset(set(dataset.books["isbn"])),
],
}
)
display(contract_checks)
# Hentikan proses jika ada kontrak yang gagal
assert contract_checks["passed"].all(), "Prepared dataset violates a documented contract."
| contract | passed | |
|---|---|---|
| 0 | rating finite and in [1, 10] | True |
| 1 | no duplicate user–ISBN | True |
| 2 | every rating has book metadata | True |
| 3 | minimum user degree >= 2 | True |
| 4 | minimum item degree >= 2 | True |
| 5 | interaction catalog is contained in content catalog | True |
Keenam pemeriksaan berhasil dilewati. Artinya, data tidak memiliki rating tidak valid, pasangan pengguna–ISBN duplikat, atau rating tanpa metadata buku. Setiap pengguna dan buku dalam collaborative filtering juga memiliki minimal dua rating. Jika salah satu pemeriksaan gagal, proses akan langsung dihentikan.
Satu ISBN belum tentu satu buku yang unik¶
ISBN menandai edisi atau format tertentu, sehingga paperback dan hardcover dari buku yang sama bisa memiliki ISBN berbeda. Edisi tersebut perlu digabung agar rekomendasi tidak menampilkan buku yang sama berulang kali dan hasil evaluasi tidak terlihat lebih baik hanya karena edisi lain masih tersedia sebagai kandidat.
catalog_with_work = dataset.books[["isbn", "book_title", "book_author"]].copy()
catalog_with_work["work_key"] = [
book_work_key(row.book_title, row.book_author, row.isbn)
for row in catalog_with_work.itertuples(index=False)
]
edition_counts = catalog_with_work.groupby("work_key").size().sort_values(ascending=False)
multi_edition_keys = edition_counts[edition_counts > 1].head(5).index
display(
pd.DataFrame(
{
"unit": ["ISBN/edition", "canonical work", "multi-edition works"],
"count": [
len(catalog_with_work),
catalog_with_work["work_key"].nunique(),
int((edition_counts > 1).sum()),
],
}
)
)
display(
catalog_with_work[catalog_with_work["work_key"].isin(multi_edition_keys)]
.sort_values(["work_key", "isbn"])
.head(15)
)
| unit | count | |
|---|---|---|
| 0 | ISBN/edition | 3000 |
| 1 | canonical work | 2816 |
| 2 | multi-edition works | 167 |
| isbn | book_title | book_author | work_key | |
|---|---|---|---|---|
| 1371 | 0002558122 | Angelas Ashes | Frank Mccourt | title:angelas ashes|author:frank mccourt |
| 190 | 000649840X | Angelas Ashes | Frank Mccourt | title:angelas ashes|author:frank mccourt |
| 1806 | 000649840x | Angelas Ashes | Frank Mccourt | title:angelas ashes|author:frank mccourt |
| 569 | 014028009X | Bridget Jones's Diary | Helen Fielding | title:bridget jones s diary|author:helen fielding |
| 730 | 0141000198 | Bridget Jones's Diary | Helen Fielding | title:bridget jones s diary|author:helen fielding |
| 34 | 0330332775 | Bridget Jones's Diary | Helen Fielding | title:bridget jones s diary|author:helen fielding |
| 842 | 0670880728 | Bridget Jones's Diary | Helen Fielding | title:bridget jones s diary|author:helen fielding |
| 135 | 0385420161 | Like Water for Chocolate: A Novel in Monthly Installments, With Recipes, Romances, and... | Laura Esquivel | title:like water for chocolate a novel in monthly installments with recipes romances a... |
| 870 | 038542017X | Like Water for Chocolate : A Novel in Monthly Installments with Recipes, Romances, and... | LAURA ESQUIVEL | title:like water for chocolate a novel in monthly installments with recipes romances a... |
| 412 | 0385474016 | Like Water for Chocolate: A Novel in Monthly Installments With Recipes, Romances and H... | Laura Esquivel | title:like water for chocolate a novel in monthly installments with recipes romances a... |
| 1316 | 0002261820 | One for My Baby | Tony Parsons | title:one for my baby|author:tony parsons |
| 2120 | 0007114788 | One for My Baby | Tony Parsons | title:one for my baby|author:tony parsons |
| 2153 | 000712614X | One for My Baby | Tony Parsons | title:one for my baby|author:tony parsons |
| 1042 | 0001841572 | Red Shift | Alan Garner | title:red shift|author:alan garner |
| 1975 | 0006742955 | Red Shift | Alan Garner | title:red shift|author:alan garner |
4. Baseline Pertama (Popularity)¶
Sebelum personalisasi, sistem perlu baseline yang murah dan selalu tersedia.
Skor popularity menggunakan mean_rating × log1p(rating_count), sehingga sebuah
buku dengan satu rating 10 tidak otomatis mengalahkan buku yang konsisten disukai
banyak pengguna.
popularity = (
dataset.ratings.groupby("isbn")
.agg(mean_rating=("book_rating", "mean"), rating_count=("book_rating", "size"))
.reset_index()
)
popularity["score"] = popularity["mean_rating"] * np.log1p(popularity["rating_count"])
popularity = (
popularity.merge(dataset.books, on="isbn", how="left")
.sort_values(["score", "rating_count", "isbn"], ascending=[False, False, True])
.reset_index(drop=True)
)
display(
popularity[
["score", "mean_rating", "rating_count", "isbn", "book_title", "book_author"]
].head(TOP_K)
)
| score | mean_rating | rating_count | isbn | book_title | book_author | |
|---|---|---|---|---|---|---|
| 0 | 50.827845 | 8.246836 | 474 | 0316666343 | The Lovely Bones: A Novel | Alice Sebold |
| 1 | 50.418451 | 8.529891 | 368 | 0385504209 | The Da Vinci Code | Dan Brown |
| 2 | 49.097076 | 8.938017 | 242 | 059035342X | Harry Potter and the Sorcerer's Stone (Harry Potter (Paperback)) | J. K. Rowling |
| 3 | 46.688186 | 9.101191 | 168 | 043935806X | Harry Potter and the Order of the Phoenix (Book 5) | J. K. Rowling |
| 4 | 46.521795 | 8.534483 | 232 | 0142001740 | The Secret Life of Bees | Sue Monk Kidd |
| 5 | 45.940752 | 8.267442 | 258 | 0312195516 | The Red Tent (Bestselling Backlist) | Anita Diamant |
| 6 | 45.804240 | 9.025157 | 159 | 0446310786 | To Kill a Mockingbird | Harper Lee |
| 7 | 45.135520 | 9.317460 | 126 | 0439139597 | Harry Potter and the Goblet of Fire (Book 4) | J. K. Rowling |
| 8 | 44.793152 | 8.858974 | 156 | 0439064872 | Harry Potter and the Chamber of Secrets (Book 2) | J. K. Rowling |
| 9 | 44.251267 | 9.134921 | 126 | 0439136350 | Harry Potter and the Prisoner of Azkaban (Book 3) | J. K. Rowling |
Popularity tidak pernah mengalami cold-user dan latency-nya rendah, tetapi tidak personal. Ia juga berisiko memperkuat pola rich get richer. Karena itu popularity cocok sebagai fallback/alternatif dan baseline pembanding, bukan otomatis jawaban akhir.
5. Collaborative Filtering¶
Item-based collaborative filtering membangun sparse matrix buku × user. Rating
dikoreksi terhadap rata-rata user yang diregularisasi, lalu model mencari item
dengan pola rating serupa. Hanya rating >= 8 yang menjadi positive seed.
collab_model = ItemBasedCollaborativeRecommender(
n_neighbors=N_NEIGHBORS,
positive_rating_threshold=POSITIVE_RATING_THRESHOLD,
).fit(dataset.books, dataset.ratings)
isbn_to_work = work_key_map(dataset.books)
positive_history = dataset.ratings[
dataset.ratings["book_rating"] >= POSITIVE_RATING_THRESHOLD
].copy()
positive_history["work_key"] = positive_history["isbn"].astype(str).map(isbn_to_work)
positive_work_counts = positive_history.groupby("user_id")["work_key"].nunique()
# Pilih user secara deterministik dengan histori cukup kaya namun tetap mudah dibaca.
moderate_users = positive_work_counts[(positive_work_counts >= 3) & (positive_work_counts <= 20)]
candidate_pool = moderate_users if not moderate_users.empty else positive_work_counts
candidate_user_id = int(
candidate_pool.rename("positive_works")
.reset_index()
.sort_values(["positive_works", "user_id"], ascending=[False, True])
.iloc[0]["user_id"]
)
user_history = (
dataset.ratings[dataset.ratings["user_id"] == candidate_user_id]
.sort_values(["book_rating", "isbn"], ascending=[False, True])
.merge(dataset.books, on="isbn", how="left")
)
display(user_history[["isbn", "book_title", "book_author", "book_rating"]].head(15))
| isbn | book_title | book_author | book_rating | |
|---|---|---|---|---|
| 0 | 0060392452 | Stupid White Men ...and Other Sorry Excuses for the State of the Nation! | Michael Moore | 10.0 |
| 1 | 0140067477 | The Tao of Pooh | Benjamin Hoff | 10.0 |
| 2 | 0380813815 | Lamb : The Gospel According to Biff, Christ's Childhood Pal | Christopher Moore | 10.0 |
| 3 | 0394820371 | The Phantom Tollbooth | Norton Juster | 10.0 |
| 4 | 0439136350 | Harry Potter and the Prisoner of Azkaban (Book 3) | J. K. Rowling | 10.0 |
| 5 | 0439139597 | Harry Potter and the Goblet of Fire (Book 4) | J. K. Rowling | 10.0 |
| 6 | 0440487617 | A Wind in the Door | Madeleine L'Engle | 10.0 |
| 7 | 0440498058 | A Wrinkle In Time | MADELEINE L'ENGLE | 10.0 |
| 8 | 0440998050 | A Wrinkle in Time | Madeleine L'Engle | 10.0 |
| 9 | 0441003257 | Good Omens | Neil Gaiman | 10.0 |
| 10 | 0446532231 | Dude, Where's My Country? | Michael Moore | 10.0 |
| 11 | 1400032717 | The Curious Incident of the Dog in the Night-Time (Vintage Contemporaries) | Mark Haddon | 10.0 |
| 12 | 0312966091 | Three To Get Deadly : A Stephanie Plum Novel (A Stephanie Plum Novel) | Janet Evanovich | 9.0 |
| 13 | 043935806X | Harry Potter and the Order of the Phoenix (Book 5) | J. K. Rowling | 9.0 |
| 14 | 0316569321 | White Oleander : A Novel | Janet Fitch | 8.0 |
strict_collaborative = collab_model.score_for_user(
candidate_user_id,
seed_items=5,
candidate_pool_size=100,
)
served_collaborative = collab_model.recommend_for_user(candidate_user_id, top_n=TOP_K)
display(
pd.DataFrame(
{
"user_id": [candidate_user_id],
"positive_canonical_works": [int(positive_work_counts[candidate_user_id])],
"strict_collaborative_candidates": [len(strict_collaborative)],
"served_recommendations": [len(served_collaborative)],
"fallback_or_filler_possible": [len(strict_collaborative) < TOP_K],
}
)
)
display(
served_collaborative[
["score", "isbn", "book_title", "book_author", "publisher"]
].head(TOP_K)
)
| user_id | positive_canonical_works | strict_collaborative_candidates | served_recommendations | fallback_or_filler_possible | |
|---|---|---|---|---|---|
| 0 | 6251 | 20 | 100 | 10 | False |
| score | isbn | book_title | book_author | publisher | |
|---|---|---|---|---|---|
| 0 | 0.442325 | 0439064864 | Harry Potter and the Chamber of Secrets (Book 2) | J. K. Rowling | Scholastic |
| 1 | 0.378919 | 0590353403 | Harry Potter and the Sorcerer's Stone (Book 1) | J. K. Rowling | Scholastic |
| 2 | 0.201885 | 0451169522 | Misery | Stephen King | Penguin USA (Paper) |
| 3 | 0.185600 | 0385334036 | The Smoke Jumper | Nicholas Evans | Delacorte Press |
| 4 | 0.162824 | 0425177173 | Atlantis Found | Clive Cussler | Berkley Publishing Group |
| 5 | 0.157975 | 0452269571 | Bastard Out of Carolina | Dorothy Allison | Plume Books |
| 6 | 0.157381 | 0316603570 | 3rd Degree | James Patterson | Little, Brown |
| 7 | 0.155608 | 0451207521 | Jackdaws | Ken Follett | Signet Book |
| 8 | 0.147447 | 0894808249 | All I Need to Know I Learned from My Cat | Suzy Becker | Workman Publishing |
| 9 | 0.146437 | 0553584510 | The Survivors Club | LISA GARDNER | Bantam |
User 6251 memiliki 20 karya dengan rating minimal 8, sehingga contoh ini mewakili pengguna dengan riwayat yang cukup kaya. Collaborative filtering menghasilkan 100 kandidat dan dapat mengisi 10 rekomendasi tanpa fallback. Setelah pengguna memberi rating tinggi pada beberapa buku Harry Potter, dua rekomendasi teratasnya adalah buku lain dari seri tersebut.
6. Content-Based filtering¶
Metadata tetap tersedia meskipun sebuah item belum memiliki banyak interaksi.
Model content mengubah title + author + publisher menjadi TF-IDF unigram/bigram,
lalu mencari cosine-neighbor dan mendeduplikasi canonical work.
content_model = ContentBasedRecommender().fit(dataset.books)
query_title = "Adventures of Huckleberry Finn"
if query_title not in set(dataset.books["book_title"]):
query_title = str(dataset.books.sort_values("isbn").iloc[0]["book_title"])
content_recommendations = content_model.recommend_similar(
title=query_title,
top_n=TOP_K,
)
display(
content_recommendations[
["score", "isbn", "book_title", "book_author", "publisher"]
]
)
| score | isbn | book_title | book_author | publisher | |
|---|---|---|---|---|---|
| 0 | 0.133710 | 0020429819 | The Macmillan Illustrated Encyclopedia of Dinosaurs and Prehistoric Animals: A Visual ... | Dougal Dixon | Macmillan General Reference |
| 1 | 0.131637 | 0020811608 | Genealogist's Encyclopedia | Leslie G. Pine | Simon & Schuster |
| 2 | 0.103506 | 0002219476 | The way to dusty death | Alistair MacLean | Collins |
| 3 | 0.099652 | 0027357813 | A Long Way from Verona | Jane Gardam | Simon & Schuster (J) |
| 4 | 0.098948 | 0026284707 | Men at Work: The Craft of Baseball | George F. Will | Simon & Schuster |
| 5 | 0.098326 | 0004116941 | Collins Encyclopedia of Fishing in Britain and Ireland | Michael Prichard | HarperCollins Publishers |
| 6 | 0.089439 | 0025439472 | The Shepherd | Joseph F. Girzone | Simon & Schuster |
| 7 | 0.089393 | 0007122217 | Illustrated Daughters of Britannia | Katie Hickman | HarperCollins Illustrated |
| 8 | 0.086254 | 0027255107 | The Winter of the Birds | Helen Cresswell | Simon & Schuster Merchandise & |
| 9 | 0.085843 | 0027334635 | Fishing | Diana Engel | Simon & Schuster |
Pada contoh ini, skor kemiripan tertinggi hanya 0,134 dan buku yang muncul tidak terlihat memiliki tema yang dekat dengan Adventures of Huckleberry Finn. Beberapa hasil justru memiliki nama penerbit yang sama atau mirip. Ini menunjukkan bahwa content-based dapat bekerja tanpa riwayat rating, tetapi metadata judul, penulis, dan penerbit saja belum tentu cukup untuk memahami isi buku.
7. Hybrid (Gabungan)¶
Hybrid menggabungkan collaborative, content profile, dan popularity setelah masing-masing dinormalisasi. Bobot dasar adalah 0,40 / 0,45 / 0,15, lalu dapat bergeser berdasarkan jumlah positive canonical works milik user.
hybrid_model = HybridRecommender(
n_neighbors=N_NEIGHBORS,
collaborative_weight=0.40,
content_weight=0.45,
popularity_weight=0.15,
dynamic_weights=True,
positive_rating_threshold=POSITIVE_RATING_THRESHOLD,
).fit(dataset.books, dataset.ratings)
hybrid_recommendations = hybrid_model.recommend_for_user(
candidate_user_id,
top_n=TOP_K,
)
display(
hybrid_recommendations[
[
"score",
"collaborative_score",
"content_score",
"popularity_score",
"source",
"reason",
"isbn",
"book_title",
"book_author",
]
]
)
| score | collaborative_score | content_score | popularity_score | source | reason | isbn | book_title | book_author | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 0.763936 | 1.000000 | 0.525982 | 0.848296 | collaborative | Item-rating patterns are similar to books the user rated 8/10 or higher. | 0439064864 | Harry Potter and the Chamber of Secrets (Book 2) | J. K. Rowling |
| 1 | 0.706356 | 0.856653 | 0.529889 | 0.834962 | collaborative | Item-rating patterns are similar to books the user rated 8/10 or higher. | 0590353403 | Harry Potter and the Sorcerer's Stone (Book 1) | J. K. Rowling |
| 2 | 0.425865 | 0.287210 | 0.369086 | 0.965948 | content | Metadata is similar to books the user rated 8/10 or higher. | 059035342X | Harry Potter and the Sorcerer's Stone (Harry Potter (Paperback)) | J. K. Rowling |
| 3 | 0.293000 | 0.456418 | 0.048663 | 0.590229 | collaborative | Item-rating patterns are similar to books the user rated 8/10 or higher. | 0451169522 | Misery | Stephen King |
| 4 | 0.292936 | 0.245098 | 0.263022 | 0.510241 | content | Metadata is similar to books the user rated 8/10 or higher. | 0380728133 | Bloodsucking Fiends: A Love Story | Christopher Moore |
| 5 | 0.261296 | 0.357147 | 0.046747 | 0.649339 | collaborative | Item-rating patterns are similar to books the user rated 8/10 or higher. | 0452269571 | Bastard Out of Carolina | Dorothy Allison |
| 6 | 0.249186 | 0.156672 | 0.228349 | 0.558403 | content | Metadata is similar to books the user rated 8/10 or higher. | 0140177396 | Of Mice and Men (Penguin Great Books of the 20th Century) | John Steinbeck |
| 7 | 0.248498 | 0.419602 | 0.024658 | 0.463743 | collaborative | Item-rating patterns are similar to books the user rated 8/10 or higher. | 0385334036 | The Smoke Jumper | Nicholas Evans |
| 8 | 0.247534 | 0.200080 | 0.142826 | 0.688202 | popularity | Popularity is the strongest signal in this blended recommendation. | 0140298479 | Bridget Jones: The Edge of Reason | Helen Fielding |
| 9 | 0.239959 | 0.325537 | 0.053182 | 0.572082 | collaborative | Item-rating patterns are similar to books the user rated 8/10 or higher. | 0316168815 | The Lovely Bones | Alice Sebold |
Dari 10 rekomendasi, enam didominasi collaborative, tiga content, dan satu
popularity. Kolom source menunjukkan sinyal terkuat, sedangkan skor akhirnya
tetap merupakan gabungan ketiganya. Dua edisi Harry Potter and the Sorcerer's
Stone masih muncul bersamaan, yang menunjukkan bahwa deduplikasi berbasis
judul–penulis belum selalu berhasil mengenali variasi judul.
Failure Mode¶
Tiga skenario berikut menunjukkan cara hybrid menyesuaikan bobot.
Pengguna tanpa riwayat mendapat rekomendasi popularity. Untuk pengguna yang baru memiliki 1–2 karya dengan rating minimal 8, bobot collaborative dikurangi dan content diperbesar. Pengguna dengan lebih dari 5 karya positif memakai bobot dasar 40% collaborative, 45% content, dan 15% popularity. Sinyal dengan bobot terbesar belum tentu menjadi sumber dominan pada setiap buku.
# Siapkan pengguna tanpa riwayat, dengan riwayat tipis, dan dengan riwayat kaya.
sparse_user_id = int(
positive_work_counts.rename("positive_works")
.reset_index()
.sort_values(["positive_works", "user_id"], ascending=[True, True])
.iloc[0]["user_id"]
)
cold_user_id = int(dataset.ratings["user_id"].max()) + 1_000_000
def effective_signal_weights(user_id, positive_works):
"""Baca bobot yang dipakai oleh routing hybrid untuk keperluan diagnosis."""
user_history = dataset.ratings[dataset.ratings["user_id"] == user_id].copy()
if user_history.empty:
return {"collaborative": 0.0, "content": 0.0, "popularity": 1.0}
collaborative_scores = hybrid_model._collaborative_scores(
user_id=user_id,
seed_items=5,
candidate_pool_size=500,
)
content_scores = hybrid_model._content_profile_scores(user_history, seed_items=5)
return hybrid_model._weights_for_history(
positive_works,
content_scores,
collaborative_scores,
allow_popularity_fallback=True,
)
# Bandingkan bobot efektif dengan sumber terkuat pada hasil Top-N.
scenario_rows = []
for scenario, user_id in (
("cold", cold_user_id),
("sparse-positive", sparse_user_id),
("richer-positive", candidate_user_id),
):
history_rows = int((dataset.ratings["user_id"] == user_id).sum())
positive_works = int(positive_work_counts.get(user_id, 0))
weights = effective_signal_weights(user_id, positive_works)
recommendations = hybrid_model.recommend_for_user(user_id, top_n=TOP_K)
source_counts = recommendations["source"].value_counts()
scenario_rows.append(
{
"scenario": scenario,
"user_id": user_id,
"history_rows": history_rows,
"positive_works": positive_works,
"recommendations": len(recommendations),
"collaborative_weight": weights["collaborative"],
"content_weight": weights["content"],
"popularity_weight": weights["popularity"],
"dominant_source": source_counts.index[0] if not source_counts.empty else "empty",
"popularity_dominant_share": float(
source_counts.get("popularity", 0) / max(1, len(recommendations))
),
}
)
display(pd.DataFrame(scenario_rows))
| scenario | user_id | history_rows | positive_works | recommendations | dominant_source | popularity_share | |
|---|---|---|---|---|---|---|---|
| 0 | cold | 1278854 | 0 | 0 | 10 | popularity | 1.0 |
| 1 | sparse-positive | 99 | 2 | 1 | 10 | collaborative | 0.4 |
| 2 | richer-positive | 6251 | 35 | 20 | 10 | collaborative | 0.1 |
Cold user tetap mendapat 10 rekomendasi, tetapi seluruhnya mengandalkan popularity. Pada sparse user, popularity menjadi sinyal terkuat untuk 4 dari 10 buku, sedangkan pada pengguna dengan riwayat kaya jumlahnya turun menjadi 1 dari 10. Hasil ini menunjukkan bahwa ketergantungan pada popularity berkurang ketika riwayat pengguna semakin kaya.
8. Cara Evaluasi Dilakukan¶
Evaluasi dibagi menjadi validation dan test. Validation dipakai untuk mencoba dan membandingkan konfigurasi model. Test disimpan dan baru dibuka setelah keputusan selesai, agar hasil akhirnya tidak bias karena test ikut dipakai memilih model.
Pada diagnostic ini, 40 kasus holdout dibagi menjadi 20 kasus validation dan 20 kasus test. Sampelnya sengaja kecil agar prosesnya cepat dijalankan dan alur evaluasinya mudah diperiksa. Tujuannya belum untuk menentukan model terbaik, tetapi memastikan penyembunyian buku target, pembentukan katalog dari data train, dan perhitungan metrik berjalan tanpa kebocoran data.
Dua code box berikut hanya menggunakan validation. Test tetap disimpan untuk evaluasi akhir, sedangkan keputusan model nantinya menggunakan repeated validation dengan sampel yang lebih besar.
evaluation_data = prepare_validation_test_evaluation(
cleaned,
sample_users=EVALUATION_SAMPLE_USERS,
validation_fraction=0.5,
min_user_ratings=MIN_USER_RATINGS,
min_book_ratings=MIN_BOOK_RATINGS,
max_books=MAX_BOOKS,
max_content_books=MAX_CONTENT_BOOKS,
random_state=RANDOM_STATE,
)
train_dataset = evaluation_data.train_dataset
validation_holdouts = evaluation_data.validation_holdout_ratings.copy()
eval_isbn_to_work = work_key_map(train_dataset.books)
eval_catalog_works = set(eval_isbn_to_work.values())
eval_history_users = set(train_dataset.ratings["user_id"].astype(int))
eval_positive_users = set(
train_dataset.ratings.loc[
train_dataset.ratings["book_rating"] >= POSITIVE_RATING_THRESHOLD,
"user_id",
].astype(int)
)
validation_holdouts["candidate_available"] = validation_holdouts["work_key"].isin(
eval_catalog_works
)
validation_holdouts["history_available"] = validation_holdouts["user_id"].isin(
eval_history_users
)
validation_holdouts["positive_seed_available"] = validation_holdouts["user_id"].isin(
eval_positive_users
)
validation_holdouts["personalization_evaluable"] = (
validation_holdouts["candidate_available"]
& validation_holdouts["history_available"]
& validation_holdouts["positive_seed_available"]
)
availability_checks = pd.DataFrame(
{
"check": [
"sampled validation",
"target in catalog",
"user history survives",
"positive seed survives",
"personalization evaluable",
],
"users": [
len(validation_holdouts),
int(validation_holdouts["candidate_available"].sum()),
int(validation_holdouts["history_available"].sum()),
int(validation_holdouts["positive_seed_available"].sum()),
int(validation_holdouts["personalization_evaluable"].sum()),
],
}
)
fig, ax = plt.subplots(figsize=(10, 4))
sns.barplot(data=availability_checks, x="users", y="check", color="#E45756", ax=ax)
ax.set(title="Validation Availability Checks", xlabel="Holdout user", ylabel="")
for container in ax.containers:
ax.bar_label(container, padding=4)
plt.tight_layout()
plt.show()
display(availability_checks)
display(
pd.DataFrame(
{
"partition": ["validation diagnostic", "reserved test (untouched)"],
"holdouts": [
len(evaluation_data.validation_holdout_ratings),
len(evaluation_data.test_holdout_ratings),
],
}
)
)
| check | users | |
|---|---|---|
| 0 | sampled validation | 20 |
| 1 | target in catalog | 5 |
| 2 | user history survives | 8 |
| 3 | positive seed survives | 8 |
| 4 | personalization evaluable | 2 |
| partition | holdouts | |
|---|---|---|
| 0 | validation diagnostic | 20 |
| 1 | reserved test (untouched) | 20 |
Dari 20 kasus validasi, buku target hanya tersedia di katalog pada 5 kasus. Setelah ketersediaan target, riwayat pengguna, dan karya positif diperiksa bersama, hanya 2 kasus yang dapat dipakai untuk menilai personalisasi. Jadi, sebagian besar kasus gagal sebelum model sempat mengurutkan buku.
eval_hybrid = HybridRecommender(
n_neighbors=N_NEIGHBORS,
collaborative_weight=0.40,
content_weight=0.45,
popularity_weight=0.15,
dynamic_weights=True,
positive_rating_threshold=POSITIVE_RATING_THRESHOLD,
).fit(train_dataset.books, train_dataset.ratings)
eval_collaborative = eval_hybrid.collaborative_model
validation_metric_rows = []
for row in validation_holdouts.itertuples(index=False):
user_id = int(row.user_id)
relevant = [str(row.work_key)]
train_history = train_dataset.ratings[train_dataset.ratings["user_id"] == user_id]
recommendations_by_model = {
"hybrid": recommendation_work_keys(
eval_hybrid.recommend_for_user(user_id, top_n=TOP_K),
eval_isbn_to_work,
),
"collaborative + fallback": recommendation_work_keys(
eval_collaborative.recommend_for_user(user_id, top_n=TOP_K),
eval_isbn_to_work,
),
"popularity": recommendation_work_keys(
eval_collaborative.recommend_popular(
top_n=TOP_K,
exclude_isbns=set(train_history["isbn"].astype(str)),
),
eval_isbn_to_work,
),
}
for model_name, recommended in recommendations_by_model.items():
validation_metric_rows.append(
{
"model": model_name,
"user_id": user_id,
"candidate_available": bool(row.candidate_available),
"positive_seed_available": bool(row.positive_seed_available),
**ranking_metrics_at_k(recommended, relevant, TOP_K),
}
)
validation_metrics = pd.DataFrame(validation_metric_rows)
metric_columns = [
f"precision_at_{TOP_K}",
f"recall_at_{TOP_K}",
f"hit_rate_at_{TOP_K}",
f"map_at_{TOP_K}",
f"mrr_at_{TOP_K}",
f"ndcg_at_{TOP_K}",
]
end_to_end_validation = validation_metrics.groupby("model")[metric_columns].mean()
conditional_validation_rows = validation_metrics[
validation_metrics["candidate_available"]
& validation_metrics["positive_seed_available"]
]
conditional_validation = conditional_validation_rows.groupby("model")[metric_columns].mean()
validation_summary = end_to_end_validation.reset_index()[
["model", f"hit_rate_at_{TOP_K}", f"ndcg_at_{TOP_K}"]
].melt(id_vars="model", var_name="metric", value_name="value")
if bool((validation_summary["value"] > 0).any()):
fig, ax = plt.subplots(figsize=(10, 4))
sns.barplot(data=validation_summary, x="model", y="value", hue="metric", ax=ax)
ax.set(
title=f"Validation Diagnostic Only (n={len(validation_holdouts)})",
xlabel="",
ylabel="Metric",
)
ax.tick_params(axis="x", rotation=12)
plt.tight_layout()
plt.show()
else:
print(
"No Top-K hits occurred in this small validation diagnostic. "
"Use the repeated benchmark below for model-selection evidence."
)
display(
{
"end_to_end": end_to_end_validation,
"conditional": conditional_validation,
"conditional_users": int(conditional_validation_rows["user_id"].nunique()),
}
)
No Top-K hits occurred in this small validation diagnostic. Use the repeated benchmark below for model-selection evidence.
{'end_to_end': precision_at_10 recall_at_10 hit_rate_at_10 \
model
collaborative + fallback 0.0 0.0 0.0
hybrid 0.0 0.0 0.0
popularity 0.0 0.0 0.0
map_at_10 mrr_at_10 ndcg_at_10
model
collaborative + fallback 0.0 0.0 0.0
hybrid 0.0 0.0 0.0
popularity 0.0 0.0 0.0 ,
'conditional': precision_at_10 recall_at_10 hit_rate_at_10 \
model
collaborative + fallback 0.0 0.0 0.0
hybrid 0.0 0.0 0.0
popularity 0.0 0.0 0.0
map_at_10 mrr_at_10 ndcg_at_10
model
collaborative + fallback 0.0 0.0 0.0
hybrid 0.0 0.0 0.0
popularity 0.0 0.0 0.0 ,
'conditional_users': 2}
Tidak ada buku target yang ditemukan dalam Top-10, baik oleh hybrid, collaborative, maupun popularity. Namun sampel ini hanya berisi 20 kasus dan hanya 2 yang dapat menilai personalisasi, sehingga hasil nol tersebut belum cukup untuk membandingkan model. Keputusan bobot harus memakai repeated validation, sedangkan fixed test baru dibuka setelah keputusan selesai.
9. Memeriksa Catatan Evaluasi pada Model Serving¶
Bagian sebelumnya menjelaskan cara evaluasi dilakukan. Sekarang kita tidak menjalankan evaluasi baru, tetapi membaca manifest yang menyertai model serving. Manifest tersebut mencatat identitas model, konfigurasi, serta hasil evaluasi ketika bundle dibuat.
Catatan ini memastikan angka evaluasi tidak terpisah dari model yang benar-benar digunakan. Namun kekuatan buktinya tetap perlu diperiksa sebelum hasilnya dianggap sebagai klaim performa.
manifest_path = MODEL_ARTIFACT.with_name(f"{MODEL_ARTIFACT.name}.manifest.json")
manifest_payload = json.loads(manifest_path.read_text(encoding="utf-8"))
deployment_evidence = manifest_payload.get("metrics", {})
deployment_availability = deployment_evidence.get("availability", {})
evidence_strength = pd.DataFrame(
{
"metric": [
"sampled holdouts",
"target available",
"history evaluable",
"personalization evaluable",
"bootstrap resamples",
],
"value": [
deployment_availability.get("sampled_holdouts", 0),
deployment_availability.get("candidate_available_holdouts", 0),
deployment_availability.get("history_evaluable_holdouts", 0),
deployment_availability.get("personalization_evaluable_holdouts", 0),
next(
(
interval.get("n_resamples", 0)
for model_intervals in deployment_evidence.get(
"confidence_intervals",
{},
).values()
for scope_intervals in model_intervals.values()
for interval in scope_intervals.values()
),
0,
),
],
}
)
display(evidence_strength)
print("Evidence label: PILOT — useful for pipeline verification, not a performance claim.")
| metric | value | |
|---|---|---|
| 0 | sampled holdouts | 10 |
| 1 | target available | 4 |
| 2 | history evaluable | 2 |
| 3 | personalization evaluable | 2 |
| 4 | bootstrap resamples | 100 |
Evidence label: PILOT — useful for pipeline verification, not a performance claim.
Evidence dalam bundle masih sangat kecil. Dari 10 kasus, hanya 4 buku target tersedia di katalog dan hanya 2 kasus dapat menilai personalisasi. Karena itu, hasil ini dipakai untuk memverifikasi pipeline serving, bukan menyatakan model sudah unggul.
pilot_summary = pd.DataFrame(deployment_evidence.get("summary", {}).get("end_to_end", []))
if not pilot_summary.empty:
pilot_metric_columns = [
column
for column in (f"hit_rate_at_{TOP_K}", f"ndcg_at_{TOP_K}")
if column in pilot_summary.columns
]
pilot_plot = pilot_summary.melt(
id_vars=["model", "sample_size"],
value_vars=pilot_metric_columns,
var_name="metric",
value_name="value",
)
fig, ax = plt.subplots(figsize=(12, 4))
sns.barplot(data=pilot_plot, x="model", y="value", hue="metric", ax=ax)
ax.set(title="Deployment Pilot: End-to-End Ranking", xlabel="", ylabel="Metric")
ax.tick_params(axis="x", rotation=18)
plt.tight_layout()
plt.show()
display(pilot_summary[["model", "sample_size", *pilot_metric_columns]])
| model | sample_size | hit_rate_at_10 | ndcg_at_10 | |
|---|---|---|---|---|
| 0 | hybrid | 10 | 0.1 | 0.05 |
| 1 | hybrid_no_popularity_strict | 10 | 0.0 | 0.00 |
| 2 | content_profile_strict | 10 | 0.0 | 0.00 |
| 3 | collaborative_with_popularity_fallback | 10 | 0.1 | 0.05 |
| 4 | popularity | 10 | 0.1 | 0.05 |
Hybrid, collaborative dengan fallback, dan popularity sama-sama menemukan 1 dari 10 target dengan NDCG@10 sebesar 0,05. Hybrid tanpa popularity dan content strict tidak menemukan target. Sampel ini belum cukup untuk membuktikan hybrid lebih baik, ia hanya menunjukkan bahwa fallback berperan penting pada pilot ini.
10. Memilih Komposisi Bobot Hybrid¶
Bagian ini menentukan apakah komposisi bobot hybrid perlu diganti. Baseline menggunakan 40% collaborative, 45% content, dan 15% popularity. Sebagai penantang, kita mencoba memperbesar content menjadi 60%, mengurangi collaborative menjadi 30%, dan popularity menjadi 10%. Challenger hanya akan menggantikan baseline jika hasilnya lebih baik dan cukup konsisten.
Pengujiannya dilakukan melalui langkah berikut:
- Fixed test dipisahkan sekali dan tidak digunakan selama pemilihan model.
- Lima validation split dibuat dari development users, masing-masing berisi 200 kasus holdout.
- Pada setiap kasus, satu karya disembunyikan dan seluruh edisinya dikeluarkan dari data train.
- Baseline dan challenger diuji pada pasangan pengguna–buku yang sama.
- Selisih keduanya diperiksa menggunakan paired bootstrap dan selection gate.
- Challenger dipilih hanya jika peningkatannya cukup besar dan konsisten. Jika tidak, baseline dipertahankan.
- Fixed test dibuka sekali untuk konfigurasi yang sudah ditetapkan.
benchmark_candidates = [
BENCHMARK_ARTIFACT,
PROJECT_ROOT
/ "artifacts"
/ "verification"
/ "repeated_split_hybrid_benchmark_schema_v2_smoke.json",
]
benchmark_path = next((path for path in benchmark_candidates if path.exists()), None)
benchmark_payload = None
if benchmark_path is None:
print(
"Formal benchmark artifact is not available. Run `uv run book-rec benchmark-hybrid` "
"to populate this section."
)
else:
verified_benchmark = read_benchmark_metrics(benchmark_path)
benchmark_payload = json.loads(benchmark_path.read_text(encoding="utf-8"))
parameters = benchmark_payload["parameters"]
is_portfolio_scale = (
int(parameters["validation_users"]) >= 200
and int(parameters["test_users"]) >= 200
and int(parameters["repetitions"]) >= 5
and int(parameters["bootstrap_resamples"]) >= 2_000
)
display(
pd.DataFrame(
{
"field": [
"artifact",
"protocol",
"validation users / repeat",
"fixed-test users",
"repetitions",
"bootstrap resamples",
"evidence label",
],
"value": [
display_path(benchmark_path, PROJECT_ROOT),
benchmark_payload["protocol"],
parameters["validation_users"],
parameters["test_users"],
parameters["repetitions"],
parameters["bootstrap_resamples"],
"PORTFOLIO-SCALE" if is_portfolio_scale else "SMOKE/PILOT",
],
}
)
)
| field | value | |
|---|---|---|
| 0 | artifact | artifacts/evaluation/repeated_split_hybrid_benchmark.json |
| 1 | protocol | canonical_work_fixed_test_repeated_validation_paired_bootstrap_v1 |
| 2 | validation users / repeat | 200 |
| 3 | fixed-test users | 200 |
| 4 | repetitions | 5 |
| 5 | bootstrap resamples | 2000 |
| 6 | evidence label | PORTFOLIO-SCALE |
Benchmark formal menggunakan lima validation split × 200 kasus, 200 kasus fixed test, dan 2.000 bootstrap resamples. Jadi, terdapat 1.000 kasus evaluasi validation, tetapi bukan berarti selalu ada 1.000 pengguna unik karena pengguna yang sama dapat muncul pada split berbeda. Skala ini lebih kuat daripada diagnostic kecil sebelumnya dan dipakai untuk memilih konfigurasi.
if benchmark_payload is not None:
validation_records = []
for configuration_record in benchmark_payload["validation"]["aggregate_by_config"]:
label = weight_label(configuration_record)
for split in configuration_record["per_split"]:
validation_records.append(
{
"split_id": split["split_id"],
"configuration": label,
"ndcg": split["end_to_end_metrics"][f"ndcg_at_{TOP_K}"],
}
)
validation_by_split = pd.DataFrame(validation_records)
fig, ax = plt.subplots(figsize=(11, 4))
sns.lineplot(
data=validation_by_split,
x="split_id",
y="ndcg",
hue="configuration",
marker="o",
ax=ax,
)
ax.set(title="NDCG per Repeated Validation Split", xlabel="", ylabel=f"NDCG@{TOP_K}")
plt.tight_layout()
plt.show()
display(validation_by_split)
| split_id | configuration | ndcg | |
|---|---|---|---|
| 0 | validation-01 | C=0.30, T=0.60, P=0.10 | 0.020869 |
| 1 | validation-02 | C=0.30, T=0.60, P=0.10 | 0.027487 |
| 2 | validation-03 | C=0.30, T=0.60, P=0.10 | 0.015528 |
| 3 | validation-04 | C=0.30, T=0.60, P=0.10 | 0.011781 |
| 4 | validation-05 | C=0.30, T=0.60, P=0.10 | 0.024871 |
| 5 | validation-01 | C=0.40, T=0.45, P=0.15 | 0.017676 |
| 6 | validation-02 | C=0.40, T=0.45, P=0.15 | 0.027976 |
| 7 | validation-03 | C=0.40, T=0.45, P=0.15 | 0.012825 |
| 8 | validation-04 | C=0.40, T=0.45, P=0.15 | 0.011781 |
| 9 | validation-05 | C=0.40, T=0.45, P=0.15 | 0.024871 |
Pada label konfigurasi, C berarti collaborative, T berarti content, dan P berarti popularity. Challenger unggul pada split 1 dan 3, baseline unggul pada split 2, sedangkan split 4 dan 5 berakhir sama. Hasil yang berubah antar-split menunjukkan bahwa satu nilai rata-rata belum cukup untuk memilih konfigurasi.
NDCG bukan satu-satunya hal yang diperiksa. Coverage menunjukkan seberapa luas katalog muncul dalam rekomendasi, sedangkan intra-list diversity menunjukkan seberapa berbeda buku-buku dalam satu daftar. Novelty mengukur kecenderungan merekomendasikan buku yang tidak terlalu populer, sementara popularity share menunjukkan porsi rekomendasi yang berasal dari 10% buku terpopuler. Nilai berikut merupakan rata-rata dari lima validation split.
if benchmark_payload is not None:
diagnostic_rows = []
for repeat in benchmark_payload["validation"]["repeats"]:
for configuration in repeat["configurations"]:
diagnostic = configuration["diagnostics"]
diagnostic_rows.append(
{
"configuration": weight_label(configuration),
"split_id": repeat["split_id"],
f"coverage@{TOP_K}": diagnostic[f"catalog_coverage_at_{TOP_K}"],
f"intra-list diversity@{TOP_K}": diagnostic[
f"intra_list_diversity_at_{TOP_K}"
],
f"novelty@{TOP_K}": diagnostic[f"novelty_at_{TOP_K}"],
f"top-10% popularity share@{TOP_K}": diagnostic[
f"top_10pct_popularity_share_at_{TOP_K}"
],
}
)
diagnostic_by_split = pd.DataFrame(diagnostic_rows)
diagnostic_summary = (
diagnostic_by_split.drop(columns="split_id")
.groupby("configuration", sort=False, as_index=False)
.mean()
)
display(diagnostic_summary.round(4))
| configuration | coverage@10 | intra-list diversity@10 | novelty@10 | top-10% popularity share@10 | |
|---|---|---|---|---|---|
| 0 | C=0.30, T=0.60, P=0.10 | 0.1484 | 0.8780 | 8.4773 | 0.7028 |
| 1 | C=0.40, T=0.45, P=0.15 | 0.1412 | 0.8914 | 8.4563 | 0.7056 |
Challenger 0,30 / 0,60 / 0,10 memiliki coverage dan novelty sedikit lebih tinggi. Baseline 0,40 / 0,45 / 0,15 memiliki intra-list diversity lebih tinggi, sedangkan porsi buku sangat populer keduanya hampir sama. Hasil diagnostic ini hanya membantu melihat karakter masing-masing konfigurasi. Keputusan mengganti baseline tetap ditentukan oleh hasil NDCG dan selection gate.
if benchmark_payload is not None:
selection = benchmark_payload["selection"]
selection_rows = []
paired = benchmark_payload["validation"]["paired_deltas_vs_baseline"]
for config_id, evidence in paired.items():
interval = evidence["end_to_end"][f"ndcg_at_{TOP_K}"]
selection_rows.append(
{
"config_id": config_id,
"mean_delta": interval["mean"],
"lower": interval["lower"],
"upper": interval["upper"],
"selection_rate": evidence["selection_rate"],
"passes_gate": config_id == selection["selected_config_id"]
and selection["decision"] != "retain_baseline",
}
)
selection_frame = pd.DataFrame(selection_rows)
config_labels = {
record["config_id"]: weight_label(record)
for record in benchmark_payload["parameters"]["weight_configs"]
}
selection_frame["configuration"] = selection_frame["config_id"].map(config_labels)
if not selection_frame.empty:
fig, ax = plt.subplots(figsize=(10, 3))
means = selection_frame["mean_delta"].to_numpy(dtype=float)
lower = selection_frame["lower"].to_numpy(dtype=float)
upper = selection_frame["upper"].to_numpy(dtype=float)
ax.errorbar(
means,
selection_frame["configuration"],
xerr=np.vstack([means - lower, upper - means]),
fmt="o",
color="#4C78A8",
capsize=5,
)
ax.axvline(
selection["min_delta"],
color="black",
linestyle="--",
linewidth=1,
label=f"minimum delta = {selection['min_delta']:.4f}",
)
ax.set(
title=f"Paired Delta NDCG@{TOP_K} terhadap Baseline",
xlabel="Mean delta dan confidence interval",
ylabel="",
)
ax.legend(loc="best")
plt.tight_layout()
plt.show()
display(selection_frame)
display(
pd.DataFrame(
{
"decision": [selection["decision"]],
"baseline_config_id": [selection["baseline_config_id"]],
"selected_config_id": [selection["selected_config_id"]],
"minimum_delta": [selection["min_delta"]],
"minimum_selection_rate": [selection["min_selection_rate"]],
}
)
)
| config_id | mean_delta | lower | upper | selection_rate | passes_gate | configuration | |
|---|---|---|---|---|---|---|---|
| 0 | cw0.300000000000_ct0.600000000000_pw0.100000000000 | 0.001081 | -0.000782 | 0.003134 | 0.4 | False | C=0.30, T=0.60, P=0.10 |
| decision | baseline_config_id | selected_config_id | minimum_delta | minimum_selection_rate | |
|---|---|---|---|---|---|
| 0 | retain_baseline | cw0.400000000000_ct0.450000000000_pw0.150000000000 | cw0.400000000000_ct0.450000000000_pw0.150000000000 | 0.0 | 0.6 |
Titik pada grafik menunjukkan rata-rata selisih NDCG challenger terhadap baseline, sedangkan garis horizontal menunjukkan confidence interval. Garis putus-putus menandai batas minimum peningkatan, yaitu nol.
Rata-rata NDCG challenger lebih tinggi 0,00108, tetapi confidence interval-nya berada pada -0,00078 hingga 0,00313 dan masih melewati nol. Challenger juga hanya unggul pada 40% split, di bawah syarat 60%. Karena peningkatannya belum konsisten, baseline tetap dipakai.
if benchmark_payload is not None:
fixed_test = benchmark_payload["fixed_test"]
fixed_availability = fixed_test["availability"]
fixed_funnel = pd.DataFrame(
{
"stage": [
"sampled",
"target available",
"history evaluable",
"personalization evaluable",
],
"holdouts": [
fixed_availability["sampled_holdouts"],
fixed_availability["candidate_available_holdouts"],
fixed_availability["history_evaluable_holdouts"],
fixed_availability["personalization_evaluable_holdouts"],
],
}
)
fixed_metrics = pd.DataFrame(
[
{
"configuration": weight_label(record),
"sampled_holdouts": record["sampled_holdouts"],
"conditional_holdouts": record["conditional_holdouts"],
**record["end_to_end_metrics"],
}
for record in fixed_test["aggregate_by_config"]
]
)
display(fixed_funnel)
display(fixed_metrics)
| stage | holdouts | |
|---|---|---|
| 0 | sampled | 200 |
| 1 | target available | 46 |
| 2 | history evaluable | 23 |
| 3 | personalization evaluable | 19 |
| configuration | sampled_holdouts | conditional_holdouts | precision_at_10 | recall_at_10 | hit_rate_at_10 | map_at_10 | mrr_at_10 | ndcg_at_10 | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | C=0.40, T=0.45, P=0.15 | 200 | 19 | 0.001 | 0.01 | 0.01 | 0.005 | 0.005 | 0.006309 |
Karena challenger tidak lolos validation gate, fixed test hanya dibuka untuk baseline. Dari 200 kasus, 46 target tersedia di katalog dan 19 kasus dapat menilai personalisasi. Baseline memperoleh HitRate@10 sebesar 1% dan NDCG@10 sebesar 0,00631. Hasil ini tidak membuktikan baseline mengalahkan challenger di fixed test karena challenger memang tidak diuji di sana.
11. Eksperimen Kapasitas Katalog¶
Bagian ini menguji apakah jumlah candidate books perlu diperbesar. Katalog yang lebih besar dapat membuat buku relevan lebih sering masuk kandidat, tetapi juga menambah waktu rekomendasi dan ukuran model. Karena itu, manfaat dan biayanya perlu dibandingkan sebelum konfigurasi baru digunakan.
Kita membandingkan tiga ukuran interaction/content catalog: 1.000 / 3.000, 3.000 / 10.000, dan 5.000 / 20.000. Holdout, bobot hybrid, neighbors, filter, dan threshold dibuat tetap agar perbedaannya benar-benar berasal dari ukuran katalog.
Pada label 1.000 / 3.000, sebanyak 1.000 buku digunakan untuk collaborative di dalam total 3.000 candidate books. Jadi, jumlahnya bukan 4.000 buku. Dua konfigurasi lainnya dibaca dengan cara yang sama.
catalog_summary = read_catalog_benchmark_summary(
CATALOG_BENCHMARK_SUMMARY,
expected_algorithms=evaluation_algorithm_contract(),
)
catalog_parameters = catalog_summary["parameters"]
catalog_source = catalog_summary["source"]
if int(catalog_parameters["k"]) != TOP_K:
raise ValueError(
"Catalog benchmark summary k does not match the notebook Top-K configuration."
)
notebook_source_fingerprint = fingerprint_dataset(
cleaned.books,
cleaned.ratings,
cleaned.users,
).fingerprint
catalog_fingerprint_matches = (
catalog_source["source_dataset_fingerprint"] == notebook_source_fingerprint
)
catalog_portfolio_scale = (
int(catalog_parameters["validation_users"]) >= 200
and int(catalog_parameters["test_users"]) >= 200
and int(catalog_parameters["repetitions"]) >= 5
and int(catalog_parameters["bootstrap_resamples"]) >= 2_000
)
display(
pd.DataFrame(
{
"field": [
"summary",
"protocol",
"raw artifact SHA-256",
"source dataset fingerprint",
"notebook dataset fingerprint",
"dataset lineage",
"validation users / repeat",
"fixed-test users",
"repetitions",
"selection confidence",
"evidence label",
],
"value": [
display_path(CATALOG_BENCHMARK_SUMMARY, PROJECT_ROOT),
catalog_summary["protocol"],
catalog_source["raw_artifact_sha256"],
catalog_source["source_dataset_fingerprint"],
notebook_source_fingerprint,
"MATCH" if catalog_fingerprint_matches else "EXTERNAL EVIDENCE / MISMATCH",
catalog_parameters["validation_users"],
catalog_parameters["test_users"],
catalog_parameters["repetitions"],
catalog_parameters["selection_confidence_level"],
"PORTFOLIO-SCALE" if catalog_portfolio_scale else "SMOKE/PILOT",
],
}
)
)
if not catalog_fingerprint_matches:
display(
Markdown(
"**Peringatan lineage:** summary berasal dari dataset berbeda. Angka benchmark "
"ditampilkan sebagai external evidence dan tidak boleh dianggap sebagai hasil "
"pengukuran dataset yang sedang dibuka notebook."
)
)
| field | value | |
|---|---|---|
| 0 | summary | docs/evidence/catalog_size_benchmark_summary.json |
| 1 | protocol | canonical_work_fixed_test_repeated_validation_paired_catalog_v1 |
| 2 | raw artifact SHA-256 | ffeef9e038f50e164097f45a5b8603896ee564d78b9a2b22d0d600913adbfc45 |
| 3 | source dataset fingerprint | 796b9fc00f906765c0201c0527f0eb2d40cfc4d877b6d525e83a552b20820146 |
| 4 | notebook dataset fingerprint | 796b9fc00f906765c0201c0527f0eb2d40cfc4d877b6d525e83a552b20820146 |
| 5 | dataset lineage | MATCH |
| 6 | validation users / repeat | 200 |
| 7 | fixed-test users | 200 |
| 8 | repetitions | 5 |
| 9 | selection confidence | 0.975 |
| 10 | evidence label | PORTFOLIO-SCALE |
Fingerprint dataset benchmark sama dengan dataset notebook, sehingga hasilnya memang berasal dari data yang sedang dibahas. Benchmark menggunakan lima validation split × 200 kasus, 200 kasus fixed test, dan selection confidence 97,5%.
catalog_selection = catalog_summary["selection"]
catalog_evidence_by_id = {
record["config_id"]: record for record in catalog_selection["candidate_evidence"]
}
catalog_rows = []
for record in catalog_summary["validation"]["aggregate_by_config"]:
config_id = record["config_id"]
evidence = catalog_evidence_by_id.get(config_id, {})
is_baseline = config_id == catalog_parameters["baseline_config_id"]
resources = record["resources"]
diagnostics = record["common_reference_diagnostics"]
catalog_rows.append(
{
"config_id": config_id,
"catalog": catalog_label(record),
"role": "baseline/reference" if is_baseline else "challenger",
f"NDCG@{TOP_K}": record["end_to_end_metrics"][f"ndcg_at_{TOP_K}"],
f"HitRate@{TOP_K}": record["end_to_end_metrics"][f"hit_rate_at_{TOP_K}"],
"content retention": record["availability"]["content_retention_rate"],
"personalization retention": record["availability"][
"personalization_retention_rate"
],
"unique recommended works": diagnostics["unique_recommended_works_at_k"],
"coverage vs common reference": diagnostics[
"coverage_vs_common_reference_at_k"
],
f"novelty@{TOP_K}": diagnostics[f"novelty_at_{TOP_K}"],
"p95 latency ms": resources["warm_inference"]["p95_ms"],
"serialized model MB": (
resources["model_proxy"]["serialized_model_bytes"] / 1_000_000
),
"p95 latency ratio": evidence.get("p95_latency_ratio", 1.0),
"serialized model ratio": evidence.get("serialized_model_ratio", 1.0),
"quality gate": evidence.get("passes_quality", pd.NA),
"reach gate": evidence.get("passes_reach", pd.NA),
"resource gate": evidence.get("passes_resources", pd.NA),
"promotion status": (
"baseline/reference"
if is_baseline
else ("passed" if evidence["passes"] else "not promoted")
),
}
)
catalog_results = pd.DataFrame(catalog_rows)
display(catalog_results.round(4))
| config_id | catalog | role | NDCG@10 | HitRate@10 | content retention | personalization retention | unique recommended works | coverage vs common reference | novelty@10 | p95 latency ms | serialized model MB | p95 latency ratio | serialized model ratio | quality gate | reach gate | resource gate | promotion status | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | ib1000_cb3000 | 1,000 / 3,000 | baseline/reference | 0.0187 | 0.028 | 0.247 | 0.126 | 391.0 | 0.0217 | 11.2977 | 105.1033 | 9.6465 | 1.0000 | 1.0000 | <NA> | <NA> | <NA> | baseline/reference |
| 1 | ib3000_cb10000 | 3,000 / 10,000 | challenger | 0.0221 | 0.035 | 0.388 | 0.233 | 682.2 | 0.0378 | 12.0351 | 232.7387 | 24.8939 | 2.2144 | 2.5806 | False | True | True | not promoted |
| 2 | ib5000_cb20000 | 5,000 / 20,000 | challenger | 0.0218 | 0.035 | 0.464 | 0.290 | 818.2 | 0.0453 | 12.4580 | 408.8601 | 41.8583 | 3.8901 | 4.3392 | False | True | False | not promoted |
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
reach_long = catalog_results.melt(
id_vars="catalog",
value_vars=["content retention", "personalization retention"],
var_name="reach metric",
value_name="rate",
)
sns.barplot(
data=reach_long,
x="catalog",
y="rate",
hue="reach metric",
ax=axes[0],
)
axes[0].set(
title="Larger Catalog Meningkatkan Reach",
xlabel="Interaction / content books",
ylabel="Holdout retention rate",
)
axes[0].tick_params(axis="x", rotation=15)
paired_catalog_deltas = catalog_summary["validation"]["paired_deltas_vs_baseline"]
delta_rows = []
for config_id, evidence in paired_catalog_deltas.items():
interval = evidence["end_to_end"][f"ndcg_at_{TOP_K}"]
catalog_record = next(
record
for record in catalog_summary["parameters"]["catalog_configs"]
if record["config_id"] == config_id
)
delta_rows.append(
{
"catalog": catalog_label(catalog_record),
"mean": interval["mean"],
"lower": interval["lower"],
"upper": interval["upper"],
}
)
delta_frame = pd.DataFrame(delta_rows)
axes[1].axvline(
catalog_selection["min_delta"],
color="black",
linestyle="--",
label=f"minimum delta = {catalog_selection['min_delta']:.4f}",
)
if delta_frame.empty:
axes[1].text(
0.5,
0.5,
"Tidak ada challenger pada summary",
ha="center",
va="center",
transform=axes[1].transAxes,
)
axes[1].set_yticks([])
else:
delta_y = np.arange(len(delta_frame))
axes[1].errorbar(
delta_frame["mean"],
delta_y,
xerr=np.vstack(
[
delta_frame["mean"] - delta_frame["lower"],
delta_frame["upper"] - delta_frame["mean"],
]
),
fmt="o",
color="#4C78A8",
capsize=5,
)
axes[1].set_yticks(delta_y, delta_frame["catalog"])
axes[1].set(
title=f"Paired Delta NDCG@{TOP_K} (Adjusted CI)",
xlabel="Challenger minus baseline",
ylabel="",
)
axes[1].legend(loc="best")
axes[2].add_patch(
plt.Rectangle(
(0, 0),
catalog_selection["max_latency_ratio"],
catalog_selection["max_serialized_model_ratio"],
color="#59A14F",
alpha=0.12,
label="resource gate",
)
)
for _, row in catalog_results.iterrows():
axes[2].scatter(
row["p95 latency ratio"],
row["serialized model ratio"],
s=90,
)
axes[2].annotate(
row["catalog"],
(
row["p95 latency ratio"],
row["serialized model ratio"],
),
xytext=(5, 5),
textcoords="offset points",
)
axes[2].axvline(
catalog_selection["max_latency_ratio"],
color="black",
linestyle="--",
label=f"batas latency = {catalog_selection['max_latency_ratio']:.1f}×",
)
axes[2].axhline(
catalog_selection["max_serialized_model_ratio"],
color="black",
linestyle="--",
label=(
f"batas ukuran model = "
f"{catalog_selection['max_serialized_model_ratio']:.1f}×"
),
)
axes[2].set(
title="Resource Gate",
xlabel="P95 latency ratio vs baseline",
ylabel="Serialized-model ratio vs baseline",
)
axes[2].legend(loc="upper left")
plt.tight_layout()
plt.show()
Grafik kiri menunjukkan katalog lebih besar membuat lebih banyak target tersedia. Grafik tengah menunjukkan rata-rata NDCG terlihat naik, tetapi confidence interval kedua challenger masih melewati nol sehingga peningkatannya belum pasti. Grafik kanan menunjukkan katalog sedang masih berada di dalam batas biaya, sedangkan katalog terbesar melewati batas latency dan ukuran model.
catalog_fixed_test = catalog_summary["fixed_test"]
catalog_decision_record = pd.DataFrame(
{
"field": [
"validation decision",
"validation selected config",
"fixed-test evaluated configs",
"deployment decision",
"deployment config",
],
"value": [
catalog_selection["decision"],
catalog_selection["selected_config_id"],
", ".join(catalog_fixed_test["evaluated_config_ids"]),
catalog_summary["deployment"]["decision"],
catalog_summary["deployment"]["deployment_config_id"],
],
}
)
catalog_gate_columns = [
"config_id",
"paired_delta_lower",
"selection_rate",
"content_retention_delta",
"personalization_retention_delta",
"p95_latency_ratio",
"serialized_model_ratio",
"passes_quality",
"passes_reach",
"passes_resources",
"passes",
]
catalog_gate_table = pd.DataFrame(
catalog_selection["candidate_evidence"],
columns=catalog_gate_columns,
)
catalog_fixed_metrics = pd.DataFrame(
[
{
"config_id": record["config_id"],
f"NDCG@{TOP_K}": record["end_to_end_metrics"][f"ndcg_at_{TOP_K}"],
f"HitRate@{TOP_K}": record["end_to_end_metrics"][f"hit_rate_at_{TOP_K}"],
"content retention": record["availability"]["content_retention_rate"],
"personalization retention": record["availability"][
"personalization_retention_rate"
],
}
for record in catalog_fixed_test["aggregate_by_config"]
]
)
display(catalog_gate_table.round(4))
display(catalog_decision_record)
display(catalog_fixed_metrics.round(4))
| config_id | paired_delta_lower | selection_rate | content_retention_delta | personalization_retention_delta | p95_latency_ratio | serialized_model_ratio | passes_quality | passes_reach | passes_resources | passes | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | ib3000_cb10000 | -0.0031 | 0.8 | 0.141 | 0.107 | 2.2144 | 2.5806 | False | True | True | False |
| 1 | ib5000_cb20000 | -0.0055 | 0.6 | 0.217 | 0.164 | 3.8901 | 4.3392 | False | True | False | False |
| field | value | |
|---|---|---|
| 0 | validation decision | retain_baseline |
| 1 | validation selected config | ib1000_cb3000 |
| 2 | fixed-test evaluated configs | ib1000_cb3000 |
| 3 | deployment decision | retain_baseline |
| 4 | deployment config | ib1000_cb3000 |
| config_id | NDCG@10 | HitRate@10 | content retention | personalization retention | |
|---|---|---|---|---|---|
| 0 | ib1000_cb3000 | 0.0063 | 0.01 | 0.23 | 0.095 |
if catalog_selection["decision"] == "retain_baseline":
catalog_decision_text = (
"Kedua katalog yang lebih besar lolos reach gate. Katalog sedang lolos "
"resource gate tetapi gagal quality gate, sedangkan katalog terbesar gagal "
"quality dan resource gate. Karena tidak ada challenger yang lolos seluruh "
"gate, baseline tetap digunakan. Fixed test hanya mengevaluasi baseline; ini "
"bukan bukti bahwa baseline mengalahkan challenger karena challenger memang "
"tidak diuji di sana."
)
else:
selected_catalog = next(
record
for record in catalog_parameters["catalog_configs"]
if record["config_id"] == catalog_selection["selected_config_id"]
)
catalog_decision_text = (
f"Challenger **{catalog_label(selected_catalog)}** melewati seluruh validation "
"gate dan menjadi satu-satunya challenger yang boleh dibuka bersama baseline "
"pada fixed test. Keputusan deployment di bawah tetap mengikuti fixed-test evidence."
)
display(
Markdown(
catalog_decision_text
+ "\n\nLatency dan ukuran model dibandingkan sebagai rasio dalam run yang sama. "
"Ukuran file model bukan ukuran peak RAM saat proses berjalan."
)
)
Kedua katalog yang lebih besar lolos reach gate. Katalog sedang lolos resource gate tetapi gagal quality gate, sedangkan katalog terbesar gagal quality dan resource gate. Karena tidak ada challenger yang lolos seluruh gate, baseline tetap digunakan. Fixed test hanya mengevaluasi baseline; ini bukan bukti bahwa baseline mengalahkan challenger karena challenger memang tidak diuji di sana.
Latency dan ukuran model dibandingkan sebagai rasio dalam run yang sama. Ukuran file model bukan ukuran peak RAM saat proses berjalan.
12. Experiment-to-Serving¶
RecommenderService.load memverifikasi manifest, checksum SHA-256, tipe model,
kompatibilitas runtime, algorithm version, schema, dan dataset fingerprint sebelum
memberi akses inference. Ini adalah boundary yang sama dengan API dan Streamlit.
service = RecommenderService.load(MODEL_ARTIFACT)
service_info = service.model_info()
service_manifest = service.manifest
lineage = pd.DataFrame(
{
"field": [
"artifact_id",
"model_type",
"created_at",
"code_sha",
"code_dirty",
"dataset_fingerprint",
"books",
"ratings",
"artifact_size_mb",
],
"value": [
service_info["artifact_id"],
service_info["model_type"],
service_info["created_at"],
service_info["code_sha"],
service_manifest.code_dirty,
service_info["dataset_fingerprint"],
service_info["books"],
service_info["ratings"],
round(MODEL_ARTIFACT.stat().st_size / (1024**2), 2),
],
}
)
display(lineage)
| field | value | |
|---|---|---|
| 0 | artifact_id | hybrid-5b00e40e8959 |
| 1 | model_type | hybrid |
| 2 | created_at | 2026-07-11T03:25:37.936712+00:00 |
| 3 | code_sha | eaa3cefe04961a09e2eb47bf2e19d8bf2c3730fc |
| 4 | code_dirty | False |
| 5 | dataset_fingerprint | 5e186967bc72dacdd2fecebf228003d3b6da42f9432b8260c984b0acb9b2ef68 |
| 6 | books | 3000 |
| 7 | ratings | 52109 |
| 8 | artifact_size_mb | 9.21 |
notebook_training_fingerprint = fingerprint_dataset(
dataset.books,
dataset.ratings,
dataset.users,
).fingerprint
serving_config = service_manifest.training_config
parity = pd.DataFrame(
{
"parameter": [
"max_books",
"max_content_books",
"neighbors",
"positive_rating_threshold",
"collaborative_weight",
"content_weight",
"popularity_weight",
"training_dataset_fingerprint",
],
"notebook": [
MAX_BOOKS,
MAX_CONTENT_BOOKS,
N_NEIGHBORS,
POSITIVE_RATING_THRESHOLD,
0.40,
0.45,
0.15,
notebook_training_fingerprint,
],
"serving": [
required_config_value(serving_config, "max_books"),
required_config_value(serving_config, "max_content_books"),
required_config_value(serving_config, "neighbors"),
required_config_value(serving_config, "positive_rating_threshold"),
required_config_value(serving_config, "collaborative_weight"),
required_config_value(serving_config, "content_weight"),
required_config_value(serving_config, "popularity_weight"),
required_config_value(service_manifest.dataset, "fingerprint"),
],
}
)
parity["matches"] = [
parity_value_matches(notebook_value, serving_value)
for notebook_value, serving_value in zip(
parity["notebook"],
parity["serving"],
strict=True,
)
]
display(parity)
| parameter | notebook | serving | matches | |
|---|---|---|---|---|
| 0 | max_books | 1000 | 1000 | True |
| 1 | max_content_books | 3000 | 3000 | True |
| 2 | neighbors | 50 | 50 | True |
| 3 | positive_rating_threshold | 8.0 | 8.0 | True |
| 4 | collaborative_weight | 0.4 | 0.4 | True |
| 5 | content_weight | 0.45 | 0.45 | True |
| 6 | popularity_weight | 0.15 | 0.15 | True |
| 7 | training_dataset_fingerprint | 5e186967bc72dacdd2fecebf228003d3b6da42f9432b8260c984b0acb9b2ef68 | 5e186967bc72dacdd2fecebf228003d3b6da42f9432b8260c984b0acb9b2ef68 | True |
Bundle aktif berisi model hybrid dengan 3.000 buku dan 52.109 rating. Seluruh konfigurasi notebook mulai dari ukuran katalog, jumlah neighbors, batas rating positif, bobot hybrid, hingga fingerprint dataset adalah konfigurasi serving yg dipakai.
deployment_config_id = catalog_summary["deployment"]["deployment_config_id"]
deployment_catalog = next(
record
for record in catalog_parameters["catalog_configs"]
if record["config_id"] == deployment_config_id
)
deployment_weights = catalog_parameters["hybrid_weights"]
candidate_pool_parameter = signature(service.model.recommend_for_user).parameters.get(
"candidate_pool_size"
)
serving_candidate_pool_size = (
candidate_pool_parameter.default
if candidate_pool_parameter is not None
and candidate_pool_parameter.default is not Parameter.empty
else MISSING_CONFIG_VALUE
)
serving_dynamic_weights = (
not bool(serving_config["disable_dynamic_weights"])
if "disable_dynamic_weights" in serving_config
else MISSING_CONFIG_VALUE
)
catalog_serving_parity = pd.DataFrame(
{
"parameter": [
"max_books",
"max_content_books",
"neighbors",
"hybrid_candidate_pool_size",
"positive_rating_threshold",
"dynamic_weights",
"min_user_ratings",
"min_book_ratings",
"max_ratings",
"deployment_random_state",
"collaborative_weight",
"content_weight",
"popularity_weight",
],
"benchmark deployment decision": [
deployment_catalog["max_interaction_books"],
deployment_catalog["max_content_books"],
catalog_parameters["neighbors"],
catalog_parameters["hybrid_candidate_pool_size"],
catalog_parameters["positive_rating_threshold"],
catalog_parameters["hybrid_dynamic_weights"],
catalog_parameters["min_user_ratings"],
catalog_parameters["min_book_ratings"],
catalog_parameters["max_ratings"],
catalog_parameters["test_random_state"],
deployment_weights["collaborative_weight"],
deployment_weights["content_weight"],
deployment_weights["popularity_weight"],
],
"serving bundle": [
required_config_value(serving_config, "max_books"),
required_config_value(serving_config, "max_content_books"),
required_config_value(serving_config, "neighbors"),
serving_candidate_pool_size,
required_config_value(serving_config, "positive_rating_threshold"),
serving_dynamic_weights,
required_config_value(serving_config, "min_user_ratings"),
required_config_value(serving_config, "min_book_ratings"),
required_config_value(serving_config, "max_ratings"),
required_config_value(serving_config, "random_state"),
required_config_value(serving_config, "collaborative_weight"),
required_config_value(serving_config, "content_weight"),
required_config_value(serving_config, "popularity_weight"),
],
}
)
catalog_serving_parity["matches"] = [
parity_value_matches(benchmark_value, serving_value)
for benchmark_value, serving_value in zip(
catalog_serving_parity["benchmark deployment decision"],
catalog_serving_parity["serving bundle"],
strict=True,
)
]
display(
pd.DataFrame(
{
"field": [
"catalog benchmark raw SHA-256",
"catalog benchmark decision",
"deployment config",
"serving artifact id",
"benchmark embedded in serving manifest",
],
"value": [
catalog_source["raw_artifact_sha256"],
catalog_summary["deployment"]["decision"],
deployment_config_id,
service_info["artifact_id"],
(
"no — existing serving bundle retained"
if catalog_summary["deployment"]["decision"] == "retain_baseline"
else "no — benchmark decision post-dates this serving bundle"
),
],
}
)
)
display(catalog_serving_parity)
| field | value | |
|---|---|---|
| 0 | catalog benchmark raw SHA-256 | ffeef9e038f50e164097f45a5b8603896ee564d78b9a2b22d0d600913adbfc45 |
| 1 | catalog benchmark decision | retain_baseline |
| 2 | deployment config | ib1000_cb3000 |
| 3 | serving artifact id | hybrid-5b00e40e8959 |
| 4 | benchmark embedded in serving manifest | no — existing serving bundle retained |
| parameter | benchmark deployment decision | serving bundle | matches | |
|---|---|---|---|---|
| 0 | max_books | 1000 | 1000 | True |
| 1 | max_content_books | 3000 | 3000 | True |
| 2 | neighbors | 50 | 50 | True |
| 3 | hybrid_candidate_pool_size | 500 | 500 | True |
| 4 | positive_rating_threshold | 8.0 | 8.0 | True |
| 5 | dynamic_weights | True | True | True |
| 6 | min_user_ratings | 2 | 2 | True |
| 7 | min_book_ratings | 2 | 2 | True |
| 8 | max_ratings | None | None | True |
| 9 | deployment_random_state | 42 | 42 | True |
| 10 | collaborative_weight | 0.4 | 0.4 | True |
| 11 | content_weight | 0.45 | 0.45 | True |
| 12 | popularity_weight | 0.15 | 0.15 | True |
catalog_serving_matches = bool(catalog_serving_parity["matches"].all())
if catalog_summary["deployment"]["decision"] == "retain_baseline":
serving_lineage_text = (
"Benchmark memutuskan untuk mempertahankan konfigurasi baseline, sehingga "
"tidak ada model baru yang perlu dipromosikan. Status kecocokan seluruh "
"parameter hasil keputusan benchmark dengan bundle serving aktif: "
f"**{'MATCH' if catalog_serving_matches else 'MISMATCH'}**."
)
else:
serving_lineage_text = (
"Benchmark memilih challenger. Sebelum model itu digunakan, seluruh parameternya "
"harus sama dengan bundle serving aktif. Hasil pemeriksaan: "
f"**{'MATCH' if catalog_serving_matches else 'MISMATCH — bundle perlu dibangun dan dipromosikan ulang'}**."
)
display(Markdown(serving_lineage_text))
Benchmark memutuskan untuk mempertahankan konfigurasi baseline, sehingga tidak ada model baru yang perlu dipromosikan. Status kecocokan seluruh parameter hasil keputusan benchmark dengan bundle serving aktif: MATCH.
Uji Singkat Setelah Model Dimuat¶
Setelah model dimuat, kita perlu memastikan service dapat menghasilkan rekomendasi tanpa error. Model kemudian dipanggil 10 kali sambil mencatat waktu responsnya. Pengujian singkat ini dilakukan pada mesin yang menjalankan notebook, sehingga hasilnya belum mewakili kondisi produksi.
serving_user_id = int(service.ratings["user_id"].value_counts().index[0])
with threadpool_limits(limits=1):
service.recommend_for_user(serving_user_id, top_n=TOP_K)
latency_ms = []
for _ in range(10):
started = perf_counter()
serving_recommendations = service.recommend_for_user(serving_user_id, top_n=TOP_K)
latency_ms.append((perf_counter() - started) * 1_000)
serving_smoke = pd.DataFrame(
{
"metric": ["runs", "result rows", "mean latency ms", "p95 latency ms"],
"value": [
len(latency_ms),
len(serving_recommendations),
float(np.mean(latency_ms)),
float(np.quantile(latency_ms, 0.95)),
],
}
)
display(serving_smoke)
display(
serving_recommendations[
["score", "source", "reason", "isbn", "book_title", "book_author"]
].head(5)
)
| metric | value | |
|---|---|---|
| 0 | runs | 10.000000 |
| 1 | result rows | 10.000000 |
| 2 | mean latency ms | 111.369970 |
| 3 | p95 latency ms | 149.580385 |
| score | source | reason | isbn | book_title | book_author | |
|---|---|---|---|---|---|---|
| 0 | 0.512362 | collaborative | Item-rating patterns are similar to books the user rated 8/10 or higher. | 0553250426 | The Clan of the Cave Bear (Earth's Children (Paperback)) | Jean M. Auel |
| 1 | 0.491339 | collaborative | Item-rating patterns are similar to books the user rated 8/10 or higher. | 0375500510 | Black and Blue : A Novel | ANNA QUINDLEN |
| 2 | 0.448067 | collaborative | Item-rating patterns are similar to books the user rated 8/10 or higher. | 031298328X | Full Tilt (Janet Evanovich's Full Series) | Janet Evanovich |
| 3 | 0.426311 | content | Metadata is similar to books the user rated 8/10 or higher. | 0060930535 | The Poisonwood Bible: A Novel | Barbara Kingsolver |
| 4 | 0.423990 | collaborative | Item-rating patterns are similar to books the user rated 8/10 or higher. | 0451186362 | Rose Madder | Stephen King |
Sepuluh pemanggilan rekomendasi berhasil diselesaikan dan daftar hasil berisi 10 buku. P95 latency tercatat sekitar 149,6 ms pada mesin yang menjalankan notebook, tetapi angka ini belum dapat dianggap sebagai jaminan latency di produksi.
13. Trade-off dan Pengujian Selanjutnya¶
| Pilihan | Kekuatan | Risiko utama |
|---|---|---|
| Popularity | murah, selalu tersedia | nonpersonal, popularity bias |
| Collaborative | personal dari perilaku bersama | sparse/cold-start |
| Content | cold item dan item similarity | metadata dangkal/overspecialization |
| Hybrid | fallback dan beberapa sumber evidence | bobot serta operasi lebih kompleks |
| Katalog lebih besar | reach dan discovery meningkat | latency serta artifact membesar |
Evaluasi offline belum membuktikan bahwa pengguna nyata akan menyukai rekomendasinya. Jika aplikasi sudah memiliki cukup pengguna, langkah berikutnya adalah melakukan A/B test. Pembagian dilakukan berdasarkan pengguna agar orang yang sama selalu mendapatkan versi model yang sama selama pengujian.
Keberhasilan utama dapat dilihat dari jumlah buku yang disimpan atau mulai dibaca. Jumlah rekomendasi yang diklik dapat menjadi sinyal awal. Waktu respons, rekomendasi kosong, penggunaan fallback, dan dominasi buku populer juga tetap dipantau agar peningkatan satu metrik tidak merusak bagian lain. Pembelian baru dapat digunakan sebagai ukuran jika transaksi memang dicatat dengan jelas.
14. Kesimpulan¶
Yang sudah ditunjukkan:
- popularity, collaborative, content, dan hybrid dapat dijalankan dalam pipeline yang sama;
- edisi lain dari buku yang disembunyikan ikut dikeluarkan dari data training untuk mencegah kebocoran;
- komposisi bobot hybrid dibandingkan melalui beberapa validation split sebelum baseline diuji pada fixed test;
- katalog yang lebih besar meningkatkan jumlah buku relevan yang dapat dijangkau, tetapi peningkatan kualitasnya belum konsisten dan biaya serving ikut bertambah;
- konfigurasi dan dataset pada bundle serving sama dengan yang diperiksa di notebook;
- model yang sudah dimuat dapat menghasilkan rekomendasi tanpa error.
Yang belum dapat disimpulkan:
- rekomendasi meningkatkan jumlah klik, buku yang disimpan, mulai dibaca, atau dibeli oleh pengguna nyata;
- performa model tetap stabil dari waktu ke waktu karena dataset tidak memiliki timestamp;
- sistem dapat menangani katalog berisi jutaan buku;
- konfigurasi challenger lebih baik daripada baseline karena hasil validation-nya belum konsisten.