分析回購週期:單一指標的侷限與突破
從單一數值到分布結構的實戰指南
分析回購週期:單一指標的侷限與突破

Photo by Mario von Rotz on Unsplash
如果你不是會員,你可以透過此鏈結瀏覽文章
假設你手上有一段足夠長的客戶歷史交易資料,商品涵蓋多個分類。在實務分析中,常常會遇到一個看似直覺、卻很難被清楚回答的問題:
「這個商品大概多久會被回購?」
這問題看似簡單,卻很容易被錯誤的統計假設誤導。我們習慣用平均或中位數描述回購週期,但這樣的做法隱含了一個前提 — 所有客戶的購買節奏大致一致。一旦這個前提不成立,單一數字不只資訊不足,甚至可能對後續的補貨、推薦與行銷決策造成誤判。
本文將介紹一套用於分析商品回購週期分布結構的方法(github),重點放在如何觀測、判斷與量化不同分類下的購買間隔行為。內容涵蓋核心分析思路、pipeline 設計,並透過一組範例資料,示範整體分析流程與輸出結果。
問題定義與常見誤區
回購週期指的是同一位客戶,從某一商品(或商品分類)的一次購買行為,到下一次購買同一商品(或分類)之間的時間間隔。分析單位可以從單一商品到商品分類不等,但單位愈寬,行為結構也愈容易變得複雜,這種複雜性不是雜訊,而是真實存在於資料中的混合行為。
實務上常見的簡化做法是使用平均數或中位數來描述回購週期,但這類方法隱含了行為分布相對單純的假設。當資料實際呈現以下情況時,這些統計量往往不足以反映真實結構:
- 分布偏態
- 存在長尾或極端值
- 同時包含多種購買節奏(多峰分布)
在這些情境下,即使平均與中位數數值接近,背後代表的行為模式仍可能截然不同。
以下透過幾組視覺化範例,說明在單峰與多峰分布下,平均數與中位數各自能反映與無法反映的資訊差異,並作為後續分析方法設計的出發點。
偏態:右偏的統計分佈導致平均數會比中位數高,回購週期的體感會被高估

Generated by Claude Sonnet 4.5.
長尾、極端值:在不經過數據轉換下同樣會拉高平均值,以下圖來說85%的用戶回購時間長度範圍中,中位數與平均值被拉得很開

Generated by Claude Sonnet 4.5.
多峰分布:多種分布混合下,以單一的中位數、平均數作為週期判斷會帶來嚴重誤差,實際上應該就檢測出的分佈數量去回推;實際應用更複雜

Generated by Claude Sonnet 4.5.
回購週期分析的核心,不在於「找一個正確的數字」,而在於判斷:
- 這個分類的購買行為是否集中
- 是否存在多種穩定的回購節奏
- 這些節奏是否足以支持差異化策略
因此,本次設計的模組採取的不是傳統的描述統計路線,而是以分布結構為中心的分析方式:先判斷結構是否單純,再決定是否有資格被壓縮成一個數字。
Pipeline

Created by author, generated by Napkin
- 交易間隔計算
- 將最原始的交易記錄轉換成購買間隔資訊
- 依據資料中指定的分類資訊區隔處理,濾除類型下區間數量太低者
- 資料清理與檢查
- 檢驗交易間隔數據;排除負值、缺失值,藉IQR除離群值
- 尺度轉換
- 根據資料量級決定尺度轉換的做法,像是log1p, yeo-johnson等
- 將購買間隔資訊做平滑處理,減少偏態產生
- 探索性視覺化
- 提供人工檢查資料形狀的視覺化;讓用戶在確認成果過程中擁有視覺化圖表佐證
- 單峰性檢定
- 透過Hartigan’s Dip Test、KDE極值法等手段檢驗資料間隔為單峰或是多峰
- 在確認為多峰情形下,載入更多工具檢測峰值落點
- 峰值檢測
- 在檢定為多峰下,進一步藉由KDE配合scipy工具或是MeanShift分群工具來找出峰所在位置,取得前在購買週期的中心點
- 模態數量量化
- 在確認多峰性跟演算法判斷過的峰值後,更進一步透過模態數量量化來檢驗是否一致
- 以GMM分群作法輔助檢驗可能的模態數量
- 穩定性檢驗
- 資料量固定下,透過統計手法重複抽樣(Bootstrapping),驗證這樣的間隔所產出的峰值是否穩定,足以支持每個峰的存在
- 結果整合與匯出
- 整合分析成果,提供彙整報告告
使用方式
- 這邊推薦用uv,在體驗過用uv搭建開發環境後回不去pip的方式了,速度飛快!
# Clone repository
git clone <repository-url>
cd RepurchaseCycleAnalysis
# Install with uv
uv sync
- 最小指令 (CLI)
uv run python -m repurchase_cycle \\
--input-path ./data/raw/combined_transaction.csv \\
--output-dir ./reports
- 輸入資料格式:

依照模組設計的邏輯,給定了至少三種資料量級的分類交易數據,最低的是Electronics,呈現單峰,最大的Supplements則有3個購買週期分佈,另外為了能有對照產出,在中量級資料中加入了Stationery並假設這類型的資料是uniform的分佈。
資料生成的則是透過 pandas numpy 套件的幫助,內建邏輯有
- 確保消費間隔至少爲1天
- 調整使用者數量並確保至少最低有50人
- 最後為每筆交易資料附加日期、價格、數量、單價跟國家
執行
依照input schema的要求準備好資料放在 ./data/raw之下,在專案目錄下直接運行指令:
uv run python -m repurchase_cycle \\
--input-path ./data/raw/combined_transaction.csv \\
--output-dir ./reports
模組的運行期間,terminal會print出一些運行中的細節資訊;執行過程中會記錄當前處理的分類屬於哪種量級的資料(對應small, medium, large會有不一樣的參數跟方法使用);不同模組使用到的參數以及執行完畢的簡易資訊。
[2026-01-27 21:42:53] INFO - repurchase_cycle - Starting repurchase cycle analysis pipeline
[2026-01-27 21:42:53] INFO - repurchase_cycle - Processing category: Electronics
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.interval_derivation - === Interval Calculation Config ===
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.interval_derivation - Mode: small
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.interval_derivation - Using config: {'uid_col': 'UserId', 'cat_col': 'Category', 'date_col': 'OrderDate', 'groupby_cols': ['UserId', 'Category'], 'keep_first_purchase': False, 'date_format': None, 'extra_cols': [], 'min_intervals_per_group': 2}
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.interval_derivation - Total transactions: 8005
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.interval_derivation - Unique users: 941
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.interval_derivation - Unique categories: 1
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.interval_derivation - Output intervals: 7064
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.interval_derivation - Single purchase dropped: 941
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.interval_derivation - Dropped due to insufficient intervals: 0
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.interval_derivation - Final columns: ['uid', 'cat', 'order_date', 'prev_order_date', 'interval_days', 'purchase_seq']
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.data_cleaning - === Data Cleaning Config ===
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.data_cleaning - Mode: small
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.data_cleaning - Using config: {'remove_negatives': True, 'missing_strategy': 'drop', 'outlier_method': 'IQR', 'outlier_threshold': 1.5, 'quantile_bounds': [0.05, 0.95], 'min_group_size_for_stats': 3}
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.data_cleaning - Removed 0 negative values
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.data_cleaning - Removed 0 missing values
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.data_cleaning - Removed 61 outliers
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.transform - === Transform Config ===
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.transform - Mode: small
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.transform - Using config: {'method_candidates': ['log1p', 'yeo_johnson', 'none'], 'auto_select_by_skewness': True, 'skew_threshold': 2.0}
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.visualization - === Visualization Config ===
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.visualization - Mode: small
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.visualization - Using config: {'sample_ratio': 0.05, 'kde_bandwidths': [0.3, 0.6, 1.0], 'plot_types': ['raincloud'], 'orient': 'h', 'palette': 'Set2', 'sigma': 0.2, 'data_hue': None, 'multi_category': False, 'base_plots_dir': './plots'}
[2026-01-27 21:42:53] INFO - repurchase_cycle.modules.visualization - run_visualization: n_rows=7003, mode=small (resolved=small)
[2026-01-27 21:42:54] INFO - repurchase_cycle.modules.unimodality_test - === Unimodality Test Config ===
[2026-01-27 21:42:54] INFO - repurchase_cycle.modules.unimodality_test - Mode: small
[2026-01-27 21:42:54] INFO - repurchase_cycle.modules.unimodality_test - Using config: {'alpha': 0.05, 'max_sample_for_test': 10000, 'value_col': 'interval_days_transformed', 'dip_bootstrap_samples': 200, 'methods_by_mode': {'small': ['dip', 'silverman'], 'medium': ['dip_subsampled', 'silverman'], 'large': ['kde_extrema', 'smoothness_emd']}, 'silverman_grid_size': 512, 'silverman_search_iters': 30, 'silverman_bootstrap_samples': 200, 'emd_bootstrap_samples': 200, 'emd_sample_size': 4000}
[2026-01-27 21:45:10] INFO - repurchase_cycle.modules.peak_detection - === Peak Detection Config ===
[2026-01-27 21:45:10] INFO - repurchase_cycle.modules.peak_detection - Mode: small
[2026-01-27 21:45:10] INFO - repurchase_cycle.modules.peak_detection - Using config: {'height_min': 0.001, 'prominence_min': 0.005, 'grid_size': 512, 'kde_bandwidth_factor': 0.5, 'kde_bandwidth': 0.5, 'meanshift_bandwidth': 1.0, 'argrelmax_order': None, 'large_density_filter': 'quantile', 'large_density_quantile': 0.7}
[2026-01-27 21:45:10] INFO - repurchase_cycle.modules.modality_quantification - === Modality Quantification Config ===
[2026-01-27 21:45:10] INFO - repurchase_cycle.modules.modality_quantification - Mode: small
[2026-01-27 21:45:10] INFO - repurchase_cycle.modules.modality_quantification - Using config: {'k_range': [1, 6], 'selection_metric': 'BIC', 'subsample_size': 20000, 'max_iter': 500, 'n_init': 5, 'kde_grid_size': 512, 'dp_weight_threshold': 0.01}
[2026-01-27 21:45:11] INFO - repurchase_cycle.modules.stability_assessment - === Stability Assessment Config ===
[2026-01-27 21:45:11] INFO - repurchase_cycle.modules.stability_assessment - Mode: small
[2026-01-27 21:45:11] INFO - repurchase_cycle.modules.stability_assessment - Using config: {'n_bootstrap': 100, 'sample_fraction': 0.8, 'support_threshold': 0.6, 'value_col': 'interval_days_transformed', 'match_tol': 'None', 'grid_size': 512}
[2026-01-27 21:45:12] INFO - repurchase_cycle.modules.reporting - === Reporting Config ===
[2026-01-27 21:45:12] INFO - repurchase_cycle.modules.reporting - Mode: small
[2026-01-27 21:45:12] INFO - repurchase_cycle.modules.reporting - Using config: {'export_formats': ['json', 'pdf', 'png'], 'provide_details': True, 'separate_category_report': True, 'reports_path': './reports'}
...
預設的報告會落在./reports 下:
/separate_reports/: 在預設config開啟下,每個分類的報告會獨立產出一份json/validation_plots/: 模組中負責驗證峰值相關的部分所產出的圖片/visualization/: 視覺化模組的結果,如果有添加額外的視覺化需求,會產生更多圖summary_all.json: 所有分類分析完畢後的精簡摘要complete_report_all.json: 所有分類分析完畢後每個模組執行細節、統計資訊等
以Electronics為例,它的summary結果如下:
{
"summary": {
"original_transaction_counts": 8000,
"original_n": 7052.0,
"n": 7015.0,
"mean": 89.83597840276127,
"median": 89.87988425925926,
"std": 9.632966665801716,
"skew": -0.006935435592411816,
"dip_p": 0.4,
"is_flat_distribution": false,
"n_peaks": 1,
"peaks": [
{
"pos": 89.87988425925926,
"pos_transformed": null,
"height": null,
"width": null,
"prominence": null,
"source": "inferred_from_unimodal_median"
}
],
"stable_peaks": [
{
"pos": 89.87988425925926,
"pos_transformed": null,
"support_ratio": 1.0,
"source": "inferred_from_unimodal_median"
}
],
"best_n_components": 1,
"consistency": "consistent",
"PEP": "Single repurchase cycle detected at ~89.9 days",
"meta": {
"unimodality_test_result": {
"dip_p": 0.4,
"method_used": "dip+silverman",
"decision": "unimodal"
},
"gmm_result": null,
"mode": "small",
"alpha_used": 0.05
}
},
"figures": {
"distribution_plot": null,
"stability_plot": null
}
}
分析與解讀
我以樣本中的Groceries為重點解讀案例( complete_report_Groceries.json),一步步看階段性的產出;Groceries屬於中資料量級,而解讀會以對應的資料量級所觸發的工具機制說明。
交易間隔計算
Groceries原始的交易資料筆數有100,000多筆,其中用戶數量有1,743位,透過前後交易時間相減可以取得共98,304筆交易間隔記錄,對於單一交易間隔者因為不具參考價值會被踢除。
{
"interval_conversion_summary": {
"total_transactions": 100047,
"unique_users": 1743,
"unique_categories": 1,
"output_intervals": 98304,
"single_purchase_dropped": 1743,
"dropped_due_to_insufficient_intervals": 0
}
}
資料清理與檢查
資料檢查在參數設定中採用IQR搭配1.5倍門檻進行過濾,全數處於有效範圍中,因此不會做任何過濾與轉換。
{
"mode": "medium",
"discard_summary": {
"total_rows": 98304,
"removed_negatives": 0,
"removed_missing": 0,
"removed_outliers": 0
}
}
尺度轉換
攸關統計分析的步驟基本上會經過一次尺度的轉換,這邊會先用skewness來判斷是否有必要做處理,假如Groceries的分布有偏態產生,預設會走向使用log1p的方式進行轉換。
{
"transform_meta": {
"method": "none",
"skewness_before": 0.3706668948181378,
"skewness_after": 0.3706668948181378,
"transform_params": {}
}
}
探索性視覺化
購買間隔的統計分布來看,平均跟中位數分別是12天跟8天,標準差是7.13,明顯不會是單一峰值的狀態,我們可以配合視覺化成果一起確認。
{
"summary_stats": {
"n": 98304.0,
"mean": 12.54424238511074,
"median": 8.880775462962962,
"std": 7.1351541791152355,
"skew": 0.3706725508563762
}
}

Generated by author
這個繪圖的方式是參考https://github.com/pog87/PtitPrince/tree/master 的raincloud plot,以混合峰值分布、箱型圖跟散布圖的概念非常適合作為這個專案模組進行解讀。初步看來,中位數雖然接近第一個高峰卻也不完全相等,貿然採用中位數作為回購週期在某種程度上來說是高估的;第二個峰值超過20天,但在沒有視覺化的幫助下我們沒有辦法判斷,因此需要接下來的模組分析協助。
單峰性檢定
是否為單一峰值則藉由dip跟silverman test確認;檢定前內部會通過kurtosis來判斷是否平坦(uniform distribution case),在不平坦下才進一步做單峰性檢定;這邊檢定結果小於0.05門檻,因此可以認定為多個峰值存在。
{
"unimodality_test_result": {
"dip_p": 0.0,
"method_used": "dip_subsampled+silverman",
"decision": "multimodal"
}
}
峰值檢測
{
"peaks_table": [
{
"pos": 7.3819941234960496,
"height": 0.12366456122423497,
"width": 4.687912445368209,
"prominence": 0.12366456122423475
},
{
"pos": 20.96775255603573,
"height": 0.08122680076228943,
"width": 4.474354029489988,
"prominence": 0.08096044973737496
}
],
"kde_plot_with_peaks": "reports/validation_plots/Groceries_peak_detection_kde_medium.png",
}
資料會用KDE來擬合分佈使曲線平滑,再透過argrelmax來判斷曲線上的局部最大值,我們可以抓到這個峰在資料上的位置、寬度、高度(機率),一定程度掌握了大概的位置。

Generated by author
模態數量量化
前面用KDE判斷出大概的位置(只能知道峰位置,不會知道比例),所以我們再透過GMM幫助我們提取與驗證這些群集的中心點與變異程度;GMM的判斷也是兩個模態,與KDE產出相符
{
"modality_result": {
"best_n_components": 2,
"aic_scores": [
135236.29379609146,
111291.79230343598,
111364.94932100382,
111409.1630893659,
111408.83783458598,
111425.63913154457
],
"bic_scores": [
135252.10077119654,
111331.30974119867,
111428.1772214241,
111496.10145244379,
111519.48666032149,
111559.99841993768
]
},
"consistency_check": {
"kde_n_peaks": 2,
"gmm_n_components": 2,
"status": "consistent"
}
}
穩定性檢驗
隨機抽樣80次,每次會取出其中70%的sample驗證購買間隔的峰值是否可以穩定產出;每次抽出的結果經過計算其峰值與前面步驟斷定的位置差異都不大,所以兩個峰的support ratio皆為100%,到此為止我們可以很有信心的說,Groceries的購買週期存在兩個尖峰,一個是7.4天,另一個大概是21天,正好與前面的資料假設相符。
這代表在實務應用上,Groceries 不適合用單一回購週期進行推播或補貨推估,更適合根據不同節奏族群,設計分段式的提醒與預測策略。
{
"stable_peaks_table": [
{
"pos": 7.3819941234960496,
"support_ratio": 1.0
},
{
"pos": 20.96775255603573,
"support_ratio": 1.0
}
],
"stability_plot": "reports/validation_plots/Groceries_stability_assessment_peaks.png",
"PEP": "2 repurchase cycles detected at ~7.4, ~21.0 days"
}

Generated by author
全分類分析成果
我們回過頭來看整份資料;四種分類經過分析工具後的產出摘要如下:

每種分類對應的分布與實際設計的尖峰位置與估計差不多,較為特殊的是Supplements透過GMM分群觀測到的component有六組,與KDE出來結果數量不同,有可能是大資料量級下在分布上有些抖動出現被演算法捕捉到,但穩定性評估環節已對三個KDE產出進行驗證,我們可根據有把握的峰值為基準使用即可。
上述四種分類與情境我們都可以透過分析模組拆解出回購週期的位置,後續就可以在對分類中進行多峰的歸納或分群應用。
限制與適用範圍
目前模組設計上還是有一些限制,使用者需要特別注意,以下列舉參考:
資料量級不足時的限制
- 在最早進行資料轉換(transaction to interval),如果有大量用戶僅出現單次購買,會被全數丟棄,這是目前模組設計上的限制
- 在判斷為單峰還是多峰時,Hartigan’s Dip Test在非常小樣本時的 p-value 並不可靠,但模組設計上會盡量配合其他檢定方法一起判斷,避開因檢定可能造成的誤判
- 在穩定性評估階段的方法中,Bootstrap 的做法會是數量乘上一個sample fraction,如果拉出來的樣本太小,一樣會造成參考的support ratio不穩定
觀測窗長度與截尾條件
- 在data_cleaning 中可以選擇要用哪種方式清洗資料,預設會用IQR搭配1.5倍標準差來排除掉離群值,但也支援用Quantile處理(default: [0.05, 0.95], 可調),這樣的機制是為了減輕極端值對分析上的影響
類別定義不穩定(分類漂移)的影響
- 分類(類別)這塊資料是由使用者自由定義,如果分類在定義上過於廣泛,那在不同資料量、長短不定的時間範圍下考慮的資料是有機會觀察到飄移的,因此初次使用會建議放入較長時間範圍,畢竟觀測時長必須要大於回購週期數才有機會被檢測出來
促銷/節日造成的混合分布
- 在峰值偵測、模態檢驗(GMM分解)、穩定性評估模組中是可用來偵測混合分佈的,但在資料框架設計上沒有支援節日、促銷的標記,所以我們沒有辦法精確的分析因活動節日所帶來的分佈影響
延伸閱讀
本文只有對專案模組的功能與使用做簡易的說明,有關pipeline下的各模組概念與原理,我預計會依照以下幾個主題說明:
- 資料轉換決策 (coming soon)
- 單峰還是多峰?(coming soon)
- 有多少個峰? (coming soon)
- 模態檢驗 (coming soon)
- 結果穩定嗎? (coming soon)
我的分享到這邊,感謝你的閱讀,如果你喜歡這篇文章,你可以:
- 👏 拍個幾下手就好,不需要按到 50 下
- ✉️ 在 Medium 上追蹤我,這會給我更多動力繼續創作
- 😜 歡迎加我 LinkedIn,一起交流想法
메타데이터
- post_id
- 48879ce0261e
- slug
- 分析回購週期-單一指標的侷限與突破-48879ce0261e
- url
- https://medium.com/@wh49hng/%E5%88%86%E6%9E%90%E5%9B%9E%E8%B3%BC%E9%80%B1%E6%9C%9F-%E5%96%AE%E4%B8%80%E6%8C%87%E6%A8%99%E7%9A%84%E4%BE%B7%E9%99%90%E8%88%87%E7%AA%81%E7%A0%B4-48879ce0261e
- canonical_url
- https://medium.com/@wh49hng/%E5%88%86%E6%9E%90%E5%9B%9E%E8%B3%BC%E9%80%B1%E6%9C%9F-%E5%96%AE%E4%B8%80%E6%8C%87%E6%A8%99%E7%9A%84%E4%BE%B7%E9%99%90%E8%88%87%E7%AA%81%E7%A0%B4-48879ce0261e
- author_url
- https://medium.com/@wh49hng
- status
- ok
- fetched_at
- 2026-07-13 06:23:13