SearchLibrium 0.0.200__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.200/src/SearchLibrium.egg-info → searchlibrium-0.0.201}/PKG-INFO +1 -1
  2. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/pyproject.toml +1 -1
  3. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/harmony.py +182 -113
  4. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/search.py +17 -7
  5. searchlibrium-0.0.201/src/SearchLibrium/version.txt +1 -0
  6. {searchlibrium-0.0.200 → searchlibrium-0.0.201/src/SearchLibrium.egg-info}/PKG-INFO +1 -1
  7. searchlibrium-0.0.200/src/SearchLibrium/version.txt +0 -1
  8. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/LICENSE +0 -0
  9. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/MANIFEST.in +0 -0
  10. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/README.md +0 -0
  11. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/setup.cfg +0 -0
  12. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/BEHier.py +0 -0
  13. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/Halton.py +0 -0
  14. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/MixedLogit.py +0 -0
  15. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/MixedLogitGSE.py +0 -0
  16. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
  17. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/RandomP.py +0 -0
  18. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
  19. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/Two_Level_Nest.py +0 -0
  20. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/__init__.py +0 -0
  21. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/__main__.py +0 -0
  22. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/_choice_model.py +0 -0
  23. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/_device.py +0 -0
  24. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/banditsa.py +0 -0
  25. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/boxcox_functions.py +0 -0
  26. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/call_meta.py +0 -0
  27. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/constraints_builder.py +0 -0
  28. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/data/Swissmetro_final.csv +0 -0
  29. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/data/TravelMode.csv +0 -0
  30. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/data/electricity.csv +0 -0
  31. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/hspbil.py +0 -0
  32. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/latent_class.py +0 -0
  33. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/logistic_regression.py +0 -0
  34. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/main.py +0 -0
  35. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/main_debug.py +0 -0
  36. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/mdcev.py +0 -0
  37. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/misc.py +0 -0
  38. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/mixed_logit.py +0 -0
  39. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/mixed_nested.py +0 -0
  40. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/mixedrrm.py +0 -0
  41. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/multinomial_logit.py +0 -0
  42. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/multinomial_nested.py +0 -0
  43. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/multinomial_probit.py +0 -0
  44. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/ordered_logit.py +0 -0
  45. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
  46. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/predict.py +0 -0
  47. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/rrm.py +0 -0
  48. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/sample_data.py +0 -0
  49. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/sapbil.py +0 -0
  50. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/selection_models.py +0 -0
  51. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/setup.py +0 -0
  52. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/siman.py +0 -0
  53. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/sparseea_agds.py +0 -0
  54. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/test_lc_de.py +0 -0
  55. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/test_mario_searches.py +0 -0
  56. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
  57. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/threshold.py +0 -0
  58. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium/zero_inflated_ordered_probit.py +0 -0
  59. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium.egg-info/SOURCES.txt +0 -0
  60. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
  61. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
  62. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium.egg-info/requires.txt +0 -0
  63. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/src/SearchLibrium.egg-info/top_level.txt +0 -0
  64. {searchlibrium-0.0.200 → searchlibrium-0.0.201}/tests/test_per_alternative_random.py +0 -0
  65. {searchlibrium-0.0.200 → 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.200
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.200"
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
@@ -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
  ''' ---------------------------------------------------------- '''
@@ -4934,7 +4934,8 @@ class Search():
4934
4934
  model.setup(X=X, y=y, varnames=varnames, isvars=isvars, alts=alts,
4935
4935
  ids=ids, transvars=transvars, fit_intercept=fit_intercept, init_coeff=init_coeff,
4936
4936
  weights=weights, avail=avail, base_alt=base_alt, maxiter=maxiter, ftol=ftol, gtol=gtol,
4937
- l2_penalty=2.0, l1_penalty=0.5)
4937
+ l2_penalty=getattr(self.param, 'l2_penalty', 0.5),
4938
+ l1_penalty=getattr(self.param, 'l1_penalty', 0.1))
4938
4939
  model.fit()
4939
4940
 
4940
4941
  return model
@@ -5051,8 +5052,7 @@ class Search():
5051
5052
  isvars = [i for i in isvars if i not in randvars.keys()]
5052
5053
 
5053
5054
  return isvars, varnames, fit_intercept
5054
-
5055
- def fit_mxl(self, X, y, varnames, alts, isvars, transvars, ids, panels, randvars, corvars,
5055
+ def fit_mxl(self, X, y, varnames, alts, isvars, transvars, ids, panels, randvars, corvars,
5056
5056
  fit_intercept, init_coeff, n_draws, weights, avail, base_alt, maxiter, ftol, gtol, save_fitted_params,
5057
5057
  halton_opts=None):
5058
5058
  # {
@@ -5063,6 +5063,7 @@ class Search():
5063
5063
  # repair the model..
5064
5064
  isvars, varnames, fit_intercept = self.process_variables(isvars, varnames, randvars)
5065
5065
 
5066
+
5066
5067
  model.setup(X=X, y=y, varnames=varnames, isvars=isvars, alts=alts, transvars=transvars, ids=ids,
5067
5068
  randvars=randvars, panels=panels, fit_intercept=fit_intercept, correlated_vars=corvars, n_draws=n_draws,
5068
5069
  init_coeff=init_coeff, weights=weights, avail=avail, base_alt=base_alt, maxiter=maxiter,
@@ -5072,7 +5073,9 @@ class Search():
5072
5073
  de_maxiter=getattr(self.param, 'de_maxiter', 3),
5073
5074
  de_tol=getattr(self.param, 'de_tol', 0.5),
5074
5075
  de_polish=getattr(self.param, 'de_polish', False),
5075
- sd_penalty=getattr(self.param, 'sd_penalty', 0.001))
5076
+ sd_penalty=getattr(self.param, 'sd_penalty', 0.001),
5077
+ reg_penalty=getattr(self.param, 'l2_penalty', 0.5),
5078
+ l1_penalty=getattr(self.param, 'l1_penalty', 0.1))
5076
5079
  model.fit()
5077
5080
 
5078
5081
  return model
@@ -5638,12 +5641,17 @@ class Search():
5638
5641
  model = RandomRegret()
5639
5642
  model.setup(X=X, y=y, varnames=all_vars, alts=alts, ids=ids,
5640
5643
  transvars=[v for v in transvars if v in all_vars])
5644
+ # Apply shrinkage parameters from search parameters
5645
+ model.reg_penalty = getattr(self.param, 'l2_penalty', 0.5)
5646
+ model.l1_penalty = getattr(self.param, 'l1_penalty', 0.1)
5641
5647
  if use_jax:
5642
5648
  model.fit_jax()
5643
5649
  else:
5644
5650
  model.fit()
5645
5651
  else:
5646
5652
  model = RandomRegret(df=df, short=False, normalize=True)
5653
+ model.reg_penalty = getattr(self.param, 'l2_penalty', 0.5)
5654
+ model.l1_penalty = getattr(self.param, 'l1_penalty', 0.1)
5647
5655
  if use_jax:
5648
5656
  model.fit_jax()
5649
5657
  else:
@@ -5745,9 +5753,7 @@ class Search():
5745
5753
  # COMPUTE MAE
5746
5754
  # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
5747
5755
  if self.mae_is_an_objective():
5748
- # Use the TEST targets/ids (previously reused the training arrays,
5749
- # which mismatched the test design matrix).
5750
- X_test = self.param.df_test[all_vars]
5756
+ X_test, _ = self._get_orthogonalized_X(all_vars) # Use same transformation for test
5751
5757
  y_test = self.param.test_choices if getattr(self.param, 'test_choices', None) is not None else self.param.choices
5752
5758
  ids_test = self.param.test_choice_id if getattr(self.param, 'test_choice_id', None) is not None else self.param.choice_id
5753
5759
  test_model = self.fit_ordered_logit(X=X_test, y=y_test, ids=ids_test, varnames=all_vars)
@@ -5780,6 +5786,10 @@ class Search():
5780
5786
  fit_intercept=False,
5781
5787
  transvars=transvars or [])
5782
5788
 
5789
+ # Apply shrinkage parameters from search parameters
5790
+ moll.reg_penalty = getattr(self.param, 'l2_penalty', 0.5)
5791
+ moll.l1_penalty = getattr(self.param, 'l1_penalty', 0.1)
5792
+
5783
5793
  moll.fit(method='BFGS')
5784
5794
  moll.report()
5785
5795
  return moll
@@ -0,0 +1 @@
1
+ 0.0.201
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.200
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>
@@ -1 +0,0 @@
1
- 0.0.200
File without changes