{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":31254,"databundleVersionId":3103714}],"dockerImageVersionId":31329,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Predictive Modeling for Micro-Trend/Fad Identification in Fast Fashion\n## Full Pipeline — PySpark + Big Data Architecture\n\n> **Why PySpark?**  \n> transactions_train.csv มี **31.7M rows** — Pandas จะ OOM บน Kaggle (16GB RAM)  \n> PySpark ใช้ lazy evaluation + distributed processing ทำให้รองรับ data ขนาดนี้ได้\n\n| Phase | Tool | Description |\n|-------|------|-------------|\n| Phase 0 | PySpark | EDA |\n| Phase 1 | PySpark | Data Prep + Stockout Masking |\n| Phase 2 | PySpark + ruptures | Hybrid Labeling (PELT + k-Shape) |\n| Phase 3 | PySpark SQL | Feature Engineering |\n| Phase 4 | Pandas (small) | Feature Selection |\n| Phase 5 | LightGBM + XGBoost + LSTM | Stacked Ensemble |\n| Phase 6 | Pandas | Threshold Optimization |\n| Phase 7 | SHAP | Explainability |","metadata":{}},{"cell_type":"code","source":"import os\nos.environ['JAVA_HOME'] = '/usr/lib/jvm/java-17-openjdk-amd64'\nos.environ['PYSPARK_PYTHON'] = 'python3'\nprint(\"JAVA_HOME set!\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:44:02.053048Z","iopub.execute_input":"2026-05-02T02:44:02.053777Z","iopub.status.idle":"2026-05-02T02:44:02.058503Z","shell.execute_reply.started":"2026-05-02T02:44:02.053745Z","shell.execute_reply":"2026-05-02T02:44:02.057516Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 1: Install & Setup ─────────────────────────────────\n!pip install pyspark lightgbm xgboost shap ruptures tslearn imbalanced-learn -q\n\nimport os, warnings\nwarnings.filterwarnings('ignore')\nos.environ['JAVA_HOME'] = '/usr/lib/jvm/java-17-openjdk-amd64'\n\n# PySpark\nfrom pyspark.sql import SparkSession\nfrom pyspark.sql import functions as F\nfrom pyspark.sql.window import Window\nfrom pyspark.sql.types import *\nfrom pyspark.ml.feature import StringIndexer, OneHotEncoder, VectorAssembler, MinMaxScaler\nfrom pyspark.ml import Pipeline\n\n# Standard\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport seaborn as sns\n\n# ML\nfrom sklearn.model_selection import StratifiedKFold\nfrom sklearn.feature_selection import mutual_info_classif\nfrom sklearn.linear_model import LogisticRegression\nfrom sklearn.calibration import CalibratedClassifierCV\nfrom sklearn.metrics import (\n    f1_score, precision_score, recall_score,\n    precision_recall_curve, average_precision_score,\n    confusion_matrix, classification_report, roc_auc_score\n)\nimport lightgbm as lgb\nimport xgboost as xgb\nimport shap\nimport ruptures as rpt\nfrom imblearn.over_sampling import SMOTE\nimport tensorflow as tf\nfrom tensorflow.keras.models import Sequential\nfrom tensorflow.keras.layers import LSTM, Dense, Dropout\nfrom tensorflow.keras.callbacks import EarlyStopping\n\nplt.style.use('seaborn-v0_8-whitegrid')\nsns.set_palette('husl')\nSEED = 42\nnp.random.seed(SEED)\nprint('Libraries loaded!')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:44:02.060119Z","iopub.execute_input":"2026-05-02T02:44:02.060562Z","iopub.status.idle":"2026-05-02T02:44:46.592921Z","shell.execute_reply.started":"2026-05-02T02:44:02.060523Z","shell.execute_reply":"2026-05-02T02:44:46.592164Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from pyspark.sql import SparkSession\n\nspark = (\n    SparkSession.builder\n    .appName('HM_Fad_Detection')\n    .master('local[*]')\n    .config('spark.driver.memory', '8g')\n    .config('spark.sql.shuffle.partitions', '8')\n    .config('spark.sql.adaptive.enabled', 'true')\n    .config('spark.sql.adaptive.coalescePartitions.enabled', 'true')\n    .getOrCreate()\n)\nspark.sparkContext.setLogLevel('ERROR')\nprint(f'Spark {spark.version} ready!')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:44:46.593909Z","iopub.execute_input":"2026-05-02T02:44:46.594720Z","iopub.status.idle":"2026-05-02T02:44:55.761269Z","shell.execute_reply.started":"2026-05-02T02:44:46.594623Z","shell.execute_reply":"2026-05-02T02:44:55.760338Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 3: Load Data with PySpark ─────────────────────────\n\nBASE = '/kaggle/input/competitions/h-and-m-personalized-fashion-recommendations/'\n\ntxn = (\n    spark.read.csv(BASE + 'transactions_train.csv', header=True, inferSchema=True)\n    .withColumn('t_dat', F.to_date('t_dat', 'yyyy-MM-dd'))\n    .withColumn('article_id',  F.col('article_id').cast('string'))\n    .withColumn('customer_id', F.col('customer_id').cast('string'))\n)\n\nart = spark.read.csv(BASE + 'articles.csv',  header=True, inferSchema=True)\ncus = spark.read.csv(BASE + 'customers.csv', header=True, inferSchema=True)\n\nart.cache()\n\nprint('Schema: transactions')\ntxn.printSchema()\nprint(f'\\nDate range: {txn.agg(F.min(\"t_dat\"), F.max(\"t_dat\")).collect()[0]}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:44:55.763585Z","iopub.execute_input":"2026-05-02T02:44:55.763978Z","iopub.status.idle":"2026-05-02T02:46:20.771360Z","shell.execute_reply.started":"2026-05-02T02:44:55.763952Z","shell.execute_reply":"2026-05-02T02:46:20.770501Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"txn.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:46:20.772330Z","iopub.execute_input":"2026-05-02T02:46:20.772670Z","iopub.status.idle":"2026-05-02T02:46:20.940988Z","shell.execute_reply.started":"2026-05-02T02:46:20.772601Z","shell.execute_reply":"2026-05-02T02:46:20.940146Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 4: Row Counts ──\n\nn_txn = txn.count()\nn_art = art.count()\nn_cus = cus.count()\n\nprint(f'Transactions : {n_txn:>12,}')\nprint(f'Articles     : {n_art:>12,}')\nprint(f'Customers    : {n_cus:>12,}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:46:20.942042Z","iopub.execute_input":"2026-05-02T02:46:20.942332Z","iopub.status.idle":"2026-05-02T02:46:31.059547Z","shell.execute_reply.started":"2026-05-02T02:46:20.942302Z","shell.execute_reply":"2026-05-02T02:46:31.058734Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n## Phase 0 — EDA with PySpark\nทำ EDA บน data เต็ม ","metadata":{}},{"cell_type":"code","source":"# ── Cell 5: Missing Value Analysis ──────────────────\n\ndef spark_missing_report(df, name):\n    agg_exprs = [F.count(F.lit(1)).alias('__total__')] + [\n        F.sum(F.col(c).isNull().cast('int')).alias(c) for c in df.columns\n    ]\n    result = df.agg(*agg_exprs).collect()[0].asDict()\n    total = result.pop('__total__')\n    \n    rows = [\n        {\n            'column': col, \n            'missing': cnt, \n            'pct': round(cnt/total*100, 2),\n            'result': '🔴 MNAR' if cnt/total > 0.30 \n                    else '🟡 MAR' if cnt/total > 0.05 \n                    else '🟢 MCAR'\n        }\n        for col, cnt in result.items() if cnt > 0\n    ]\n    \n    print(f'\\n=== Missing Values: {name} (total rows: {total:,}) ===')\n    if not rows:\n        print('No missing values'); return pd.DataFrame()\n    \n    report = pd.DataFrame(rows).sort_values('pct', ascending=False)\n    print(report.to_string(index=False))\n    return report\n\nspark_missing_report(art, 'articles')\nspark_missing_report(cus, 'customers')\n\nspark_missing_report(txn.select('t_dat','article_id','customer_id','price'), 'transactions (key cols)')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:46:31.060790Z","iopub.execute_input":"2026-05-02T02:46:31.061103Z","iopub.status.idle":"2026-05-02T02:47:15.996289Z","shell.execute_reply.started":"2026-05-02T02:46:31.061071Z","shell.execute_reply":"2026-05-02T02:47:15.995576Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 6: Cardinality Check ──────────────────────\ncat_cols = [\n    'product_type_name', 'product_group_name',\n    'graphical_appearance_name', 'colour_group_name',\n    'department_name', 'index_name', 'garment_group_name'\n]\ncard_exprs = [F.countDistinct(c).alias(c) for c in cat_cols]\ncardinality = art.agg(*card_exprs).collect()[0].asDict()\ncard_df = pd.DataFrame(cardinality.items(), columns=['feature','unique_values'])\ncard_df = card_df.sort_values('unique_values', ascending=False)\n\nprint('=== Cardinality (articles) ===')\nprint(card_df.to_string(index=False))\n\nfig, ax = plt.subplots(figsize=(9, 5))\ncard_df.plot(kind='barh', x='feature', y='unique_values', ax=ax, color='steelblue', legend=False)\nax.axvline(50, color='red', linestyle='--', label='One-Hot limit (50)')\nax.set_title('Cardinality — Articles Categorical Features', fontweight='bold')\nax.legend()\nplt.tight_layout(); plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:47:15.997506Z","iopub.execute_input":"2026-05-02T02:47:15.998011Z","iopub.status.idle":"2026-05-02T02:47:18.056442Z","shell.execute_reply.started":"2026-05-02T02:47:15.997976Z","shell.execute_reply":"2026-05-02T02:47:18.055803Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 7: Sales Distribution + Cold-Start Analysis ──\n!pip install powerlaw -q\nimport powerlaw\n\n# 1. Identify \"Dead-on-Arrival\" articles\nall_articles = art.select('article_id').distinct()\nsold_articles = txn.select('article_id').distinct()\n\ndead_on_arrival = all_articles.subtract(sold_articles)\nn_dead = dead_on_arrival.count()\nn_total_art = all_articles.count()\n\nprint(f'\\n Dead-on-Arrival Articles: {n_dead:,} / {n_total_art:,} ({n_dead/n_total_art*100:.2f}%)')\nprint('   → These are MAX-RISK candidates. Investigate before Phase 1.')\n\ndead_profile = (\n    dead_on_arrival.join(art, 'article_id')\n    .groupBy('product_group_name')\n    .count()\n    .orderBy(F.desc('count'))\n)\nprint('\\nDead-on-Arrival profile by product_group:')\ndead_profile.show(10, truncate=False)\n\n# 2. Sales distribution\nsales_per_art = (\n    txn.groupBy('article_id')\n    .count()\n    .withColumnRenamed('count', 'total_sales')\n    .orderBy(F.desc('total_sales'))\n)\nsales_per_art.cache()\n\nsales_per_art.select('total_sales').describe().show()\nsales_pd = sales_per_art.toPandas()\n\n# 3. RIGOROUS Power Law fit (Clauset et al. method)\nfit = powerlaw.Fit(sales_pd['total_sales'].values, discrete=True, verbose=False)\nprint(f'\\n=== Rigorous Power Law Fit ===')\nprint(f'  Estimated alpha (α)  : {fit.alpha:.3f}')\nprint(f'  Estimated x_min      : {fit.xmin:.0f}')\nprint(f'  → Power Law applies for sales >= {fit.xmin:.0f}')\n\nfor alt in ['lognormal', 'exponential', 'stretched_exponential']:\n    R, p = fit.distribution_compare('power_law', alt, normalized_ratio=True)\n    verdict = '✓ PL better' if R > 0 and p < 0.05 else '✗ PL not better'\n    print(f'  PL vs {alt:25s}: R={R:+.3f}, p={p:.4f}  → {verdict}')\n\n# 4. Quantile breakdown for stratified sampling\nprint('\\n=== Sales Quantiles ===')\nquantiles = sales_per_art.approxQuantile('total_sales', [0.5, 0.75, 0.90, 0.95, 0.99], 0.01)\nlabels = ['p50', 'p75', 'p90', 'p95', 'p99']\nfor l, q in zip(labels, quantiles):\n    print(f'  {l}: {q:>8.0f} sales')\n\n# 5. Visualization — log-log + powerlaw fit overlay\nfig, axes = plt.subplots(1, 2, figsize=(14, 5))\n\n# Left: rank-frequency log-log\nsales_pd['rank'] = range(1, len(sales_pd) + 1)\naxes[0].loglog(sales_pd['rank'], sales_pd['total_sales'], 'o', alpha=0.3, ms=2)\naxes[0].set_xlabel('Rank (log)'); axes[0].set_ylabel('Sales (log)')\naxes[0].set_title(f'Rank-Frequency (slope ≈ -{fit.alpha:.2f})')\naxes[0].grid(True, alpha=0.3)\n\n# Right: CCDF with powerlaw fit\nfit.plot_ccdf(ax=axes[1], color='steelblue', linewidth=2, label='Empirical CCDF')\nfit.power_law.plot_ccdf(ax=axes[1], color='red', linestyle='--', label=f'PL fit (α={fit.alpha:.2f})')\nfit.lognormal.plot_ccdf(ax=axes[1], color='green', linestyle=':', label='Lognormal fit')\naxes[1].set_xlabel('Sales (log)'); axes[1].set_ylabel('P(X ≥ x)')\naxes[1].set_title('CCDF Comparison: Power Law vs Lognormal')\naxes[1].legend()\n\nplt.tight_layout(); plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:47:18.058945Z","iopub.execute_input":"2026-05-02T02:47:18.059185Z","iopub.status.idle":"2026-05-02T02:49:00.952949Z","shell.execute_reply.started":"2026-05-02T02:47:18.059164Z","shell.execute_reply":"2026-05-02T02:49:00.952210Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n## Phase 1 — Data Preparation with PySpark","metadata":{}},{"cell_type":"code","source":"# ═══════════════════════════════════════════════════════════════════\n#  LOCKED-IN CONSTANTS FROM PHASE 0 EDA\n# ═══════════════════════════════════════════════════════════════════\n\n# From Phase 0.1 — Dead-on-Arrival handling\nN_DEAD_ON_ARRIVAL = 995 \n\n# From Phase 0.2 — Lognormal + Power Law tail analysis\nPOWER_LAW_XMIN    = 1821     \nPOWER_LAW_ALPHA   = 3.00       \n\n# Phase 1 configuration \nMIN_ACTIVE_WEEKS  = 3       \nCENSORING_WEEKS   = 26       \nMAX_WEEKS_PIVOT   = 26     \n\n# Stockout detection parameters\nSTOCKOUT_BASELINE_WINDOW = 4   \nSTOCKOUT_DROP_THRESHOLD  = 0.30  \nSTOCKOUT_RECOVERY_THRESH = 0.60 \nSTOCKOUT_MIN_BASELINE    = 2   \n\nprint('Phase 0 constants loaded')\nprint(f'   x_min = {POWER_LAW_XMIN}, α = {POWER_LAW_ALPHA}')\nprint(f'   Min active weeks = {MIN_ACTIVE_WEEKS} (Fad-preserving)')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:49:00.954222Z","iopub.execute_input":"2026-05-02T02:49:00.955143Z","iopub.status.idle":"2026-05-02T02:49:00.960567Z","shell.execute_reply.started":"2026-05-02T02:49:00.955106Z","shell.execute_reply":"2026-05-02T02:49:00.959814Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 8: Weekly Aggregation with DENSE time series ──\n\ndataset_start, dataset_end = txn.agg(\n    F.min('t_dat').alias('start'),\n    F.max('t_dat').alias('end')\n).collect()[0]\nprint(f'Dataset period: {dataset_start} → {dataset_end}')\n\n# ─── Step 1: Aggregate to SPARSE weekly (only weeks with sales) ───\nweekly_sparse = (\n    txn\n    .withColumn('week_start', F.date_trunc('week', F.col('t_dat')))  # Monday as week start\n    .groupBy('article_id', 'week_start')\n    .agg(\n        F.count(F.lit(1))                  .alias('weekly_sales'),\n        F.countDistinct('customer_id')     .alias('unique_buyers'),\n        F.countDistinct('sales_channel_id').alias('n_channels')\n    )\n)\n\n# ─── Step 2: Compute launch_week & last_seen_week per article ───\nlifecycle = (\n    weekly_sparse\n    .groupBy('article_id')\n    .agg(\n        F.min('week_start').alias('launch_week'),\n        F.max('week_start').alias('last_seen_week')\n    )\n    .withColumn(\n        'weeks_from_launch_to_dataset_end',\n        (F.datediff(F.lit(dataset_end), F.col('launch_week')) / 7).cast('int')\n    )\n    .withColumn(\n        'is_censored',\n        (F.col('weeks_from_launch_to_dataset_end') < CENSORING_WEEKS).cast('int')\n    )\n)\n\nn_total = lifecycle.count()\nn_censored = lifecycle.filter(F.col('is_censored') == 1).count()\nprint(f'Total articles with transactions: {n_total:,}')\nprint(f'  → Right-censored (launched < {CENSORING_WEEKS}w before end): {n_censored:,} ({n_censored/n_total*100:.1f}%)')\nprint(f'  → Usable for training: {n_total - n_censored:,}')\n\n# ─── Step 3: Build DENSE skeleton (every week from launch → last_seen) ───\ndense_skeleton = (\n    lifecycle\n    .filter(F.col('is_censored') == 0)  \n    .select(\n        'article_id',\n        'launch_week',\n        F.explode(\n            F.sequence(\n                F.col('launch_week'),\n                F.col('last_seen_week'),\n                F.expr('INTERVAL 7 DAYS')\n            )\n        ).alias('week_start')\n    )\n)\n\n# ─── Step 4: Join sparse data onto dense skeleton → zero-fill gaps ───\nweekly = (\n    dense_skeleton\n    .join(weekly_sparse, on=['article_id', 'week_start'], how='left')\n    .fillna(0, subset=['weekly_sales', 'unique_buyers', 'n_channels'])\n    .withColumn(\n        'weeks_since_launch',\n        (F.datediff('week_start', 'launch_week') / 7).cast('int')\n    )\n)\n\nweekly.cache()\nn_records = weekly.count()\n\nprint(f'\\n✅ Dense weekly records: {n_records:,}')\nprint(f'   Expansion ratio: {n_records / weekly_sparse.count():.2f}x '\n      f'(sparse → dense; higher = more stockout/dormant weeks)')\n\n# Preview\nprint('\\n--- Sample (article 110065002) ---')\nweekly.filter(F.col('article_id') == '110065002') \\\n      .select('week_start', 'weeks_since_launch', 'weekly_sales', 'unique_buyers') \\\n      .orderBy('weeks_since_launch').show(10)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:49:00.961836Z","iopub.execute_input":"2026-05-02T02:49:00.962143Z","iopub.status.idle":"2026-05-02T02:53:30.233176Z","shell.execute_reply.started":"2026-05-02T02:49:00.962121Z","shell.execute_reply":"2026-05-02T02:53:30.232311Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 9: Multi-Tier Stockout Detection ──\n\n# ─── Updated Thresholds ───\nSTOCKOUT_BASELINE_WINDOW = 4       \nSTOCKOUT_MIN_BASELINE    = 3.0     \nDROP_SEVERE   = 0.20 \nDROP_MODERATE = 0.50  \nRECOVERY_STRONG  = 0.70  \nRECOVERY_PARTIAL = 0.40 \n\nw_art      = Window.partitionBy('article_id').orderBy('weeks_since_launch')\nw_baseline = w_art.rowsBetween(-STOCKOUT_BASELINE_WINDOW, -1)\n\nweekly = (\n    weekly\n    # 1. Rolling baseline (past 4 weeks)\n    .withColumn('baseline_sales', F.avg('weekly_sales').over(w_baseline))\n    \n    # 2. Future recovery window\n    .withColumn('sales_lead1', F.lead('weekly_sales', 1).over(w_art))\n    .withColumn('sales_lead2', F.lead('weekly_sales', 2).over(w_art))\n    .withColumn('sales_lead3', F.lead('weekly_sales', 3).over(w_art))\n    \n    # 3. Ratios\n    .withColumn('drop_ratio',\n        F.col('weekly_sales') / (F.col('baseline_sales') + 1.0))\n    \n    .withColumn('future_recovery',\n        F.greatest(F.col('sales_lead1'), F.col('sales_lead2'), F.col('sales_lead3')) /\n        (F.col('baseline_sales') + 1.0))\n    \n    # 4. Tiered stockout detection (3 levels of severity)\n    .withColumn('stockout_tier',\n        F.when(\n            # Tier 3 (SEVERE)\n            (F.col('drop_ratio')      < DROP_SEVERE) &\n            (F.col('future_recovery') > RECOVERY_STRONG) &\n            (F.col('baseline_sales')  >= STOCKOUT_MIN_BASELINE),\n            3\n        ).when(\n            # Tier 2 (MODERATE)\n            (F.col('drop_ratio')      < DROP_MODERATE) &\n            (F.col('future_recovery') > RECOVERY_PARTIAL) &\n            (F.col('baseline_sales')  >= STOCKOUT_MIN_BASELINE),\n            2\n        ).when(\n            # Tier 1 (MILD)\n            (F.col('drop_ratio')     < DROP_MODERATE) &\n            (F.col('baseline_sales') >= 5.0),\n            1\n        ).otherwise(0))\n    \n    # 5. Binary flag\n    .withColumn('is_stockout',\n        (F.col('stockout_tier') > 0).cast('int'))\n    \n    # 6. Impute value ตาม tier\n    .withColumn('weekly_sales_clean',\n        F.when(F.col('stockout_tier') == 3, F.col('baseline_sales'))\n         .when(F.col('stockout_tier') == 2, F.col('baseline_sales') * 0.7)  \n         .when(F.col('stockout_tier') == 1, F.col('baseline_sales') * 0.5)  \n         .otherwise(F.col('weekly_sales')))\n)\n\nweekly.unpersist()\nweekly.cache()\n_ = weekly.count()\n\n# ─── Diagnostic ───\ntotal_weeks  = weekly.count()\ntier_dist = (\n    weekly.groupBy('stockout_tier')\n    .agg(F.count('*').alias('count'))\n    .orderBy('stockout_tier')\n).toPandas()\ntier_dist['pct'] = tier_dist['count'] / total_weeks * 100\n\nprint('=== Stockout Tier Distribution ===')\nprint(tier_dist.to_string(index=False))\n\ntotal_stockout_rate = tier_dist.loc[tier_dist['stockout_tier'] > 0, 'pct'].sum()\nprint(f'\\nTotal stockout rate (all tiers): {total_stockout_rate:.2f}%')\nprint(f'   Expected: 5-15% in fast fashion')\n\nif   total_stockout_rate < 4:  print('   ⚠️  ยังต่ำ — พิจารณา relax อีก')\nelif total_stockout_rate > 20: print('   ⚠️  สูงเกิน — tighten thresholds')\nelse:                          print('   ✅ Within expected range')\n\n# Sample ของแต่ละ tier\nprint('\\n--- Samples by Tier ---')\nfor tier in [3, 2, 1]:\n    print(f'\\nTier {tier}:')\n    weekly.filter(F.col('stockout_tier') == tier) \\\n          .select('article_id', 'weeks_since_launch', 'baseline_sales',\n                  'weekly_sales', 'sales_lead1', 'sales_lead2', \n                  'drop_ratio', 'future_recovery', 'weekly_sales_clean') \\\n          .show(3, truncate=False)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:53:30.235305Z","iopub.execute_input":"2026-05-02T02:53:30.235687Z","iopub.status.idle":"2026-05-02T02:53:41.624255Z","shell.execute_reply.started":"2026-05-02T02:53:30.235647Z","shell.execute_reply":"2026-05-02T02:53:41.623440Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 10: Filter + Normalize + Regime-Aware Stratification ──\n# ─── Step 1: Compute active weeks per article (non-zero sales) ───\nactive_weeks = (\n    weekly\n    .filter(F.col('weekly_sales_clean') > 0)\n    .groupBy('article_id')\n    .agg(F.count('*').alias('active_weeks'))\n)\n\n# ─── Step 2: Filter: >= 3 active weeks ───\nvalid_arts = active_weeks.filter(F.col('active_weeks') >= MIN_ACTIVE_WEEKS)\n\nn_before = weekly.select('article_id').distinct().count()\nn_after  = valid_arts.count()\n\nweekly = weekly.join(valid_arts.select('article_id'), on='article_id', how='inner')\nprint(f'Articles before filter: {n_before:,}')\nprint(f'Articles after filter (>= {MIN_ACTIVE_WEEKS} active weeks): {n_after:,}')\nprint(f'   Excluded: {n_before - n_after:,} articles with <{MIN_ACTIVE_WEEKS} active weeks (duds)')\n\n# ─── Step 3: Min-Max normalize per article ───\nw_norm = Window.partitionBy('article_id')\nweekly = (\n    weekly\n    .withColumn('art_min', F.min('weekly_sales_clean').over(w_norm))\n    .withColumn('art_max', F.max('weekly_sales_clean').over(w_norm))\n    .withColumn('norm_sales',\n        F.when(F.col('art_max') == F.col('art_min'), F.lit(0.0))\n         .otherwise(\n             (F.col('weekly_sales_clean') - F.col('art_min')) /\n             (F.col('art_max') - F.col('art_min'))\n         ))\n)\n\n# ─── Step 4: Regime-aware stratification ───\ntotal_sales_df = (\n    weekly.groupBy('article_id')\n    .agg(F.sum('weekly_sales_clean').alias('total_sales'))\n    .withColumn('regime',\n        F.when(F.col('total_sales') >= POWER_LAW_XMIN, 'tail')\n         .otherwise('body'))\n)\n\nw_body = Window.partitionBy('regime').orderBy(F.log1p('total_sales'))\ntotal_sales_df = (\n    total_sales_df\n    .withColumn(\n        'strat_bucket',\n        F.when(F.col('regime') == 'tail', F.ntile(2).over(w_body))\n         .otherwise(F.ntile(4).over(w_body)))\n    .withColumn(\n        'strat_key',\n        F.concat(F.col('regime'), F.lit('_Q'), F.col('strat_bucket')))\n)\n\n# Regime distribution report\nprint('\\n=== Regime Distribution (from Power Law EDA) ===')\nregime_dist = total_sales_df.groupBy('regime').agg(\n    F.count('*').alias('n_articles'),\n    F.min('total_sales').alias('min_sales'),\n    F.max('total_sales').alias('max_sales'),\n    F.mean('total_sales').alias('mean_sales')\n).toPandas()\nprint(regime_dist.to_string(index=False))\n\nprint('\\n=== Stratum Key Distribution ===')\ntotal_sales_df.groupBy('strat_key').count().orderBy('strat_key').show()\n\n# Join stratum back to weekly\nweekly = weekly.join(\n    total_sales_df.select('article_id', 'regime', 'strat_key', 'total_sales'),\n    on='article_id', how='left'\n)\n\n# Re-cache final weekly DataFrame\nweekly.unpersist()\nweekly.cache()\nn_valid = weekly.select('article_id').distinct().count()\nprint(f'\\n✅ Final valid articles: {n_valid:,}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:53:41.625311Z","iopub.execute_input":"2026-05-02T02:53:41.625658Z","iopub.status.idle":"2026-05-02T02:54:04.705974Z","shell.execute_reply.started":"2026-05-02T02:53:41.625606Z","shell.execute_reply":"2026-05-02T02:54:04.704465Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 11: Sales Pivot with Validation ──\n\npivot_spark = (\n    weekly\n    .filter(\n        (F.col('weeks_since_launch') >= 0) & \n        (F.col('weeks_since_launch') < MAX_WEEKS_PIVOT)\n    )\n    .groupBy('article_id')\n    .pivot('weeks_since_launch', list(range(MAX_WEEKS_PIVOT)))\n    .agg(F.first('norm_sales'))\n    .fillna(0)\n)\n\nnew_cols = ['article_id'] + [f'w{i}' for i in range(MAX_WEEKS_PIVOT)]\npivot_spark = pivot_spark.toDF(*new_cols)\n\nprint('=== Pre-Collect Sanity Check ===')\npivot_spark.select(\n    F.count('*').alias('n_articles'),\n    F.mean('w0').alias('mean_w0'),\n    F.mean('w3').alias('mean_w3'),\n    F.mean('w12').alias('mean_w12'),\n    F.mean('w25').alias('mean_w25')\n).show()\nprint('↑ Expect: mean_w0 > mean_w3 > mean_w12 > mean_w25 '\n      '(peak at launch, decay over time)')\n\npivot = pivot_spark.toPandas().set_index('article_id')\nprint(f'\\n Sales pivot shape: {pivot.shape}  (articles × weeks)')\n\n# ─── Diagnostic 1: Death-week distribution ───\nweek_cols = [f'w{i}' for i in range(MAX_WEEKS_PIVOT)]\ndeath_week = (pivot[week_cols] > 0).iloc[:, ::-1].cummax(axis=1).iloc[:, ::-1].sum(axis=1)\n\nprint('\\n=== Death Week Distribution ===')\nprint(f'(last week with sales > 0; short death_week = Fad candidate)')\nprint(death_week.describe().to_string())\nprint(f'\\n  Articles dying by week 6 (short Fad):  '\n      f'{(death_week <= 6).sum():,} ({(death_week <= 6).mean()*100:.1f}%)')\nprint(f'  Articles dying by week 12 (medium):    '\n      f'{((death_week > 6) & (death_week <= 12)).sum():,} '\n      f'({((death_week > 6) & (death_week <= 12)).mean()*100:.1f}%)')\nprint(f'  Articles still alive at week 25:       '\n      f'{(death_week > 25).sum():,} ({(death_week > 25).mean()*100:.1f}%)')\n\n# ─── Diagnostic 2: Visualize curve shapes by regime ───\nregime_df = total_sales_df.select('article_id', 'regime').toPandas().set_index('article_id')\npivot_with_regime = pivot.join(regime_df, how='left')\n\nfig, axes = plt.subplots(1, 2, figsize=(14, 4))\n\n# Sample 30 articles per regime\nfor ax, regime, color in zip(axes, ['body', 'tail'], ['steelblue', 'crimson']):\n    sample = pivot_with_regime[pivot_with_regime['regime'] == regime].sample(\n        min(30, (pivot_with_regime['regime'] == regime).sum()), random_state=42\n    )\n    for idx in sample.index:\n        ax.plot(range(MAX_WEEKS_PIVOT), sample.loc[idx, week_cols].values, \n                alpha=0.3, color=color, lw=1)\n    ax.set_title(f'{regime.upper()} regime — 30 random articles', fontweight='bold')\n    ax.set_xlabel('Weeks since launch'); ax.set_ylabel('Normalized sales')\n    ax.grid(alpha=0.3)\n\nplt.tight_layout(); plt.show()\n\nprint('\\n Phase 1 complete')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:54:04.709546Z","iopub.execute_input":"2026-05-02T02:54:04.709895Z","iopub.status.idle":"2026-05-02T02:54:13.823551Z","shell.execute_reply.started":"2026-05-02T02:54:04.709861Z","shell.execute_reply":"2026-05-02T02:54:13.821972Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n## Phase 2 — Hybrid Labeling (PELT + k-Mean)","metadata":{}},{"cell_type":"code","source":"# ── Cell 12: PELT ──\n\nFAD_MAX_PEAK_WEEK     = 6\nFAD_MIN_DROP_RATIO    = 0.70  \nFAD_MIN_PEAK_RATIO    = 4.0    \nFAD_LOW_TAIL_RATIO    = 0.20  \nPELT_PENALTY          = 2\n\npelt_results = []\nfor art_id, row in pivot.iterrows():\n    series = row.values.astype(float)\n    \n    nonzero = np.nonzero(series)[0]\n    if len(nonzero) == 0:\n        pelt_results.append({\n            'article_id': art_id, 'pelt_fad': 0, 'n_breakpoints': 0,\n            'peak_week': 0, 'death_week': 0,\n            'peak_ratio': 0.0, 'drop_ratio': 0.0, 'tail_ratio': 0.0,\n        })\n        continue\n    \n    death_week = int(nonzero[-1])\n    series_trimmed = series[:death_week + 1]\n    \n    if len(series_trimmed) < 5:\n        pelt_results.append({\n            'article_id': art_id, 'pelt_fad': 0, 'n_breakpoints': 0,\n            'peak_week': 0, 'death_week': death_week,\n            'peak_ratio': 0.0, 'drop_ratio': 0.0, 'tail_ratio': 0.0,\n        })\n        continue\n    \n    try:\n        algo = rpt.Pelt(model='rbf', min_size=2).fit(series_trimmed)\n        bkps = algo.predict(pen=PELT_PENALTY)\n        n_bkps = len(bkps) - 1\n    except Exception:\n        n_bkps = 0\n    \n    peak_week = int(np.argmax(series_trimmed))\n    peak_value = series_trimmed[peak_week]\n    \n    if peak_week < len(series_trimmed) - 1:\n        post_peak_mean = series_trimmed[peak_week + 1:].mean() + 1e-8\n        peak_ratio = peak_value / post_peak_mean\n        tail_ratio = post_peak_mean / (peak_value + 1e-8)\n    else:\n        peak_ratio = 0.0\n        tail_ratio = 1.0\n    \n    drop_ratio = (peak_value - series_trimmed[-1]) / (peak_value + 1e-8)\n    \n    is_fad = (\n        (n_bkps >= 1) and\n        (peak_week <= FAD_MAX_PEAK_WEEK) and\n        (peak_ratio > FAD_MIN_PEAK_RATIO) and\n        (tail_ratio < FAD_LOW_TAIL_RATIO) and\n        (drop_ratio > FAD_MIN_DROP_RATIO)\n    )\n    \n    pelt_results.append({\n        'article_id': art_id,\n        'pelt_fad': int(is_fad),\n        'n_breakpoints': n_bkps,\n        'peak_week': peak_week,\n        'death_week': death_week,\n        'peak_ratio': round(peak_ratio, 2),\n        'drop_ratio': round(drop_ratio, 3),\n        'tail_ratio': round(tail_ratio, 3),\n    })\n\npelt_df = pd.DataFrame(pelt_results)\n\n# ─── Diagnostics ───\nprint('PELT done.')\nprint(f'\\n{pelt_df[\"pelt_fad\"].value_counts()}')\nprint(f'\\nFad rate (PELT): {pelt_df[\"pelt_fad\"].mean():.2%}')\n\nprint('\\n=== Criteria Pass Rates (individual) ===')\nprint(f'  Has breakpoint (n≥1)   : {(pelt_df[\"n_breakpoints\"] >= 1).mean():.2%}')\nprint(f'  Peak week ≤ {FAD_MAX_PEAK_WEEK}           : {(pelt_df[\"peak_week\"] <= FAD_MAX_PEAK_WEEK).mean():.2%}')\nprint(f'  Peak ratio > {FAD_MIN_PEAK_RATIO}          : {(pelt_df[\"peak_ratio\"] > FAD_MIN_PEAK_RATIO).mean():.2%}')\nprint(f'  Tail ratio < {FAD_LOW_TAIL_RATIO}          : {(pelt_df[\"tail_ratio\"] < FAD_LOW_TAIL_RATIO).mean():.2%}')\nprint(f'  Drop ratio > {FAD_MIN_DROP_RATIO}          : {(pelt_df[\"drop_ratio\"] > FAD_MIN_DROP_RATIO).mean():.2%}')\n\npelt_fads = pelt_df[pelt_df['pelt_fad'] == 1]\nif len(pelt_fads) > 0:\n    print('\\n=== PELT-Fad Candidates: Temporal Profile ===')\n    print(f'Peak week   — mean: {pelt_fads[\"peak_week\"].mean():.1f}, '\n          f'median: {pelt_fads[\"peak_week\"].median():.0f}')\n    print(f'Peak ratio  — mean: {pelt_fads[\"peak_ratio\"].mean():.2f}')\n    print(f'Drop ratio  — mean: {pelt_fads[\"drop_ratio\"].mean():.2f}')\n    print(f'Tail ratio  — mean: {pelt_fads[\"tail_ratio\"].mean():.3f}')\n\n# Sanity check\nprint('\\n=== Sanity Check: Article 110065002 ===')\ncheck = pelt_df[pelt_df['article_id'] == '110065002']\nif len(check) > 0:\n    print(check.to_string(index=False))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:54:13.825969Z","iopub.execute_input":"2026-05-02T02:54:13.826446Z","iopub.status.idle":"2026-05-02T02:54:55.896975Z","shell.execute_reply.started":"2026-05-02T02:54:13.826402Z","shell.execute_reply":"2026-05-02T02:54:55.896172Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 13 Distributed Clustering ──\n\nfrom pyspark.ml.feature import VectorAssembler\nfrom pyspark.ml.clustering import KMeans, BisectingKMeans\nfrom pyspark.ml.evaluation import ClusteringEvaluator\nimport time\n\n# ─── Config ───\nK_SEARCH_RANGE    = range(3, 9)\nFAD_MAX_PEAK_WEEK = 6\n\n# ═══════════════════════════════════════════════════════\n#  Step 1: Prepare data in Spark\n# ═══════════════════════════════════════════════════════\n\nprint('Preparing data in Spark...')\n\nweek_cols = [f'w{i}' for i in range(26)]\npivot_reset = pivot.reset_index()\npivot_spark = spark.createDataFrame(pivot_reset)\n\nassembler = VectorAssembler(inputCols=week_cols, outputCol='features')\ndata_spark = assembler.transform(pivot_spark).select('article_id', 'features')\ndata_spark.cache()\n\nprint(f'   Rows: {data_spark.count():,}')\nprint(f'   Feature vector length: 26')\n\n# ═══════════════════════════════════════════════════════\n#  Step 2: Elbow Method — หา optimal k\n# ═══════════════════════════════════════════════════════\n\nprint(f'\\n🔍 Searching optimal k over range {list(K_SEARCH_RANGE)}...')\n\nelbow_results = []\nfor k in K_SEARCH_RANGE:\n    t0 = time.time()\n    \n    kmeans = KMeans(\n        k=k,\n        featuresCol='features',\n        predictionCol='cluster',\n        seed=42,\n        maxIter=100,\n        initMode='k-means||', \n    )\n    model = kmeans.fit(data_spark)\n    \n    inertia = model.summary.trainingCost\n    elapsed = time.time() - t0\n    \n    elbow_results.append({'k': k, 'inertia': inertia, 'time': elapsed})\n    print(f'   k={k}  inertia={inertia:.2f}  time={elapsed:.1f}s')\n\nelbow_df = pd.DataFrame(elbow_results)\n\n# ─── Kneedle-based Elbow Detection ───\nks_vals = elbow_df['k'].values.astype(float)\ninertia_vals = elbow_df['inertia'].values\n\nx_norm = (ks_vals - ks_vals.min()) / (ks_vals.max() - ks_vals.min())\ny_norm = (inertia_vals - inertia_vals.min()) / (inertia_vals.max() - inertia_vals.min())\n\nline_vec = np.array([x_norm[-1] - x_norm[0], y_norm[-1] - y_norm[0]])\nline_vec_norm = line_vec / np.linalg.norm(line_vec)\n\ndistances = []\nfor i in range(len(ks_vals)):\n    point_vec = np.array([x_norm[i] - x_norm[0], y_norm[i] - y_norm[0]])\n    proj = np.dot(point_vec, line_vec_norm) * line_vec_norm\n    perp = point_vec - proj\n    distances.append(np.linalg.norm(perp))\n\nBEST_K = int(ks_vals[np.argmax(distances)])\nprint(f'\\n📍 Elbow at k = {BEST_K}')\n\n# Elbow Plot\nfig, axes = plt.subplots(1, 2, figsize=(13, 4))\naxes[0].plot(ks_vals, inertia_vals, 'o-', lw=2, ms=8, color='steelblue')\naxes[0].axvline(BEST_K, linestyle='--', color='crimson', lw=2, label=f'Elbow k={BEST_K}')\naxes[0].set_xlabel('k'); axes[0].set_ylabel('Inertia (WSSSE)')\naxes[0].set_title('Elbow Method — Spark KMeans'); axes[0].legend(); axes[0].grid(alpha=0.3)\n\naxes[1].bar(ks_vals, distances, color='coral', edgecolor='darkred')\naxes[1].axvline(BEST_K, linestyle='--', color='crimson', lw=2)\naxes[1].set_xlabel('k'); axes[1].set_ylabel('Perpendicular distance')\naxes[1].set_title('Kneedle Distance'); axes[1].grid(alpha=0.3)\nplt.suptitle(f'Optimal k Search — Elbow at k={BEST_K}', fontweight='bold')\nplt.tight_layout(); plt.show()\n\n# ═══════════════════════════════════════════════════════\n#  Step 3: Fit final KMeans with best k\n# ═══════════════════════════════════════════════════════\n\nprint(f'\\n Fitting final KMeans with k={BEST_K} (distributed)...')\nt0 = time.time()\n\nfinal_kmeans = KMeans(\n    k=BEST_K,\n    featuresCol='features',\n    predictionCol='cluster',\n    seed=42,\n    maxIter=300,\n    initMode='k-means||',\n)\nfinal_model = final_kmeans.fit(data_spark)\nclustered = final_model.transform(data_spark)\n\nprint(f'   Elapsed: {time.time() - t0:.1f}s')\n\n# Silhouette Score\nevaluator = ClusteringEvaluator(\n    featuresCol='features', predictionCol='cluster',\n    metricName='silhouette'\n)\nsilhouette = evaluator.evaluate(clustered)\nprint(f'   Silhouette Score: {silhouette:.4f}')\n\n# ═══════════════════════════════════════════════════════\n#  Step 4: Extract labels + Compute centroids\n# ═══════════════════════════════════════════════════════\n\nkshape_labels_df = clustered.select('article_id', 'cluster').toPandas()\nkshape_labels_df['article_id'] = kshape_labels_df['article_id'].astype(str)\n\ncentroid_spark = (\n    pivot_spark\n    .join(\n        clustered.select(\n            F.col('article_id').cast('string').alias('article_id'), \n            'cluster'\n        ),\n        on='article_id'\n    )\n)\n\ncentroids = {}\nfor i in range(BEST_K):\n    cluster_data = centroid_spark.filter(F.col('cluster') == i)\n    centroid_values = cluster_data.select(\n        *[F.avg(c).alias(c) for c in week_cols]\n    ).collect()[0]\n    centroids[i] = np.array([centroid_values[c] for c in week_cols])\n\nkshape_labels = kshape_labels_df.set_index('article_id')['cluster'].reindex(pivot.index).values\n\nprint(f'   Labels shape: {kshape_labels.shape}')\nprint(f'   Centroids computed: {len(centroids)}')\n\n# ═══════════════════════════════════════════════════════\n#  Step 5: Auto-Detect Fad Clusters\n# ═══════════════════════════════════════════════════════\n\nprint(f'\\n Computing cluster metrics (k={BEST_K})...')\ncluster_metrics = []\nfor i in range(BEST_K):\n    centroid = centroids[i]\n    n_members = int((kshape_labels == i).sum())\n    \n    peak_w = int(np.argmax(centroid))\n    peak_v = centroid[peak_w]\n    end_v  = centroid[-1]\n    min_v  = centroid.min()\n    \n    prominence = peak_v - min_v\n    width_at_half = int((centroid > peak_v * 0.5).sum())\n    sharpness = prominence / max(1, width_at_half)\n    drop = (peak_v - end_v) / (abs(peak_v) + 1e-8)\n    is_early_peak = peak_w <= FAD_MAX_PEAK_WEEK\n    \n    cluster_metrics.append({\n        'cluster': i, 'n': n_members,\n        'peak_week': peak_w, 'peak_value': round(peak_v, 4),\n        'end_value': round(end_v, 4),\n        'prominence': round(prominence, 4),\n        'width_at_half': width_at_half,\n        'sharpness': round(sharpness, 4),\n        'drop': round(drop, 2),\n        'is_early_peak': is_early_peak,\n    })\n\ncm_df = pd.DataFrame(cluster_metrics).sort_values('sharpness', ascending=False)\nprint('\\n=== Cluster Metrics (sorted by sharpness) ===')\nprint(cm_df.to_string(index=False))\n\n# Auto-detect\nFAD_CRITERIA = {'prominence': 0.05, 'sharpness': 0.01, 'drop': 0.3}\n\nfad_mask = (\n    (cm_df['prominence'] > FAD_CRITERIA['prominence']) &\n    (cm_df['sharpness']  > FAD_CRITERIA['sharpness']) &\n    (cm_df['drop']       > FAD_CRITERIA['drop']) &\n    (cm_df['is_early_peak'])\n)\nauto_fad_clusters = cm_df[fad_mask]['cluster'].tolist()\n\nprint(f'\\n Auto-detected FAD_CLUSTERS: {auto_fad_clusters}')\nprint(f'   Criteria: prominence > {FAD_CRITERIA[\"prominence\"]}, '\n      f'sharpness > {FAD_CRITERIA[\"sharpness\"]}, '\n      f'drop > {FAD_CRITERIA[\"drop\"]}, peak_week ≤ {FAD_MAX_PEAK_WEEK}')\n\n# Rejected clusters\nrejected = cm_df[~fad_mask]\nif len(rejected) > 0:\n    print(f'\\n   ❌ Rejected:')\n    for _, row in rejected.iterrows():\n        reasons = []\n        if row['prominence'] <= FAD_CRITERIA['prominence']: reasons.append(f'low prominence')\n        if row['sharpness'] <= FAD_CRITERIA['sharpness']: reasons.append(f'low sharpness')\n        if row['drop'] <= FAD_CRITERIA['drop']: reasons.append(f'low drop ({row[\"drop\"]})')\n        if not row['is_early_peak']: reasons.append(f'late peak (w{row[\"peak_week\"]})')\n        print(f'      Cluster {int(row[\"cluster\"])}: {\", \".join(reasons)}')\n\n# ═══════════════════════════════════════════════════════\n#  Step 6: Visualize Centroids\n# ═══════════════════════════════════════════════════════\n\nfig, axes = plt.subplots(1, BEST_K, figsize=(max(13, 3.5 * BEST_K), 4.5))\nif BEST_K == 1: axes = [axes]\n\nfor i, ax in enumerate(axes):\n    centroid = centroids[i]\n    n = int((kshape_labels == i).sum())\n    metrics = cm_df[cm_df['cluster'] == i].iloc[0]\n    \n    is_fad = i in auto_fad_clusters\n    color = 'crimson' if is_fad else 'steelblue'\n    bg = '#ffebeb' if is_fad else 'white'\n    ax.set_facecolor(bg)\n    \n    ax.plot(centroid, color=color, lw=2.5)\n    ax.axvline(FAD_MAX_PEAK_WEEK, color='green', linestyle='--', alpha=0.3, lw=1)\n    ax.axvline(metrics['peak_week'], color='orange', linestyle=':', alpha=0.6, lw=1)\n    \n    title = f'Cluster {i} (n={n:,})\\npeak@w{metrics[\"peak_week\"]}, drop={metrics[\"drop\"]:.0%}'\n    title += '\\n🎯 FAD' if is_fad else '\\n(not Fad)'\n    ax.set_title(title, fontsize=10, fontweight='bold')\n    ax.set_xlabel('Weeks since launch'); ax.grid(alpha=0.3)\n\nplt.suptitle(f'Spark KMeans Centroids (k={BEST_K}) — Fad = peak ≤ week {FAD_MAX_PEAK_WEEK}',\n             fontweight='bold', fontsize=12)\nplt.tight_layout(); plt.show()\n\n# ═══════════════════════════════════════════════════════\n#  Step 7: Save for Cell 14\n# ═══════════════════════════════════════════════════════\n\nkshape_df = pd.DataFrame({\n    'article_id': pivot.index,\n    'kshape_cluster': kshape_labels,\n})\nFAD_CLUSTERS = auto_fad_clusters\n\nn_fad = sum(cm_df[cm_df['cluster'].isin(FAD_CLUSTERS)]['n'])\nprint(f'\\n{\"=\"*60}')\nprint(f'✅ Cell 13 COMPLETE (Spark KMeans — Distributed)')\nprint(f'   Optimal k       : {BEST_K}')\nprint(f'   Silhouette Score : {silhouette:.4f}')\nprint(f'   Fad clusters     : {FAD_CLUSTERS}')\nprint(f'   Fad members      : {n_fad:,} ({n_fad/len(pivot)*100:.1f}%)')\nprint(f'   Non-Fad members  : {len(pivot)-n_fad:,}')\nprint(f'{\"=\"*60}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:54:55.897988Z","iopub.execute_input":"2026-05-02T02:54:55.898312Z","iopub.status.idle":"2026-05-02T02:57:08.916565Z","shell.execute_reply.started":"2026-05-02T02:54:55.898289Z","shell.execute_reply":"2026-05-02T02:57:08.915493Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ══════════════════════════════════════════════════════════════════\n#  Cluster Validation & Profiling\n# ══════════════════════════════════════════════════════════════════\n\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom scipy import stats\nimport numpy as np\nimport pandas as pd\n\nprint('=== Pre-check ===')\nprint(f'  pivot shape:      {pivot.shape}')\nprint(f'  kshape_labels:    {len(kshape_labels)} labels, {len(set(kshape_labels))} clusters')\nprint(f'  BEST_K:           {BEST_K}')\nprint(f'  FAD_CLUSTERS:     {FAD_CLUSTERS}')\nprint(f'  centroids:        {len(centroids)} clusters')\n\n\n# ══════════════════════════════════════════════════════════════\n#  PART 1: VALIDATION — Cluster\n# ══════════════════════════════════════════════════════════════\n\nprint('\\n' + '=' * 70)\nprint('  PART 1: CLUSTER VALIDATION')\nprint('=' * 70)\n\nprint('\\n--- 1.1 Visual: Centroid vs Actual Members ---')\n\nfig, axes = plt.subplots(2, BEST_K, figsize=(4.5 * BEST_K, 8))\n\nfor i in range(BEST_K):\n    member_idx = np.where(kshape_labels == i)[0]\n    n_members = len(member_idx)\n\n    sample_size = min(50, n_members)\n    sample_idx = np.random.choice(member_idx, sample_size, replace=False)\n    \n    is_fad = i in FAD_CLUSTERS\n    color = 'crimson' if is_fad else 'steelblue'\n    \n    ax = axes[0, i]\n    for idx in sample_idx:\n        ax.plot(pivot.iloc[idx].values, alpha=0.15, color=color, lw=0.5)\n    \n    centroid = centroids[i]\n    ax.plot(centroid, color='black', lw=3, label='Centroid')\n    ax.axvline(6, color='green', linestyle='--', alpha=0.4, lw=1, label='Fad boundary (w6)')\n    ax.set_title(f'Cluster {i} (n={n_members:,})\\n{\"🎯 FAD\" if is_fad else \"Non-Fad\"}',\n                 fontweight='bold', fontsize=11)\n    ax.set_xlabel('Weeks')\n    ax.set_ylabel('Normalized Sales')\n    ax.legend(fontsize=7, loc='upper right')\n    ax.grid(alpha=0.3)\n    \n    ax2 = axes[1, i]\n    member_data = pivot.iloc[member_idx].values\n    peak_weeks = member_data.argmax(axis=1)\n    \n    ax2.hist(peak_weeks, bins=26, color=color, alpha=0.7, edgecolor='white')\n    ax2.axvline(6, color='red', linestyle='--', lw=2, label='Fad boundary (w6)')\n    \n    pct_early = (peak_weeks <= 6).mean() * 100\n    ax2.set_title(f'Peak Week Distribution\\nmedian={np.median(peak_weeks):.0f}, '\n                  f'{pct_early:.0f}% ≤ w6', fontsize=10)\n    ax2.set_xlabel('Peak Week')\n    ax2.set_ylabel('Count')\n    ax2.legend(fontsize=8)\n    ax2.grid(alpha=0.3)\n\nplt.suptitle('Cluster Validation: Centroid vs Actual Members\\n'\n             '(Row 1: เส้นบาง = สมาชิกจริง, เส้นหนา = centroid  |  '\n             'Row 2: กระจายตัวของ peak week)',\n             fontweight='bold', fontsize=12)\nplt.tight_layout()\nplt.show()\n\nprint('\\n--- 1.2 Cluster Quality Metrics ---\\n')\n\ndef compute_drops(data):\n    drops = []\n    for row in data:\n        pw = row.argmax()\n        peak_val = row[pw]\n        drop = (peak_val - row[-1]) / (peak_val + 1e-8) if peak_val > 0 else 0\n        drops.append(drop)\n    return np.array(drops)\n\ndef compute_gini_array(data):\n    ginis = []\n    for row in data:\n        arr = np.sort(np.abs(row))\n        n = len(arr)\n        if arr.sum() == 0:\n            ginis.append(0)\n        else:\n            idx = np.arange(1, n + 1)\n            ginis.append((2 * (idx * arr).sum()) / (n * arr.sum()) - (n + 1) / n)\n    return np.array(ginis)\n\ncluster_quality = []\nfor i in range(BEST_K):\n    member_idx = np.where(kshape_labels == i)[0]\n    member_data = pivot.iloc[member_idx].values\n    centroid = centroids[i]\n\n    # Intra-cluster distance\n    distances = np.sqrt(((member_data - centroid) ** 2).sum(axis=1))\n    \n    # Peak week statistics\n    peak_weeks = member_data.argmax(axis=1)\n    pct_early_peak = (peak_weeks <= 6).mean()\n    \n    # Drop ratio statistics\n    drops = compute_drops(member_data)\n    \n    # Gini coefficient\n    ginis = compute_gini_array(member_data)\n    \n    # Early sales concentration\n    early_conc = member_data[:, :6].sum(axis=1) / (member_data.sum(axis=1) + 1e-8)\n    \n    cluster_quality.append({\n        'Cluster': i,\n        'N': len(member_idx),\n        'Is_Fad': '✅ FAD' if i in FAD_CLUSTERS else '❌ No',\n        'Avg_Dist': round(distances.mean(), 3),\n        'Median_Peak_Wk': int(np.median(peak_weeks)),\n        '%_Peak≤w6': f'{pct_early_peak:.0%}',\n        'Mean_Drop': f'{drops.mean():.0%}',\n        'Mean_Gini': round(ginis.mean(), 3),\n        'Mean_Early_Conc': f'{early_conc.mean():.0%}',\n    })\n\nquality_df = pd.DataFrame(cluster_quality)\nprint(quality_df.to_string(index=False))\n\nprint('\\nการอ่านตาราง:')\nprint('  Avg_Dist      : ระยะทางเฉลี่ยถึง centroid (ต่ำ = cluster แน่น ดี)')\nprint('  %_Peak≤w6     : สัดส่วนสมาชิกที่ peak ภายใน 6 สัปดาห์ (Fad ควรสูง)')\nprint('  Mean_Drop     : ยอดขายตกจาก peak เฉลี่ย (Fad ควรสูง)')\nprint('  Mean_Gini     : ความกระจุกตัวของยอดขาย (Fad ควรสูง)')\nprint('  Mean_Early_Conc: สัดส่วนยอดขายช่วง 6 สัปดาห์แรก (Fad ควรสูง)')\n\n\nprint('\\n--- 1.3 Statistical Test: Fad vs Non-Fad ---\\n')\n\nfad_mask = np.isin(kshape_labels, FAD_CLUSTERS)\nfad_data = pivot.values[fad_mask]\nnonfad_data = pivot.values[~fad_mask]\n\nprint(f'Fad articles:     {fad_data.shape[0]:,}')\nprint(f'Non-Fad articles: {nonfad_data.shape[0]:,}\\n')\n\nfad_peaks = fad_data.argmax(axis=1)\nnonfad_peaks = nonfad_data.argmax(axis=1)\n\nfad_drops = compute_drops(fad_data)\nnonfad_drops = compute_drops(nonfad_data)\n\nfad_gini = compute_gini_array(fad_data)\nnonfad_gini = compute_gini_array(nonfad_data)\n\nfad_conc = fad_data[:, :6].sum(axis=1) / (fad_data.sum(axis=1) + 1e-8)\nnonfad_conc = nonfad_data[:, :6].sum(axis=1) / (nonfad_data.sum(axis=1) + 1e-8)\n\ntests = [\n    {\n        'name': 'Peak Week',\n        'hypothesis': 'Fad peak เร็วกว่า Non-Fad (Fad < Non-Fad)',\n        'fad_vals': fad_peaks, 'nonfad_vals': nonfad_peaks,\n        'alternative': 'less',\n        'fad_fmt': f'{fad_peaks.mean():.1f}', 'nonfad_fmt': f'{nonfad_peaks.mean():.1f}',\n    },\n    {\n        'name': 'Drop Ratio',\n        'hypothesis': 'Fad ดรอปมากกว่า Non-Fad (Fad > Non-Fad)',\n        'fad_vals': fad_drops, 'nonfad_vals': nonfad_drops,\n        'alternative': 'greater',\n        'fad_fmt': f'{fad_drops.mean():.2%}', 'nonfad_fmt': f'{nonfad_drops.mean():.2%}',\n    },\n    {\n        'name': 'Gini Coefficient',\n        'hypothesis': 'Fad กระจุกตัวกว่า Non-Fad (Fad > Non-Fad)',\n        'fad_vals': fad_gini, 'nonfad_vals': nonfad_gini,\n        'alternative': 'greater',\n        'fad_fmt': f'{fad_gini.mean():.3f}', 'nonfad_fmt': f'{nonfad_gini.mean():.3f}',\n    },\n    {\n        'name': 'Early Concentration',\n        'hypothesis': 'Fad ขายช่วงแรกมากกว่า Non-Fad (Fad > Non-Fad)',\n        'fad_vals': fad_conc, 'nonfad_vals': nonfad_conc,\n        'alternative': 'greater',\n        'fad_fmt': f'{fad_conc.mean():.2%}', 'nonfad_fmt': f'{nonfad_conc.mean():.2%}',\n    },\n]\n\ntest_results = []\nfor t in tests:\n    stat, pval = stats.mannwhitneyu(\n        t['fad_vals'], t['nonfad_vals'], alternative=t['alternative']\n    )\n    test_results.append({\n        'Test': t['name'],\n        'Hypothesis': t['hypothesis'],\n        'Fad Mean': t['fad_fmt'],\n        'Non-Fad Mean': t['nonfad_fmt'],\n        'p-value': f'{pval:.2e}',\n        'Significant?': '✅ Yes' if pval < 0.05 else '❌ No',\n    })\n\ntest_df = pd.DataFrame(test_results)\nprint(test_df.to_string(index=False))\nprint('\\n(Mann-Whitney U test, one-sided, α = 0.05)')\n\nn_pass = sum(1 for r in test_results if '✅' in r['Significant?'])\nprint(f'\\nผ่าน {n_pass}/4 tests', end='')\nif n_pass == 4:\n    print(' → ✅ ยืนยันว่า Fad clusters แตกต่างจาก Non-Fad อย่างมีนัยสำคัญทุกมิติ')\nelif n_pass >= 3:\n    print(' → 🟡 ส่วนใหญ่ผ่าน ยอมรับได้')\nelse:\n    print(' → 🔴 ผ่านน้อย ควรทบทวนการเลือก cluster')\n    \n\nfig, axes = plt.subplots(1, 4, figsize=(18, 4))\n\nviz_data = [\n    (fad_peaks, nonfad_peaks, 'Peak Week', 'Fad peak เร็วกว่า'),\n    (fad_drops, nonfad_drops, 'Drop Ratio', 'Fad ดรอปมากกว่า'),\n    (fad_gini, nonfad_gini, 'Gini Coefficient', 'Fad กระจุกตัวกว่า'),\n    (fad_conc, nonfad_conc, 'Early Concentration', 'Fad ขายช่วงแรกมากกว่า'),\n]\n\nfor ax, (fad_v, nonfad_v, title, interp) in zip(axes, viz_data):\n    ax.hist(nonfad_v, bins=30, alpha=0.5, color='steelblue', label='Non-Fad', density=True)\n    ax.hist(fad_v, bins=30, alpha=0.5, color='crimson', label='Fad', density=True)\n    ax.set_title(f'{title}\\n({interp})', fontsize=10, fontweight='bold')\n    ax.legend(fontsize=8)\n    ax.grid(alpha=0.3)\n\nplt.suptitle('Statistical Validation: Fad vs Non-Fad Distribution Comparison\\n'\n             '(2 กลุ่มต้องแยกออกจากกันชัดเจน)', fontweight='bold')\nplt.tight_layout()\nplt.show()\n\n\n# ══════════════════════════════════════════════════════════════\n#  PART 2: PROFILING\n# ══════════════════════════════════════════════════════════════\n\nprint('\\n' + '=' * 70)\nprint('  PART 2: CLUSTER PROFILING')\nprint('=' * 70)\n\nart_pd = art.toPandas()\nart_pd['article_id'] = art_pd['article_id'].astype(str)  # ← เพิ่มบรรทัดนี้\nprofile = kshape_df.merge(art_pd, on='article_id', how='left')\nprofile['is_fad_cluster'] = profile['kshape_cluster'].isin(FAD_CLUSTERS).map(\n    {True: 'Fad', False: 'Non-Fad'}\n)\n\n\nprint('\\n--- 2.1 Product Group Distribution (% ภายในแต่ละ cluster) ---\\n')\n\ngroup_dist = pd.crosstab(\n    profile['kshape_cluster'].map(\n        lambda x: f'C{x} {\"(FAD)\" if x in FAD_CLUSTERS else \"(Non-Fad)\"}'\n    ),\n    profile['product_group_name'],\n    normalize='index'\n).round(3) * 100\n\nprint(group_dist.to_string())\n\n\nprint('\\n--- 2.2 Fad Rate by Product Group ---\\n')\n\nfad_by_group = (\n    profile.groupby('product_group_name')\n    .agg(\n        total=('article_id', 'count'),\n        n_fad=('is_fad_cluster', lambda x: (x == 'Fad').sum())\n    )\n)\nfad_by_group['fad_rate'] = (fad_by_group['n_fad'] / fad_by_group['total'] * 100).round(1)\nfad_by_group = fad_by_group.sort_values('fad_rate', ascending=False)\n\nprint(fad_by_group.to_string())\n\n# Plot\nfig, ax = plt.subplots(figsize=(10, 5))\nfad_by_group['fad_rate'].plot(kind='barh', ax=ax, color='crimson', alpha=0.7)\nax.set_xlabel('Fad Rate (%)')\nax.set_title('Fad Rate by Product Group\\n(กลุ่มสินค้าไหนมีสัดส่วน Fad สูงที่สุด)', fontweight='bold')\noverall_rate = profile['is_fad_cluster'].eq('Fad').mean() * 100\nax.axvline(overall_rate, color='gray', linestyle='--',\n           label=f'Overall rate ({overall_rate:.1f}%)')\nax.legend()\nax.grid(alpha=0.3)\nplt.tight_layout()\nplt.show()\n\n\nprint('\\n--- 2.3 Top 15 Product Types in Fad Clusters ---\\n')\n\nfad_articles = profile[profile['is_fad_cluster'] == 'Fad']\nfad_type_counts = fad_articles['product_type_name'].value_counts().head(15)\n\nprint(f'{\"Product Type\":<40s} {\"Count\":>8s} {\"% of Fad\":>10s}')\nprint('-' * 60)\nfor ptype, count in fad_type_counts.items():\n    pct = count / len(fad_articles) * 100\n    print(f'{ptype:<40s} {count:>8,} {pct:>9.1f}%')\n\n\nprint('\\n--- 2.4 Color Distribution: Fad vs Non-Fad ---\\n')\n\nif 'colour_group_name' in profile.columns:\n    color_comp = pd.crosstab(\n        profile['is_fad_cluster'],\n        profile['colour_group_name'],\n        normalize='index'\n    ).T\n\n    color_comp['diff'] = color_comp.get('Fad', 0) - color_comp.get('Non-Fad', 0)\n    color_comp = color_comp.sort_values('diff', ascending=False)\n\n    print('Top 10 สีที่ Fad มีสัดส่วนสูงกว่า Non-Fad:')\n    top_colors = color_comp.head(10)\n    for color_name, row in top_colors.iterrows():\n        fad_pct = row.get('Fad', 0) * 100\n        nonfad_pct = row.get('Non-Fad', 0) * 100\n        print(f'  {color_name:<25s}  Fad: {fad_pct:5.1f}%  Non-Fad: {nonfad_pct:5.1f}%  '\n              f'(diff: {(fad_pct - nonfad_pct):+.1f}%)')\nelse:\n    print('colour_group_name column not found')\n\n\nprint('\\n--- 2.5 Graphical Appearance: Fad vs Non-Fad ---\\n')\n\nif 'graphical_appearance_name' in profile.columns:\n    appear_comp = pd.crosstab(\n        profile['is_fad_cluster'],\n        profile['graphical_appearance_name'],\n        normalize='index'\n    ).T\n\n    appear_comp['diff'] = appear_comp.get('Fad', 0) - appear_comp.get('Non-Fad', 0)\n    appear_comp = appear_comp.sort_values('diff', ascending=False)\n\n    print('Top 10 ลายที่ Fad มีสัดส่วนสูงกว่า Non-Fad:')\n    for appear_name, row in appear_comp.head(10).iterrows():\n        fad_pct = row.get('Fad', 0) * 100\n        nonfad_pct = row.get('Non-Fad', 0) * 100\n        print(f'  {appear_name:<25s}  Fad: {fad_pct:5.1f}%  Non-Fad: {nonfad_pct:5.1f}%  '\n              f'(diff: {(fad_pct - nonfad_pct):+.1f}%)')\nelse:\n    print('  graphical_appearance_name column not found')\n\n\nprint('\\n--- 2.6 Department Fad Rate (Top 10, min 20 articles) ---\\n')\n\nif 'department_name' in profile.columns:\n    dept_fad = (\n        profile.groupby('department_name')\n        .agg(\n            total=('article_id', 'count'),\n            n_fad=('is_fad_cluster', lambda x: (x == 'Fad').sum())\n        )\n    )\n    dept_fad['fad_rate'] = (dept_fad['n_fad'] / dept_fad['total'] * 100).round(1)\n    dept_fad = dept_fad.query('total >= 20').sort_values('fad_rate', ascending=False).head(10)\n    print(dept_fad.to_string())\nelse:\n    print('  department_name column not found')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:57:08.917953Z","iopub.execute_input":"2026-05-02T02:57:08.918321Z","iopub.status.idle":"2026-05-02T02:57:18.174858Z","shell.execute_reply.started":"2026-05-02T02:57:08.918296Z","shell.execute_reply":"2026-05-02T02:57:18.174088Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 14: Hybrid Label ──\n\nfrom sklearn.metrics import cohen_kappa_score\n\nprint(f'FAD_CLUSTERS = {FAD_CLUSTERS}')\nprint(f'BEST_K = {BEST_K}\\n')\n\nfor c in sorted(FAD_CLUSTERS):\n    n = int((kshape_labels == c).sum())\n    peak_w = int(np.argmax(centroids[c]))\n    print(f'   Cluster {c} (peak@w{peak_w}): {n:,} articles')\n\nn_fad_kshape = sum((kshape_labels == c).sum() for c in FAD_CLUSTERS)\nprint(f'   Total Fad members (k-Shape): {n_fad_kshape:,} ({n_fad_kshape/len(pivot)*100:.1f}%)')\n\nnon_fad_clusters = [c for c in range(BEST_K) if c not in FAD_CLUSTERS]\nprint(f'\\n   Non-Fad clusters: {non_fad_clusters}')\nfor c in non_fad_clusters:\n    n = int((kshape_labels == c).sum())\n    peak_w = int(np.argmax(centroids[c]))\n    print(f'   Cluster {c} (peak@w{peak_w}): {n:,} articles')\n\nlabels = pelt_df.merge(kshape_df, on='article_id', how='inner')\n\nlabels['kshape_fad'] = labels['kshape_cluster'].isin(FAD_CLUSTERS).astype(int)\n\nlabels['fad_label'] = (\n    (labels['pelt_fad'] == 1) & (labels['kshape_fad'] == 1)\n).astype(int)\n\nprint('\\n=== Label Agreement Matrix ===')\nagreement = pd.crosstab(\n    labels['pelt_fad'].map({0: 'PELT=0', 1: 'PELT=1'}),\n    labels['kshape_fad'].map({0: 'kShape=0', 1: 'kShape=1'}),\n    margins=True, margins_name='Total'\n)\nprint(agreement)\n\n\npelt_rate   = labels['pelt_fad'].mean()\nkshape_rate = labels['kshape_fad'].mean()\nhybrid_rate = labels['fad_label'].mean()\n\nprint(f'\\n=== Rates Comparison ===')\nprint(f'PELT alone       : {pelt_rate:.2%}  ({labels[\"pelt_fad\"].sum():>6,} articles)')\nprint(f'k-Shape alone    : {kshape_rate:.2%}  ({labels[\"kshape_fad\"].sum():>6,} articles)')\nprint(f'Hybrid (AND)     : {hybrid_rate:.2%}  ({labels[\"fad_label\"].sum():>6,} articles)')\nprint(f'Phase 1 baseline : 4.10%   (death ≤ 6 weeks estimate)')\n\n\nkappa = cohen_kappa_score(labels['pelt_fad'], labels['kshape_fad'])\nprint(f'\\n=== Cohen\\'s Kappa (Inter-Method Agreement) ===')\nprint(f'  Kappa = {kappa:.3f}')\nif kappa >= 0.60:\n    print('  ✅ Substantial agreement')\nelif kappa >= 0.40:\n    print('  🟡 Moderate agreement')\nelif kappa >= 0.20:\n    print('  🟠 Fair agreement')\nelse:\n    print('  🔴 Weak agreement')\n\n\nprint(f'\\n=== Fad Label Composition by Cluster ===')\nfad_composition = labels[labels['fad_label'] == 1].groupby('kshape_cluster').size()\nfor cluster, count in fad_composition.items():\n    pct = count / labels['fad_label'].sum() * 100\n    peak_w = int(np.argmax(centroids[cluster]))\n    print(f'  Cluster {cluster} (peak@w{peak_w}): {count:>5,} articles ({pct:.1f}% of all Fads)')\n\n\nprint(f'\\n=== Final Fad Temporal Profile ===')\nfinal_fads = labels[labels['fad_label'] == 1]\nif len(final_fads) > 0:\n    print(f'  Peak week   — median: {final_fads[\"peak_week\"].median():.0f}, '\n          f'mean: {final_fads[\"peak_week\"].mean():.1f}')\n    print(f'  Peak ratio  — mean: {final_fads[\"peak_ratio\"].mean():.2f}')\n    print(f'  Drop ratio  — mean: {final_fads[\"drop_ratio\"].mean():.2f}')\n    print(f'  Tail ratio  — mean: {final_fads[\"tail_ratio\"].mean():.3f}')\n\n\nprint(f'\\n=== Final Hybrid Label Distribution ===')\nprint(labels['fad_label'].value_counts())\nprint(f'Final Fad rate: {hybrid_rate:.2%}')\n\n\nprint(f'\\n=== Sanity Check ===')\nif hybrid_rate < 0.02:\n    print('  🔴 ต่ำเกินไป < 2%')\nelif hybrid_rate > 0.15:\n    print('  🟡 สูงกว่าเป้าเล็กน้อย > 15%')\nelif 0.03 <= hybrid_rate <= 0.12:\n    print('  ✅ Good — อยู่ในช่วงที่เหมาะสม')\nelse:\n    print(f'  🟡 Acceptable — {hybrid_rate:.2%}')\n\nprint('\\n Phase 2 complete')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:57:18.176068Z","iopub.execute_input":"2026-05-02T02:57:18.176431Z","iopub.status.idle":"2026-05-02T02:57:18.316863Z","shell.execute_reply.started":"2026-05-02T02:57:18.176407Z","shell.execute_reply":"2026-05-02T02:57:18.315886Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n## Phase 3 — Feature Engineering with PySpark","metadata":{}},{"cell_type":"code","source":"# ── Cell 15: Dynamic Features  ──\n\nEARLY_WINDOW = 5\nMAX_WEEKS = 26\n\n\nearly_spark = (\n    weekly\n    .filter(\n        (F.col('weeks_since_launch') >= 0) &\n        (F.col('weeks_since_launch') <= EARLY_WINDOW)\n    )\n    .groupBy('article_id')\n    .pivot('weeks_since_launch', list(range(EARLY_WINDOW + 1)))\n    .agg(F.first('weekly_sales_clean'))\n    .fillna(0)\n)\n\nearly_cols = early_spark.columns\nearly_spark = early_spark.toDF(\n    *(['article_id'] + [f'sales_w{c}' for c in early_cols[1:]])\n)\n\n\nearly_agg = (\n    weekly\n    .filter(F.col('weeks_since_launch') <= EARLY_WINDOW)\n    .groupBy('article_id')\n    .agg(\n        F.max('weekly_sales_clean').alias('early_peak_sales'),\n        F.sum('weekly_sales_clean').alias('early_total_sales'),\n        F.sum('unique_buyers').alias('early_unique_buyers'),\n        F.max('n_channels').alias('early_max_channels'),\n        F.avg('weekly_sales_clean').alias('early_avg_sales'),\n    )\n)\n\n\nlifecycle_agg = (\n    weekly\n    .filter(F.col('weeks_since_launch') < MAX_WEEKS)\n    .groupBy('article_id')\n    .agg(\n        F.max('weekly_sales_clean').alias('peak_sales'),\n        F.sum('weekly_sales_clean').alias('total_sales_clean'),\n        F.avg('weekly_sales_clean').alias('avg_sales'),\n    )\n)\n\n\nw_peak = Window.partitionBy('article_id').orderBy(F.desc('weekly_sales_clean'))\n\ntime_to_peak = (\n    weekly\n    .filter(F.col('weeks_since_launch') < MAX_WEEKS) \n    .withColumn('rn', F.row_number().over(w_peak))\n    .filter(F.col('rn') == 1)\n    .select('article_id', F.col('weeks_since_launch').alias('time_to_peak'))\n)\n\n\npeak_weeks_pd = time_to_peak.toPandas().set_index('article_id')\n\ndecay_results = []\nfor art_id, row in pivot.iterrows():\n    series = row.values.astype(float)\n    \n    if art_id in peak_weeks_pd.index:\n        pw = int(peak_weeks_pd.loc[art_id, 'time_to_peak'])\n    else:\n        pw = int(np.argmax(series))\n    \n    peak_val = series[pw]\n    \n    if pw < MAX_WEEKS - 3:\n        end_w = pw + 3\n        end_val = series[end_w]\n        decay = (end_val - peak_val) / 3.0\n    elif pw < MAX_WEEKS - 1:\n        end_val = series[-1]\n        decay = (end_val - peak_val) / max(1, MAX_WEEKS - 1 - pw)\n    else:\n        decay = 0.0\n    \n    is_early_peak = int(pw <= EARLY_WINDOW)\n    \n    decay_results.append({\n        'article_id': art_id,\n        'post_peak_decay': round(decay, 4),\n        'is_early_peak_flag': is_early_peak,\n    })\n\ndecay_spark = spark.createDataFrame(pd.DataFrame(decay_results))\n\n\ndyn = (\n    early_spark\n    .join(early_agg,      on='article_id', how='left')\n    .join(lifecycle_agg,  on='article_id', how='left')\n    .join(time_to_peak,   on='article_id', how='left')\n    .join(decay_spark,    on='article_id', how='left')\n    \n    # ── Velocity & Acceleration ──\n    .withColumn('velocity_w1_w0',\n        F.col('sales_w1') / (F.col('sales_w0') + 1.0))\n    .withColumn('velocity_w2_w1',\n        F.col('sales_w2') / (F.col('sales_w1') + 1.0))\n    .withColumn('acceleration',\n        (F.col('sales_w2') - F.col('sales_w1')) - \n        (F.col('sales_w1') - F.col('sales_w0')))\n    \n    # ── Peak ratios ──\n    .withColumn('peak_to_w0_ratio',\n        F.col('peak_sales') / (F.col('sales_w0') + 1.0))\n    .withColumn('early_peak_to_avg',\n        F.col('early_peak_sales') / (F.col('early_avg_sales') + 1.0))\n    \n    # ── Pre-peak velocity ──\n    .withColumn('pre_peak_velocity',\n        F.when(F.col('time_to_peak') > 0,\n            F.col('early_peak_sales') / (F.col('time_to_peak') + 1.0)\n        ).otherwise(F.col('sales_w0')))\n    \n    # ── Early concentration (% of total sales in early window) ──\n    .withColumn('early_sales_concentration',\n        F.col('early_total_sales') / (F.col('total_sales_clean') + 1.0))\n    \n    # ── Buyer intensity ──\n    .withColumn('early_buyer_intensity',\n        F.col('early_unique_buyers') / (F.col('early_total_sales') + 1.0))\n)\n\ndyn.cache()\nn_features = len(dyn.columns) - 1  # minus article_id\n\n# ═══════════════════════════════════════════════════════\n#  Diagnostics\n# ═══════════════════════════════════════════════════════\n\nprint(f' Dynamic features computed.')\nprint(f'   Early window: weeks 0-{EARLY_WINDOW}')\nprint(f'   Features: {n_features}')\n\nprint('\\n=== time_to_peak Distribution (should be 0-25) ===')\ndyn.select(\n    F.min('time_to_peak').alias('min'),\n    F.max('time_to_peak').alias('max'),\n    F.mean('time_to_peak').alias('mean'),\n    F.expr('percentile_approx(time_to_peak, 0.5)').alias('median'),\n).show()\n\nprint('=== post_peak_decay Distribution ===')\ndyn.select(\n    F.mean('post_peak_decay').alias('mean'),\n    F.min('post_peak_decay').alias('min'),\n    F.max('post_peak_decay').alias('max'),\n    F.sum(F.when(F.col('post_peak_decay') != 0, 1).otherwise(0)).alias('non_zero_count'),\n    F.count('*').alias('total'),\n).show()\n\n# Preview\nprint('--- Sample output ---')\ndyn.select('article_id', 'velocity_w1_w0', 'time_to_peak', \n           'post_peak_decay', 'is_early_peak_flag',\n           'early_sales_concentration').show(10)\n\n# Fad-likely articles preview\nn_early_peak = dyn.filter(F.col('is_early_peak_flag') == 1).count()\nprint(f'\\nArticles with early peak (≤ week {EARLY_WINDOW}): {n_early_peak:,} '\n      f'({n_early_peak/dyn.count()*100:.1f}%)')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:57:18.318034Z","iopub.execute_input":"2026-05-02T02:57:18.318322Z","iopub.status.idle":"2026-05-02T02:57:36.083973Z","shell.execute_reply.started":"2026-05-02T02:57:18.318290Z","shell.execute_reply":"2026-05-02T02:57:36.082898Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 16: Gini Coefficient  ──\n\nfrom pyspark.sql.types import DoubleType\n\n@F.udf(DoubleType())\ndef gini_udf(sales_list):\n    \"\"\"Gini coefficient: 0 = equal distribution, 1 = all sales in one week\"\"\"\n    if not sales_list:\n        return 0.0\n    arr = np.sort(np.abs(np.array(sales_list, dtype=float)))\n    n = len(arr)\n    if n == 0 or arr.sum() == 0:\n        return 0.0\n    idx = np.arange(1, n + 1)\n    return float((2 * (idx * arr).sum()) / (n * arr.sum()) - (n + 1) / n)\n\ngini_spark = (\n    weekly.groupBy('article_id')\n    .agg(F.collect_list('weekly_sales_clean').alias('sales_list'))\n    .withColumn('gini_coeff', gini_udf('sales_list'))\n    .select('article_id', 'gini_coeff')\n)\n\ngini_spark.cache()\nprint(' Gini computed.')\n\ngini_pd = gini_spark.toPandas()\nprint(f'   Mean Gini: {gini_pd[\"gini_coeff\"].mean():.3f}')\nprint(f'   Std:  {gini_pd[\"gini_coeff\"].std():.3f}')\ngini_spark.show(3)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:57:36.085238Z","iopub.execute_input":"2026-05-02T02:57:36.085596Z","iopub.status.idle":"2026-05-02T02:57:41.827477Z","shell.execute_reply.started":"2026-05-02T02:57:36.085561Z","shell.execute_reply":"2026-05-02T02:57:41.826617Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 17: Repeat Purchase + Interpurchase CV ──\n\n# ─── Part A: Repeat Purchase Rate (per article) ───\nmulti_buy = (\n    txn.groupBy('article_id', 'customer_id')\n    .agg(F.count('*').alias('n_purchases'))\n)\n\nbuyer_stats = (\n    multi_buy.groupBy('article_id')\n    .agg(\n        F.count('customer_id').alias('total_buyers'),\n        F.sum((F.col('n_purchases') >= 2).cast('int')).alias('repeat_buyers'),\n        F.avg('n_purchases').alias('avg_purchases_per_buyer'),\n    )\n    .withColumn('repeat_purchase_rate',\n        F.col('repeat_buyers') / (F.col('total_buyers') + 1.0))\n)\n\n# ─── Part B: Inter-purchase CV (per article) ───\n\nw_art_txn = Window.partitionBy('article_id').orderBy('t_dat')\n\ncv_spark = (\n    txn\n    .withColumn('row_num', F.row_number().over(w_art_txn))\n    .withColumn('lag_date', F.lag('t_dat', 1).over(w_art_txn))\n    .withColumn('gap_days', F.datediff('t_dat', 'lag_date').cast('double'))\n    .filter(F.col('gap_days').isNotNull())\n    .groupBy('article_id')\n    .agg(\n        F.mean('gap_days').alias('mean_gap'),\n        F.stddev('gap_days').alias('std_gap'),\n        F.count('gap_days').alias('n_gaps'),\n    )\n    .withColumn('interpurchase_cv',\n        F.when(F.col('n_gaps') >= 3, \n            F.col('std_gap') / (F.col('mean_gap') + 1.0)\n        ).otherwise(0.0))\n    .select('article_id', 'mean_gap', 'interpurchase_cv')\n)\n\nbuyer_stats.cache()\ncv_spark.cache()\nprint(' Buyer behavior features done.')\nprint(f'   Buyer stats rows: {buyer_stats.count():,}')\nprint(f'   CV stats rows: {cv_spark.count():,}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:57:41.828510Z","iopub.execute_input":"2026-05-02T02:57:41.828888Z","iopub.status.idle":"2026-05-02T02:59:40.397076Z","shell.execute_reply.started":"2026-05-02T02:57:41.828853Z","shell.execute_reply":"2026-05-02T02:59:40.396120Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 18: Static Features + Target Encoding ──\n\n# ─── Part A: Select static features ───\nart_feat_spark = art.select(\n    'article_id',\n    'product_type_no',           \n    'department_no',             \n    'graphical_appearance_no',   \n    'colour_group_code',         \n    'perceived_colour_value_id',\n    'perceived_colour_master_id',\n    'index_code',                \n    'index_group_no',\n    'section_no',\n    'garment_group_no',          \n)\n\n# ─── Part B: Target Encoding for high-cardinality features ───\nlabels_spark = spark.createDataFrame(\n    labels[['article_id', 'fad_label']].astype({'fad_label': 'int32'})\n)\n\n# Target encoding: department_no\ndept_target = (\n    art_feat_spark\n    .join(labels_spark, 'article_id', 'inner')\n    .groupBy('department_no')\n    .agg(\n        F.mean('fad_label').alias('dept_fad_rate'),\n        F.count('*').alias('dept_count'),\n    )\n)\n\n# Target encoding: product_type_no\nptype_target = (\n    art_feat_spark\n    .join(labels_spark, 'article_id', 'inner')\n    .groupBy('product_type_no')\n    .agg(\n        F.mean('fad_label').alias('ptype_fad_rate'),\n        F.count('*').alias('ptype_count'),\n    )\n)\n\n# Smoothed target encoding: smooth toward global mean for rare categories\nglobal_fad_rate = labels['fad_label'].mean()\nSMOOTH_FACTOR = 20  \n\ndept_target = dept_target.withColumn('dept_target_enc',\n    (F.col('dept_fad_rate') * F.col('dept_count') + global_fad_rate * SMOOTH_FACTOR) /\n    (F.col('dept_count') + SMOOTH_FACTOR)\n)\n\nptype_target = ptype_target.withColumn('ptype_target_enc',\n    (F.col('ptype_fad_rate') * F.col('ptype_count') + global_fad_rate * SMOOTH_FACTOR) /\n    (F.col('ptype_count') + SMOOTH_FACTOR)\n)\n\n# Join target encodings back\nart_feat_spark = (\n    art_feat_spark\n    .join(dept_target.select('department_no', 'dept_target_enc'), 'department_no', 'left')\n    .join(ptype_target.select('product_type_no', 'ptype_target_enc'), 'product_type_no', 'left')\n)\n\n# ─── Part C: Interaction features ───\n# All-over pattern × bright colour\nart_feat_spark = (\n    art_feat_spark\n    .withColumn('is_allover_pattern',\n        (F.col('graphical_appearance_no').isin([74])).cast('int'))\n    .withColumn('is_bright_colour',\n        (F.col('perceived_colour_value_id').isin([3, 4])).cast('int'))\n    .withColumn('allover_x_bright',\n        F.col('is_allover_pattern') * F.col('is_bright_colour'))\n)\n\nart_feat_spark.cache()\nprint(' Static features done.')\nprint(f'   Columns: {len(art_feat_spark.columns)}')\nprint(f'   Target encoding: department_no, product_type_no (smoothed, factor={SMOOTH_FACTOR})')\nprint(f'   Global fad rate for smoothing: {global_fad_rate:.4f}')\n\n# Preview target encoding\nprint('\\n--- Department Target Encoding (top 5 highest Fad rate) ---')\ndept_target.orderBy(F.desc('dept_fad_rate')).show(5)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:59:40.398345Z","iopub.execute_input":"2026-05-02T02:59:40.398759Z","iopub.status.idle":"2026-05-02T02:59:42.385000Z","shell.execute_reply.started":"2026-05-02T02:59:40.398722Z","shell.execute_reply":"2026-05-02T02:59:42.384019Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 19: Merge All Features ──\n\n# ─── Part A: Stockout features per article ───\nstockout_features = (\n    weekly\n    .groupBy('article_id')\n    .agg(\n        F.sum('is_stockout').alias('n_stockout_weeks'),\n        F.max('stockout_tier').alias('max_stockout_tier'),\n        F.sum(\n            F.when(\n                (F.col('weeks_since_launch') <= EARLY_WINDOW) & (F.col('is_stockout') == 1), 1\n            ).otherwise(0)\n        ).alias('early_stockout_count'),\n    )\n)\n\n# ─── Part B: Labels ───\nlabels_spark = spark.createDataFrame(\n    labels[['article_id', 'fad_label', 'peak_week', 'kshape_cluster',\n            'peak_ratio', 'drop_ratio', 'tail_ratio']].copy()\n)\n\n# ─── Part C: Regime info ───\nregime_spark = total_sales_df.select('article_id', 'regime', 'strat_key', 'total_sales')\n\n# ─── Part D: Merge everything ───\nfeatures_spark = (\n    labels_spark.select('article_id', 'fad_label')  \n    .join(dyn,              on='article_id', how='left')  \n    .join(gini_spark,       on='article_id', how='left')  \n    .join(buyer_stats.select('article_id', 'total_buyers', \n                             'repeat_purchase_rate', 'avg_purchases_per_buyer'),\n                            on='article_id', how='left')   \n    .join(cv_spark,         on='article_id', how='left')  \n    .join(art_feat_spark,   on='article_id', how='left')   \n    .join(stockout_features,on='article_id', how='left')   \n    .join(regime_spark,     on='article_id', how='left')  \n    .fillna(0)\n)\n\nfeatures_spark.cache()\nn_rows = features_spark.count()\nn_cols = len(features_spark.columns)\n\nprint(f' Feature matrix assembled.')\nprint(f'   Rows: {n_rows:,}')\nprint(f'   Columns: {n_cols}')\n\n# ─── Diagnostic: Feature categories ───\nfeature_cols = [c for c in features_spark.columns if c not in ['article_id', 'fad_label']]\nprint(f'   Feature columns: {len(feature_cols)}')\n\n# Check for unexpected nulls\nnull_counts = features_spark.select([\n    F.sum(F.col(c).isNull().cast('int')).alias(c) for c in feature_cols\n]).collect()[0].asDict()\nnulls = {k: v for k, v in null_counts.items() if v > 0}\nif nulls:\n    print(f'\\n   ⚠️  Columns with nulls after fillna: {nulls}')\nelse:\n    print(f'   ✅ No nulls remaining')\n\n# ─── Collect to Pandas ───\nfeatures = features_spark.toPandas()\nprint(f'\\n   Pandas shape: {features.shape}')\n\n# ─── Label distribution in final feature matrix ───\nprint(f'\\n=== Label Distribution ===')\nprint(features['fad_label'].value_counts())\nprint(f'Fad rate: {features[\"fad_label\"].mean():.2%}')\n\n# ─── Feature summary by type ───\nprint(f'\\n=== Feature Summary ===')\ndynamic_cols = ['sales_w0','sales_w1','sales_w2','sales_w3','sales_w4','sales_w5',\n                'velocity_w1_w0','velocity_w2_w1','acceleration',\n                'peak_to_w0_ratio','early_peak_to_avg','post_peak_decay',\n                'pre_peak_velocity','early_sales_concentration','early_buyer_intensity',\n                'time_to_peak','early_peak_sales','early_total_sales',\n                'early_unique_buyers','early_max_channels','early_avg_sales',\n                'peak_sales','total_sales_clean','avg_sales']\nbehavioral_cols = ['gini_coeff','total_buyers','repeat_purchase_rate',\n                   'avg_purchases_per_buyer','interpurchase_cv','mean_gap']\nstatic_cols = ['product_type_no','department_no','graphical_appearance_no',\n               'colour_group_code','perceived_colour_value_id','perceived_colour_master_id',\n               'index_code','index_group_no','section_no','garment_group_no',\n               'dept_target_enc','ptype_target_enc',\n               'is_allover_pattern','is_bright_colour','allover_x_bright']\noperational_cols = ['n_stockout_weeks','max_stockout_tier','early_stockout_count']\n\nfor name, cols in [('Dynamic (early signals)', dynamic_cols),\n                    ('Behavioral (buyer)', behavioral_cols),\n                    ('Static (article metadata)', static_cols),\n                    ('Operational (stockout)', operational_cols)]:\n    present = [c for c in cols if c in features.columns]\n    print(f'  {name}: {len(present)} features')\n\nprint(f'\\n Phase 3 complete')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T02:59:42.386468Z","iopub.execute_input":"2026-05-02T02:59:42.386792Z","iopub.status.idle":"2026-05-02T03:00:00.479333Z","shell.execute_reply.started":"2026-05-02T02:59:42.386759Z","shell.execute_reply":"2026-05-02T03:00:00.478224Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n## Phase 4 — Feature Selection","metadata":{}},{"cell_type":"code","source":"# ── Cell 20: Feature Selection (MI + Correlation + RFE) ──\n\nfrom sklearn.feature_selection import RFE, mutual_info_classif\nimport lightgbm as lgb\n\n# ─── Step 0: Prepare X and y ───\nDROP_COLS = [\n    'article_id', 'fad_label',             \n    'regime', 'strat_key', 'total_sales',   \n]\n\nexisting_drops = [c for c in DROP_COLS if c in features.columns]\nprint(f'Dropping {len(existing_drops)} non-feature columns: {existing_drops}')\n\nX_df = features.drop(columns=existing_drops, errors='ignore').copy()\ny_all = features['fad_label'].values\n\ncat_cols = X_df.select_dtypes(include=['object', 'category']).columns.tolist()\nif cat_cols:\n    print(f'Encoding {len(cat_cols)} categorical columns: {cat_cols}')\n    for col in cat_cols:\n        X_df[col] = X_df[col].astype('category').cat.codes\n\nprint(f'\\nStarting feature selection: {X_df.shape[1]} features, {len(y_all):,} samples')\nprint(f'Fad rate: {y_all.mean():.2%}')\n\n# ═══════════════════════════════════════════════════════\n#  Step 1: Mutual Information\n# ═══════════════════════════════════════════════════════\n\nprint('\\n' + '='*50)\nprint('Step 1: Mutual Information')\nprint('='*50)\n\nmi = mutual_info_classif(X_df, y_all, random_state=SEED, n_neighbors=5)\nmi_df = pd.DataFrame({\n    'feature': X_df.columns, \n    'mi': mi\n}).sort_values('mi', ascending=False)\n\n# Threshold: keep features with MI > 0.001\nMI_THRESH = 0.001\nsel_mi = mi_df.loc[mi_df['mi'] >= MI_THRESH, 'feature'].tolist()\ndropped_mi = mi_df.loc[mi_df['mi'] < MI_THRESH, 'feature'].tolist()\n\nprint(f'MI threshold: {MI_THRESH}')\nprint(f'Features kept: {len(sel_mi)}')\nif dropped_mi:\n    print(f'Features dropped (no predictive power): {dropped_mi}')\n\n# Visualization: MI scores\nfig, ax = plt.subplots(figsize=(10, max(6, len(mi_df) * 0.3)))\ncolors = ['crimson' if mi >= MI_THRESH else 'lightgray' for mi in mi_df['mi']]\nax.barh(range(len(mi_df)), mi_df['mi'].values, color=colors)\nax.set_yticks(range(len(mi_df)))\nax.set_yticklabels(mi_df['feature'].values, fontsize=8)\nax.axvline(MI_THRESH, color='red', linestyle='--', alpha=0.7, label=f'Threshold={MI_THRESH}')\nax.set_xlabel('Mutual Information')\nax.set_title('Step 1: Mutual Information Scores\\n(red = kept, gray = dropped)', fontweight='bold')\nax.legend()\nax.invert_yaxis()\nplt.tight_layout()\nplt.show()\n\n# Top 10 features by MI\nprint('\\nTop 10 features by MI:')\nprint(mi_df.head(10).to_string(index=False))\n\n# ═══════════════════════════════════════════════════════\n#  Step 2: Correlation Pruning\n# ═══════════════════════════════════════════════════════\n\nprint('\\n' + '='*50)\nprint('Step 2: Correlation Pruning')\nprint('='*50)\n\nCORR_THRESH = 0.85\n\ncorr = X_df[sel_mi].astype('float32').corr().abs()\nupper = corr.where(np.triu(np.ones(corr.shape), k=1).astype(bool))\n\n# For each pair with corr > threshold, drop the one with LOWER MI\nto_drop_corr = set()\nfor col in upper.columns:\n    high_corr_cols = upper.index[upper[col] > CORR_THRESH].tolist()\n    for hc in high_corr_cols:\n        # Keep the one with higher MI\n        mi_col = mi_df.set_index('feature').loc[col, 'mi']\n        mi_hc = mi_df.set_index('feature').loc[hc, 'mi']\n        drop_this = hc if mi_col >= mi_hc else col\n        to_drop_corr.add(drop_this)\n\nsel_corr = [c for c in sel_mi if c not in to_drop_corr]\n\nprint(f'Correlation threshold: {CORR_THRESH}')\nprint(f'Features kept: {len(sel_corr)}')\nif to_drop_corr:\n    print(f'Features dropped (redundant): {sorted(to_drop_corr)}')\n\n# Visualization: Correlation heatmap of remaining features\nfig, ax = plt.subplots(figsize=(12, 10))\ncorr_final = X_df[sel_corr].astype('float32').corr()\nmask = np.triu(np.ones_like(corr_final), k=1)\nsns.heatmap(corr_final, mask=mask, cmap='RdBu_r', center=0, \n            vmin=-1, vmax=1, annot=False, square=True,\n            linewidths=0.5, ax=ax, cbar_kws={'shrink': 0.8})\nax.set_title(f'Step 2: Correlation Matrix ({len(sel_corr)} features after pruning)',\n             fontweight='bold')\nplt.tight_layout()\nplt.show()\n\n# ═══════════════════════════════════════════════════════\n#  Step 3: RFE with LightGBM\n# ═══════════════════════════════════════════════════════\n\nprint('\\n' + '='*50)\nprint('Step 3: Recursive Feature Elimination (RFE)')\nprint('='*50)\n\nX_rfe = X_df[sel_corr]\n\n# Target: select 15-20 features (lean but powerful)\nN_TARGET = min(20, len(sel_corr))\n\nmodel = lgb.LGBMClassifier(\n    n_estimators=200,\n    max_depth=6,\n    learning_rate=0.05,\n    random_state=SEED,\n    class_weight='balanced',\n    verbose=-1,\n    n_jobs=-1,\n)\n\nrfe = RFE(\n    estimator=model,\n    n_features_to_select=N_TARGET,\n    step=2,\n    verbose=0,\n)\n\nrfe.fit(X_rfe, y_all)\n\nFINAL_FEATURES = [f for f, s in zip(sel_corr, rfe.support_) if s]\nrfe_rankings = pd.DataFrame({\n    'feature': sel_corr,\n    'rfe_rank': rfe.ranking_,\n    'selected': rfe.support_,\n}).sort_values('rfe_rank')\n\nprint(f'Target features: {N_TARGET}')\nprint(f'Final features: {len(FINAL_FEATURES)}')\n\n# Show RFE rankings\nprint('\\nRFE Rankings (1 = selected):')\nprint(rfe_rankings.to_string(index=False))\n\n# ═══════════════════════════════════════════════════════\n#  Step 4: Summary & Comparison\n# ═══════════════════════════════════════════════════════\n\nprint('\\n' + '='*50)\nprint('FINAL FEATURE SET')\nprint('='*50)\n\n# Combine MI and RFE info\nfinal_info = (\n    mi_df[mi_df['feature'].isin(FINAL_FEATURES)]\n    .merge(rfe_rankings[rfe_rankings['selected']], on='feature')\n    .sort_values('mi', ascending=False)\n)\n\nprint(f'\\n{len(FINAL_FEATURES)} features selected:')\nprint(final_info[['feature', 'mi', 'rfe_rank']].to_string(index=False))\n\n# Categorize final features\ndynamic_final = [f for f in FINAL_FEATURES if any(f.startswith(p) for p in \n    ['sales_w', 'velocity', 'acceleration', 'peak_', 'early_', 'time_to', 'post_peak', 'pre_peak', 'is_early'])]\nbehavioral_final = [f for f in FINAL_FEATURES if f in \n    ['gini_coeff', 'total_buyers', 'repeat_purchase_rate', 'avg_purchases_per_buyer', 'interpurchase_cv', 'mean_gap']]\nstatic_final = [f for f in FINAL_FEATURES if f in \n    ['product_type_no', 'department_no', 'graphical_appearance_no', 'colour_group_code',\n     'perceived_colour_value_id', 'perceived_colour_master_id', 'index_code', 'index_group_no',\n     'section_no', 'garment_group_no', 'dept_target_enc', 'ptype_target_enc',\n     'is_allover_pattern', 'is_bright_colour', 'allover_x_bright']]\noperational_final = [f for f in FINAL_FEATURES if f in \n    ['n_stockout_weeks', 'max_stockout_tier', 'early_stockout_count']]\n\nprint(f'\\nFeature breakdown:')\nprint(f'  Dynamic (early signals)     : {len(dynamic_final)}  {dynamic_final}')\nprint(f'  Behavioral (buyer)          : {len(behavioral_final)}  {behavioral_final}')\nprint(f'  Static (article metadata)   : {len(static_final)}  {static_final}')\nprint(f'  Operational (stockout)       : {len(operational_final)}  {operational_final}')\n\n# Visualization: Final feature importance (MI)\nfig, ax = plt.subplots(figsize=(10, max(5, len(FINAL_FEATURES) * 0.35)))\nfinal_sorted = final_info.sort_values('mi', ascending=True)\n\n# Color by category\ndef get_color(f):\n    if f in dynamic_final: return 'steelblue'\n    if f in behavioral_final: return 'coral'\n    if f in static_final: return 'mediumseagreen'\n    if f in operational_final: return 'gold'\n    return 'gray'\n\ncolors = [get_color(f) for f in final_sorted['feature']]\nax.barh(range(len(final_sorted)), final_sorted['mi'].values, color=colors)\nax.set_yticks(range(len(final_sorted)))\nax.set_yticklabels(final_sorted['feature'].values, fontsize=9)\nax.set_xlabel('Mutual Information')\nax.set_title(f'Final {len(FINAL_FEATURES)} Features by MI Score', fontweight='bold')\n\n# Legend\nfrom matplotlib.patches import Patch\nlegend_items = [\n    Patch(color='steelblue', label='Dynamic'),\n    Patch(color='coral', label='Behavioral'),\n    Patch(color='mediumseagreen', label='Static'),\n    Patch(color='gold', label='Operational'),\n]\nax.legend(handles=legend_items, loc='lower right')\nplt.tight_layout()\nplt.show()\n\n# ═══════════════════════════════════════════════════════\n#  Prepare final X, y\n# ═══════════════════════════════════════════════════════\n\nX = X_df[FINAL_FEATURES].copy()\ny = y_all\n\nprint(f'\\n Phase 4 complete')\nprint(f'   X shape: {X.shape}')\nprint(f'   y shape: {y.shape}')\nprint(f'   Fad rate: {y.mean():.2%}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T03:00:00.484355Z","iopub.execute_input":"2026-05-02T03:00:00.484838Z","iopub.status.idle":"2026-05-02T03:00:36.621586Z","shell.execute_reply.started":"2026-05-02T03:00:00.484806Z","shell.execute_reply":"2026-05-02T03:00:36.620771Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 20.5: Export ──\nimport os, json\n\nOUTPUT_DIR = '/kaggle/working/processed_data'\nos.makedirs(OUTPUT_DIR, exist_ok=True)\n\nexport_df = features_spark.select(\n    'article_id', 'fad_label', *FINAL_FEATURES\n).toPandas()\n\n# 1. Save features\nexport_df.to_parquet(f'{OUTPUT_DIR}/features_final.parquet', index=False)\nexport_df.to_csv(f'{OUTPUT_DIR}/features_final.csv', index=False)\nprint(f'✅ Feature matrix: {export_df.shape}')\n\n# 2. Save labels\nlabels.to_parquet(f'{OUTPUT_DIR}/labels_hybrid.parquet', index=False)\nlabels.to_csv(f'{OUTPUT_DIR}/labels_hybrid.csv', index=False)\nprint(f'✅ Labels: {labels.shape}')\n\n# 3. Save pivot (for LSTM)\npivot.to_parquet(f'{OUTPUT_DIR}/pivot_26weeks.parquet')\nprint(f'✅ Pivot: {pivot.shape}')\n\n# 4. Save constants\nconstants = {\n    'FINAL_FEATURES': FINAL_FEATURES,\n    'EARLY_WINDOW': EARLY_WINDOW,\n    'MAX_WEEKS': MAX_WEEKS,\n    'KSHAPE_K': BEST_K,\n    'FAD_CLUSTERS': FAD_CLUSTERS,\n    'FINAL_FAD_RATE': float(labels['fad_label'].mean()),\n    'n_articles': len(export_df),\n    'n_features': len(FINAL_FEATURES),\n}\n\nwith open(f'{OUTPUT_DIR}/pipeline_constants.json', 'w') as f:\n    json.dump(constants, f, indent=2)\nprint(f'✅ Constants: {len(constants)} items')\n\n# 5. Save MI scores\nmi_df.to_csv(f'{OUTPUT_DIR}/mi_scores.csv', index=False)\nprint(f'✅ MI scores')\n\n# Summary\nprint(f'\\n{\"=\"*50}')\nfor f_name in sorted(os.listdir(OUTPUT_DIR)):\n    size = os.path.getsize(f'{OUTPUT_DIR}/{f_name}') / 1024 / 1024\n    print(f'  {f_name:40s} {size:.1f} MB')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T03:00:36.622678Z","iopub.execute_input":"2026-05-02T03:00:36.623018Z","iopub.status.idle":"2026-05-02T03:00:41.172926Z","shell.execute_reply.started":"2026-05-02T03:00:36.622993Z","shell.execute_reply":"2026-05-02T03:00:41.171993Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"![spark job.png](attachment:737c20f5-44a1-490d-bb76-54a40a36b2ba.png)","metadata":{},"attachments":{"737c20f5-44a1-490d-bb76-54a40a36b2ba.png":{"image/png":"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"}}},{"cell_type":"markdown","source":"### GCP Infrastructure\n\n|  | Master Node | Worker Node |\n|-------|------|-------------|\n| Number of nodes | 1 | 2 |\n| Machine Type | n1-standard-8 | n1-standard-8 |\n| vCPU | 8 | 8 |\n| core | 4 | 4 |\n| Memory | 30GB | 30GB |\n| Storage | 50GB | 100GB |\n","metadata":{}},{"cell_type":"markdown","source":"---\n## Phase 5 — Supervised Model","metadata":{}},{"cell_type":"markdown","source":"### LSTM model","metadata":{}},{"cell_type":"code","source":"\nimport os\nimport re\nimport json\nimport random\nfrom pathlib import Path\n\nimport numpy as np\nimport optuna\nfrom imblearn.over_sampling import SMOTE\nimport pandas as pd\n\nimport tensorflow as tf\nfrom tensorflow.keras import layers, models, regularizers, callbacks\n\nfrom sklearn.model_selection import train_test_split, StratifiedKFold\nfrom sklearn.utils.class_weight import compute_class_weight\nfrom sklearn.metrics import (\n    accuracy_score,\n    precision_score,\n    recall_score,\n    f1_score,\n    roc_auc_score,\n    average_precision_score,\n    confusion_matrix,\n    classification_report\n)\n\nSEED = 42\n\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntf.keras.utils.set_random_seed(SEED)\n\nprint(\"TensorFlow:\", tf.__version__)\nprint(\"GPU:\", tf.config.list_physical_devices(\"GPU\"))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T03:00:41.174754Z","iopub.execute_input":"2026-05-02T03:00:41.175012Z","iopub.status.idle":"2026-05-02T03:00:41.831729Z","shell.execute_reply.started":"2026-05-02T03:00:41.174990Z","shell.execute_reply":"2026-05-02T03:00:41.830624Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"#### Data Preparation for LSTM","metadata":{}},{"cell_type":"code","source":"# Importing the data from clustering on GCP\nWORK_DIR = \"/kaggle/working/processed_data\"\n\nfeatures_df = pd.read_parquet(f\"{WORK_DIR}/features_final.parquet\")\npivot_df = pd.read_parquet(f\"{WORK_DIR}/pivot_26weeks.parquet\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T03:00:41.832927Z","iopub.execute_input":"2026-05-02T03:00:41.833272Z","iopub.status.idle":"2026-05-02T03:00:41.972210Z","shell.execute_reply.started":"2026-05-02T03:00:41.833237Z","shell.execute_reply":"2026-05-02T03:00:41.971200Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"features_df.info()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T03:00:41.973349Z","iopub.execute_input":"2026-05-02T03:00:41.974470Z","iopub.status.idle":"2026-05-02T03:00:41.997753Z","shell.execute_reply.started":"2026-05-02T03:00:41.974421Z","shell.execute_reply":"2026-05-02T03:00:41.997077Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"pivot_df.info()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T03:00:41.998692Z","iopub.execute_input":"2026-05-02T03:00:41.999039Z","iopub.status.idle":"2026-05-02T03:00:42.012569Z","shell.execute_reply.started":"2026-05-02T03:00:41.999003Z","shell.execute_reply":"2026-05-02T03:00:42.011620Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Data Preparation for LSTM\nfeatures_df[\"article_id\"] = features_df[\"article_id\"].astype(str)\n\npivot_df = pivot_df.reset_index()\npivot_df.rename(columns={pivot_df.columns[0]: \"article_id\"}, inplace=True)\npivot_df[\"article_id\"] = pivot_df[\"article_id\"].astype(str)\n\nMAX_WEEKS = 26\nweek_cols = [f\"w{i}\" for i in range(MAX_WEEKS) if f\"w{i}\" in pivot_df.columns]\n\nlabel_df = features_df[[\"article_id\", \"fad_label\"]].copy()\nlabel_df[\"fad_label\"] = label_df[\"fad_label\"].astype(int)\n\n# Inner join pivot_df and label from features_df\ndata = pivot_df[[\"article_id\"] + week_cols].merge(\n    label_df,\n    on=\"article_id\",\n    how=\"inner\"\n)\n\nprint(\"Merged data shape:\", data.shape)\nprint(data[\"fad_label\"].value_counts())\n\n# Prepare arrays\nX_seq = data[week_cols].astype(\"float32\").values\n\n# Reshape for LSTM: (Samples, TimeSteps, Features)\nX_seq = X_seq.reshape(X_seq.shape[0], X_seq.shape[1], 1)\ny = data[\"fad_label\"].values.astype(int)\narticle_ids = data[\"article_id\"].values\n\nprint(\"\\nX_seq shape:\", X_seq.shape)\nprint(\"y shape:\", y.shape)\n\n# stratify train test split 70/15/15\nidx = np.arange(len(y))\ntrain_val_idx, test_idx = train_test_split(\n    idx,\n    test_size=0.15,\n    random_state=SEED,\n    stratify=y\n)\n\ntrain_idx, val_idx = train_test_split(\n    train_val_idx,\n    test_size=0.1765,\n    random_state=SEED,\n    stratify=y[train_val_idx]\n)\n\nX_train, y_train = X_seq[train_idx], y[train_idx]\nX_val, y_val = X_seq[val_idx], y[val_idx]\nX_test, y_test = X_seq[test_idx], y[test_idx]","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T03:00:42.013719Z","iopub.execute_input":"2026-05-02T03:00:42.014050Z","iopub.status.idle":"2026-05-02T03:00:42.182042Z","shell.execute_reply.started":"2026-05-02T03:00:42.014005Z","shell.execute_reply":"2026-05-02T03:00:42.181302Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"#### Default LSTM","metadata":{}},{"cell_type":"code","source":"# Default LSTM\ntf.keras.backend.clear_session()\ntf.keras.utils.set_random_seed(42)\n\nn_steps = X_train.shape[1]\nlstm_units = 32\n\n# Building Architecture\ninputs = layers.Input(shape=(n_steps, 1))\nx = layers.LSTM(units=lstm_units)(inputs)\n# Randomly drop 20% of the connections to prevent overfitting\nx = layers.Dropout(0.2)(x)\noutputs = layers.Dense(1, activation=\"sigmoid\")(x)\n\nmodel = models.Model(inputs, outputs)\n\n# Compile Model\nmodel.compile(\n    optimizer=\"adam\",\n    loss=\"binary_crossentropy\",\n    metrics=[\n        tf.keras.metrics.AUC(curve=\"PR\", name=\"pr_auc\"),\n        tf.keras.metrics.AUC(curve=\"ROC\", name=\"roc_auc\")\n    ]\n)\n\nmodel.summary()\n\n# Fit the model\nhistory = model.fit(\n    X_train, \n    y_train,\n    validation_data=(X_val, y_val),\n    epochs=50,\n    batch_size=512,\n    verbose=0\n)\n\n# Predict on test set\ntest_prob = model.predict(X_test, batch_size=1024).ravel()\n\n# Convert Probability to Binary Classification with >50% threshold for calculate F1-score\nthreshold = 0.5 \ntest_pred = (test_prob >= threshold).astype(int)\n\n# F1-score Evaluation on test set\nprint(f\"Test F1-Score: {f1_score(y_test, test_pred, zero_division=0):.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:54:15.183800Z","iopub.execute_input":"2026-05-02T04:54:15.184331Z","iopub.status.idle":"2026-05-02T04:55:04.094364Z","shell.execute_reply.started":"2026-05-02T04:54:15.184299Z","shell.execute_reply":"2026-05-02T04:55:04.093440Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"#### Hyperparameter Tuning with Optuna & Refit best model\n- Dynamic SMOTE คือการ Apply SMOTE เฉพาะใน training set ของ FOLD นั้นๆ เพื่อป้องกัน data leakage\n- ไม่ใช้ training set ที่ถูก SMOTE เพราะจะทำให้เกิดการ leakage","metadata":{}},{"cell_type":"code","source":"# Hyperparameter Tuning using Optuna\n# Merge train and val indices BEFORE SMOTE for doing Stratified K-Fold CV\nX_cv = X_seq[train_val_idx]\ny_cv = y[train_val_idx]\n\n# Define Optuna function\ndef objective(trial):\n    # Set parameters space\n    lstm_units = trial.suggest_int('lstm_units', 16, 128, step=16)\n    lr = trial.suggest_float('learning_rate', 1e-4, 1e-2, log=True)\n\n    n_splits = 3  \n    skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42)\n    fold_scores = []\n    \n    for fold, (tr_idx, va_idx) in enumerate(skf.split(X_cv, y_cv)):\n        tf.keras.backend.clear_session()\n        tf.keras.utils.set_random_seed(42)\n        \n        # Split data for the fold\n        X_tr, y_tr = X_cv[tr_idx], y_cv[tr_idx]\n        X_va, y_va = X_cv[va_idx], y_cv[va_idx]\n        \n        # Apply Dynamic SMOTE for protecting data leakage\n        samples, timesteps, features = X_tr.shape\n        X_tr_smote_2d, y_tr_smote = smote.fit_resample(X_tr.reshape(samples, -1), y_tr)\n        X_tr_smote = X_tr_smote_2d.reshape(-1, timesteps, features)\n        \n        # Building Architecture\n        inputs = layers.Input(shape=(timesteps, features))\n        x = layers.LSTM(units=lstm_units)(inputs)\n        x = layers.Dropout(0.2)(x)\n        outputs = layers.Dense(1, activation=\"sigmoid\")(x)\n        model = models.Model(inputs, outputs)\n\n        model.compile(\n            optimizer=tf.keras.optimizers.Adam(learning_rate=lr),\n            loss=\"binary_crossentropy\",\n            metrics=[tf.keras.metrics.AUC(curve=\"PR\", name=\"pr_auc\")]\n        )\n\n        early_stop = callbacks.EarlyStopping(\n            monitor=\"val_pr_auc\", mode=\"max\", patience=3, restore_best_weights=True\n        )\n\n        # Fit on Dynamic SMOTE data\n        history = model.fit(\n            X_tr_smote, y_tr_smote,\n            validation_data=(X_va, y_va),\n            epochs=25, \n            batch_size=512,\n            callbacks=[early_stop],\n            verbose=0\n        )\n\n        best_fold_pr_auc = max(history.history['val_pr_auc'])\n        fold_scores.append(best_fold_pr_auc)\n\n    return np.mean(fold_scores)\n\n# Run Optuna Study\nstudy = optuna.create_study(direction=\"maximize\", sampler=optuna.samplers.TPESampler(seed=42))\nstudy.optimize(objective, n_trials=1)\n\nprint(\"\\nBest trial:\")\nbest_trial = study.best_trial\nprint(f\"  Best Avg CV PR-AUC: {best_trial.value:.4f}\")\nfor key, value in best_trial.params.items():\n    print(f\"    {key}: {value}\")\n\n\n# Retrain best model\ntf.keras.backend.clear_session()\ntf.keras.utils.set_random_seed(42)\n\nbest_units = best_trial.params['lstm_units']\nbest_lr = best_trial.params['learning_rate']","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T03:02:01.311262Z","iopub.execute_input":"2026-05-02T03:02:01.311698Z","iopub.status.idle":"2026-05-02T03:03:29.633300Z","shell.execute_reply.started":"2026-05-02T03:02:01.311655Z","shell.execute_reply":"2026-05-02T03:03:29.632431Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"#### Best trial (n_trials = 10):\n- Best Avg CV PR-AUC: 0.9153\n- lstm_units: 80\n- learning_rate: 0.0026070247583707684","metadata":{}},{"cell_type":"code","source":"# Retrain best LSTM model\nbest_units = 80\nbest_lr = 0.0026070247583707684\n\n# Apply SMOTE to X_train\nsamples, timesteps, features = X_train.shape\nX_train_smote_2d, y_train_smote = smote.fit_resample(X_train.reshape(samples, -1), y_train)\nX_train_smote = X_train_smote_2d.reshape(-1, timesteps, features)\n\n# Rebuild Final Model\ninputs = layers.Input(shape=(timesteps, features))\nx = layers.LSTM(units=best_units)(inputs)\nx = layers.Dropout(0.2)(x)\noutputs = layers.Dense(1, activation=\"sigmoid\")(x)\nfinal_model = models.Model(inputs, outputs)\n\nfinal_model.compile(\n    optimizer=tf.keras.optimizers.Adam(learning_rate=best_lr),\n    loss=\"binary_crossentropy\",\n    metrics=[tf.keras.metrics.AUC(curve=\"PR\", name=\"pr_auc\")]\n)\n\nfinal_early_stop = callbacks.EarlyStopping(\n    monitor=\"val_pr_auc\", \n    mode=\"max\", \n    patience=8, \n    restore_best_weights=True,\n    verbose=0\n)\n\n# Train on the SMOTE X_train, validate on X_val\nfinal_history = final_model.fit(\n    X_train_smote, y_train_smote,\n    validation_data=(X_val, y_val),\n    epochs=50,\n    batch_size=512,\n    callbacks=[final_early_stop],\n    verbose=0\n)\n\n# Predict on Test Set and Evaluate\ntest_prob = final_model.predict(X_test, batch_size=1024, verbose=0).ravel()\ntest_pred = (test_prob >= 0.5).astype(int)\n\nprint(f\"\\nFinal LSTM Test F1-Score: {f1_score(y_test, test_pred, zero_division=0):.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:59:15.020308Z","iopub.execute_input":"2026-05-02T04:59:15.021200Z","iopub.status.idle":"2026-05-02T05:00:22.192941Z","shell.execute_reply.started":"2026-05-02T04:59:15.021169Z","shell.execute_reply":"2026-05-02T05:00:22.192066Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"#### LSTM OOF prediction (unbiased prediction) for Stacking model\n- แบ่ง training set ออกเป็น k folds\n- train LSTM with best parameters บน k-1 folds และ predict บน 1 fold ที่เหลือ\n- Loop จน predict ครบทั้ง training set เพื่อนำไป train Stacking Model","metadata":{}},{"cell_type":"code","source":"# Set up OOF with data BEFORE SMOTE\nX_train_pure = X_seq[train_idx]\ny_train_pure = y[train_idx]\n\nN_FOLDS = 5\nskf = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=42)\n\n# Array for (unbiased) predictions of training set\noof_lstm_prob = np.zeros(len(y_train_pure), dtype=np.float32)\n\n# Initialize SMOTE for the loop\nsmote = SMOTE(random_state=42)\n\n# Predict K-Fold training set without data leakage\nfor fold, (tr_idx, va_idx) in enumerate(skf.split(X_train_pure, y_train_pure), start=1):\n    print(f\"\\n  Training Fold {fold} / {N_FOLDS}\")\n    \n    # Split data for this fold\n    X_tr, y_tr = X_train_pure[tr_idx], y_train_pure[tr_idx]\n    X_va, y_va = X_train_pure[va_idx], y_train_pure[va_idx]\n    \n    # Apply Dynamic SMOTE\n    samples, timesteps, features = X_tr.shape\n    X_tr_2d = X_tr.reshape(samples, timesteps * features)\n    \n    X_tr_smote_2d, y_tr_smote = smote.fit_resample(X_tr_2d, y_tr)\n    X_tr_smote = X_tr_smote_2d.reshape(-1, timesteps, features)\n    \n    # Build Model\n    tf.keras.backend.clear_session()\n    tf.keras.utils.set_random_seed(42)\n    \n    inputs = layers.Input(shape=(timesteps, features))\n    x = layers.LSTM(units=best_units)(inputs) # best params from Optuna\n    x = layers.Dropout(0.2)(x) \n    outputs = layers.Dense(1, activation=\"sigmoid\")(x)\n    model = models.Model(inputs, outputs)\n    \n    model.compile(\n        optimizer=tf.keras.optimizers.Adam(learning_rate=best_lr), # best params from Optuna\n        loss=\"binary_crossentropy\",\n        metrics=[tf.keras.metrics.AUC(curve=\"PR\", name=\"val_pr_auc\")]\n    )\n    \n    early_stop = callbacks.EarlyStopping(\n        monitor=\"val_val_pr_auc\",\n        mode=\"max\", \n        patience=5, \n        restore_best_weights=True,\n        verbose=0\n    )\n    \n    # Train Model\n    model.fit(\n        X_tr_smote, y_tr_smote,\n        validation_data=(X_va, y_va),\n        epochs=50,\n        batch_size=512,\n        callbacks=[early_stop],\n        verbose=0\n    )\n    \n    # Predict on the Validation Fold\n    val_preds = model.predict(X_va, batch_size=1024, verbose=0).ravel()\n    \n    # Store predictions\n    oof_lstm_prob[va_idx] = val_preds\n\n# Evaluate Overall OOF\noof_lstm_pred = (oof_lstm_prob >= 0.5).astype(int)\n\nprint(f\"\\nOverall LSTM OOF F1-Score : {f1_score(y_train_pure, oof_lstm_pred, zero_division=0):.4f}\")\n\n# Save for stacking model\nlstm_oof_df = pd.DataFrame({\n    'lstm_oof_prob': oof_lstm_prob,\n    'fad_label': y_train_pure\n})\nlstm_oof_df.to_csv('lstm_oof.csv', index=False)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T03:05:04.890053Z","iopub.execute_input":"2026-05-02T03:05:04.890884Z","iopub.status.idle":"2026-05-02T03:09:44.469188Z","shell.execute_reply.started":"2026-05-02T03:05:04.890850Z","shell.execute_reply":"2026-05-02T03:09:44.467511Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### XGBoost","metadata":{}},{"cell_type":"markdown","source":"#### Data Preparation for XGBClassifier","metadata":{}},{"cell_type":"code","source":"WORK_DIR = \"/kaggle/working/processed_data\"\n\nxgb_df = pd.read_parquet(f\"{WORK_DIR}/features_final.parquet\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T03:10:32.241092Z","iopub.execute_input":"2026-05-02T03:10:32.242196Z","iopub.status.idle":"2026-05-02T03:10:32.286942Z","shell.execute_reply.started":"2026-05-02T03:10:32.242158Z","shell.execute_reply":"2026-05-02T03:10:32.285899Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"xgb_df.info()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T03:10:32.288508Z","iopub.execute_input":"2026-05-02T03:10:32.288824Z","iopub.status.idle":"2026-05-02T03:10:32.313508Z","shell.execute_reply.started":"2026-05-02T03:10:32.288797Z","shell.execute_reply":"2026-05-02T03:10:32.312457Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import numpy as np\n\n# Data Preparation\nxgb_df['article_id'] = xgb_df['article_id'].astype(str)\nfeature_cols = [c for c in xgb_df.columns if c not in ('fad_label', 'article_id')]\n\n# Reset index to perfectly match your np.arange indices\nxgb_df = xgb_df.reset_index(drop=True)\n\n# Slice the XGBoost features using indices same as LSTM for Stacking Model\nX_train_xgb = xgb_df.iloc[train_idx][feature_cols]\nX_val_xgb   = xgb_df.iloc[val_idx][feature_cols]\nX_test_xgb  = xgb_df.iloc[test_idx][feature_cols]\n\ny_train_xgb = y[train_idx]\ny_val_xgb   = y[val_idx]\ny_test_xgb  = y[test_idx]\n\n# Check class weight\nneg, pos = np.bincount(y_train_xgb)\nspw = neg / pos\nprint(f'scale_pos_weight: {spw:.4f}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T03:10:32.315075Z","iopub.execute_input":"2026-05-02T03:10:32.315449Z","iopub.status.idle":"2026-05-02T03:10:32.361318Z","shell.execute_reply.started":"2026-05-02T03:10:32.315420Z","shell.execute_reply":"2026-05-02T03:10:32.360409Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"#### Default XGBClassifier","metadata":{}},{"cell_type":"code","source":"# Default model\nxgb_default = xgb.XGBClassifier(\n    scale_pos_weight=spw,\n    random_state=SEED,\n    verbosity=0\n)\n\n# Fit XGB model\nxgb_default.fit(X_train_xgb, y_train_xgb, eval_set=[(X_val_xgb, y_val_xgb)], verbose = False)\n\n# Evaluate F1-score on test set\nf1_default_val = f1_score(y_val_xgb, (xgb_default.predict_proba(X_val_xgb)[:,1] >= 0.5).astype(int), zero_division=0)\nprint(f'Default model Val F1      : {f1_default_val:.4f}')\nf1_default_test = f1_score(y_test_xgb, (xgb_default.predict_proba(X_test_xgb)[:,1] >= 0.5).astype(int), zero_division=0)\nprint(f'Default model Test F1      : {f1_default_test:.4f}')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:04:54.866607Z","iopub.execute_input":"2026-05-02T04:04:54.867570Z","iopub.status.idle":"2026-05-02T04:04:55.570704Z","shell.execute_reply.started":"2026-05-02T04:04:54.867526Z","shell.execute_reply":"2026-05-02T04:04:55.569364Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"#### Hyperparameter Tuning with Optuna & Refit best model","metadata":{}},{"cell_type":"code","source":"# Hyperparameter Tuning with Optuna\ndef xgb_objective(trial):\n    params = {\n        'n_estimators': trial.suggest_int('n_estimators', 100, 1000, step=100),\n        'learning_rate': trial.suggest_float('learning_rate', 0.01, 0.2, log=True),\n        'max_depth': trial.suggest_int('max_depth', 3, 9),\n        'subsample': trial.suggest_float('subsample', 0.6, 1.0),\n        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.6, 1.0),\n        'min_child_weight': trial.suggest_int('min_child_weight', 1, 7),\n        'scale_pos_weight': spw,\n        'tree_method': 'hist',\n        'eval_metric': 'aucpr',\n        'random_state': 42,\n    }\n    \n    fold_scores = []\n    tune_skf = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)\n\n    X_train_np = np.array(X_train_xgb)\n    y_train_np = np.array(y_train_xgb)\n    \n    for tr_idx, va_idx in tune_skf.split(X_train_np, y_train_np):\n        X_tr, y_tr = X_train_np[tr_idx], y_train_np[tr_idx]\n        X_va, y_va = X_train_np[va_idx], y_train_np[va_idx]\n        \n        model = xgb.XGBClassifier(**params)\n        model.fit(X_tr, y_tr,eval_set=[(X_va, y_va)], verbose=False)\n        \n        va_prob = model.predict_proba(X_va)[:, 1]\n        fold_scores.append(average_precision_score(y_va, va_prob))\n        \n    return np.mean(fold_scores)\n\nstudy_xgb = optuna.create_study(direction=\"maximize\", sampler=optuna.samplers.TPESampler(seed=42))\nstudy_xgb.optimize(xgb_objective, n_trials=10)\n\nbest_xgb_params = study_xgb.best_trial.params\nprint(\"\\nBest XGBoost Params:\")\nfor key, value in best_xgb_params.items():\n    print(f\"  {key}: {value}\")\n\nbest_xgb_params = study_xgb.best_trial.params\n\nbest_xgb_params.update({\n    'scale_pos_weight': spw,\n    'tree_method': 'hist',\n    'eval_metric': 'aucpr',\n    'random_state': 42\n})\n\n# Refit best model\nfinal_xgb_model = xgb.XGBClassifier(**best_xgb_params)\nfinal_xgb_model.fit(X_train_xgb, y_train_xgb, eval_set=[(X_val_xgb, y_val_xgb)],verbose=False)\n\ntest_prob_xgb = final_xgb_model.predict_proba(X_test_xgb)[:, 1]\ntest_pred_xgb = (test_prob_xgb >= 0.5).astype(int)\nprint(f\"\\n Tuned Test F1-Score: {f1_score(y_test_xgb, test_pred_xgb, zero_division=0):.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:05:08.938138Z","iopub.execute_input":"2026-05-02T04:05:08.938520Z","iopub.status.idle":"2026-05-02T04:06:40.835866Z","shell.execute_reply.started":"2026-05-02T04:05:08.938476Z","shell.execute_reply":"2026-05-02T04:06:40.835285Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"#### Best XGBoost Params:\n- n_estimators: 600\n- learning_rate: 0.017398074711291726\n- max_depth: 9\n- subsample: 0.9100531293444458\n- colsample_bytree: 0.9757995766256756\n- min_child_weight: 7","metadata":{}},{"cell_type":"markdown","source":"#### XGB OOF prediction (unbiased prediction) for Stacking model","metadata":{}},{"cell_type":"code","source":"# Set up OOF\nN_FOLDS = 5\nskf = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=42)\n\n\noof_xgb_prob = np.zeros(len(y_train_xgb), dtype=np.float32)\ntest_preds_xgb_cv = np.zeros(len(y_test_xgb), dtype=np.float32)\n\n\nX_train_np = np.array(X_train_xgb)\ny_train_np = np.array(y_train_xgb)\nX_test_np = np.array(X_test_xgb) \n\nmodel_params = best_xgb_params.copy()\nmodel_params.update({\n    'scale_pos_weight': spw,\n    'tree_method': 'hist',\n    'eval_metric': 'aucpr',\n    'random_state': 42\n})\n\n# K-Fold Training Loop\nfor fold, (tr_idx, va_idx) in enumerate(skf.split(X_train_np, y_train_np), start=1):\n    print(f\"  Training Fold {fold}/{N_FOLDS}\")\n    \n    X_tr, y_tr = X_train_np[tr_idx], y_train_np[tr_idx]\n    X_va, y_va = X_train_np[va_idx], y_train_np[va_idx]\n    \n    model = xgb.XGBClassifier(**model_params)\n    model.fit(X_tr, y_tr, eval_set=[(X_va, y_va)], verbose=False)\n    \n    oof_xgb_prob[va_idx] = model.predict_proba(X_va)[:, 1]\n    test_preds_xgb_cv += model.predict_proba(X_test_np)[:, 1] / N_FOLDS\n\n# Evaluate\nprint(f\"Final OOF ROC-AUC: {roc_auc_score(y_train_xgb, oof_xgb_prob):.4f}\")\n\n# Save\noof_df = pd.DataFrame({\n    'xgb_oof_prob': oof_xgb_prob,\n    'actual_label': y_train_xgb\n})\noof_df.to_csv('xgb_oof_predictions.csv', index=False)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:19:39.942076Z","iopub.execute_input":"2026-05-02T04:19:39.942829Z","iopub.status.idle":"2026-05-02T04:20:11.747367Z","shell.execute_reply.started":"2026-05-02T04:19:39.942801Z","shell.execute_reply":"2026-05-02T04:20:11.746669Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Stacking Model (LSTM + XGB with Logistic Regression as a meta-learner)","metadata":{}},{"cell_type":"markdown","source":"#### Data Preparation for Stacking Model","metadata":{}},{"cell_type":"code","source":"xg_oof_df = pd.read_csv('xgb_oof_predictions.csv')\nlstm_oof_df = pd.read_csv('lstm_oof.csv')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:20:11.748611Z","iopub.execute_input":"2026-05-02T04:20:11.749322Z","iopub.status.idle":"2026-05-02T04:20:11.777110Z","shell.execute_reply.started":"2026-05-02T04:20:11.749296Z","shell.execute_reply":"2026-05-02T04:20:11.776114Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"xg_oof_df.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:20:11.778298Z","iopub.execute_input":"2026-05-02T04:20:11.778776Z","iopub.status.idle":"2026-05-02T04:20:11.787649Z","shell.execute_reply.started":"2026-05-02T04:20:11.778749Z","shell.execute_reply":"2026-05-02T04:20:11.786625Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"lstm_oof_df.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:20:11.789518Z","iopub.execute_input":"2026-05-02T04:20:11.789993Z","iopub.status.idle":"2026-05-02T04:20:11.803407Z","shell.execute_reply.started":"2026-05-02T04:20:11.789967Z","shell.execute_reply":"2026-05-02T04:20:11.802716Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Data Preparation\n# Prepare Training data from OOF\nX_meta_train = np.column_stack((oof_lstm_prob, oof_xgb_prob))\ny_meta_train = y_train_pure \n\nprint(f\"Meta-Training Matrix Shape: {X_meta_train.shape}\")\n\n# Prepare Test data\ntest_lstm_prob = final_model.predict(X_test, batch_size=1024).ravel()\ntest_xgb_prob  = final_xgb_model.predict_proba(X_test_xgb)[:, 1]\n\nX_meta_test = np.column_stack((test_lstm_prob, test_xgb_prob))\ny_meta_test = y_test\n\nprint(f\"Meta-Test Matrix Shape: {X_meta_test.shape}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:20:11.804617Z","iopub.execute_input":"2026-05-02T04:20:11.804971Z","iopub.status.idle":"2026-05-02T04:20:12.118261Z","shell.execute_reply.started":"2026-05-02T04:20:11.804948Z","shell.execute_reply":"2026-05-02T04:20:12.117579Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"#### Default Stacking Model","metadata":{}},{"cell_type":"code","source":"# Default Stacking model\nmeta_lr = LogisticRegression(random_state=42)\nmeta_lr.fit(X_meta_train, y_meta_train)\n\n# Predict and Evaluate\nmeta_test_prob = meta_lr.predict_proba(X_meta_test)[:, 1]\nmeta_test_pred = meta_lr.predict(X_meta_test)\n\nprint(f\"Test F1-Score: {f1_score(y_meta_test, meta_test_pred, zero_division=0):.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:20:12.119176Z","iopub.execute_input":"2026-05-02T04:20:12.119398Z","iopub.status.idle":"2026-05-02T04:20:12.169204Z","shell.execute_reply.started":"2026-05-02T04:20:12.119377Z","shell.execute_reply":"2026-05-02T04:20:12.168619Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"#### Hyperparameter Tuning using Optuna","metadata":{}},{"cell_type":"code","source":"# Hyperparameter Tuning using Optuna\ndef meta_objective(trial):\n    # Set parameter space\n    c_val = trial.suggest_float('C', 1e-4, 1e2, log=True)\n    class_weight = trial.suggest_categorical('class_weight', [None, 'balanced'])\n    solver = trial.suggest_categorical('solver', ['lbfgs', 'newton-cholesky'])\n    penalty = 'l2'\n\n    model = LogisticRegression(\n        C=c_val,\n        class_weight=class_weight,\n        solver=solver,\n        penalty=penalty,\n        random_state=42,\n        max_iter=1000\n    )\n    \n    # Evaluate using Stratified K-Fold\n    skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)\n    fold_scores = []\n    \n    for tr_idx, va_idx in skf.split(X_meta_train, y_meta_train):\n        X_tr, y_tr = X_meta_train[tr_idx], y_meta_train[tr_idx]\n        X_va, y_va = X_meta_train[va_idx], y_meta_train[va_idx]\n        \n        model.fit(X_tr, y_tr)\n        va_prob = model.predict_proba(X_va)[:, 1]\n        score = average_precision_score(y_va, va_prob)\n        fold_scores.append(score)\n        \n    return np.mean(fold_scores)\n\nstudy_meta = optuna.create_study(direction=\"maximize\", sampler=optuna.samplers.TPESampler(seed=42))\nstudy_meta.optimize(meta_objective, n_trials=50)\n\nprint(\"\\nBest Meta-Learner Parameters:\")\nfor key, value in study_meta.best_trial.params.items():\n    print(f\"  {key}: {value}\")\nprint(f\"  Best CV PR-AUC: {study_meta.best_trial.value:.4f}\")\n\nbest_meta_params = study_meta.best_trial.params\nbest_meta_params['penalty'] = 'l2'\n\n# Refit best Stacking Model\nbest_meta_lr = LogisticRegression(\n    **best_meta_params,\n    random_state=42,\n    max_iter=1000\n)\n\nbest_meta_lr.fit(X_meta_train, y_meta_train)\n\n# 4. Predict and Evaluate on the Test Set\nmeta_test_prob = best_meta_lr.predict_proba(X_meta_test)[:, 1]\nmeta_test_pred = best_meta_lr.predict(X_meta_test)\n\n\nprint(f\"Test F1-Score: {f1_score(y_meta_test, meta_test_pred, zero_division=0):.4f}\")\n\nprint(\"\\nClassification Report:\")\nprint(classification_report(y_meta_test, meta_test_pred, digits=4))\n\n# Save Stacking model\ntest_article_ids = data.iloc[test_idx]['article_id'].values \n\nstacking_results = pd.DataFrame({\n    'article_id': test_article_ids,\n    'fad_label': y_meta_test,\n    'stack_prob': meta_test_prob,\n    'stack_pred': meta_test_pred,\n    'lstm_prob': test_lstm_prob,\n    'xgb_prob': test_xgb_prob,\n})\nstacking_results.to_csv('stacking_test_predictions.csv', index=False)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:50:15.760321Z","iopub.execute_input":"2026-05-02T04:50:15.761163Z","iopub.status.idle":"2026-05-02T04:50:23.935174Z","shell.execute_reply.started":"2026-05-02T04:50:15.761132Z","shell.execute_reply":"2026-05-02T04:50:23.934499Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"#### Explain model using SHAP","metadata":{}},{"cell_type":"code","source":"# Stacking Model\nimport shap\nimport matplotlib.pyplot as plt\nimport numpy as np\n\n# Calculate SHAP values for the test set\nshap.initjs()\nexplainer_meta = shap.LinearExplainer(best_meta_lr, X_meta_train)\nshap_values_meta = explainer_meta.shap_values(X_meta_test)\n\n# Summary Bar Plot (Overall Importance)\nplt.figure(figsize=(8, 4))\nplt.title(\"Meta-Learner Feature Importance (Average Impact)\")\nshap.summary_plot(\n    shap_values_meta, \n    X_meta_test, \n    feature_names=['LSTM Probability', 'XGBoost Probability'], \n    plot_type=\"bar\",\n    show=False\n)\nplt.show()\n\n# Beeswarm Plot (Directional Impact)\nplt.figure(figsize=(8, 4))\nplt.title(\"Meta-Learner Directional Impact\")\nshap.summary_plot(\n    shap_values_meta, \n    X_meta_test, \n    feature_names=['LSTM Probability', 'XGBoost Probability'],\n    show=False\n)\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:50:40.886276Z","iopub.execute_input":"2026-05-02T04:50:40.887111Z","iopub.status.idle":"2026-05-02T04:50:41.362402Z","shell.execute_reply.started":"2026-05-02T04:50:40.887081Z","shell.execute_reply":"2026-05-02T04:50:41.361621Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"weights = best_meta_lr.coef_[0]  # [lstm_weight, xgb_weight]\nintercept = best_meta_lr.intercept_[0]\n\nprint(\"Meta-Learner Weights\")\nprint(f\"  LSTM  coefficient : {weights[0]:+.4f}\")\nprint(f\"  XGB   coefficient : {weights[1]:+.4f}\")\nprint(f\"  Intercept         : {intercept:+.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T05:11:32.411786Z","iopub.execute_input":"2026-05-02T05:11:32.412299Z","iopub.status.idle":"2026-05-02T05:11:32.417915Z","shell.execute_reply.started":"2026-05-02T05:11:32.412266Z","shell.execute_reply":"2026-05-02T05:11:32.416815Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# XGBoost\n# TreeExplainer for explain XGB\nexplainer_xgb = shap.TreeExplainer(final_xgb_model)\n\n# Calculate SHAP values for the test set\nshap_values_xgb = explainer_xgb.shap_values(X_test_xgb)\n\n# Top 10 Most Important Features (Bar Chart)\nplt.figure(figsize=(10, 6))\nplt.title(\"XGBoost: Top 20 Most Important Features\")\nshap.summary_plot(\n    shap_values_xgb, \n    X_test_xgb, \n    plot_type=\"bar\", \n    max_display=10,\n    show=False\n)\nplt.show()\n\n# Detailed Beeswarm Plot\nplt.figure(figsize=(10, 6))\nplt.title(\"XGBoost: Feature Impact on 'Fad' Prediction\")\nshap.summary_plot(\n    shap_values_xgb, \n    X_test_xgb, \n    max_display=10,\n    show=False\n)\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:35:45.327328Z","iopub.execute_input":"2026-05-02T04:35:45.328177Z","iopub.status.idle":"2026-05-02T04:39:22.455576Z","shell.execute_reply.started":"2026-05-02T04:35:45.328145Z","shell.execute_reply":"2026-05-02T04:39:22.454770Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#LSTM \nn_steps = X_train.shape[1]\n\ndef lstm_predict_wrapper(data_2d):\n    data_3d = data_2d.reshape(-1, n_steps, 1)\n    return model.predict(data_3d, verbose=0).flatten()\n\n\nbackground_summary = shap.kmeans(X_train[:150].reshape(-1, n_steps), 25) \nexplainer = shap.KernelExplainer(lstm_predict_wrapper, background_summary)\n\n# Calculate SHAP values\ndata_to_explain = X_test[:100].reshape(-1, n_steps)\nshap_values = explainer.shap_values(data_to_explain)\nif isinstance(shap_values, list): shap_values = shap_values[0]\n\n# Visualize importance\ntime_importance = np.mean(np.abs(shap_values), axis=0)\nlabels = [f\"Step {i} (T-{n_steps - 1 - i})\" for i in range(n_steps)]\n\nimportance_df = pd.DataFrame({'time_step': labels, 'importance': time_importance}).sort_values(by='importance', ascending=False)\n\nplt.figure(figsize=(10, 8))\nplt.barh(importance_df['time_step'], importance_df['importance'], color='teal')\nplt.gca().invert_yaxis()\nplt.title(f\"Importance over {n_steps} Time Steps\")\nplt.xlabel(\"Mean |SHAP Value|\")\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T05:20:20.448612Z","iopub.execute_input":"2026-05-02T05:20:20.449425Z","iopub.status.idle":"2026-05-02T05:26:55.141966Z","shell.execute_reply.started":"2026-05-02T05:20:20.449395Z","shell.execute_reply":"2026-05-02T05:26:55.141155Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Model Conclusion\n#### F1-score Table \n\n| Model | Default Model | Tuned Model |\n|-------|------|-------------|\n| LSTM | 0.6991 | 0.9091 |\n| XGBoost | 0.6265 | 0.6555 |\n| Stacking Model | 0.8994 | 0.8994 |\n\nแม้ว่าโมเดล LSTM จะทำคะแนน F1-score ได้สูงกว่าเล็กน้อยแต่เราตัดสินใจเลือกใช้โมเดล Logistic Regression Stacking Ensemble เป็นโมเดลหลัก\n\nเพราะการผสมความสามารถของทั้งโมเดล LSTM และ XGBoost ทำให้โมเดล Stacking มีความแข็งแกร่ง มีเสถียรภาพ และสามารถอธิบายเหตุผลในสถานการณ์จริงได้ดีกว่า การใช้เพียง 1 โมเดล","metadata":{}},{"cell_type":"markdown","source":"---\n## Phase 6 — Cost-Sensitive Threshold Optimization","metadata":{}},{"cell_type":"code","source":"# ── Cell 27: Threshold Sweep ─────────────────────────\nfrom sklearn.metrics import precision_score, recall_score\nimport matplotlib.pyplot as plt\n\n# 1. Load the merged results from the stacking step\nfrom pathlib import Path\nresults_df = pd.read_csv(\"stacking_test_predictions.csv\")\ny_test = results_df[\"fad_label\"].values\nfinal_pred = results_df[\"stack_prob\"].values\n\n# 2. Business Cost Configuration\n# FN (missed Fad) is 10x more expensive than FP (over-flagging a non-fad)\nCOST_FN, COST_FP = 10, 1  \n\nthresholds = np.arange(0.05, 0.96, 0.01)\ncosts, f1s, precs, recs = [], [], [], []\n\nfor t in thresholds:\n    yp = (final_pred >= t).astype(int)\n    tn, fp, fn, tp = confusion_matrix(y_test, yp, labels=[0,1]).ravel()\n    \n    # Calculate weighted cost and standard metrics\n    costs.append(COST_FN*fn + COST_FP*fp)\n    f1s.append(f1_score(y_test, yp, zero_division=0))\n    precs.append(precision_score(y_test, yp, zero_division=0))\n    recs.append(recall_score(y_test, yp, zero_division=0))\n\n# 3. Identify Optimal Threshold\nOPTIMAL_T = thresholds[np.argmin(costs)]\nprint(f'Optimal Business Threshold: {OPTIMAL_T:.2f}')\nprint(f'Metrics at this threshold: F1={f1s[np.argmin(costs)]:.4f}, Recall={recs[np.argmin(costs)]:.4f}')\n\n# 4. Plotting\nfig, axes = plt.subplots(1, 2, figsize=(14, 5))\n\n# Plot 1: Cost Minimization\naxes[0].plot(thresholds, costs, 'coral', lw=2)\naxes[0].axvline(OPTIMAL_T, color='navy', linestyle='--', label=f'Optimal T={OPTIMAL_T:.2f}')\naxes[0].set_xlabel('Threshold')\naxes[0].set_ylabel('Total Weighted Cost')\naxes[0].set_title(f'Business Cost Function (FN={COST_FN}x)', fontweight='bold')\naxes[0].legend()\n\n# Plot 2: Traditional Metrics\naxes[1].plot(thresholds, f1s,   label='F1',        color='black', alpha=0.3, linestyle='--')\naxes[1].plot(thresholds, precs, label='Precision', color='green')\naxes[1].plot(thresholds, recs,  label='Recall',    color='coral')\naxes[1].axvline(OPTIMAL_T, color='navy', linestyle='--')\naxes[1].set_xlabel('Threshold')\naxes[1].set_ylabel('Score')\naxes[1].set_title('Precision vs. Recall Sweep', fontweight='bold')\naxes[1].legend()\n\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:39:44.774321Z","iopub.execute_input":"2026-05-02T04:39:44.775224Z","iopub.status.idle":"2026-05-02T04:39:45.786336Z","shell.execute_reply.started":"2026-05-02T04:39:44.775191Z","shell.execute_reply":"2026-05-02T04:39:45.785532Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 28: Evaluation + Business Metrics (Real Price Data) ──\n\nimport pandas as pd\nimport numpy as np\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom sklearn.metrics import (\n    confusion_matrix, classification_report, roc_auc_score,\n    average_precision_score, precision_recall_curve,\n    f1_score, precision_score, recall_score\n)\n\n# ═══════════════════════════════════════════════════════\n#  PART 1: Model Evaluation\n# ═══════════════════════════════════════════════════════\n\nfrom pathlib import Path\nresults_df = pd.read_csv(\"stacking_test_predictions.csv\")\ny_test = results_df[\"fad_label\"].values\nfinal_probs = results_df[\"stack_prob\"].values\n\n# ใช้ threshold จาก Cell 27\ny_pred = (final_probs >= OPTIMAL_T).astype(int)\n\ncm = confusion_matrix(y_test, y_pred)\ntn, fp, fn, tp = cm.ravel()\npr_auc = average_precision_score(y_test, final_probs)\nroc_auc = roc_auc_score(y_test, final_probs)\n\nprint('=' * 70)\nprint('  PART 1: Model Evaluation (Stacking Ensemble)')\nprint('=' * 70)\nprint(f'\\nOptimal Threshold: {OPTIMAL_T:.2f}')\nprint(f'Confusion Matrix: TP={tp:,}, FP={fp:,}, FN={fn:,}, TN={tn:,}')\nprint(classification_report(y_test, y_pred, target_names=['Non-Fad', 'Fad']))\nprint(f'PR-AUC : {pr_auc:.4f}')\nprint(f'ROC-AUC: {roc_auc:.4f}')\n\nactual_recall = tp / (tp + fn) if (tp + fn) > 0 else 0\nactual_precision = tp / (tp + fp) if (tp + fp) > 0 else 0\nactual_f1 = f1_score(y_test, y_pred)\n\n# ═══════════════════════════════════════════════════════\n#  PART 2: Business Metrics (Real Price Data)\n# ═══════════════════════════════════════════════════════\n\nprint('\\n' + '=' * 70)\nprint('  PART 2: Business Metrics (จาก Price Data จริง)')\nprint('=' * 70)\n\n# ─── 2.0 เตรียมข้อมูลราคา ───\nprint('\\n--- Preparing price data ---')\n\nprice_stats = (\n    txn.groupBy('article_id')\n    .agg(\n        F.avg('price').alias('avg_price'),\n        F.min('price').alias('min_price'),\n        F.max('price').alias('max_price'),\n        F.first('price').alias('first_price'),\n        F.count('*').alias('n_transactions'),\n    )\n    .withColumn('price_drop_pct',\n        (F.col('first_price') - F.col('min_price')) / (F.col('first_price') + 0.001))\n    .withColumn('was_marked_down',\n        (F.col('price_drop_pct') > 0.20).cast('int'))\n    .withColumn('markdown_loss_per_item',\n        F.col('first_price') - F.col('min_price'))\n)\n\nprice_pd = price_stats.toPandas()\nprice_pd['article_id'] = price_pd['article_id'].astype(str)\n\n# Merge predictions กับ price data\ntest_articles = results_df[['article_id', 'fad_label']].copy()\ntest_articles['article_id'] = test_articles['article_id'].astype(str)\ntest_articles['y_pred'] = y_pred\ntest_articles['y_prob'] = final_probs\n\n# Classify: TP, FP, FN, TN\ntest_articles['outcome'] = 'TN'\ntest_articles.loc[(test_articles['fad_label'] == 1) & (test_articles['y_pred'] == 1), 'outcome'] = 'TP'\ntest_articles.loc[(test_articles['fad_label'] == 0) & (test_articles['y_pred'] == 1), 'outcome'] = 'FP'\ntest_articles.loc[(test_articles['fad_label'] == 1) & (test_articles['y_pred'] == 0), 'outcome'] = 'FN'\n\nbiz = test_articles.merge(price_pd, on='article_id', how='inner')\nprint(f'   Test articles with price data: {len(biz):,}')\nprint(f'   TP={tp:,}, FP={fp:,}, FN={fn:,}, TN={tn:,}')\n\n# ─── 2.1 Metric 1: Markdown Avoidance ───\nprint('\\n' + '-' * 50)\nprint('  Metric 1: Estimated Markdown Avoidance')\nprint('-' * 50)\n\n# TP: Fad ที่จับได้ถูก → ถ้าลดผลิตจะ avoid markdown\ntp_data = biz[biz['outcome'] == 'TP']\ntp_marked_down = tp_data[tp_data['was_marked_down'] == 1]\n\n# FN: Fad ที่พลาด → ยังเกิด markdown loss\nfn_data = biz[biz['outcome'] == 'FN']\nfn_marked_down = fn_data[fn_data['was_marked_down'] == 1]\n\nPRODUCTION_REDUCTION = 0.50\n\n# Markdown ที่ avoid ได้ (จาก TP)\ntp_markdown_loss = (\n    tp_marked_down['markdown_loss_per_item'] *\n    tp_marked_down['n_transactions'] *\n    tp_marked_down['price_drop_pct']\n).sum()\navoided_markdown = tp_markdown_loss * PRODUCTION_REDUCTION\n\n# Markdown ที่ยังเกิด (จาก FN — missed Fads)\nfn_markdown_loss = (\n    fn_marked_down['markdown_loss_per_item'] *\n    fn_marked_down['n_transactions'] *\n    fn_marked_down['price_drop_pct']\n).sum()\n\nprint(f'\\nTP (Fad จับได้): {len(tp_data):,} articles')\nprint(f'   TP ที่ถูก markdown:  {len(tp_marked_down):,} ({len(tp_marked_down)/max(1,len(tp_data))*100:.1f}%)')\nprint(f'   Markdown loss (TP):  {tp_markdown_loss:>12,.2f} SEK')\nprint(f'   Avoided (ลดผลิต {PRODUCTION_REDUCTION:.0%}): {avoided_markdown:>12,.2f} SEK')\n\nprint(f'\\nFN (Fad ที่พลาด): {len(fn_data):,} articles')\nprint(f'   FN ที่ถูก markdown:  {len(fn_marked_down):,} ({len(fn_marked_down)/max(1,len(fn_data))*100:.1f}%)')\nprint(f'   Remaining loss (FN): {fn_markdown_loss:>12,.2f} SEK')\n\nprint(f'\\nNet Markdown Improvement: {avoided_markdown:,.2f} SEK')\n\n# ─── 2.2 Metric 2: Full-Price STR ───\nprint('\\n' + '-' * 50)\nprint('  Metric 2: Full-Price Sell-Through Rate')\nprint('-' * 50)\n\n# Current state (ไม่มีโมเดล)\nfad_test = biz[biz['fad_label'] == 1]\nnonfad_test = biz[biz['fad_label'] == 0]\n\ncurrent_fad_str = (fad_test['was_marked_down'] == 0).mean()\ncurrent_nonfad_str = (nonfad_test['was_marked_down'] == 0).mean()\ncurrent_overall_str = (biz['was_marked_down'] == 0).mean()\n\n# Improved state (โมเดลช่วย)\n# TP: ลดผลิต → markdown ลดลง 50%\n# FN: ไม่รู้ → markdown เท่าเดิม\ntp_current_markdown_rate = tp_data['was_marked_down'].mean() if len(tp_data) > 0 else 0\ntp_improved_markdown_rate = tp_current_markdown_rate * (1 - PRODUCTION_REDUCTION)\n\nn_fad_test = len(fad_test)\nn_tp = len(tp_data)\nn_fn = len(fn_data)\n\n# Improved Fad STR\nif n_fad_test > 0:\n    fn_str = (fn_data['was_marked_down'] == 0).mean() if len(fn_data) > 0 else current_fad_str\n    tp_improved_str = 1 - tp_improved_markdown_rate\n    improved_fad_str = (tp_improved_str * n_tp + fn_str * n_fn) / n_fad_test\nelse:\n    improved_fad_str = current_fad_str\n\nimproved_overall_str = (\n    improved_fad_str * n_fad_test + current_nonfad_str * len(nonfad_test)\n) / len(biz)\n\nprint(f'\\n{\"Category\":<25s} {\"Current\":<12s} {\"With Model\":<12s} {\"Change\":<10s}')\nprint('-' * 60)\nprint(f'{\"Fad items\":<25s} {current_fad_str:<12.1%} {improved_fad_str:<12.1%} '\n      f'+{(improved_fad_str - current_fad_str)*100:.1f}pp')\nprint(f'{\"Non-Fad items\":<25s} {current_nonfad_str:<12.1%} {current_nonfad_str:<12.1%} '\n      f'  0.0pp')\nprint(f'{\"Overall\":<25s} {current_overall_str:<12.1%} {improved_overall_str:<12.1%} '\n      f'+{(improved_overall_str - current_overall_str)*100:.1f}pp')\n\n# ─── 2.3 Metric 3: Inventory Turnover (Simulated) ───\nprint('\\n' + '-' * 50)\nprint('  Metric 3: Inventory Turnover (Simulated)')\nprint('-' * 50)\n\nLEAD_TIME_WEEKS = 4\n\nfad_avg_txn = fad_test['n_transactions'].mean()\nnonfad_avg_txn = nonfad_test['n_transactions'].mean()\n\ncurrent_fad_turnover = fad_avg_txn / LEAD_TIME_WEEKS\ncurrent_nonfad_turnover = nonfad_avg_txn / LEAD_TIME_WEEKS\n\n# TP: ลดสต็อก 50% → turnover เพิ่ม 2x\n# FN: เท่าเดิม\nimproved_fad_turnover = (\n    (current_fad_turnover / (1 - PRODUCTION_REDUCTION)) * (n_tp / max(1, n_fad_test)) +\n    current_fad_turnover * (n_fn / max(1, n_fad_test))\n)\n\nprint(f'\\n⚠️  Simulated (ไม่มี inventory data จริง)')\nprint(f'\\n{\"Category\":<25s} {\"Current\":<12s} {\"With Model\":<12s}')\nprint('-' * 50)\nprint(f'{\"Fad items\":<25s} {current_fad_turnover:<12.1f}x {improved_fad_turnover:<12.1f}x')\nprint(f'{\"Non-Fad items\":<25s} {current_nonfad_turnover:<12.1f}x {current_nonfad_turnover:<12.1f}x')\n\n# ─── 2.4 Metric 4: Cost-Benefit Analysis ───\nprint('\\n' + '-' * 50)\nprint('  Metric 4: Cost-Benefit Analysis')\nprint('-' * 50)\n\n# Costs\nCLOUD_COST_PER_HOUR = 2.50\nHOURS_PER_RUN = 1\nRUNS_PER_MONTH = 4\nDEVELOPMENT_HOURS = 200\nDEV_HOURLY_RATE = 30\nSEK_TO_USD = 0.095\n\nmonthly_cloud_cost = CLOUD_COST_PER_HOUR * HOURS_PER_RUN * RUNS_PER_MONTH\nannual_cloud_cost = monthly_cloud_cost * 12\ndevelopment_cost = DEVELOPMENT_HOURS * DEV_HOURLY_RATE\ntotal_year1_cost = annual_cloud_cost + development_cost\n\n# Benefits (จาก actual model performance)\nbenefit_sek = avoided_markdown\nbenefit_usd = benefit_sek * SEK_TO_USD\n\n# FP cost: opportunity loss จากการลดผลิต Non-Fad ที่ถูก flag ผิด\nfp_data = biz[biz['outcome'] == 'FP']\nfp_opp_loss_sek = (fp_data['avg_price'] * fp_data['n_transactions'] * 0.10).sum()\nfp_opp_loss_usd = fp_opp_loss_sek * SEK_TO_USD\n\nnet_benefit_usd = benefit_usd - fp_opp_loss_usd\n\nroi_year1 = (net_benefit_usd - total_year1_cost) / total_year1_cost * 100\nroi_year2 = (net_benefit_usd - annual_cloud_cost) / annual_cloud_cost * 100\n\nprint(f'\\n--- Costs ---')\nprint(f'   Cloud (annual):      ${annual_cloud_cost:>10,.2f}')\nprint(f'   Development:         ${development_cost:>10,.2f}')\nprint(f'   Total Year 1:        ${total_year1_cost:>10,.2f}')\n\nprint(f'\\n--- Benefits (Actual Model at Recall={actual_recall:.0%}) ---')\nprint(f'   Markdown avoided:    {benefit_sek:>12,.2f} SEK (${benefit_usd:>10,.2f})')\nprint(f'   FP opportunity loss: {fp_opp_loss_sek:>12,.2f} SEK (${fp_opp_loss_usd:>10,.2f})')\nprint(f'   Net benefit:         ${net_benefit_usd:>10,.2f}')\n\nprint(f'\\n--- ROI ---')\nprint(f'   Year 1:  {roi_year1:>+.1f}%')\nprint(f'   Year 2+: {roi_year2:>+.1f}%')\n\nif net_benefit_usd > 0 and net_benefit_usd > total_year1_cost:\n    print(f'   ✅ ROI เป็นบวกตั้งแต่ปีแรก')\nelif net_benefit_usd > 0:\n    payback_months = total_year1_cost / (net_benefit_usd / 12)\n    print(f'   🟡 Payback period: {payback_months:.1f} months')\nelse:\n    print(f'   ⚠️ Net benefit ติดลบ — ดู caveat เรื่อง encoded price')\n\n# ═══════════════════════════════════════════════════════\n#  PART 3: Visualization\n# ═══════════════════════════════════════════════════════\n\nfig, axes = plt.subplots(2, 2, figsize=(14, 10))\n\n# 3.1 Confusion Matrix\nsns.heatmap(cm, annot=True, fmt='d', cmap='Blues', ax=axes[0, 0],\n            xticklabels=['Non-Fad', 'Fad'], yticklabels=['Non-Fad', 'Fad'])\naxes[0, 0].set_title(f'Confusion Matrix (T={OPTIMAL_T:.2f})', fontweight='bold')\naxes[0, 0].set_xlabel('Predicted')\naxes[0, 0].set_ylabel('Actual')\n\n# 3.2 PR Curve\nprec_c, rec_c, _ = precision_recall_curve(y_test, final_probs)\naxes[0, 1].plot(rec_c, prec_c, color='steelblue', lw=2,\n                label=f'Stacking (PR-AUC={pr_auc:.3f})')\naxes[0, 1].axhline(y_test.mean(), color='red', linestyle='--', label='Baseline')\naxes[0, 1].set_xlabel('Recall')\naxes[0, 1].set_ylabel('Precision')\naxes[0, 1].set_title('Precision-Recall Curve', fontweight='bold')\naxes[0, 1].legend()\naxes[0, 1].grid(alpha=0.3)\n\n# 3.3 Business Impact: Markdown Avoidance by Outcome\noutcome_counts = biz.groupby('outcome').agg(\n    n_articles=('article_id', 'count'),\n    n_marked_down=('was_marked_down', 'sum'),\n    total_markdown=('markdown_loss_per_item', lambda x: (x * biz.loc[x.index, 'n_transactions'] * biz.loc[x.index, 'price_drop_pct']).sum())\n).reindex(['TP', 'FP', 'FN', 'TN'])\n\ncolors_map = {'TP': 'green', 'FP': 'orange', 'FN': 'red', 'TN': 'steelblue'}\nbars = axes[1, 0].bar(\n    outcome_counts.index,\n    outcome_counts['n_articles'],\n    color=[colors_map.get(x, 'gray') for x in outcome_counts.index],\n    alpha=0.7, edgecolor='gray'\n)\naxes[1, 0].set_title('Articles by Prediction Outcome', fontweight='bold')\naxes[1, 0].set_ylabel('Count')\nfor bar, val in zip(bars, outcome_counts['n_articles']):\n    axes[1, 0].text(bar.get_x() + bar.get_width()/2, bar.get_height() + 50,\n                    f'{val:,}', ha='center', fontsize=10)\naxes[1, 0].grid(alpha=0.3, axis='y')\n\n# 3.4 STR Comparison\nstr_data = pd.DataFrame({\n    'Category': ['Fad\\n(Current)', 'Fad\\n(With Model)', 'Non-Fad', 'Overall\\n(Current)', 'Overall\\n(With Model)'],\n    'STR': [current_fad_str, improved_fad_str, current_nonfad_str, current_overall_str, improved_overall_str],\n    'Color': ['#ff7675', '#00b894', '#74b9ff', '#b2bec3', '#00b894']\n})\nbars = axes[1, 1].bar(str_data['Category'], str_data['STR'] * 100, color=str_data['Color'], alpha=0.8, edgecolor='gray')\naxes[1, 1].set_ylabel('Full-Price STR (%)')\naxes[1, 1].set_title('Full-Price Sell-Through Rate', fontweight='bold')\nfor bar, val in zip(bars, str_data['STR']):\n    axes[1, 1].text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.5,\n                    f'{val:.1%}', ha='center', fontsize=10)\naxes[1, 1].grid(alpha=0.3, axis='y')\n\nplt.suptitle(f'Phase 6: Model Evaluation & Business Impact\\n'\n             f'(Recall={actual_recall:.1%}, Precision={actual_precision:.1%}, F1={actual_f1:.4f})',\n             fontweight='bold', fontsize=13)\nplt.tight_layout()\nplt.show()\n\n# ═══════════════════════════════════════════════════════\n#  Summary\n# ═══════════════════════════════════════════════════════\n\nprint('\\n' + '=' * 70)\nprint('  SUMMARY')\nprint('=' * 70)\n\nsummary = pd.DataFrame([\n    {'Metric': 'Model — F1 Score', 'Value': f'{actual_f1:.4f}'},\n    {'Metric': 'Model — Recall', 'Value': f'{actual_recall:.1%}'},\n    {'Metric': 'Model — Precision', 'Value': f'{actual_precision:.1%}'},\n    {'Metric': 'Model — PR-AUC', 'Value': f'{pr_auc:.4f}'},\n    {'Metric': 'Threshold (Cost-Optimized)', 'Value': f'{OPTIMAL_T:.2f}'},\n    {'Metric': '', 'Value': ''},\n    {'Metric': 'Markdown Avoided (TP)', 'Value': f'{avoided_markdown:,.2f} SEK'},\n    {'Metric': 'Markdown Remaining (FN)', 'Value': f'{fn_markdown_loss:,.2f} SEK'},\n    {'Metric': 'Full-Price STR — Fad', 'Value': f'{current_fad_str:.1%} → {improved_fad_str:.1%}'},\n    {'Metric': 'Full-Price STR — Overall', 'Value': f'{current_overall_str:.1%} → {improved_overall_str:.1%}'},\n    {'Metric': '', 'Value': ''},\n    {'Metric': 'Year 1 ROI', 'Value': f'{roi_year1:+.1f}%'},\n    {'Metric': 'Year 2+ ROI', 'Value': f'{roi_year2:+.1f}%'},\n])\n\nprint('\\n' + summary.to_string(index=False))\n\nprint(f'\\n⚠️  Caveat: H&M price data ถูก encode/normalize ไม่ใช่ราคาจริง')\nprint(f'   ตัวเลข absolute (SEK) ใช้เปรียบเทียบสัดส่วนได้ แต่ไม่ใช่มูลค่าจริง')\nprint(f'   Pattern และ % improvement เชื่อถือได้')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:39:45.787987Z","iopub.execute_input":"2026-05-02T04:39:45.788513Z","iopub.status.idle":"2026-05-02T04:40:18.995462Z","shell.execute_reply.started":"2026-05-02T04:39:45.788488Z","shell.execute_reply":"2026-05-02T04:40:18.994714Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n## Phase 7 — SHAP + Counterfactual Explainability","metadata":{}},{"cell_type":"code","source":"import matplotlib.pyplot as plt\nimport shap\nimport numpy as np\n\n# 1. Visualize Meta-Learner Weights\nmodel_names = ['LSTM Probability', 'XGBoost Probability']\nweights = best_meta_lr.coef_[0]\n\nplt.figure(figsize=(8, 4))\ncolors = ['skyblue', 'coral']\nplt.barh(model_names, weights, color=colors)\nplt.axvline(0, color='black', lw=0.8)\nplt.title('Meta-Learner Coefficients (Importance of Base Models)', fontweight='bold')\nplt.xlabel('Coefficient Weight')\nplt.grid(axis='x', linestyle='--', alpha=0.6)\nplt.show()\n\n# 2. SHAP Feature Importance (XGBoost, pandas-based)\nsample_size = min(500, len(X_val_xgb))\nX_sample_features = X_val_xgb[:sample_size]\n\nexplainer  = shap.TreeExplainer(final_xgb_model)\nshap_values = explainer.shap_values(X_sample_features)\n\n# Handle binary classification output shapes\nsv = shap_values[1] if isinstance(shap_values, list) else shap_values\n\nfeature_names = FINAL_FEATURES if FINAL_FEATURES is not None else feature_cols\n\nplt.figure(figsize=(10, 8))\nshap.summary_plot(\n    sv,\n    X_sample_features,\n    feature_names=feature_names,\n    max_display=20,\n    show=False\n)\nplt.title('SHAP Global Feature Importance (XGBoost Component)', fontweight='bold', fontsize=14)\nplt.tight_layout()\nplt.show()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:40:18.996513Z","iopub.execute_input":"2026-05-02T04:40:18.996821Z","iopub.status.idle":"2026-05-02T04:40:29.548600Z","shell.execute_reply.started":"2026-05-02T04:40:18.996797Z","shell.execute_reply":"2026-05-02T04:40:29.547709Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Cell 31: Final Summary + Stop Spark ────────────────────\nprint('=' * 55)\nprint('   FINAL PIPELINE SUMMARY')\nprint('=' * 55)\nmetrics = {\n    'F1-Score (Fad)'     : f1_score(y_test, y_pred),\n    'Precision (Fad)'    : precision_score(y_test, y_pred),\n    'Recall (Fad)'       : recall_score(y_test, y_pred),\n    'PR-AUC'             : pr_auc,\n    'ROC-AUC'            : roc_auc_score(y_test, final_pred),\n    'Optimal Threshold'  : OPTIMAL_T,\n    'TP / FP / FN / TN'  : f'{tp} / {fp} / {fn} / {tn}',\n    'Net Business Benefit': f'{avoided_markdown:,.0f} SEK avoided (test set)'\n}\nfor k, v in metrics.items():\n    print(f'  {k:25s}: {v:.4f}' if isinstance(v, float) else f'  {k:25s}: {v}')\nprint('=' * 55)\n\n\n# ═══════════════════════════════════════════════════════\n#  Full-Scale Projection\n#  (Test set → Full catalog)\n# ═══════════════════════════════════════════════════════\n\nprint('\\n' + '=' * 70)\nprint('  FULL-SCALE PROJECTION')\nprint('  (Scale จาก test set → full catalog)')\nprint('=' * 70)\n\n# Scale factor\nn_test = len(results_df)\nn_full = 80899\nscale_factor = n_full / n_test\n\nprojected_saving_sek = avoided_markdown * scale_factor\nprojected_saving_usd = projected_saving_sek * SEK_TO_USD\nannual_saving_usd = projected_saving_usd * 2  # 2 cycles per year\n\nprint(f'\\n   Test set:     {n_test:,} articles → {avoided_markdown:,.0f} SEK saved')\nprint(f'   Scale factor: {scale_factor:.2f}x')\nprint(f'   Full catalog: {n_full:,} articles → {projected_saving_sek:,.0f} SEK saved')\nprint(f'   Per year (2 cycles): ${annual_saving_usd:,.0f} USD')\n\n# Projected ROI\nproj_roi_year1 = (annual_saving_usd - total_year1_cost) / total_year1_cost * 100\nproj_payback = total_year1_cost / (annual_saving_usd / 12)\n\nprint(f'\\n   Year 1 ROI:   {proj_roi_year1:+.1f}%')\nprint(f'   Payback:      {proj_payback:.0f} months')\nprint(f'   Annual savings vs cloud cost: {annual_saving_usd/annual_cloud_cost:.0f}x return')\n\n# spark.stop()\n# print('Spark session stopped.')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-02T04:40:29.550509Z","iopub.execute_input":"2026-05-02T04:40:29.550837Z","iopub.status.idle":"2026-05-02T04:40:29.578556Z","shell.execute_reply.started":"2026-05-02T04:40:29.550811Z","shell.execute_reply":"2026-05-02T04:40:29.577546Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n## ✅ Pipeline Complete\n\n**Big Data Architecture:**\n```\n31.7M rows\n    └─ PySpark (lazy + parallel)  ← Phase 0, 1, 3\n           ↓ .toPandas() only when data is small (~105K articles)\n    └─ Pandas / sklearn / LightGBM / XGBoost / LSTM  ← Phase 4-7\n```\n\n**3 values to update from Phase 0 EDA:**\n- `FAD_CLUSTER` (Cell 14) — cluster ID with Fad shape\n- `EARLY_WINDOW` (Cell 15) — week cohort curves diverge\n- `CUTOFF` (Cell 21) — temporal split from imbalance EDA\n- `bright_colours` / `is_allover_pattern` codes (Cell 18)","metadata":{}}]}