Analyzing Repurchase Cycles: When Single Metrics Aren’t Enough
A practical guide to detecting and analyzing multi-modal repurchase patterns in customer data
Analyzing Repurchase Cycles: When Single Metrics Aren’t Enough

Photo by Mario von Rotz on Unsplash
If you are not a member you can read the story here.
Imagine working with years of customer transaction data spanning multiple product categories. At some point, you’ll face a deceptively simple question:
“How often do customers repurchase this product?”
The question seems straightforward, but the standard approach — using mean or median — rests on a critical assumption: that customers follow roughly the same purchasing rhythm. When this assumption fails, a single summary statistic doesn’t just oversimplify — it can mislead restocking schedules, recommendation engines, and marketing campaigns.
This article presents a method for analyzing the structure of repurchase cycle distributions (github). We examine how to detect, quantify, and interpret distinct purchasing patterns across product categories, supported by pipeline design and a worked example.
Problem Definition and Common Pitfalls
The repurchase cycle is the time between consecutive purchases of the same product (or category) by a single customer. You can analyze this at the individual product level or aggregate up to categories — but broader groupings introduce more behavioral complexity. Critically, this complexity isn’t measurement error; it reflects genuine variation in how different customers buy.
The default approach — summarizing cycles with a mean or median — assumes relatively homogeneous behavior. That assumption breaks down when you encounter:
- Skewed distributions
- Long tails or extreme values
- Multiple purchasing rhythms (multimodal distributions)
Even when mean and median align closely, the underlying purchase patterns can differ dramatically.
The visualizations below show what summary statistics capture — and what they miss — across unimodal and multimodal distributions. These examples motivate the analytical framework we’ll build.
Skewness: Right-skewed distributions pull the mean above the median. Relying on the mean overstates how quickly most customers actually repurchase.

Generated by Claude Sonnet 4.5.
Long tails and extreme values: Outliers drag the mean upward even when most customers cluster within a narrow range. In the chart below, 85% of users repurchase within a tight window — yet the mean sits far above the median.

Generated by Claude Sonnet 4.5.
Multimodal distributions: When customers split into distinct repurchase patterns, a single summary statistic is worse than useless — it misrepresents every group. The correct approach identifies each mode separately, though real-world data rarely splits this cleanly.

Generated by Claude Sonnet 4.5.
Repurchase cycle analysis isn’t about finding the answer — it’s about asking the right questions:
- Does this category show consistent purchasing behavior?
- Are there multiple stable repurchase patterns?
- Do those patterns warrant distinct operational strategies?
Our approach inverts the traditional workflow. Instead of defaulting to summary statistics, we start with the distribution structure: assess its complexity first, then decide if collapsing to a single metric even makes sense.
Pipeline

Created by author, generated by Napkin
1. Transaction Interval Calculation
- Convert raw transactions into purchase intervals
- Group by category and drop customers with insufficient repurchase history
2. Data Cleaning and Validation
- Remove negative values, missing data, and outliers (IQR-based filtering)
3. Scale Transformation
- Apply transformations (log1p, Yeo-Johnson, etc.) based on data characteristics
- Reduce skewness to improve downstream analysis
4. Exploratory Visualization
- Generate distribution plots for manual inspection and quality checks
5. Unimodality Test
- Test for single vs. multiple modes (Hartigan’s Dip Test, KDE extrema)
- Trigger additional peak detection if multimodal
6. Peak Detection
- Identify mode locations using KDE and clustering (scipy, MeanShift)
- Extract candidate repurchase cycle centers
7. Modality Quantification
- Cross-validate detected peaks with GMM clustering
- Confirm the number of distinct purchasing patterns
8. Stability Assessment
- Bootstrap resample to test peak robustness
- Verify that identified modes are statistically stable, not sampling artifacts
9. Result Integration and Export
- Aggregate findings across all categories into a comprehensive report
Usage
This project uses uv for dependency management. Once you’ve tried it, pip feels painfully slow by comparison.
# Clone repository
git clone <repository-url>
cd RepurchaseCycleAnalysis
# Install with uv
uv sync
Minimal Command (CLI)
uv run python -m repurchase_cycle \\
--input-path ./data/raw/combined_transaction.csv \\
--output-dir ./reports
Input Data Format
Provide raw transaction records with these minimum required fields:

Example transaction CSV:
UserId,Category,OrderDate,Amount
U000001,Electronics,2023-01-15,899.99
U000001,Electronics,2023-03-25,450.50
Sample Data
We’ve included a sample data generator (./scripts/generate_sample_data.py) that creates datasets with different distribution patterns to showcase the pipeline's capabilities.
The portfolio of dataset:

The synthetic dataset spans three volume tiers to test different pipeline behaviors:
- Electronics (small, ~8K records): Single-mode distribution centered at 90 days
- Groceries (medium, ~100K records): Bimodal with peaks at 7 and 21 days
- Stationery (medium, ~80K records): Uniform distribution as a control case
- Supplements (large, ~1.2M records): Trimodal with peaks at 30, 60, and 90 days
Generation uses pandas and numpy with these constraints:
- Minimum 1-day intervals between purchases
- At least 50 users per category
- Each transaction includes date, price, quantity, unit price, and country
Execution
Once your transaction data matches the required schema, place it in ./data/raw/ and execute:
uv run python -m repurchase_cycle \\
--input-path ./data/raw/combined_transaction.csv \\
--output-dir ./reports
During execution, the terminal displays runtime details including:
- Data volume tier for each category (small/medium/large), which determines parameter settings
- Module-specific configurations
- Completion status for each stage
[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'}
...
The pipeline generates outputs in ./reports/:
**/separate_reports/** — Individual JSON reports per category (optional)**/validation_plots/** — Peak validation diagnostics**/visualization/** — Exploratory distribution plots**summary_all.json** — Aggregate summary across all categories**complete_report_all.json** — Full execution details and module statistics
Here’s the summary output for Electronics:
{
"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
}
}
Analysis and Interpretation
We’ll walk through the Groceries results (complete_report_Groceries.json) as a worked example. As a medium-sized dataset, it triggers different analytical methods than small or large categories.
Transaction Interval Calculation
The raw data contains 100,047 transactions from 1,743 users. Computing intervals between consecutive purchases yields 98,304 records — users with only a single purchase are dropped since they provide no repurchase information.
{
"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
}
}
Data Cleaning and Validation
Outlier detection uses IQR with a 1.5× threshold. In this case, all intervals fall within bounds — no records removed.
{
"mode": "medium",
"discard_summary": {
"total_rows": 98304,
"removed_negatives": 0,
"removed_missing": 0,
"removed_outliers": 0
}
}
Scale Transformation
Skewness is checked to decide if transformation (log1p, Yeo-Johnson, etc.) would improve analysis. With skewness at 0.37, Groceries falls below the threshold — transformation skipped.
{
"transform_meta": {
"method": "none",
"skewness_before": 0.3706668948181378,
"skewness_after": 0.3706668948181378,
"transform_params": {}
}
}
Exploratory Visualization
The summary statistics hint at complexity: mean = 12 days, median = 8.9 days, SD = 7.1. The gap between mean and median suggests skewness or multimodality — confirmed by the distribution plot below.
{
"summary_stats": {
"n": 98304.0,
"mean": 12.54424238511074,
"median": 8.880775462962962,
"std": 7.1351541791152355,
"skew": 0.3706725508563762
}
}

Generated by author
The raincloud plot (adapted from https://github.com/pog87/PtitPrince/tree/master) combines density, boxplot, and raw data — ideal for spotting multimodal structure.
Two distinct modes are visible: one near 7 days, another around 21 days. The median (8.9 days) sits close to the first peak but would overestimate the shorter cycle and badly misrepresent the longer one. This is exactly why we need formal peak detection.
Unimodality Test
The pipeline first checks for uniform distributions (via kurtosis), then applies formal tests if warranted. For Groceries, both Hartigan’s Dip Test and Silverman’s bandwidth test reject unimodality (p < 0.05) — confirming multiple modes.
{
"unimodality_test_result": {
"dip_p": 0.0,
"method_used": "dip_subsampled+silverman",
"decision": "multimodal"
}
}
Peak Detection
{
"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 smooths the empirical distribution, then argrelmax identifies local maxima. For each detected peak, we extract position, height (density), width, and prominence.

Generated by author
Modality Quantification
While KDE locates peaks, it doesn’t estimate cluster proportions or variance. We cross-validate with Gaussian Mixture Models (GMM), which also selects k=2 components — confirming the KDE findings.
{
"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"
}
}
Stability Assessment
Bootstrap resampling (100 iterations, 80% sample size) tests whether the detected peaks persist across subsamples. Both modes appear in 100% of resamples with minimal position drift — confirming they’re robust features, not sampling artifacts.
{
"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"
}
Practical implication: Groceries exhibits two distinct purchasing rhythms (~7 and ~21 days). Using a single repurchase window for reminders or inventory forecasts would misalign with both groups — better to segment customers and tailor strategies accordingly.

Generated by author
Summary Across All Categories
The full dataset yields these results:

Detected peaks align well with the ground-truth modes (this is synthetic data, after all). One exception: Supplements shows 6 GMM components versus 3 KDE peaks. This likely reflects noise or minor modes in the 1.2M-record dataset — but stability assessment confirms only the three major peaks are robust, so we retain those.
The pipeline successfully decomposed repurchase patterns across all test cases — single-mode, bimodal, trimodal, and uniform. These identified cycles can now inform customer segmentation or targeted retention strategies.
Limitations and Scope
Small Sample Issues
- Single-purchase users are dropped during interval calculation. If most customers purchase only once, the dataset may become too sparse for reliable analysis.
- Hartigan’s Dip Test loses power below ~500 intervals. The pipeline mitigates this by combining multiple tests (Silverman, KDE extrema).
- Bootstrap stability assessment requires sufficient data to resample. Very small datasets yield noisy support ratios.
Observation Window Requirements
Analysis requires observation windows longer than the repurchase cycles you’re trying to detect. Short windows produce right-censored data that biases results downward.
Outlier handling: By default, IQR-based filtering (1.5× threshold) removes extreme intervals. Alternative: quantile clipping ([0.05, 0.95], configurable in data_cleaning settings).
Category Definition Stability
User-defined categories should be consistent and specific. Overly broad categories (e.g., “Food”) may exhibit temporal drift as assortment changes. Start with longer historical windows to capture stable patterns.
Promotions and Seasonality
The framework detects multimodal structure but doesn’t attribute modes to causes. If peaks align with recurring promotions or holidays, you’ll need external metadata to distinguish event-driven spikes from organic purchasing rhythms.
Further Reading
This article offers only a high-level overview of the project’s modules and how they are used.
For a deeper dive into the ideas and statistical reasoning behind each stage of the pipeline, I plan to cover the following topics in separate articles:
- Data transformation choices (coming soon)
- Unimodal or multimodal distributions? (coming soon)
- How many peaks are there? (coming soon)
- Verifying modality results (coming soon)
- Are the results stable? (coming soon)
That’s all for this article — thanks for reading. If you enjoyed this article, you can:
- 👏 Clap a few times — no need to hit all 50
- ✉️Follow me on Medium to stay updated and keep me motivated
- 😜 Connect with me on LinkedIn — I’d love to exchange ideas
메타데이터
- post_id
- d7d4548ec774
- slug
- analyzing-repurchase-cycles-when-single-metrics-arent-enough-d7d4548ec774
- url
- https://medium.com/@wh49hng/analyzing-repurchase-cycles-when-single-metrics-arent-enough-d7d4548ec774
- canonical_url
- https://medium.com/@wh49hng/analyzing-repurchase-cycles-when-single-metrics-arent-enough-d7d4548ec774
- author_url
- https://medium.com/@wh49hng
- status
- ok
- fetched_at
- 2026-07-13 06:23:13