| # prediction.py | |
| import pandas as pd | |
| import numpy as np | |
| import pickle | |
| import json | |
| from sklearn.preprocessing import MinMaxScaler | |
| from sklearn.metrics.pairwise import cosine_similarity | |
| from scipy.spatial.distance import cdist | |
| # ========================================================= | |
| # LOAD FILES | |
| # ========================================================= | |
| # Memuat semua file yang dibutuhkan: | |
| # - kmeans_model.pkl : model clustering yang sudah dilatih | |
| # - app_metadata.json : metadata seperti nama fitur, label cluster, dan range harga | |
| # - df_clean.csv : dataset smartphone yang sudah dibersihkan | |
| # - feat_df_clean.csv : dataset fitur yang sudah dinormalisasi untuk similarity | |
| with open("kmeans_model.pkl", "rb") as f: | |
| kmeans = pickle.load(f) | |
| with open("app_metadata.json") as f: | |
| meta = json.load(f) | |
| df = pd.read_csv("df_clean.csv", index_col=0) | |
| feat_df = pd.read_csv("feat_df_clean.csv", index_col=0) | |
| FEATURE_COLS = meta["FEATURE_COLS"] | |
| CLUSTER_LABELS = { | |
| int(k): v | |
| for k, v in meta["CLUSTER_LABELS"].items() | |
| } | |
| price_min = meta["price_min"] | |
| price_max = meta["price_max"] | |
| # ========================================================= | |
| # PRIORITY WEIGHTS | |
| # ========================================================= | |
| # Setiap priority memiliki bobot yang berbeda untuk setiap fitur. | |
| # Bobot ini menentukan fitur mana yang paling penting saat menghitung | |
| # kecocokan antara preferensi user dengan smartphone. | |
| # | |
| # Contoh: priority "gaming" memberikan bobot tertinggi ke cpu_tier_norm | |
| # dan ram_norm karena performa CPU dan RAM paling penting untuk gaming. | |
| PRIORITY_WEIGHTS = { | |
| 'gaming': { | |
| 'cpu_tier_norm': 0.35, # raw processing power is #1 for gamers | |
| 'ram_norm': 0.30, # games need memory for loading assets | |
| 'battery_norm': 0.25, # games are battery-hungry | |
| 'price_to_perf': 0.25, # gaming phones can be pricey; value matters | |
| 'camera_norm': 0.05, # camera is secondary for gamers | |
| 'battery_efficiency': 0.05, | |
| }, | |
| 'camera': { | |
| 'camera_norm': 0.55, # dominant: highest MP sensor is the main ask | |
| 'cpu_tier_norm': 0.15, # camera processing pipeline needs a decent chip | |
| 'ram_norm': 0.10, | |
| 'battery_norm': 0.10, | |
| 'battery_efficiency': 0.10, | |
| 'price_to_perf': 0.10, | |
| }, | |
| 'budget': { | |
| 'battery_norm': 0.15, | |
| 'battery_efficiency': 0.25, | |
| 'price_to_perf': 0.40, | |
| 'ram_norm': 0.10, | |
| 'cpu_tier_norm': 0.05, | |
| 'camera_norm': 0.05, | |
| }, | |
| 'balanced': { | |
| 'cpu_tier_norm': 0.20, | |
| 'ram_norm': 0.20, | |
| 'price_to_perf': 0.20, | |
| 'battery_norm': 0.15, | |
| 'camera_norm': 0.20, | |
| 'battery_efficiency': 0.10, | |
| }, | |
| } | |
| # Mapping priority ke nama segmen cluster tertentu | |
| # Gaming dan camera langsung diarahkan ke cluster yang relevan | |
| PRIORITY_TO_SEGMENT = { | |
| 'gaming': 'Gaming / Performance', | |
| 'camera': 'Camera-focused', | |
| 'budget': 'Budget', | |
| 'flagship': 'Flagship', | |
| } | |
| # ========================================================= | |
| # PRICE SCALER | |
| # ========================================================= | |
| # MinMaxScaler digunakan untuk mengubah nilai harga (USD) ke skala 0-1 | |
| # agar bisa dibandingkan dengan fitur-fitur lain yang sudah dinormalisasi. | |
| # Scaler di-fit dengan nilai min dan max harga dari dataset. | |
| price_scaler = MinMaxScaler() | |
| price_scaler.fit([ | |
| [price_min], | |
| [price_max] | |
| ]) | |
| # ========================================================= | |
| # BUILD USER VECTOR | |
| # ========================================================= | |
| # Fungsi ini membuat vektor preferensi user berdasarkan: | |
| # - budget_usd : anggaran user dalam USD | |
| # - priority : fokus utama user (gaming/camera/battery/balanced) | |
| # | |
| # Vektor ini nantinya akan dibandingkan dengan vektor fitur setiap | |
| # smartphone menggunakan cosine similarity. | |
| def build_user_vector( | |
| budget_usd: float, | |
| priority: str | |
| ) -> np.ndarray: | |
| # Ambil bobot sesuai priority user | |
| weights = PRIORITY_WEIGHTS.get( | |
| priority, | |
| PRIORITY_WEIGHTS['balanced'] | |
| ) | |
| max_w = max(weights.values()) | |
| # Normalisasi budget user ke skala 0-1 | |
| budget_pos = float( | |
| np.clip( | |
| price_scaler.transform([[budget_usd]])[0][0], | |
| 0.0, | |
| 1.0 | |
| ) | |
| ) | |
| vec = np.zeros(len(FEATURE_COLS)) | |
| for i, feat in enumerate(FEATURE_COLS): | |
| if feat == 'price_norm': | |
| # Posisi harga user dalam skala dataset | |
| vec[i] = budget_pos | |
| elif feat == 'price_to_perf': | |
| # User dengan budget rendah cenderung lebih memperhatikan | |
| # nilai per harga, sehingga nilainya dibalik (1 - budget_pos) | |
| w = weights.get(feat, 0.0) / max_w | |
| vec[i] = w * (1.0 - budget_pos) | |
| else: | |
| # Fitur lain dinormalisasi berdasarkan bobotnya | |
| w = weights.get(feat, 0.0) | |
| vec[i] = (w / max_w) if w > 0 else 0.5 | |
| return vec | |
| # ========================================================= | |
| # RECOMMEND PHONES | |
| # ========================================================= | |
| # Fungsi utama rekomendasi dengan tambahan filter spesifikasi. | |
| # | |
| # Parameter: | |
| # - budget_usd : anggaran maksimum user (USD) | |
| # - priority : fokus utama (gaming/camera/battery/balanced) | |
| # - top_n : jumlah rekomendasi yang ditampilkan | |
| # - min_ram : minimal RAM yang diinginkan (GB), default 0 = tidak difilter | |
| # - min_camera_mp : minimal kamera utama (MP), default 0 = tidak difilter | |
| # - min_battery : minimal kapasitas baterai (mAh), default 0 = tidak difilter | |
| # | |
| # Alur kerja: | |
| # 1. Bangun vektor preferensi user | |
| # 2. Tentukan cluster terbaik berdasarkan priority | |
| # 3. Filter smartphone berdasarkan budget + spesifikasi minimum | |
| # 4. Hitung cosine similarity antara vektor user dan vektor setiap smartphone | |
| # 5. Hitung proximity (seberapa dekat harga smartphone dengan budget user) | |
| # 6. Gabungkan similarity dan proximity menjadi Match Score | |
| # 7. Kembalikan top-N smartphone dengan Match Score tertinggi | |
| def recommend_phones( | |
| budget_usd: float, | |
| priority: str, | |
| top_n: int = 5, | |
| min_ram: int = 0, | |
| min_camera_mp: int = 0, | |
| min_battery: int = 0, | |
| min_year: int = 0, | |
| ): | |
| # ----------------------------------------- | |
| # STEP 1: Bangun vektor preferensi user | |
| # ----------------------------------------- | |
| user_vec = build_user_vector(budget_usd, priority).reshape(1, -1) | |
| # ----------------------------------------- | |
| # STEP 2: Tentukan cluster terbaik | |
| # ----------------------------------------- | |
| reverse_lbl = { | |
| label: cid | |
| for cid, label in CLUSTER_LABELS.items() | |
| } | |
| if priority in PRIORITY_TO_SEGMENT: | |
| # Untuk gaming dan camera, langsung arahkan ke cluster yang sesuai | |
| best_cluster = reverse_lbl.get( | |
| PRIORITY_TO_SEGMENT[priority], | |
| int(np.argmin(cdist(user_vec, kmeans.cluster_centers_)[0])) | |
| ) | |
| else: | |
| # Untuk balanced dan battery, cari cluster terdekat dari vektor user | |
| best_cluster = int( | |
| np.argmin( | |
| cdist(user_vec, kmeans.cluster_centers_, metric='euclidean')[0] | |
| ) | |
| ) | |
| seg_name = CLUSTER_LABELS[best_cluster] | |
| # ----------------------------------------- | |
| # STEP 3: Filter berdasarkan budget + spesifikasi minimum | |
| # ----------------------------------------- | |
| in_cluster = df['cluster_kmeans'] == best_cluster | |
| in_budget = df['price'] <= budget_usd | |
| # Filter spesifikasi minimum dari user | |
| in_ram = df['ram'] >= min_ram if min_ram > 0 else True | |
| in_camera = df['main_camera_mp'] >= min_camera_mp if min_camera_mp > 0 else True | |
| in_battery = df['battery_capacity'] >= min_battery if min_battery > 0 else True | |
| in_year = df['launched year'] >= min_year if min_year > 0 else True | |
| pool = df[in_cluster & in_budget & in_ram & in_camera & in_battery & in_year].copy() | |
| # Jika tidak ada smartphone yang memenuhi semua kriteria dalam cluster, | |
| # perluas pencarian ke semua cluster (tetapi tetap filter spesifikasi) | |
| if pool.empty: | |
| pool = df[in_budget & in_ram & in_camera & in_battery & in_year].copy() | |
| seg_name += " (all clusters)" | |
| # Jika masih kosong setelah filter spesifikasi, kembalikan pesan kosong | |
| if pool.empty: | |
| return pd.DataFrame(), seg_name + " — no phones match your filters" | |
| # ----------------------------------------- | |
| # STEP 4 & 5: Hitung cosine similarity + proximity harga | |
| # ----------------------------------------- | |
| ALPHA = 0.4 # Bobot proximity harga terhadap similarity (40% proximity, 60% similarity) | |
| pool_X = feat_df.loc[pool.index, FEATURE_COLS].values | |
| # Cosine similarity: mengukur seberapa "searah" vektor user dengan vektor smartphone | |
| cos_sims = cosine_similarity(user_vec, pool_X)[0] | |
| # Proximity: mengukur seberapa dekat harga smartphone dengan budget user | |
| # Semakin dekat harganya ke budget, semakin tinggi nilai proximity-nya | |
| proximity = np.clip( | |
| 1.0 - np.abs(budget_usd - pool['price'].values) / (budget_usd + 1e-6), | |
| 0, 1 | |
| ) | |
| # ----------------------------------------- | |
| # STEP 6: Gabungkan menjadi Match Score | |
| # ----------------------------------------- | |
| pool['Match Score (%)'] = ( | |
| cos_sims * (ALPHA * proximity + (1.0 - ALPHA)) * 100 | |
| ).round(1) | |
| # ----------------------------------------- | |
| # STEP 7: Ambil top-N hasil terbaik | |
| # ----------------------------------------- | |
| result_cols = [ | |
| 'brand', 'model', 'price', | |
| 'ram', 'battery_capacity', 'main_camera_mp', | |
| 'Match Score (%)' | |
| ] | |
| result_cols = [c for c in result_cols if c in pool.columns] | |
| result = ( | |
| pool | |
| .nlargest(top_n, 'Match Score (%)')[result_cols] | |
| .reset_index(drop=True) | |
| ) | |
| result.index += 1 | |
| return result, seg_name | |