11import copy
22import warnings
3+ import os
4+ import csv
5+ import math
6+ from bisect import bisect
37
48import pyANOVAapprox .analysis as ANOVAanalysis
59import pyANOVAapprox .bandwidth as ANOVAbandwidth
@@ -398,6 +402,88 @@ def approximate(
398402
399403 estimate_rates = ANOVAbandwidth .estimate_rates
400404
405+ def _compute_cv_error (
406+ self ,
407+ D ,
408+ t ,
409+ setting ,
410+ lam ,
411+ solver ,
412+ solver_max_iter ,
413+ solver_weights ,
414+ solver_verbose ,
415+ solver_tol ,
416+ min_B_step = 2 ,
417+ cv_max_iter = 20 ,
418+ verbosity = 0 ,
419+ ):
420+ """
421+ Computes GCV error for a fixed budget B.
422+
423+ If optimize_budget=True, performs a binary-search style minimization over
424+ [B_min, B_max] and returns (best_B, best_cv_error).
425+ Otherwise returns cv_error for the provided B.
426+ """
427+
428+ def _cv_for_budget (B_curr ):
429+ bw = compute_bandwidth (B_curr , D , t )
430+
431+ # Create temporary setting for this bandwidth
432+ temp_setting = copy .copy (setting )
433+ temp_setting .N = [bw [i ] for i in setting .U ]
434+
435+ # Save active setting and attach temporary objects
436+ self .addSetting (temp_setting )
437+ settingnr_temp = self .aktsetting
438+ self .addTrafo (settingnr_temp )
439+
440+
441+ self ._approximate (
442+ lam ,
443+ settingnr = settingnr_temp ,
444+ max_iter = solver_max_iter ,
445+ weights = solver_weights ,
446+ verbose = solver_verbose ,
447+ solver = solver ,
448+ tol = solver_tol ,
449+ )
450+
451+ M = self .X .shape [0 ]
452+ nfreqs = np .sum ([np .prod (np .array (bw [u ])- 1 ) for u in temp_setting .U ])+ 1
453+ return 1 / M * np .linalg .norm (self .evaluate (settingnr = settingnr_temp , lam = lam ) - self .y )** 2 / (1 - nfreqs / M )** 2
454+
455+ B_min = float (max (sum (5 ** len (u ) for u in setting .U ), 1 ))
456+ B_max = float (max (B_min + 1.0 , len (self .y ) / 3.0 ))
457+
458+ # Binary-search style minimization using local slope sign from finite differences.
459+ for cv_iter in range (cv_max_iter ):
460+ mid = 0.5 * (B_min + B_max )
461+ delta = max (1.0 , 0.01 * mid )
462+ left_B = max (B_min , mid - delta )
463+ right_B = min (B_max , mid + delta )
464+
465+ cv_left = _cv_for_budget (left_B )
466+ cv_right = _cv_for_budget (right_B )
467+
468+ if verbosity > 4 :
469+ print (
470+ f" CV iteration { cv_iter + 1 } : "
471+ f"B_left={ left_B :.2f} , B_right={ right_B :.2f} , "
472+ f"cv_left={ cv_left :.6e} , cv_right={ cv_right :.6e} "
473+ )
474+
475+ if cv_right < cv_left :
476+ B_min = mid
477+ else :
478+ B_max = mid
479+
480+ if (B_max - B_min ) <= min_B_step :
481+ break
482+
483+ best_B = 0.5 * (B_min + B_max )
484+ best_cv = _cv_for_budget (best_B )
485+ return best_B , best_cv
486+
401487 def _autoapproximate (
402488 self ,
403489 lam ,
@@ -410,6 +496,9 @@ def _autoapproximate(
410496 solver_weights ,
411497 solver_verbose ,
412498 solver_tol ,
499+ cross_validation ,
500+ min_B_step ,
501+ cv_max_iter ,
413502 ):
414503 settingnrs = []
415504 setting = self .getSetting (settingnr )
@@ -430,6 +519,29 @@ def _autoapproximate(
430519 for idx in range (maxiter ):
431520 if verbosity > 0 :
432521 print ("===== Iteration " , str (idx + 1 ), " =====" )
522+
523+ # Optional: Use cross-validation to find optimal B
524+ if cross_validation :
525+ if verbosity > 0 :
526+ print ("Running binary search for optimal bandwidth via cross-validation..." )
527+
528+ B , cv_val = self ._compute_cv_error (
529+ D = D ,
530+ t = t ,
531+ setting = copy .copy (setting ),
532+ lam = lam ,
533+ solver = solver ,
534+ solver_max_iter = solver_max_iter ,
535+ solver_weights = solver_weights ,
536+ solver_verbose = solver_verbose ,
537+ solver_tol = solver_tol ,
538+ min_B_step = min_B_step ,
539+ cv_max_iter = cv_max_iter ,
540+ verbosity = verbosity ,
541+ )
542+ if verbosity > 0 :
543+ print (f"Optimal B from CV: { B :.1f} (cv={ cv_val :.6e} )" )
544+
433545 bw = compute_bandwidth (B , D , t )
434546 if setting .N is not None :
435547 self .addSetting (setting )
@@ -490,7 +602,23 @@ def autoapproximate(
490602 solver_weights = None ,
491603 solver_verbose = False ,
492604 solver_tol = 1e-8 ,
605+ cross_validation = False ,
606+ min_B_step = 2 ,
607+ cv_max_iter = 20 ,
493608 ):
609+ """
610+ Automatic approximation with optional cross-validation for bandwidth selection.
611+
612+ Parameters:
613+ -----------
614+ use_cross_validation : bool, optional
615+ If True, uses cross-validation with binary search to find optimal bandwidth B
616+ in each iteration. Default is False.
617+ cv_tol : float, optional
618+ Convergence tolerance for cross-validation binary search. Default is 1e-3.
619+ cv_max_iter : int, optional
620+ Maximum number of iterations for cross-validation binary search. Default is 20.
621+ """
494622 settingnr = self .getSettingNr (settingnr )
495623 setting = self .getSetting (settingnr )
496624
@@ -515,6 +643,9 @@ def autoapproximate(
515643 solver_weights = solver_weights ,
516644 solver_verbose = solver_verbose ,
517645 solver_tol = solver_tol ,
646+ cross_validation = cross_validation ,
647+ min_B_step = min_B_step ,
648+ cv_max_iter = cv_max_iter ,
518649 )
519650 self .lam [l ] = self .lam [l ] + settingnrs
520651
0 commit comments