# 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