11from datetime import date , datetime , timedelta
22from statistics import median
33import math
4+ import random
45
56
67DEFAULT_CYCLE_LENGTH = 28
1011MIN_PERIOD_LENGTH = 1
1112MAX_PERIOD_LENGTH = 14
1213
14+ BOOTSTRAP_ITERATIONS = 1000
15+ CI_LEVELS = {"80" : 0.80 , "95" : 0.95 }
16+
1317
1418def parse_date (value ):
1519 if isinstance (value , date ):
@@ -81,6 +85,165 @@ def _detect_irregularity(values, threshold=7):
8185 return None
8286
8387
88+ def _bootstrap_resample (values , n_iterations = BOOTSTRAP_ITERATIONS , seed = None ):
89+ """Generate bootstrap resamples of the weighted average cycle length.
90+
91+ Uses bias-corrected percentile method for more accurate intervals
92+ when the underlying distribution is skewed (common for cycle lengths).
93+ """
94+ if not values or len (values ) < 2 :
95+ return []
96+
97+ rng = random .Random (seed )
98+ n = len (values )
99+ estimates = []
100+
101+ for _ in range (n_iterations ):
102+ sample = [rng .choice (values ) for _ in range (n )]
103+ estimate = _weighted_average (sample )
104+ if estimate is not None :
105+ estimates .append (estimate )
106+
107+ estimates .sort ()
108+ return estimates
109+
110+
111+ def _compute_confidence_interval (bootstrap_estimates , level = 0.95 ):
112+ """Compute confidence interval from bootstrap distribution.
113+
114+ Uses the percentile method with bias correction for asymmetric distributions.
115+ Returns (lower, upper) bounds as number of days.
116+ """
117+ if not bootstrap_estimates :
118+ return None , None
119+
120+ n = len (bootstrap_estimates )
121+ alpha = 1.0 - level
122+ lower_idx = max (0 , int (math .floor ((alpha / 2 ) * n )))
123+ upper_idx = min (n - 1 , int (math .ceil ((1 - alpha / 2 ) * n )) - 1 )
124+
125+ return bootstrap_estimates [lower_idx ], bootstrap_estimates [upper_idx ]
126+
127+
128+ def _compute_confidence_score (valid_intervals , all_intervals ):
129+ """Compute a 0-1 confidence score reflecting prediction reliability.
130+
131+ Factors:
132+ - Sample size: more cycles = more confidence (logarithmic scaling)
133+ - Regularity: lower coefficient of variation = more confidence
134+ - Data recency: penalize if latest intervals are more variable than historical
135+ """
136+ if not valid_intervals :
137+ return 0.0
138+
139+ n = len (valid_intervals )
140+
141+ # Factor 1: Sample size (logarithmic, saturates around 12 cycles)
142+ size_score = min (1.0 , math .log (n + 1 ) / math .log (13 ))
143+
144+ # Factor 2: Regularity (coefficient of variation)
145+ mean_len = sum (valid_intervals ) / n
146+ if mean_len == 0 :
147+ return 0.0
148+ std = _std_deviation (valid_intervals )
149+ cv = std / mean_len
150+ regularity_score = max (0.0 , 1.0 - (cv / 0.3 ))
151+
152+ # Factor 3: Recency consistency (last 3 vs all)
153+ if n >= 4 :
154+ recent = valid_intervals [- 3 :]
155+ recent_std = _std_deviation (recent ) if len (recent ) >= 2 else 0
156+ recency_score = max (0.0 , 1.0 - (recent_std / max (std , 1.0 )))
157+ else :
158+ recency_score = 0.5
159+
160+ # Weighted combination
161+ score = (0.4 * size_score ) + (0.4 * regularity_score ) + (0.2 * recency_score )
162+ return round (min (1.0 , max (0.0 , score )), 3 )
163+
164+
165+ def _build_prediction_intervals (valid_intervals , next_period_date , average_cycle ):
166+ """Build confidence intervals for the predicted next period date.
167+
168+ Returns a dict with 80% and 95% intervals as date ranges,
169+ plus a numeric confidence score.
170+ """
171+ if len (valid_intervals ) < 2 :
172+ # Insufficient data: return wide default intervals
173+ margin_80 = 5
174+ margin_95 = 10
175+ return {
176+ "ci_80" : {
177+ "lower" : (next_period_date - timedelta (days = margin_80 )).isoformat (),
178+ "upper" : (next_period_date + timedelta (days = margin_80 )).isoformat (),
179+ "margin_days" : margin_80 ,
180+ },
181+ "ci_95" : {
182+ "lower" : (next_period_date - timedelta (days = margin_95 )).isoformat (),
183+ "upper" : (next_period_date + timedelta (days = margin_95 )).isoformat (),
184+ "margin_days" : margin_95 ,
185+ },
186+ "confidence_score" : _compute_confidence_score (valid_intervals , valid_intervals ),
187+ "method" : "default_wide" ,
188+ "n_cycles" : len (valid_intervals ),
189+ }
190+
191+ # Bootstrap the weighted average cycle length
192+ bootstrap_estimates = _bootstrap_resample (valid_intervals , seed = 42 )
193+
194+ if not bootstrap_estimates :
195+ std = _std_deviation (valid_intervals )
196+ margin = max (1 , round (std ))
197+ return {
198+ "ci_80" : {
199+ "lower" : (next_period_date - timedelta (days = margin )).isoformat (),
200+ "upper" : (next_period_date + timedelta (days = margin )).isoformat (),
201+ "margin_days" : margin ,
202+ },
203+ "ci_95" : {
204+ "lower" : (next_period_date - timedelta (days = margin * 2 )).isoformat (),
205+ "upper" : (next_period_date + timedelta (days = margin * 2 )).isoformat (),
206+ "margin_days" : margin * 2 ,
207+ },
208+ "confidence_score" : _compute_confidence_score (valid_intervals , valid_intervals ),
209+ "method" : "std_fallback" ,
210+ "n_cycles" : len (valid_intervals ),
211+ }
212+
213+ # Compute intervals at both levels
214+ result = {
215+ "confidence_score" : _compute_confidence_score (valid_intervals , valid_intervals ),
216+ "method" : "bootstrap" ,
217+ "n_iterations" : BOOTSTRAP_ITERATIONS ,
218+ "n_cycles" : len (valid_intervals ),
219+ }
220+
221+ for label , level in CI_LEVELS .items ():
222+ lower_len , upper_len = _compute_confidence_interval (bootstrap_estimates , level )
223+
224+ if lower_len is None or upper_len is None :
225+ margin = max (1 , round (_std_deviation (valid_intervals )))
226+ lower_len = average_cycle - margin
227+ upper_len = average_cycle + margin
228+
229+ # Convert cycle length bounds to date bounds relative to latest start
230+ lower_diff = average_cycle - lower_len
231+ upper_diff = upper_len - average_cycle
232+
233+ # Ensure asymmetric intervals for irregular cycles
234+ margin_lower = max (1 , abs (round (lower_diff )))
235+ margin_upper = max (1 , abs (round (upper_diff )))
236+
237+ result [f"ci_{ label } " ] = {
238+ "lower" : (next_period_date - timedelta (days = margin_lower )).isoformat (),
239+ "upper" : (next_period_date + timedelta (days = margin_upper )).isoformat (),
240+ "margin_days_lower" : margin_lower ,
241+ "margin_days_upper" : margin_upper ,
242+ }
243+
244+ return result
245+
246+
84247def predict_cycle (cycles , today = None , fallback_cycle_length = DEFAULT_CYCLE_LENGTH ):
85248 today = parse_date (today or date .today ())
86249 normalized = normalize_cycles (cycles )
@@ -149,6 +312,11 @@ def predict_cycle(cycles, today=None, fallback_cycle_length=DEFAULT_CYCLE_LENGTH
149312 confidence_latest = (next_period + timedelta (days = margin_days )).isoformat ()
150313 irregularity_note = _detect_irregularity (valid_intervals )
151314
315+ # Build bootstrap-based prediction intervals
316+ prediction_intervals = _build_prediction_intervals (
317+ valid_intervals , next_period , average_cycle
318+ )
319+
152320 return {
153321 "hasHistory" : True ,
154322 "averageCycleLength" : average_cycle ,
@@ -163,9 +331,11 @@ def predict_cycle(cycles, today=None, fallback_cycle_length=DEFAULT_CYCLE_LENGTH
163331 "latest" : confidence_latest ,
164332 "marginDays" : margin_days ,
165333 },
334+ "predictionIntervals" : prediction_intervals ,
166335 "ovulationDate" : ovulation_date .isoformat (),
167336 "ovulationWindowStart" : window_start .isoformat (),
168337 "ovulationWindowEnd" : window_end .isoformat (),
169338 "confidence" : confidence ,
339+ "confidenceScore" : prediction_intervals ["confidence_score" ],
170340 "irregularityNote" : irregularity_note ,
171341 }
0 commit comments