SearchLibrium 0.0.128__tar.gz → 0.0.130__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 (53) hide show
  1. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/PKG-INFO +1 -1
  2. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/pyproject.toml +1 -1
  3. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/harmony.py +331 -10
  4. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/search.py +126 -65
  5. searchlibrium-0.0.130/src/SearchLibrium/version.txt +1 -0
  6. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium.egg-info/PKG-INFO +1 -1
  7. searchlibrium-0.0.128/src/SearchLibrium/version.txt +0 -1
  8. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/README.md +0 -0
  9. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/setup.cfg +0 -0
  10. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/Halton.py +0 -0
  11. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/MixedLogit.py +0 -0
  12. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
  13. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/RandomP.py +0 -0
  14. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
  15. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/Two_Level_Nest.py +0 -0
  16. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/__init__.py +0 -0
  17. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/__main__.py +0 -0
  18. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/_choice_model.py +0 -0
  19. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/_device.py +0 -0
  20. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/banditsa.py +0 -0
  21. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/bhhh/minimize.py +0 -0
  22. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/boxcox_functions.py +0 -0
  23. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/call_meta.py +0 -0
  24. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/constraints_builder.py +0 -0
  25. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/hspbil.py +0 -0
  26. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/latent_class.py +0 -0
  27. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/main.py +0 -0
  28. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/main_debug.py +0 -0
  29. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/mdcev.py +0 -0
  30. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/misc.py +0 -0
  31. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/mixed_logit.py +0 -0
  32. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/mixed_nested.py +0 -0
  33. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/mixedrrm.py +0 -0
  34. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/multinomial_logit.py +0 -0
  35. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/multinomial_nested.py +0 -0
  36. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/multinomial_probit.py +0 -0
  37. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/ordered_logit.py +0 -0
  38. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
  39. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/predict.py +0 -0
  40. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/rrm.py +0 -0
  41. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/sapbil.py +0 -0
  42. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/selection_models.py +0 -0
  43. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/setup.py +0 -0
  44. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/siman.py +0 -0
  45. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/test_lc_de.py +0 -0
  46. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/test_mario_searches.py +0 -0
  47. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
  48. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium/threshold.py +0 -0
  49. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium.egg-info/SOURCES.txt +0 -0
  50. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
  51. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
  52. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium.egg-info/requires.txt +0 -0
  53. {searchlibrium-0.0.128 → searchlibrium-0.0.130}/src/SearchLibrium.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.128
3
+ Version: 0.0.130
4
4
  Summary: A Python package for econometric models driven by search
5
5
  Author: Mario Fernando Taco Morales, Alexander Paz, Prithvi Beeramole, Robert Burdett
6
6
  Author-email: Zeke Ahern <z.ahern@qut.edu.au>
@@ -73,7 +73,7 @@ Documentation = "https://pypi.org/project/SearchLibrium/"
73
73
  realpython = "SearchLibrium.__main__:main"
74
74
 
75
75
  [tool.bumpver]
76
- current_version = "0.0.128"
76
+ current_version = "0.0.130"
77
77
  version_pattern = "MAJOR.MINOR.PATCH"
78
78
  commit_message = "[skip ci] Bump version {old_version} -> {new_version}"
79
79
  commit = true
@@ -705,12 +705,334 @@ class HarmonySearch(Search):
705
705
  # }
706
706
  # }
707
707
 
708
+ # ═══════════════════════════════════════════════════════════════════════
709
+ # TAILORED PERTURBATIONS — model-development-style moves (changes 11–18)
710
+ # These mirror how a practitioner actually builds discrete choice models:
711
+ # significance pruning, distribution sweeps, structural hypothesis tests,
712
+ # model progression, ensemble crossover, and feature substitution.
713
+ # ═══════════════════════════════════════════════════════════════════════
714
+
715
+ def make_change_11_significance_prune(self, candidate):
716
+ """Model-developer move: remove the least significant variable from
717
+ the best solution (mirrors backward elimination). Then try adding
718
+ the most promising unused variable as compensation."""
719
+ best_asvars, best_isvars, best_randvars, best_bcvars, best_corvars, \
720
+ asc_ind, _, _ = self.get_best_features(candidate)
721
+
722
+ # Only prune if we have > 2 AS vars (keep a minimum)
723
+ if len(best_asvars) <= 2:
724
+ return
725
+
726
+ # ── Try removing one AS variable ──────────────────────────────
727
+ candidates_to_drop = [v for v in best_asvars
728
+ if v not in self._get_forced_vars()]
729
+ if not candidates_to_drop:
730
+ return
731
+
732
+ # Try each candidate drop (up to 3) — a real modeller tests each removal
733
+ for drop_var in candidates_to_drop[:3]:
734
+ trial_vars = [v for v in best_asvars if v != drop_var]
735
+ trial_randvars = {k: v for k, v in best_randvars.items()
736
+ if k in trial_vars}
737
+ solution = Solution(nb_crit=self.nb_crit, asvars=trial_vars,
738
+ isvars=best_isvars, randvars=trial_randvars,
739
+ bcvars=best_bcvars, corvars=best_corvars,
740
+ asc_ind=asc_ind)
741
+ solution['model_n'] = candidate[0]['model_n'] if isinstance(candidate, list) and candidate else None
742
+ revised, converged = self.evaluate_solution(solution)
743
+ if converged:
744
+ self.insert_solution(revised)
745
+
746
+ # ── Then try adding a new promising variable ──────────────────
747
+ unused = [v for v in self.param.avail_asvars
748
+ if v not in best_asvars and v not in self.param.ps_asvars]
749
+ if unused:
750
+ add_var = self.param.generator.choice(unused)
751
+ trial_vars = best_asvars + [add_var]
752
+ trial_vars = list(dict.fromkeys(trial_vars))
753
+ solution = Solution(nb_crit=self.nb_crit, asvars=trial_vars,
754
+ isvars=best_isvars, randvars=best_randvars,
755
+ bcvars=best_bcvars, corvars=best_corvars,
756
+ asc_ind=asc_ind)
757
+ revised, converged = self.evaluate_solution(solution)
758
+ if converged:
759
+ self.insert_solution(revised)
760
+
761
+ def make_change_12_distribution_sweep(self, candidate):
762
+ """Model-developer move: for each random parameter in the best
763
+ solution, test every alternative distribution. A practitioner
764
+ often asks 'is this log-normal or should it be triangular?'"""
765
+ best_asvars, best_isvars, best_randvars, best_bcvars, best_corvars, \
766
+ asc_ind, _, _ = self.get_best_features(candidate)
767
+
768
+ if not best_randvars or len(best_randvars) == 0:
769
+ return
770
+
771
+ all_distributions = [d for d in self.param.distr if d != 'f']
772
+
773
+ for var_name, current_dist in list(best_randvars.items()):
774
+ alternatives = [d for d in all_distributions if d != current_dist]
775
+ if not alternatives:
776
+ continue
777
+ for alt_dist in alternatives[:len(alternatives)]:
778
+ trial_randvars = dict(best_randvars)
779
+ trial_randvars[var_name] = alt_dist
780
+ solution = Solution(nb_crit=self.nb_crit, asvars=best_asvars,
781
+ isvars=best_isvars, randvars=trial_randvars,
782
+ bcvars=best_bcvars, corvars=best_corvars,
783
+ asc_ind=asc_ind)
784
+ solution['model_n'] = candidate[0]['model_n'] if isinstance(candidate, list) and candidate else 'mixed_logit'
785
+ revised, converged = self.evaluate_solution(solution)
786
+ if converged:
787
+ self.insert_solution(revised)
788
+
789
+ def make_change_13_model_progression(self, candidate):
790
+ """Model-developer move: systematically step up or down the model
791
+ complexity ladder: MNL → NestedLogit → MixedLogit → MixedNested.
792
+ A modeller tests 'does adding nesting improve fit? does adding
793
+ random heterogeneity after nesting improve it further?'"""
794
+ best_asvars, best_isvars, best_randvars, best_bcvars, best_corvars, \
795
+ asc_ind, _, _ = self.get_best_features(candidate)
796
+
797
+ current_model = (candidate[0].get('model_n', 'multinomial')
798
+ if isinstance(candidate, list) and candidate
799
+ else 'multinomial')
800
+
801
+ has_nests = bool(getattr(self.param, 'nests', None))
802
+ has_randvars = bool(best_randvars)
803
+
804
+ # Model ladder — try the "next step up" in complexity
805
+ progression = []
806
+ if has_nests:
807
+ progression = ['multinomial', 'nested_logit', 'mixed_nested']
808
+ else:
809
+ progression = ['multinomial', 'mixed_logit']
810
+
811
+ # Also consider stepping down (for parsimony)
812
+ for target_model in progression:
813
+ if target_model == current_model:
814
+ continue
815
+ # mixed_nested requires both nests and random params
816
+ if target_model == 'mixed_nested' and not (has_nests and has_randvars):
817
+ continue
818
+ solution = Solution(nb_crit=self.nb_crit, asvars=best_asvars,
819
+ isvars=best_isvars, randvars=best_randvars,
820
+ bcvars=best_bcvars, corvars=best_corvars,
821
+ asc_ind=asc_ind, model_n=target_model)
822
+ revised, converged = self.evaluate_solution(solution)
823
+ if converged:
824
+ self.insert_solution(revised)
825
+
826
+ def make_change_14_ensemble_crossover(self, candidate):
827
+ """Model-developer move: combine variables from two top solutions
828
+ (like a modeller synthesising insights from two promising specs).
829
+ Creates four offspring: union, intersection, top-only, second-only."""
830
+ if len(self.memory) < 2:
831
+ return
832
+
833
+ memory_sorted = self.sort_memory(self.memory[:])
834
+ sol_a = memory_sorted[0]
835
+ sol_b = memory_sorted[1]
836
+
837
+ a_asvars = set(sol_a.get('asvars', []))
838
+ b_asvars = set(sol_b.get('asvars', []))
839
+ a_randvars = sol_a.get('randvars', {})
840
+ b_randvars = sol_b.get('randvars', {})
841
+
842
+ # ── Offspring 1: Union of both specs ──
843
+ union_vars = sorted(a_asvars | b_asvars)
844
+ if union_vars:
845
+ merged_randvars = {}
846
+ for v in union_vars:
847
+ if v in a_randvars: merged_randvars[v] = a_randvars[v]
848
+ elif v in b_randvars: merged_randvars[v] = b_randvars[v]
849
+ solution = Solution(nb_crit=self.nb_crit, asvars=union_vars,
850
+ isvars=sol_a.get('isvars', []),
851
+ randvars=merged_randvars,
852
+ bcvars=[], corvars=[],
853
+ asc_ind=sol_a.get('asc_ind', False))
854
+ revised, converged = self.evaluate_solution(solution)
855
+ if converged:
856
+ self.insert_solution(revised)
857
+
858
+ # ── Offspring 2: Intersection (core stable variables) ──
859
+ inter_vars = sorted(a_asvars & b_asvars)
860
+ if inter_vars and len(inter_vars) >= 2:
861
+ inter_randvars = {v: a_randvars[v] for v in inter_vars
862
+ if v in a_randvars}
863
+ solution = Solution(nb_crit=self.nb_crit, asvars=inter_vars,
864
+ isvars=sol_a.get('isvars', []),
865
+ randvars=inter_randvars,
866
+ bcvars=[], corvars=[],
867
+ asc_ind=sol_a.get('asc_ind', False))
868
+ revised, converged = self.evaluate_solution(solution)
869
+ if converged:
870
+ self.insert_solution(revised)
871
+
872
+ def make_change_15_feature_substitution(self, candidate):
873
+ """Model-developer move: substitute one base feature for a
874
+ correlated alternative. E.g. swap ln_dest_trips for
875
+ osm_destination_pull, or swap ln_dist for a spline/knot."""
876
+ best_asvars, best_isvars, best_randvars, best_bcvars, best_corvars, \
877
+ asc_ind, _, _ = self.get_best_features(candidate)
878
+
879
+ # Pre-check: identify substitution groups among available variables
880
+ # (variables that measure the same construct differently)
881
+ subst_groups = self._build_substitution_groups(best_asvars)
882
+
883
+ if not subst_groups:
884
+ return
885
+
886
+ for original, replacement in subst_groups:
887
+ trial_vars = [replacement if v == original else v
888
+ for v in best_asvars]
889
+ trial_randvars = {replacement if k == original else k: v
890
+ for k, v in best_randvars.items()}
891
+ solution = Solution(nb_crit=self.nb_crit, asvars=trial_vars,
892
+ isvars=best_isvars, randvars=trial_randvars,
893
+ bcvars=best_bcvars, corvars=best_corvars,
894
+ asc_ind=asc_ind)
895
+ solution['model_n'] = candidate[0]['model_n'] if isinstance(candidate, list) and candidate else None
896
+ revised, converged = self.evaluate_solution(solution)
897
+ if converged:
898
+ self.insert_solution(revised)
899
+
900
+ def make_change_16_structural_hypothesis(self, candidate):
901
+ """Model-developer move: test a structural hypothesis by taking
902
+ one origin profile and sweeping across ALL destination attributes.
903
+ 'Does bike ownership moderate sensitivity to every destination
904
+ feature, or just distance?'"""
905
+ best_asvars, best_isvars, best_randvars, best_bcvars, best_corvars, \
906
+ asc_ind, _, _ = self.get_best_features(candidate)
907
+
908
+ # Find which origin profiles are currently used via interactions
909
+ active_origins = set()
910
+ for v in best_asvars:
911
+ if v.startswith('inter__prop_'):
912
+ parts = v.replace('inter__', '').split('__')
913
+ if len(parts) >= 1:
914
+ active_origins.add(parts[0])
915
+
916
+ if not active_origins:
917
+ return
918
+
919
+ # Pick one active origin profile to sweep
920
+ origins_list = sorted(active_origins)
921
+ origin = self.param.generator.choice(origins_list)
922
+
923
+ # For this origin, add interactions with every OTHER destination attr
924
+ # that isn't already present (structural completeness hypothesis)
925
+ for var_name in self.param.avail_asvars:
926
+ if not var_name.startswith('inter__'):
927
+ continue
928
+ if var_name in best_asvars:
929
+ continue
930
+ if origin not in var_name:
931
+ continue
932
+
933
+ trial_vars = best_asvars + [var_name]
934
+ trial_vars = list(dict.fromkeys(trial_vars))
935
+ trial_vars = self.remove_collinear_vars(trial_vars)
936
+ solution = Solution(nb_crit=self.nb_crit, asvars=trial_vars,
937
+ isvars=best_isvars, randvars=best_randvars,
938
+ bcvars=best_bcvars, corvars=best_corvars,
939
+ asc_ind=asc_ind)
940
+ revised, converged = self.evaluate_solution(solution)
941
+ if converged:
942
+ self.insert_solution(revised)
943
+
944
+ def _build_substitution_groups(self, asvars):
945
+ """Identify groups of variables that measure similar constructs
946
+ and can be substituted for one another."""
947
+ groups = []
948
+ # Group 1: destination size measures
949
+ size_measures = [v for v in asvars
950
+ if v in {'ln_dest_trips', 'osm_destination_pull',
951
+ 'acdc_aggl', 'osm_poi_intensity'}]
952
+ for i in range(len(size_measures)):
953
+ for j in range(i + 1, len(size_measures)):
954
+ groups.append((size_measures[i], size_measures[j]))
955
+
956
+ # Group 2: distance-related measures
957
+ dist_measures = [v for v in asvars
958
+ if v in {'ln_dist', 'dist_km',
959
+ 'acdc_nearest_dest_km', 'acdc_weighted_mean_dist_km'}]
960
+ for i in range(len(dist_measures)):
961
+ for j in range(i + 1, len(dist_measures)):
962
+ groups.append((dist_measures[i], dist_measures[j]))
963
+
964
+ # Group 3: OSM amenity measures
965
+ osm_measures = [v for v in asvars
966
+ if v.startswith('osm_') and not v.startswith('osm_dest')]
967
+ for i in range(len(osm_measures)):
968
+ for j in range(i + 1, len(osm_measures)):
969
+ groups.append((osm_measures[i], osm_measures[j]))
970
+
971
+ return groups
972
+
973
+ def _get_forced_vars(self):
974
+ """Return list of variables that must never be removed."""
975
+ forced = set()
976
+ forced.update(getattr(self.param, 'ps_asvars', []))
977
+ forced.update(getattr(self.param, 'ps_isvars', []))
978
+ if hasattr(self.param, 'pres_spec_constr') and self.param.pres_spec_constr:
979
+ forced.update(self.param.pres_spec_constr.get('force_include', []))
980
+ return forced
981
+
982
+ def local_search_developer(self, iter: int, phase: str = 'early'):
983
+ """
984
+ Run developer-style local improvement moves on the top solutions.
985
+ Phase determines which moves are used:
986
+ - early (first 40%): broad exploration (structural sweep, substitutions)
987
+ - mid (40-70%): refinement (significance pruning, distribution sweep)
988
+ - late (70-100%): convergence (model progression, ensemble crossover)
989
+ """
990
+ if len(self.memory) < 2:
991
+ return
992
+
993
+ memory_sorted = self.sort_memory(self.memory[:])
994
+ top_n = min(5, len(memory_sorted))
995
+ candidates = memory_sorted[:top_n]
996
+
997
+ # Weight schedule: more moves in mid/late phases
998
+ if phase == 'early':
999
+ moves = [self.make_change_16_structural_hypothesis,
1000
+ self.make_change_15_feature_substitution]
1001
+ per_candidate = 1
1002
+ elif phase == 'mid':
1003
+ moves = [self.make_change_11_significance_prune,
1004
+ self.make_change_12_distribution_sweep,
1005
+ self.make_change_15_feature_substitution]
1006
+ per_candidate = 2
1007
+ else:
1008
+ moves = [self.make_change_13_model_progression,
1009
+ self.make_change_14_ensemble_crossover,
1010
+ self.make_change_11_significance_prune]
1011
+ per_candidate = 3
1012
+
1013
+ for cand in candidates[:per_candidate]:
1014
+ for move in moves:
1015
+ try:
1016
+ move(cand)
1017
+ except Exception:
1018
+ pass
1019
+
708
1020
  def local_search(self):
709
1021
  # {
710
- # Identify candidate solutions
711
- candidate = [sol for sol in self.memory if abs(sol.obj[0]) < BOUND]
1022
+ # ── Phase-aware local search ─────────────────────────────────
1023
+ # Uses the current improvisation progress to switch between
1024
+ # exploration (early), refinement (mid), and convergence (late).
1025
+ progress = getattr(self, '_improv_iter', 0) / max(self.maxiter, 1)
1026
+ if progress < 0.4:
1027
+ phase = 'early'
1028
+ elif progress < 0.7:
1029
+ phase = 'mid'
1030
+ else:
1031
+ phase = 'late'
1032
+ self.local_search_developer(0, phase=phase)
712
1033
 
713
- # TODO
1034
+ # Also run the original local search changes (1-10) at reduced rate
1035
+ candidate = [sol for sol in self.memory if abs(sol.obj[0]) < BOUND]
714
1036
  # }
715
1037
 
716
1038
  ''' ---------------------------------------------------------- '''
@@ -755,6 +1077,8 @@ class HarmonySearch(Search):
755
1077
  best, current = [], []
756
1078
  for iter in range(self.maxiter):
757
1079
  # {
1080
+ self._improv_iter = iter # track for phase-aware local search
1081
+
758
1082
  # Compute consideration rate and pitch value
759
1083
  # This code introduces oscillations (a.k.a., variations) based on the iteration number.
760
1084
  # The result is scaled by the sine function only when its value is non-negative.
@@ -768,13 +1092,10 @@ class HarmonySearch(Search):
768
1092
  # {
769
1093
  self.insert_solution(curr_sol)
770
1094
 
771
- #if iter > int(self.prop_local * self.maxiter):
772
- # {
773
- # Run local search
774
- #best_sol = self.memory[0]
775
- #best.append(best_sol.obj[0])
776
- #current.append(curr_sol.obj[0])
777
- # }
1095
+ # Phase-aware local search: every 10 iterations, run
1096
+ # developer-style moves on the top solutions
1097
+ if iter > 0 and iter % 10 == 0 and len(self.memory) >= 2:
1098
+ self.local_search()
778
1099
  # }
779
1100
  # }
780
1101
 
@@ -1223,6 +1223,20 @@ class Search():
1223
1223
  self.idnum = idnum
1224
1224
  self.local_impr = 0
1225
1225
 
1226
+ # ── Banlist: specifications that have already failed ────────────
1227
+ # Signatures (SHA-256 hashes) of specs that crashed or returned
1228
+ # non-converged / infinite results. These are never visited again.
1229
+ self._banlist = set()
1230
+
1231
+ # ── Variable-level failure tracker ──────────────────────────────
1232
+ # {varname: count} — how many times each variable appeared in a
1233
+ # failed (non-convergent, exception, infinite-LL) specification.
1234
+ # When count exceeds _var_attrition_limit the variable is removed
1235
+ # from the available lists so it never appears again for the
1236
+ # remainder of the search.
1237
+ self._var_failures = {}
1238
+ self._var_attrition_limit = 15 # failures before permanent removal
1239
+
1226
1240
  # ── Latent class feature toggles ────────────────────────────────
1227
1241
  self.optimise_class = kwargs.get('optimise_class', False)
1228
1242
  self.optimise_membership = kwargs.get('optimise_membership', False)
@@ -2160,6 +2174,24 @@ class Search():
2160
2174
  if 'corvars' in solution and v in solution['corvars']:
2161
2175
  solution['corvars'] = [x for x in solution['corvars'] if x != v]
2162
2176
 
2177
+ def _cull_attrited_vars(self):
2178
+ """Remove variables from the available pools that have exceeded the
2179
+ failure threshold. Once removed they stay out for the rest of
2180
+ the search, freeing the algorithm to focus on viable variables.
2181
+ """
2182
+ limit = self._var_attrition_limit
2183
+ forced = set(self._get_forced_vars())
2184
+ to_kill = {v for v, cnt in self._var_failures.items()
2185
+ if cnt >= limit and v not in forced}
2186
+ if not to_kill:
2187
+ return
2188
+ for v in sorted(to_kill):
2189
+ if hasattr(self.param, 'avail_asvars') and v in self.param.avail_asvars:
2190
+ self.param.avail_asvars.remove(v)
2191
+ if hasattr(self.param, 'avail_isvars') and v in self.param.avail_isvars:
2192
+ self.param.avail_isvars.remove(v)
2193
+ print(f" [attrition] '{v}' removed from search after {self._var_failures[v]} failures")
2194
+
2163
2195
  def _apply_latent_class_constraints(self, solution, lc_constraints):
2164
2196
  """Apply constraints specific to latent class models."""
2165
2197
  # Example: force variables to appear only in certain classes
@@ -3685,6 +3717,11 @@ class Search():
3685
3717
  ''' ---------------------------------------------------------- '''
3686
3718
  def evaluate_solution(self, sol):
3687
3719
  # {
3720
+ sig = self.setup_signature(sol)
3721
+ if sig in self._banlist:
3722
+ sol['converged'] = False
3723
+ return (sol, False)
3724
+
3688
3725
  as_vars, is_vars, rand_vars, bc_vars, corvars, asc_ind = self.get_components(sol)
3689
3726
  all_vars = is_vars + as_vars
3690
3727
  all_vars = [var for var in self.param.varnames if var in all_vars]
@@ -3692,17 +3729,32 @@ class Search():
3692
3729
  # Estimate model if input variables are present in specification
3693
3730
  if not all_vars:
3694
3731
  sol['converged'] = False
3732
+ self._banlist.add(sig)
3695
3733
  return (sol, False)
3696
3734
 
3697
- # Run backward elimination if enabled (default True), otherwise a single fit
3698
- all_sig = getattr(self.param, 'all_sig', True)
3699
- if all_sig:
3700
- sol, converged = self.backward_eliminate(sol)
3701
- else:
3702
- result = self.evaluate_model(sol)
3703
- aic, bic, loglik, mae, asvars, isvars, randvars, bcvars, corvars, converged, sol = result
3704
- sol['bcvars'] = bcvars
3705
- sol['aic'], sol['bic'], sol['loglik'], sol['mae'] = aic, bic, loglik, mae
3735
+ try:
3736
+ # Run backward elimination if enabled (default True), otherwise a single fit
3737
+ all_sig = getattr(self.param, 'all_sig', True)
3738
+ if all_sig:
3739
+ sol, converged = self.backward_eliminate(sol)
3740
+ else:
3741
+ result = self.evaluate_model(sol)
3742
+ aic, bic, loglik, mae, asvars, isvars, randvars, bcvars, corvars, converged, sol = result
3743
+ sol['bcvars'] = bcvars
3744
+ sol['aic'], sol['bic'], sol['loglik'], sol['mae'] = aic, bic, loglik, mae
3745
+ except Exception:
3746
+ sol['converged'] = False
3747
+ self._banlist.add(sig)
3748
+ if hasattr(self, 'best_solution') and self.best_solution is not None:
3749
+ fail_vars = set(as_vars + is_vars)
3750
+ base_as = set(self.best_solution.get('asvars', []))
3751
+ base_is = set(self.best_solution.get('isvars', []))
3752
+ new_vars = fail_vars - (base_as | base_is)
3753
+ if new_vars:
3754
+ for v in new_vars:
3755
+ self._var_failures[v] = self._var_failures.get(v, 0) + 1
3756
+ self._cull_attrited_vars()
3757
+ return (sol, False)
3706
3758
 
3707
3759
  if converged or (isinstance(sol.get('loglik'), float) and math.isfinite(sol.get('loglik', float('nan')))):
3708
3760
  # {
@@ -3720,6 +3772,20 @@ class Search():
3720
3772
  # {
3721
3773
  self.not_converged += 1
3722
3774
  sol['converged'] = False
3775
+ # ── Banlist: never visit this exact specification again
3776
+ self._banlist.add(sig)
3777
+ # ── Variable attrition: only blame vars ADDED vs the
3778
+ # currently-accepted best (neighbour) model.
3779
+ # Skip entirely on init / startup — no baseline yet.
3780
+ if hasattr(self, 'best_solution') and self.best_solution is not None:
3781
+ fail_vars = set(as_vars + is_vars)
3782
+ base_as = set(self.best_solution.get('asvars', []))
3783
+ base_is = set(self.best_solution.get('isvars', []))
3784
+ new_vars = fail_vars - (base_as | base_is)
3785
+ if new_vars:
3786
+ for v in new_vars:
3787
+ self._var_failures[v] = self._var_failures.get(v, 0) + 1
3788
+ self._cull_attrited_vars()
3723
3789
  # ── Convergence diagnostic: explain why the model did not converge
3724
3790
  self._diagnose_nonconvergence(sol, model_n=sol.get('model_n', ''))
3725
3791
  # }
@@ -4009,17 +4075,8 @@ class Search():
4009
4075
  model.fit()
4010
4076
 
4011
4077
 
4012
- #def fit_random_regret(self, X=None, y=None, varnames = None, alts = None, isvars = None, transvars = None, ids =None, weights = None, panels = None, avail = None, base_alt = None, df,, **kwargs):
4013
- #if df # does not have co
4014
-
4015
- def fit_random_regret(self, df):
4016
- model = RandomRegret(df=df, short=False, normalize=True)
4017
- #a = (df, s)
4018
- # model.setup_long()
4019
- # model.setup(X, y, varnames,alts , isvars, transvars, ids, weights, panels, avail, base_alt)
4020
- model.fit()
4021
- model.report()
4022
- return model
4078
+ # fit_random_regret moved below with transvars support
4079
+ # (see evaluate_rrm section)
4023
4080
 
4024
4081
 
4025
4082
 
@@ -4166,40 +4223,27 @@ class Search():
4166
4223
 
4167
4224
  def evaluate_nested_logit(self, sol):
4168
4225
  """Evaluates a Nested Logit model."""
4169
- # Extract relevant model parameters
4170
- sol =self.apply_constraints(sol)
4226
+ sol = self.apply_constraints(sol)
4171
4227
  as_vars, is_vars, asc_ind = sol['asvars'], sol['isvars'], sol['asc_ind']
4172
- logging.info('testing state')
4173
- state = sol['state']
4174
-
4175
- #state['nest_vars']
4176
- #sol = self.apply_constraints(sol)
4177
- #sol = self.repair_solution(sol)
4178
- # Define nests and lambda values (adjust based on your data)
4179
- # TODO NEED TO FEED IN THE NESTS FROM params
4228
+ bc_vars = self.define_bc_vars(sol)
4180
4229
  nests = self.param.nests
4181
4230
  lambdas = self.param.lambdas
4182
- #lambdas_mapping = self.param.lambdas_mapping
4183
4231
 
4184
- # Filter the variables to include in the model
4185
4232
  all_vars = as_vars + is_vars
4186
4233
  if len(all_vars) == 0:
4187
4234
  raise ValueError('need a variable: todo debug why')
4188
4235
  all_vars = [var for var in self.param.varnames if var in all_vars]
4189
4236
  nest_vars = [var for var in self.param.varnest if var in all_vars]
4190
4237
 
4191
- #to do if nest_vars is None repair
4192
- # if nest vars)not in all_var repair
4193
-
4194
- # Prepare data for the nested logit model
4195
4238
  X, y = self.param.df[all_vars].values, self.param.choices
4196
4239
  X_nest = self.param.df[nest_vars]
4197
4240
 
4198
- # Fit the Nested Logit model
4199
4241
  model = NestedLogit(_jax=getattr(self.param, '_jax', True))
4200
- model.setup(X=X, X_nest = X_nest, y=y, varnames=all_vars, isvars=is_vars,
4242
+ model.setup(X=X, X_nest=X_nest, y=y, varnames=all_vars, isvars=is_vars,
4201
4243
  alts=self.param.alt_var, ids=self.param.choice_id,
4202
- nests=nests, lambdas=lambdas, fit_intercept=asc_ind, return_grad=self.param.grad, return_hess=self.param.hess)
4244
+ nests=nests, lambdas=lambdas, fit_intercept=asc_ind,
4245
+ transvars=bc_vars,
4246
+ return_grad=self.param.grad, return_hess=self.param.hess)
4203
4247
 
4204
4248
  model.fit()
4205
4249
 
@@ -4234,6 +4278,7 @@ class Search():
4234
4278
  sol = self.apply_constraints(sol)
4235
4279
  as_vars, is_vars, asc_ind = sol['asvars'], sol['isvars'], sol['asc_ind']
4236
4280
  randvars = sol.get('randvars', {})
4281
+ bc_vars = self.define_bc_vars(sol)
4237
4282
 
4238
4283
  nests = self.param.nests
4239
4284
  lambdas = self.param.lambdas
@@ -4257,6 +4302,7 @@ class Search():
4257
4302
  nests=nests,
4258
4303
  lambdas=lambdas,
4259
4304
  randvars=randvars,
4305
+ transvars=bc_vars,
4260
4306
  panels=self.param.ind_id,
4261
4307
  fit_intercept=asc_ind,
4262
4308
  n_draws=n_draws,
@@ -4277,30 +4323,26 @@ class Search():
4277
4323
 
4278
4324
 
4279
4325
  def evaluate_nested_logit_ml(self, sol):
4280
- """Evaluates a Nested Logit model."""
4281
- # Extract relevant model parameters
4326
+ """Evaluates a Multi-Layer Nested Logit model."""
4282
4327
  as_vars, is_vars, asc_ind = sol['asvars'], sol['isvars'], sol['asc_ind']
4328
+ bc_vars = self.define_bc_vars(sol)
4283
4329
 
4284
- # Define nests and lambda values (adjust based on your data)
4285
- #TODO NEED TO FEED IN THE NESTS FROM params
4286
4330
  nests = self.param.nests
4287
4331
  lambdas = self.param.lambdas
4288
4332
  lambdas_mapping = self.param.lambdas_mapping
4289
4333
 
4290
- # Filter the variables to include in the model
4291
4334
  all_vars = as_vars + is_vars
4292
- if len(all_vars) ==0:
4335
+ if len(all_vars) == 0:
4293
4336
  raise ValueError('need a variable: todo debug why')
4294
4337
  all_vars = [var for var in self.param.varnames if var in all_vars]
4295
4338
 
4296
- # Prepare data for the nested logit model
4297
4339
  X, y = self.param.df[all_vars].values, self.param.choices
4298
4340
 
4299
- # Fit the Nested Logit model
4300
4341
  model = MultiLayerNestedLogit()
4301
4342
  model.setup(X=X, y=y, varnames=all_vars, isvars=is_vars,
4302
- alts=self.param.alt_var, ids=self.param.choice_id,
4303
- nests=nests, lambdas=lambdas, lambdas_mapping = lambdas_mapping, fit_intercept=asc_ind, return_grad=False)
4343
+ alts=self.param.alt_var, ids=self.param.choice_id,
4344
+ nests=nests, lambdas=lambdas, lambdas_mapping=lambdas_mapping,
4345
+ transvars=bc_vars, fit_intercept=asc_ind, return_grad=False)
4304
4346
 
4305
4347
  model.fit()
4306
4348
 
@@ -4383,26 +4425,42 @@ class Search():
4383
4425
 
4384
4426
  return sub, all_attr_vars
4385
4427
 
4386
- def fit_random_regret(self, df, use_jax=True):
4387
- model = RandomRegret(df=df, short=False, normalize=True)
4388
- if use_jax:
4389
- model.fit_jax()
4428
+ def fit_random_regret(self, df, use_jax=True, transvars=None):
4429
+ if transvars:
4430
+ # Build model via setup() so transvars flow through pre_process
4431
+ # RRM uses attribute_vars as the model variables
4432
+ all_vars = list(df.columns.difference(['id', 'alt', 'choice', 'weight']))
4433
+ X = df[all_vars].values
4434
+ y = df['choice'].values.astype(np.int32)
4435
+ alts = df['alt'].values.astype(np.int32)
4436
+ ids = df['id'].values.astype(np.int32)
4437
+ model = RandomRegret()
4438
+ model.setup(X=X, y=y, varnames=all_vars, alts=alts, ids=ids,
4439
+ transvars=[v for v in transvars if v in all_vars])
4440
+ if use_jax:
4441
+ model.fit_jax()
4442
+ else:
4443
+ model.fit()
4390
4444
  else:
4391
- model.fit()
4445
+ model = RandomRegret(df=df, short=False, normalize=True)
4446
+ if use_jax:
4447
+ model.fit_jax()
4448
+ else:
4449
+ model.fit()
4392
4450
  model.report()
4393
4451
  return model
4394
4452
 
4395
4453
  def evaluate_rrm(self, sol):
4396
4454
  as_vars, is_vars, asc_ind = sol['asvars'], sol['isvars'], sol['asc_ind']
4455
+ bc_vars = self.define_bc_vars(sol)
4456
+ bc_vars = [v for v in bc_vars if v not in self.param.isvarnames]
4397
4457
 
4398
4458
  df, attr_vars = self._build_rrm_df(self.param.df, as_vars, is_vars)
4399
- model = self.fit_random_regret(df=df)
4459
+ model = self.fit_random_regret(df=df, transvars=bc_vars)
4400
4460
  sol['model'] = model
4401
4461
  sol['coeff'] = model.coeff_est if hasattr(model, 'coeff_est') else model.beta
4402
4462
  converged = model.converged
4403
4463
  aic, bic, loglik = model.aic, model.bic, model.loglik
4404
- bc_vars = self.define_bc_vars(sol)
4405
- bc_vars = [v for v in bc_vars if v not in self.param.isvarnames]
4406
4464
  rand_vars, cor_vars = {}, []
4407
4465
 
4408
4466
  if self.mae_is_an_objective():
@@ -4432,7 +4490,8 @@ class Search():
4432
4490
  try:
4433
4491
  model.setup(X=X, y=y, varnames=all_vars, alts=self.param.alt_var,
4434
4492
  isvars=is_vars, ids=self.param.choice_id,
4435
- randvars=rand_vars, panels=self.param.ind_id,
4493
+ randvars=rand_vars, transvars=bc_vars,
4494
+ panels=self.param.ind_id,
4436
4495
  avail=self.param.avail, base_alt=self.param.base_alt,
4437
4496
  maxiter=self.param.maxiter, ftol=self.param.ftol,
4438
4497
  gtol=self.param.gtol)
@@ -4457,12 +4516,13 @@ class Search():
4457
4516
  def evaluate_ordered_logit(self,sol):
4458
4517
 
4459
4518
  as_vars, is_vars, asc_ind = sol['asvars'], sol['isvars'], sol['asc_ind']
4519
+ bc_vars = self.define_bc_vars(sol)
4520
+ bc_vars = [var for var in bc_vars if var not in self.param.isvarnames]
4460
4521
 
4461
4522
 
4462
4523
  all_vars = as_vars + is_vars
4463
4524
 
4464
4525
  all_vars = [var for var in self.param.varnames if var in all_vars]
4465
- all_vars = all_vars
4466
4526
 
4467
4527
  # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4468
4528
  #df_long = misc.wide_to_long(self.param.df, id_col='id', alt_list=self.param.alt_var, alt_name='alt')
@@ -4470,13 +4530,13 @@ class Search():
4470
4530
  #y = df_long['choice']
4471
4531
  X, y = self.param.df[all_vars], self.param.choices
4472
4532
  J = len(np.unique(self.param.alt_var))
4473
- model = self.fit_ordered_logit(X=X, y=y, ids = self.param.choice_id, varnames = all_vars, choices = J)
4474
- sol['model'] = model # Store the model object
4533
+ model = self.fit_ordered_logit(X=X, y=y, ids=self.param.choice_id,
4534
+ varnames=all_vars, choices=J,
4535
+ transvars=bc_vars)
4536
+ sol['model'] = model
4475
4537
  sol['coeff'] = model.coeff_est
4476
4538
  converged = model.converged
4477
4539
  aic, bic, loglik = model.aic, model.bic, model.loglik
4478
- bc_vars = self.define_bc_vars(sol)
4479
- bc_vars = [var for var in bc_vars if var not in self.param.isvarnames]
4480
4540
  alts = self.param.alt_var
4481
4541
  rand_vars, cor_vars = {}, []
4482
4542
 
@@ -4498,7 +4558,7 @@ class Search():
4498
4558
  return tuple
4499
4559
 
4500
4560
 
4501
- def fit_ordered_logit(self, X, y, ids, varnames, choices):
4561
+ def fit_ordered_logit(self, X, y, ids, varnames, choices, transvars=None):
4502
4562
 
4503
4563
 
4504
4564
  moll = OrderedLogitLong(X=X.values,
@@ -4509,7 +4569,8 @@ class Search():
4509
4569
  distr='logit',
4510
4570
  start=None,
4511
4571
  normalize=False,
4512
- fit_intercept=False)
4572
+ fit_intercept=False,
4573
+ transvars=transvars or [])
4513
4574
 
4514
4575
  moll.fit(method='BFGS')
4515
4576
  moll.report()
@@ -0,0 +1 @@
1
+ 0.0.130
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.128
3
+ Version: 0.0.130
4
4
  Summary: A Python package for econometric models driven by search
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.128