SearchLibrium 0.0.199__tar.gz → 0.0.201__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {searchlibrium-0.0.199/src/SearchLibrium.egg-info → searchlibrium-0.0.201}/PKG-INFO +1 -1
  2. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/pyproject.toml +1 -1
  3. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/call_meta.py +125 -7
  4. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/harmony.py +182 -113
  5. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/hspbil.py +27 -1
  6. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/sapbil.py +44 -13
  7. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/search.py +116 -23
  8. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/siman.py +1 -1
  9. searchlibrium-0.0.201/src/SearchLibrium/version.txt +1 -0
  10. {searchlibrium-0.0.199 → searchlibrium-0.0.201/src/SearchLibrium.egg-info}/PKG-INFO +1 -1
  11. searchlibrium-0.0.199/src/SearchLibrium/version.txt +0 -1
  12. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/LICENSE +0 -0
  13. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/MANIFEST.in +0 -0
  14. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/README.md +0 -0
  15. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/setup.cfg +0 -0
  16. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/BEHier.py +0 -0
  17. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/Halton.py +0 -0
  18. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/MixedLogit.py +0 -0
  19. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/MixedLogitGSE.py +0 -0
  20. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
  21. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/RandomP.py +0 -0
  22. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
  23. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/Two_Level_Nest.py +0 -0
  24. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/__init__.py +0 -0
  25. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/__main__.py +0 -0
  26. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/_choice_model.py +0 -0
  27. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/_device.py +0 -0
  28. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/banditsa.py +0 -0
  29. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/boxcox_functions.py +0 -0
  30. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/constraints_builder.py +0 -0
  31. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/data/Swissmetro_final.csv +0 -0
  32. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/data/TravelMode.csv +0 -0
  33. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/data/electricity.csv +0 -0
  34. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/latent_class.py +0 -0
  35. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/logistic_regression.py +0 -0
  36. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/main.py +0 -0
  37. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/main_debug.py +0 -0
  38. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/mdcev.py +0 -0
  39. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/misc.py +0 -0
  40. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/mixed_logit.py +0 -0
  41. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/mixed_nested.py +0 -0
  42. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/mixedrrm.py +0 -0
  43. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/multinomial_logit.py +0 -0
  44. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/multinomial_nested.py +0 -0
  45. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/multinomial_probit.py +0 -0
  46. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/ordered_logit.py +0 -0
  47. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
  48. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/predict.py +0 -0
  49. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/rrm.py +0 -0
  50. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/sample_data.py +0 -0
  51. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/selection_models.py +0 -0
  52. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/setup.py +0 -0
  53. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/sparseea_agds.py +0 -0
  54. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/test_lc_de.py +0 -0
  55. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/test_mario_searches.py +0 -0
  56. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
  57. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/threshold.py +0 -0
  58. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium/zero_inflated_ordered_probit.py +0 -0
  59. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium.egg-info/SOURCES.txt +0 -0
  60. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
  61. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
  62. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium.egg-info/requires.txt +0 -0
  63. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/src/SearchLibrium.egg-info/top_level.txt +0 -0
  64. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/tests/test_per_alternative_random.py +0 -0
  65. {searchlibrium-0.0.199 → searchlibrium-0.0.201}/tests/test_zero_inflated_ordered_probit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.199
3
+ Version: 0.0.201
4
4
  Summary: Automated specification search for discrete choice models (logit, mixed logit, nested logit, RRM and more) using metaheuristic optimisation
5
5
  Author: Mario Fernando Taco Morales, Alexander Paz, Prithvi Beeramole, Robert Burdett
6
6
  Author-email: Zeke Ahern <z.ahern@qut.edu.au>
@@ -100,7 +100,7 @@ Documentation = "https://pypi.org/project/SearchLibrium/"
100
100
  realpython = "SearchLibrium.__main__:main"
101
101
 
102
102
  [tool.bumpver]
103
- current_version = "0.0.199"
103
+ current_version = "0.0.201"
104
104
  version_pattern = "MAJOR.MINOR.PATCH"
105
105
  commit_message = "[skip ci] Bump version {old_version} -> {new_version}"
106
106
  commit = true
@@ -95,6 +95,8 @@ def estimate_ctrl(parameters, algorithm='sa', thorough=False, deep=False):
95
95
  tuple
96
96
  SA : (tI, tF, max_temp_steps, max_iter)
97
97
  HS : (max_mem, maxiter, max_harm, min_harm, max_pitch, min_pitch)
98
+ AGDS: (pop_size, maxiter, pc0, pm0)
99
+ TA : (max_threshold, max_steps, max_iter)
98
100
  """
99
101
  ps = _problem_size(parameters)
100
102
  c = ps['complexity']
@@ -132,13 +134,13 @@ def estimate_ctrl(parameters, algorithm='sa', thorough=False, deep=False):
132
134
  elif algorithm == 'hs':
133
135
  # Harmony memory size and improvisation iterations scale with complexity
134
136
  if c < 50:
135
- max_mem, maxiter = max(10, 10 * scale), max(100, 100 * scale)
137
+ max_mem, maxiter = max(20, 20 * scale), max(100, 100 * scale)
136
138
  elif c < 200:
137
- max_mem, maxiter = max(15, 15 * scale), max(300, 300 * scale)
139
+ max_mem, maxiter = max(25, 25 * scale), max(300, 300 * scale)
138
140
  elif c < 600:
139
- max_mem, maxiter = max(20, 20 * scale), max(500, 500 * scale)
141
+ max_mem, maxiter = max(30, 30 * scale), max(500, 500 * scale)
140
142
  else:
141
- max_mem, maxiter = max(25, 25 * scale), max(800, 800 * scale)
143
+ max_mem, maxiter = max(40, 40 * scale), max(800, 800 * scale)
142
144
 
143
145
  # Harmony/pitch rates adapt: wider exploration band for bigger problems
144
146
  if deep or thorough:
@@ -164,8 +166,34 @@ def estimate_ctrl(parameters, algorithm='sa', thorough=False, deep=False):
164
166
  pc0 = 0.9
165
167
  ctrl = (pop_size, maxiter, pc0, None)
166
168
 
169
+ elif algorithm == 'ta':
170
+ # Threshold Accepting: threshold decreases linearly from max_threshold to 0
171
+ # over max_steps, with max_iter evaluations per step
172
+ if c < 50:
173
+ max_threshold = max(500, 500 * scale)
174
+ max_steps = max(50, 50 * scale)
175
+ max_iter = max(30, 30 * scale)
176
+ elif c < 200:
177
+ max_threshold = max(1000, 1000 * scale)
178
+ max_steps = max(100, 100 * scale)
179
+ max_iter = max(50, 50 * scale)
180
+ elif c < 600:
181
+ max_threshold = max(2000, 2000 * scale)
182
+ max_steps = max(150, 150 * scale)
183
+ max_iter = max(80, 80 * scale)
184
+ else:
185
+ max_threshold = max(5000, 5000 * scale)
186
+ max_steps = max(200, 200 * scale)
187
+ max_iter = max(100, 100 * scale)
188
+
189
+ if deep:
190
+ max_threshold = max(max_threshold, 10000)
191
+ max_steps = max(max_steps, 300)
192
+
193
+ ctrl = (max_threshold, max_steps, max_iter)
194
+
167
195
  else:
168
- raise ValueError(f"Unknown algorithm '{algorithm}'. Use 'sa', 'hs' or 'agds'.")
196
+ raise ValueError(f"Unknown algorithm '{algorithm}'. Use 'sa', 'hs', 'agds', or 'ta'.")
169
197
 
170
198
  return ctrl
171
199
 
@@ -180,6 +208,13 @@ def _describe_ctrl(ctrl, algorithm):
180
208
  'number of cooling steps',
181
209
  'evaluations per cooling step',
182
210
  )
211
+ elif algorithm == 'ta':
212
+ names = ('max_threshold', 'max_steps', 'max_iter')
213
+ hints = (
214
+ 'initial threshold — higher = more exploration',
215
+ 'number of threshold reduction steps',
216
+ 'evaluations per threshold step',
217
+ )
183
218
  elif algorithm == 'agds':
184
219
  names = ('pop_size', 'maxiter', 'pc0', 'pm0')
185
220
  hints = (
@@ -326,6 +361,9 @@ def call_siman(parameters, init_sol=None, ctrl=None, thorough=False, deep=False,
326
361
  Maximum search depth for production runs.
327
362
  **kwargs
328
363
  ``id_num`` — run identifier (int, used in log file names).
364
+ ``max_time`` — maximum wall-clock time in seconds (default: inf).
365
+ ``max_total_iter`` — maximum total temperature steps (default: 100000).
366
+ ``calibrate_tI`` — whether to auto-calibrate initial temperature (default: True if ctrl not provided).
329
367
  Any other kwargs are forwarded to the SA constructor.
330
368
 
331
369
  Returns
@@ -383,6 +421,11 @@ def call_sapbil(parameters, init_sol=None, ctrl=None, **kwargs):
383
421
  If omitted the values are estimated from the problem size.
384
422
  **kwargs
385
423
  ``id_num`` — run identifier (int, used in log file names).
424
+ ``pbil_l_bounds`` — dict of learning rate bounds per decision type
425
+ (default: thesis-based values from Taco-Morales 2026).
426
+ Example: {"inclusion": (0.02, 0.25), "random": (0.02, 0.15), ...}
427
+ ``pbil_p_low`` — minimum probability clamp (default: 0.05).
428
+ ``pbil_p_high`` — maximum probability clamp (default: 0.95).
386
429
  Any other kwargs are forwarded to the SAPBIL constructor.
387
430
 
388
431
  Returns
@@ -430,6 +473,9 @@ def call_banditsa(parameters, init_sol=None, ctrl=None, **kwargs):
430
473
  If omitted the values are estimated from the problem size.
431
474
  **kwargs
432
475
  ``id_num`` - run identifier (int, used in log file names).
476
+ ``bandit_prior_alpha`` — Thompson sampling prior alpha (default: 1.0).
477
+ ``bandit_prior_beta`` — Thompson sampling prior beta (default: 1.0).
478
+ ``bandit_epsilon`` — epsilon for epsilon-greedy exploration (default: 0.05).
433
479
  Any other kwargs are forwarded to the BanditSA constructor.
434
480
 
435
481
  Returns
@@ -485,6 +531,10 @@ def call_harmony(parameters, init_sol=None, ctrl=None, thorough=False, deep=Fals
485
531
  Overrides thorough. Default False.
486
532
  **kwargs
487
533
  ``id_num`` — run identifier.
534
+ ``prop_local`` — proportion of iterations before local search (default: 0.8).
535
+ ``threshold`` — convergence threshold (default: 15).
536
+ ``generate_plots`` — whether to generate convergence plots (default: False).
537
+ Any other kwargs are forwarded to the HarmonySearch constructor via set_control_parameters.
488
538
 
489
539
  Returns
490
540
  -------
@@ -509,6 +559,9 @@ def call_harmony(parameters, init_sol=None, ctrl=None, thorough=False, deep=Fals
509
559
  print()
510
560
 
511
561
  solver = HarmonySearch(parameters, ctrl=ctrl, idnum=id_num)
562
+ # Forward remaining kwargs to set_control_parameters
563
+ if kwargs:
564
+ solver.set_control_parameters(**kwargs)
512
565
  existing = [init_sol] if init_sol is not None else None
513
566
  solver.run_search(existing_sols=existing)
514
567
  solver.close_files()
@@ -539,6 +592,9 @@ def call_agds(parameters, init_sol=None, ctrl=None, thorough=False, deep=False,
539
592
  Scale up population / generations for a more thorough search.
540
593
  **kwargs
541
594
  ``id_num`` — run identifier.
595
+ ``ref_divisions`` — NSGA-III reference point divisions (default: 12).
596
+ ``generate_plots`` — whether to generate convergence plots (default: False).
597
+ Any other kwargs are forwarded to the SparseEAAGDS constructor via set_control_parameters.
542
598
 
543
599
  Returns
544
600
  -------
@@ -563,6 +619,9 @@ def call_agds(parameters, init_sol=None, ctrl=None, thorough=False, deep=False,
563
619
  print()
564
620
 
565
621
  solver = SparseEAAGDS(parameters, ctrl=ctrl, idnum=id_num)
622
+ # Forward remaining kwargs to set_control_parameters
623
+ if kwargs:
624
+ solver.set_control_parameters(**kwargs)
566
625
  existing = [init_sol] if init_sol is not None else None
567
626
  solver.run_search(existing_sols=existing)
568
627
  solver.close_files()
@@ -589,6 +648,14 @@ def call_harmony_pbil(parameters, init_sol=None, ctrl=None, **kwargs):
589
648
  If omitted the values are estimated from the problem size.
590
649
  **kwargs
591
650
  ``id_num`` — run identifier.
651
+ ``pbil_l_bounds`` — dict of learning rate bounds per decision type
652
+ (default: thesis-based values from Taco-Morales 2026).
653
+ ``pbil_p_low`` — minimum probability clamp (default: 0.05).
654
+ ``pbil_p_high`` — maximum probability clamp (default: 0.95).
655
+ ``prop_local`` — proportion of iterations before local search (default: 0.8).
656
+ ``threshold`` — convergence threshold (default: 15).
657
+ ``generate_plots`` — whether to generate convergence plots (default: False).
658
+ Any other kwargs are forwarded to the HSPBIL constructor.
592
659
 
593
660
  Returns
594
661
  -------
@@ -610,7 +677,7 @@ def call_harmony_pbil(parameters, init_sol=None, ctrl=None, **kwargs):
610
677
  print(_describe_ctrl(ctrl, 'hs'))
611
678
  print()
612
679
 
613
- solver = HSPBIL(parameters, init_sol, ctrl, idnum=id_num)
680
+ solver = HSPBIL(parameters, init_sol, ctrl, idnum=id_num, **kwargs)
614
681
  existing = [init_sol] if init_sol is not None else None
615
682
  solver.run_search(existing_sols=existing)
616
683
  solver.close_files()
@@ -659,6 +726,7 @@ def call_search(parameters, init_sol=None, algorithm='sa', ctrl=None, thorough=F
659
726
  >>> best = call_search(params) # SA, auto ctrl
660
727
  >>> best = call_search(params, algorithm='banditsa') # BanditSA, auto ctrl
661
728
  >>> best = call_search(params, algorithm='hs') # HS, auto ctrl
729
+ >>> best = call_search(params, algorithm='ta') # TA, auto ctrl
662
730
  >>> best = call_search(params, thorough=True) # Thorough SA
663
731
  >>> best = call_search(params, algorithm='hs', deep=True) # Deep HS
664
732
  >>> best = call_search(params, ctrl=(500,0.001,80,15))# SA, manual ctrl
@@ -678,13 +746,63 @@ def call_search(parameters, init_sol=None, algorithm='sa', ctrl=None, thorough=F
678
746
  thorough=thorough, deep=deep, **kwargs)
679
747
  elif algorithm in ('hspbil', 'harmony_pbil', 'hs_pbil', 'hs+pbil'):
680
748
  return call_harmony_pbil(parameters, init_sol=init_sol, ctrl=ctrl, **kwargs)
749
+ elif algorithm in ('ta', 'threshold', 'threshold_accepting'):
750
+ return call_threshold(parameters, init_sol=init_sol, ctrl=ctrl, **kwargs)
681
751
  else:
682
752
  raise ValueError(
683
753
  f"Unknown algorithm '{algorithm}'. "
684
- f"Choose 'sa', 'sapbil', 'banditsa', 'hs', or 'hspbil'."
754
+ f"Choose 'sa', 'sapbil', 'banditsa', 'hs', 'hspbil', or 'ta'."
685
755
  )
686
756
 
687
757
 
758
+ # ─────────────────────────────────────────────────────────────────────────────
759
+ # Threshold Accepting
760
+ # ─────────────────────────────────────────────────────────────────────────────
761
+
762
+ def call_threshold(parameters, init_sol=None, ctrl=None, **kwargs):
763
+ """
764
+ Run Threshold Accepting search.
765
+
766
+ Parameters
767
+ ----------
768
+ parameters : Parameters
769
+ Problem definition.
770
+ init_sol : Solution, optional
771
+ Warm-start solution.
772
+ ctrl : tuple, optional
773
+ ``(max_threshold, max_steps, max_iter)``. If omitted the values are estimated
774
+ from the problem size.
775
+ **kwargs
776
+ ``id_num`` — run identifier.
777
+
778
+ Returns
779
+ -------
780
+ Solution
781
+ Best solution found.
782
+ """
783
+ if ctrl is None:
784
+ ctrl = kwargs.pop('ctrl', None)
785
+
786
+ id_num = kwargs.pop('id_num', None)
787
+
788
+ if ctrl is None:
789
+ ctrl = estimate_ctrl(parameters, algorithm='ta')
790
+ print(f"[TA] Auto-estimated hyperparameters (problem complexity "
791
+ f"= {_problem_size(parameters)['complexity']}):")
792
+ else:
793
+ print("[TA] Using provided hyperparameters:")
794
+
795
+ print(_describe_ctrl(ctrl, 'ta'))
796
+ print()
797
+
798
+ solver = TA(parameters, init_sol, ctrl, id_num, **kwargs)
799
+ solver.run()
800
+ solver.close_files()
801
+ best = solver.return_best()
802
+ _print_dashboard(solver, best, algorithm='TA')
803
+ return best
804
+
805
+
688
806
  # ─────────────────────────────────────────────────────────────────────────────
689
807
  # Parallel SA variants (unchanged interface, improved ctrl handling)
690
808
  # ─────────────────────────────────────────────────────────────────────────────
@@ -255,106 +255,96 @@ class HarmonySearch(Search):
255
255
  ''' A new solution, could either be built from an existing one '''
256
256
  ''' or constructed randomly. '''
257
257
  ''' ---------------------------------------------------------- '''
258
- def build_solution(self, memory, prop):
258
+ def build_solution(self, memory, harm_rate):
259
259
  # {
260
- """ This function decides whether to build a new solution from an existing solution
261
- in the harmony memory or to generate a completely new solution, based on a random number and the
262
- Harmony Memory Consideration Rate (HMCR). If the random number is less than or equal to prop,
263
- it selects a proportion of the features from a randomly chosen existing solution to build the new solution.
264
- Otherwise, it generates a completely new solution """
265
-
266
-
267
- bin = [0,1] # Binary values
268
- prob = [1-prop, prop] # Range
269
- new_sol = Solution(nb_crit=self.nb_crit) # Create a new solution object
270
-
271
- # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
272
- # IS THIS NECESSARY?
273
- '''fronts, pareto = None, None
274
- if nb_crit > 1: # {
275
- memory = self.non_dominant_sorting(memory)
276
- fronts = self.get_fronts(memory)
277
- pareto = self.get_pareto(fronts, memory)
278
- # }'''
279
- # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
280
- if self.param.generator.rand() > prop:
281
- new_sol = self.generate_solution() # Generate a new solution
282
- else:
283
- # {
284
- choice = self.param.generator.choice(len(memory)) # Choose one of the member solutions
285
- chosen_sol = memory[choice] # Define reference to the chosen member solution
286
-
287
- # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
288
- # OPTIONAL CODE.
289
- # size = len(chosen_sol['asvars'])
290
- # new_asvars_index = self.param.generator.choice(bin, size=size, p=prob)
291
- # new_asvars = [i for (i, v) in zip(chosen_sol['asvars'], new_asvars_index) if v]
292
- # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
293
-
294
- # Randomly select a subset of the variables from the chosen solution
295
- size = int((len(chosen_sol['asvars'])) * prop)
296
- new_asvars = list(self.param.generator.choice(chosen_sol['asvars'], size=size, replace=False))
297
- n_asvars = sorted(list(set().union(new_asvars, self.param.ps_asvars)))
298
- new_asvars = self.remove_redundant_asvars(n_asvars, self.param.trans_asvars, self.param.asvarnames)
299
- new_asvars = self.remove_collinear_vars(new_asvars)
300
- new_asvars = self._apply_mutual_exclusion_filter(new_asvars)
301
- new_sol['asvars'] = new_asvars
302
-
303
- # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
304
- # Randomly select a subset of the variables from the chosen solution
305
- # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
306
- size = int((len(chosen_sol['isvars'])) * prop)
307
- new_isvars = list(self.param.generator.choice(chosen_sol['isvars'], size=size, replace=False))
308
- new_isvars = sorted(list(set().union(new_isvars, self.param.ps_isvars)))
309
- new_isvars = self.remove_collinear_vars(new_isvars)
310
- new_isvars = self._apply_mutual_exclusion_filter(new_isvars)
311
- new_sol['isvars'] = new_isvars
312
-
313
- # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
314
- # Include variables in new solution based on the chosen solution
315
- # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
316
- new_randvars = {k: v for k, v in chosen_sol['randvars'].items() if k in new_asvars}
260
+ """ Build a new solution using Harmony Memory Consideration.
261
+
262
+ For each candidate variable, with probability harm_rate (HMCR), copy
263
+ the value from a randomly selected harmony in memory; otherwise
264
+ generate a random value. This is the standard HS approach.
265
+ """
266
+ new_sol = Solution(nb_crit=self.nb_crit)
267
+
268
+ # Collect all candidate variables
269
+ all_as_candidates = list(self.param.asvarnames or [])
270
+ all_is_candidates = list(self.param.isvarnames or [])
271
+
272
+ # --- AS variables: for each, decide via HMCR ---
273
+ new_asvars = []
274
+ for var in all_as_candidates:
275
+ if self.param.generator.rand() <= harm_rate:
276
+ # Copy from memory: randomly pick a harmony and take its value for this var
277
+ if memory:
278
+ chosen = self.param.generator.choice(len(memory))
279
+ if var in memory[chosen].get('asvars', []):
280
+ new_asvars.append(var)
281
+ else:
282
+ # Random generation: include with some base probability
283
+ if self.param.generator.rand() < 0.3: # base inclusion probability
284
+ new_asvars.append(var)
285
+
286
+ # Enforce pre-specified
287
+ new_asvars = sorted(list(set().union(new_asvars, self.param.ps_asvars or [])))
288
+ new_asvars = self.remove_redundant_asvars(new_asvars, self.param.trans_asvars, self.param.asvarnames)
289
+ new_asvars = self.remove_collinear_vars(new_asvars)
290
+ new_asvars = self._apply_mutual_exclusion_filter(new_asvars)
291
+ new_sol['asvars'] = new_asvars
292
+
293
+ # --- IS variables: same HMCR logic ---
294
+ new_isvars = []
295
+ for var in all_is_candidates:
296
+ if self.param.generator.rand() <= harm_rate:
297
+ if memory:
298
+ chosen = self.param.generator.choice(len(memory))
299
+ if var in memory[chosen].get('isvars', []):
300
+ new_isvars.append(var)
301
+ else:
302
+ if self.param.generator.rand() < 0.3:
303
+ new_isvars.append(var)
304
+
305
+ new_isvars = sorted(list(set().union(new_isvars, self.param.ps_isvars or [])))
306
+ new_isvars = self.remove_collinear_vars(new_isvars)
307
+ new_isvars = self._apply_mutual_exclusion_filter(new_isvars)
308
+ new_sol['isvars'] = new_isvars
309
+
310
+ # --- Derived features (randvars, bcvars, corvars) from chosen harmony ---
311
+ # Pick one reference harmony for derived features
312
+ if memory and (new_asvars or new_isvars):
313
+ ref_sol = memory[self.param.generator.choice(len(memory))]
314
+
315
+ # Random coefficients: only for variables that are in asvars
316
+ new_randvars = {k: v for k, v in ref_sol.get('randvars', {}).items() if k in new_asvars}
317
317
  new_sol['randvars'] = new_randvars
318
318
 
319
- new_bcvars = [var for var in chosen_sol['bcvars']
320
- if var in new_asvars and var not in self.param.ps_corvars]
319
+ # Box-Cox: only for vars in asvars, not in corvars
320
+ ref_bcvars = ref_sol.get('bcvars', [])
321
+ new_bcvars = [v for v in ref_bcvars if v in new_asvars and v not in self.param.ps_corvars]
321
322
  new_sol['bcvars'] = new_bcvars
322
323
 
323
- new_corvars = chosen_sol['corvars']
324
- if new_corvars:
325
- new_corvars = [var for var in chosen_sol['corvars']
326
- if var in new_randvars.keys() and var not in new_bcvars]
327
- new_sol['corvars'] = new_corvars
324
+ # Correlation: only for randvars, not bcvars
325
+ ref_corvars = ref_sol.get('corvars', [])
326
+ if ref_corvars:
327
+ new_corvars = [v for v in ref_corvars if v in new_randvars and v not in new_bcvars]
328
+ new_sol['corvars'] = new_corvars
329
+ else:
330
+ new_sol['corvars'] = []
328
331
 
329
- # Take fit_intercept from chosen solution
330
- new_sol['asc_ind'] = chosen_sol['asc_ind']
332
+ # Intercept
333
+ new_sol['asc_ind'] = ref_sol.get('asc_ind', False)
331
334
 
332
- # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
335
+ # Latent class specs
333
336
  if getattr(self.param, 'allow_latent_corvars', False):
334
- if chosen_sol['class_params_spec'] is not None:
335
- # {
336
- class_params_spec = copy.deepcopy(chosen_sol['class_params_spec'])
337
- for ii, class_params in enumerate(class_params_spec):
338
- # {
339
- class_params_index = self.param.generator.choice(bin, size=len(class_params), p=prob)
340
- class_params_spec[ii] = np.array([i for (i, v) in zip(class_params, class_params_index) if v],
341
- dtype=class_params.dtype)
342
- # }
343
- new_sol['class_params_spec'] = class_params_spec
344
- # }
345
- # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
346
- if chosen_sol['member_params_spec'] is not None:
347
- # {
348
- member_params_spec = copy.deepcopy(chosen_sol['member_params_spec'])
349
- for ii, member_params in enumerate(member_params_spec):
350
- # {
351
- member_params_index = self.param.generator.choice(bin, size=len(member_params), p=prob)
352
- member_params_spec[ii] = np.array([i for (i, v) in zip(member_params, member_params_index) if v],
353
- dtype=member_params.dtype)
354
- # }
355
- new_sol['member_params_spec'] = member_params_spec
356
- # }
357
- # }
337
+ if ref_sol.get('class_params_spec') is not None:
338
+ new_sol['class_params_spec'] = copy.deepcopy(ref_sol['class_params_spec'])
339
+ if ref_sol.get('member_params_spec') is not None:
340
+ new_sol['member_params_spec'] = copy.deepcopy(ref_sol['member_params_spec'])
341
+ else:
342
+ new_sol['randvars'] = {}
343
+ new_sol['bcvars'] = []
344
+ new_sol['corvars'] = []
345
+ new_sol['asc_ind'] = False
346
+ new_sol['class_params_spec'] = None
347
+ new_sol['member_params_spec'] = None
358
348
 
359
349
  self._enforce_mutual_exclusion(new_sol)
360
350
  return new_sol
@@ -425,33 +415,79 @@ class HarmonySearch(Search):
425
415
 
426
416
  ''' ---------------------------------------------------------- '''
427
417
  ''' Function. Performs the pitch adjustment operation to '''
428
- ''' fine-tune a given solution. The process includes adding '''
429
- ''' new features or removing existing ones based on a binary '''
430
- ''' indicator. The resulting solution is evaluated and inserted'''
431
- ''' The solutions in memory are then filtered '''
418
+ ''' fine-tune a given solution. In classic HS, this is a '''
419
+ ''' small perturbation (x_new = x_old +- bw * rand()). For '''
420
+ ''' discrete model specification, we make SMALL changes: '''
421
+ ''' toggle one variable, change one distribution, etc. '''
432
422
  ''' ---------------------------------------------------------- '''
433
423
  def pitch_adjustment(self, sol, pitch):
434
424
  # {
435
425
  adjusted_solution = copy.deepcopy(sol)
436
- perturbations = [self.perturb_asfeature, self.perturb_isfeature, self.perturb_model_t]
437
-
438
- if self.param.allow_random:
439
- perturbations.append(self.perturb_randfeature)
440
- if adjusted_solution['randvars']:
441
- perturbations.append(self.perturb_distribution)
442
426
 
427
+ # Bandwidth controls how many small changes to make
428
+ # In standard HS: x_new = x_old + bw * randn()
429
+ # Here: bw ~ pitch, make 0-2 small changes
430
+ n_changes = 0
431
+ if self.param.generator.rand() < pitch:
432
+ n_changes = 1
433
+ if self.param.generator.rand() < pitch:
434
+ n_changes = 2
435
+
436
+ # Build list of SMALL adjustment operations
437
+ small_adjustments = []
438
+
439
+ # AS variable: add one missing or remove one present (small toggle)
440
+ if self.param.asvarnames:
441
+ missing_as = [v for v in self.param.asvarnames if v not in adjusted_solution.get('asvars', [])]
442
+ present_as = [v for v in adjusted_solution.get('asvars', []) if v not in self.param.ps_asvars]
443
+ if missing_as:
444
+ small_adjustments.append(('add_as', lambda s: self._add_one_asvar(s, missing_as)))
445
+ if present_as:
446
+ small_adjustments.append(('rem_as', lambda s: self._remove_one_asvar(s, present_as)))
447
+
448
+ # IS variable: similar
449
+ if self.param.isvarnames:
450
+ missing_is = [v for v in self.param.isvarnames if v not in adjusted_solution.get('isvars', [])]
451
+ present_is = [v for v in adjusted_solution.get('isvars', []) if v not in self.param.ps_isvars]
452
+ if missing_is:
453
+ small_adjustments.append(('add_is', lambda s: self._add_one_isvar(s, missing_is)))
454
+ if present_is:
455
+ small_adjustments.append(('rem_is', lambda s: self._remove_one_isvar(s, present_is)))
456
+
457
+ # Random coefficient: change distribution of ONE random var
458
+ if self.param.allow_random and adjusted_solution.get('randvars'):
459
+ small_adjustments.append(('chg_dist', self.perturb_distribution))
460
+
461
+ # Box-Cox: add/remove one
443
462
  if self.param.allow_bcvars:
444
- perturbations.append(self.perturb_bcfeature)
445
-
446
- if self.param.allow_corvars and adjusted_solution['randvars']:
447
- perturbations.append(self.perturb_corfeature)
448
-
449
- perturbation_count = 1 + int(self.param.generator.rand() < max(0.0, min(1.0, pitch)))
450
- perturbation_count = min(perturbation_count, len(perturbations))
451
- selected_perturbations = self.param.generator.choice(perturbations, size=perturbation_count, replace=False)
452
-
453
- for perturbation in np.atleast_1d(selected_perturbations):
454
- adjusted_solution = perturbation(adjusted_solution)
463
+ missing_bc = [v for v in adjusted_solution.get('asvars', [])
464
+ if v not in adjusted_solution.get('bcvars', []) and v not in self.param.ps_corvars]
465
+ present_bc = [v for v in adjusted_solution.get('bcvars', []) if v not in self.param.ps_bcvars]
466
+ if missing_bc:
467
+ small_adjustments.append(('add_bc', lambda s: self._add_one_bcvar(s, missing_bc)))
468
+ if present_bc:
469
+ small_adjustments.append(('rem_bc', lambda s: self._remove_one_bcvar(s, present_bc)))
470
+
471
+ # Correlation: add/remove one (needs >=2 randvars)
472
+ if self.param.allow_corvars and len(adjusted_solution.get('randvars', {})) >= 2:
473
+ missing_cor = [v for v in adjusted_solution.get('randvars', {})
474
+ if v not in adjusted_solution.get('corvars', []) and v not in adjusted_solution.get('bcvars', [])]
475
+ present_cor = [v for v in adjusted_solution.get('corvars', []) if v not in self.param.ps_corvars]
476
+ if missing_cor:
477
+ small_adjustments.append(('add_cor', lambda s: self._add_one_corvar(s, missing_cor)))
478
+ if present_cor:
479
+ small_adjustments.append(('rem_cor', lambda s: self._remove_one_corvar(s, present_cor)))
480
+
481
+ # Model type change (rare)
482
+ if self.param.avail_models is not None and len(self.param.avail_models) > 1:
483
+ small_adjustments.append(('chg_model', self.perturb_model_t))
484
+
485
+ # Apply n_changes small adjustments
486
+ for _ in range(n_changes):
487
+ if not small_adjustments:
488
+ break
489
+ _, adj_fn = self.param.generator.choice(small_adjustments)
490
+ adjusted_solution = adj_fn(adjusted_solution)
455
491
 
456
492
  adjusted_solution['randvars'] = self.normalize_randvars(
457
493
  adjusted_solution['asvars'],
@@ -463,6 +499,39 @@ class HarmonySearch(Search):
463
499
  return adjusted_solution, converged
464
500
  # }
465
501
 
502
+ # --- Helper methods for small single-variable adjustments ---
503
+ def _add_one_asvar(self, sol, candidates):
504
+ var = self.param.generator.choice(candidates)
505
+ return self.perturb_add_asfeature(sol) if hasattr(self, 'perturb_add_asfeature') else sol
506
+
507
+ def _remove_one_asvar(self, sol, candidates):
508
+ var = self.param.generator.choice(candidates)
509
+ return self.perturb_remove_asfeature(sol) if hasattr(self, 'perturb_remove_asfeature') else sol
510
+
511
+ def _add_one_isvar(self, sol, candidates):
512
+ var = self.param.generator.choice(candidates)
513
+ return self.perturb_add_isfeature(sol) if hasattr(self, 'perturb_add_isfeature') else sol
514
+
515
+ def _remove_one_isvar(self, sol, candidates):
516
+ var = self.param.generator.choice(candidates)
517
+ return self.perturb_remove_isfeature(sol) if hasattr(self, 'perturb_remove_isfeature') else sol
518
+
519
+ def _add_one_bcvar(self, sol, candidates):
520
+ var = self.param.generator.choice(candidates)
521
+ return self.perturb_add_bcfeature(sol) if hasattr(self, 'perturb_add_bcfeature') else sol
522
+
523
+ def _remove_one_bcvar(self, sol, candidates):
524
+ var = self.param.generator.choice(candidates)
525
+ return self.perturb_remove_bcfeature(sol) if hasattr(self, 'perturb_remove_bcfeature') else sol
526
+
527
+ def _add_one_corvar(self, sol, candidates):
528
+ var = self.param.generator.choice(candidates)
529
+ return self.perturb_add_corfeature(sol) if hasattr(self, 'perturb_add_corfeature') else sol
530
+
531
+ def _remove_one_corvar(self, sol, candidates):
532
+ var = self.param.generator.choice(candidates)
533
+ return self.perturb_remove_corfeature(sol) if hasattr(self, 'perturb_remove_corfeature') else sol
534
+
466
535
  ''' ---------------------------------------------------------- '''
467
536
  ''' Function. Extracts the best features '''
468
537
  ''' ---------------------------------------------------------- '''
@@ -34,14 +34,40 @@ class HSPBIL(HarmonySearch):
34
34
 
35
35
  Overrides the standard uniform-random pitch perturbations with
36
36
  probability-weighted decisions that learn from accepted solutions.
37
+
38
+ Parameters
39
+ ----------
40
+ param : Parameters
41
+ Problem definition (from search.py).
42
+ init_sol : Solution or None
43
+ Optional warm-start solution.
44
+ ctrl : tuple
45
+ ``(max_mem, maxiter, max_harm, min_harm, max_pitch, min_pitch)``
46
+ idnum : int or str
47
+ Run identifier for log files.
48
+ **kwargs
49
+ Forwarded to the :class:`HarmonySearch` base class constructor.
50
+ PBIL-specific kwargs:
51
+ ``pbil_l_bounds`` — dict of learning rate bounds per decision type
52
+ (default: thesis-based values from Taco-Morales 2026).
53
+ ``pbil_p_low`` — minimum probability clamp (default: 0.05).
54
+ ``pbil_p_high`` — maximum probability clamp (default: 0.95).
37
55
  """
38
56
 
39
57
  def __init__(self, param, init_sol, ctrl, idnum=0, **kwargs):
58
+ # Extract PBIL-specific kwargs before passing to parent
59
+ pbil_l_bounds = kwargs.pop('pbil_l_bounds', None)
60
+ pbil_p_low = kwargs.pop('pbil_p_low', None)
61
+ pbil_p_high = kwargs.pop('pbil_p_high', None)
62
+
40
63
  super().__init__(param, ctrl=ctrl, idnum=idnum)
41
64
 
42
65
  varnames = list(param.asvarnames or [])
43
66
  distributions = list(param.distr or ["n", "ln", "tn", "u", "t"])
44
- self.prob_matrix = ProbabilityMatrix(varnames, distributions)
67
+ self.prob_matrix = ProbabilityMatrix(varnames, distributions,
68
+ l_bounds=pbil_l_bounds,
69
+ p_low=pbil_p_low,
70
+ p_high=pbil_p_high)
45
71
 
46
72
  self._ps_asvars = set(getattr(param, "ps_asvars", []) or [])
47
73
  self._ps_randvars = set(