SearchLibrium 0.0.151__tar.gz → 0.0.152__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.

Potentially problematic release.


This version of SearchLibrium might be problematic. Click here for more details.

Files changed (60) hide show
  1. {searchlibrium-0.0.151/src/SearchLibrium.egg-info → searchlibrium-0.0.152}/PKG-INFO +1 -1
  2. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/pyproject.toml +1 -1
  3. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/constraints_builder.py +73 -0
  4. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/search.py +227 -36
  5. searchlibrium-0.0.152/src/SearchLibrium/version.txt +1 -0
  6. {searchlibrium-0.0.151 → searchlibrium-0.0.152/src/SearchLibrium.egg-info}/PKG-INFO +1 -1
  7. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium.egg-info/SOURCES.txt +2 -1
  8. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium.egg-info/top_level.txt +1 -0
  9. searchlibrium-0.0.152/src/data_generator/latent_class_gen.py +174 -0
  10. searchlibrium-0.0.151/src/SearchLibrium/version.txt +0 -1
  11. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/MANIFEST.in +0 -0
  12. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/README.md +0 -0
  13. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/setup.cfg +0 -0
  14. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/BEHier.py +0 -0
  15. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/Halton.py +0 -0
  16. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/MixedLogit.py +0 -0
  17. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/Mode_Activity_Nested.py +0 -0
  18. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/RandomP.py +0 -0
  19. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/SEARCH_SM_MARIO.py +0 -0
  20. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/Two_Level_Nest.py +0 -0
  21. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/__init__.py +0 -0
  22. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/__main__.py +0 -0
  23. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/_choice_model.py +0 -0
  24. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/_device.py +0 -0
  25. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/banditsa.py +0 -0
  26. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/bhhh/minimize.py +0 -0
  27. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/boxcox_functions.py +0 -0
  28. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/call_meta.py +0 -0
  29. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/data/Swissmetro_final.csv +0 -0
  30. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/data/TravelMode.csv +0 -0
  31. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/data/electricity.csv +0 -0
  32. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/harmony.py +0 -0
  33. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/hspbil.py +0 -0
  34. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/latent_class.py +0 -0
  35. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/main.py +0 -0
  36. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/main_debug.py +0 -0
  37. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/mdcev.py +0 -0
  38. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/misc.py +0 -0
  39. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/mixed_logit.py +0 -0
  40. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/mixed_nested.py +0 -0
  41. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/mixedrrm.py +0 -0
  42. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/multinomial_logit.py +0 -0
  43. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/multinomial_nested.py +0 -0
  44. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/multinomial_probit.py +0 -0
  45. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/ordered_logit.py +0 -0
  46. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/ordered_logit_mixed.py +0 -0
  47. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/predict.py +0 -0
  48. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/rrm.py +0 -0
  49. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/sample_data.py +0 -0
  50. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/sapbil.py +0 -0
  51. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/selection_models.py +0 -0
  52. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/setup.py +0 -0
  53. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/siman.py +0 -0
  54. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/test_lc_de.py +0 -0
  55. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/test_mario_searches.py +0 -0
  56. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/test_sapbil_vs_banditsa.py +0 -0
  57. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium/threshold.py +0 -0
  58. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium.egg-info/dependency_links.txt +0 -0
  59. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium.egg-info/entry_points.txt +0 -0
  60. {searchlibrium-0.0.151 → searchlibrium-0.0.152}/src/SearchLibrium.egg-info/requires.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.151
3
+ Version: 0.0.152
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>
@@ -82,7 +82,7 @@ Documentation = "https://pypi.org/project/SearchLibrium/"
82
82
  realpython = "SearchLibrium.__main__:main"
83
83
 
84
84
  [tool.bumpver]
85
- current_version = "0.0.151"
85
+ current_version = "0.0.152"
86
86
  version_pattern = "MAJOR.MINOR.PATCH"
87
87
  commit_message = "[skip ci] Bump version {old_version} -> {new_version}"
88
88
  commit = true
@@ -135,6 +135,79 @@ class ConstraintBuilder:
135
135
  self._print(f"Mutually exclusive group: {variables}")
136
136
  return self
137
137
 
138
+ # ========================
139
+ # Alternative-Specific Variable Constraints
140
+ # ========================
141
+
142
+ def asvar_alt_spec(self, variable: str, alts: List[str]) -> 'ConstraintBuilder':
143
+ """
144
+ Break alternative-specific variables into subsets so they don't all
145
+ apply uniformly to the model. For each variable, creates
146
+ alternative-specific dummy columns only for the given *alts*, while
147
+ keeping the original variable for the remaining alternatives as a
148
+ generic coefficient. The search will then select among these
149
+ alt-specific columns as part of the variable search.
150
+
151
+ Parameters
152
+ ----------
153
+ variable : str
154
+ Base variable name (e.g. 'TIME', 'COST').
155
+ alts : list of str
156
+ Alternatives for which alt-specific coefficients should be
157
+ created (e.g. ['Car', 'Train']). Other alternatives share a
158
+ single generic coefficient.
159
+
160
+ Returns
161
+ -------
162
+ ConstraintBuilder
163
+ Self for method chaining
164
+
165
+ Example
166
+ -------
167
+ >>> constraints.asvar_alt_spec('TIME', ['Car', 'Train'])
168
+ >>> constraints.asvar_alt_spec('COST', ['Bus', 'Car'])
169
+
170
+ This creates ``TIME_Car``, ``TIME_Train``, ``COST_Bus``, ``COST_Car``
171
+ in the active variable pool while keeping ``TIME`` and ``COST`` as
172
+ generic columns for the remaining alternatives.
173
+ """
174
+ if 'asvar_alt_spec' not in self._constraints:
175
+ self._constraints['asvar_alt_spec'] = {}
176
+ self._constraints['asvar_alt_spec'][variable] = list(alts)
177
+ self._print(f"Alt-specific subset: '{variable}' → {alts}")
178
+ return self
179
+
180
+ def incompatible_specs(self, *alt_var_names: str) -> 'ConstraintBuilder':
181
+ """
182
+ Declare that certain alternative-specific variable names (e.g.,
183
+ ``TIME_Car`` and ``COST_Car``) are incompatible and must not appear
184
+ together in the same model. Each call creates one exclusion group.
185
+
186
+ This is applied **after** the alternative-specific dummies are
187
+ created, so you can declare constraints on the resulting column
188
+ names.
189
+
190
+ Parameters
191
+ ----------
192
+ *alt_var_names : str
193
+ Alt-specific column names forming a mutually exclusive group.
194
+
195
+ Returns
196
+ -------
197
+ ConstraintBuilder
198
+ Self for method chaining
199
+
200
+ Example
201
+ -------
202
+ >>> constraints.incompatible_specs('TIME_Car', 'COST_Car')
203
+ >>> constraints.incompatible_specs('FARE_Train', 'TIME_Train')
204
+ """
205
+ if 'incompatible_specs' not in self._constraints:
206
+ self._constraints['incompatible_specs'] = []
207
+ self._constraints['incompatible_specs'].append(list(alt_var_names))
208
+ self._print(f"Incompatible alt-specific group: {alt_var_names}")
209
+ return self
210
+
138
211
  def min_behavioral(self, min_count: int, *variables: str) -> 'ConstraintBuilder':
139
212
  """
140
213
  Require at least ``min_count`` variables from a behavioural pool
@@ -1703,6 +1703,26 @@ class Search():
1703
1703
  ''' ---------------------------------------------------------- '''
1704
1704
  def remove_redundant_asvars(self, asvars, transasvars, asvarnames):
1705
1705
  # {
1706
+ # Gather variables that are part of an asvar_alt_spec constraint so
1707
+ # that both the generic column AND its alt-specific dummies are kept
1708
+ # together (the constraint requires both forms to coexist).
1709
+ constraints = getattr(self.param, 'pres_spec_constr', None) or {}
1710
+ alt_spec_map = constraints.get('asvar_alt_spec', {})
1711
+ constrained_base = set()
1712
+ constrained_dummies = set()
1713
+ if alt_spec_map:
1714
+ for base_var, alts in alt_spec_map.items():
1715
+ if base_var in asvars:
1716
+ constrained_base.add(base_var)
1717
+ for alt in alts:
1718
+ dummy_name = f"{base_var}_{alt}"
1719
+ if dummy_name in asvars:
1720
+ constrained_dummies.add(dummy_name)
1721
+
1722
+ # Merge constrained vars -- they always travel together so exclude
1723
+ # them from the either/or logic below.
1724
+ constrained_merged = constrained_base | constrained_dummies
1725
+
1706
1726
  # Filter out elements from asvars that contain any substring present in transasvars.
1707
1727
  redundant_asvars = [var for var in asvars if any(subvar in var for subvar in transasvars)]
1708
1728
  unique_vars = [var for var in asvars if var not in redundant_asvars]
@@ -1710,8 +1730,10 @@ class Search():
1710
1730
  # When transformations are not applied, the redundancy is created
1711
1731
  # if a variable has both generic & alt-spec coeffs
1712
1732
  if len(transasvars) == 0: # {
1713
- gen_var_select = [var for var in asvars if var in asvarnames]
1714
- alspec_final = [var for var in asvars if var not in gen_var_select]
1733
+ gen_var_select = [var for var in asvars
1734
+ if var in asvarnames and var not in constrained_merged]
1735
+ alspec_final = [var for var in asvars
1736
+ if var not in asvarnames and var not in constrained_merged]
1715
1737
  # }
1716
1738
  else:
1717
1739
  # {
@@ -1719,12 +1741,16 @@ class Search():
1719
1741
  for var in transasvars:
1720
1742
  # {
1721
1743
  redun_vars = [item for item in asvars if var in item]
1722
- gen_var = [var for var in redun_vars if var in asvarnames]
1744
+ gen_var = [v for v in redun_vars
1745
+ if v in asvarnames and v not in constrained_merged]
1723
1746
  if gen_var:
1724
1747
  gen_var_select.append(np.random.choice(gen_var))
1725
- alspec_redun_vars = [item for item in asvars if var in item and item not in asvarnames]
1726
- trans_alspec = [item for item in alspec_redun_vars if any(sub_item in item for sub_item in boxc_l)]
1727
- lin_alspec = [var for var in alspec_redun_vars if var not in trans_alspec]
1748
+ alspec_redun_vars = [item for item in asvars
1749
+ if var in item and item not in asvarnames
1750
+ and item not in constrained_merged]
1751
+ trans_alspec = [item for item in alspec_redun_vars
1752
+ if any(sub_item in item for sub_item in boxc_l)]
1753
+ lin_alspec = [v for v in alspec_redun_vars if v not in trans_alspec]
1728
1754
  choice = np.random.randint(2) # Chooses a 0 or 1
1729
1755
  ref = lin_alspec if choice else trans_alspec
1730
1756
  alspec_final.extend(ref)
@@ -1738,6 +1764,8 @@ class Search():
1738
1764
  else:
1739
1765
  final_asvars = alspec_final
1740
1766
 
1767
+ # Append constrained vars (both generic base and alt-specific dummies)
1768
+ final_asvars.extend(list(constrained_merged))
1741
1769
  final_asvars.extend(unique_vars) # Extend the list
1742
1770
 
1743
1771
  # Remove duplicates while preserving the order of elements
@@ -1771,6 +1799,7 @@ class Search():
1771
1799
  asvars.extend(self.param.ps_asvars)
1772
1800
  asvars = self.remove_redundant_asvars(asvars, self.param.trans_asvars, self.param.asvarnames)
1773
1801
  asvars = self._apply_mutual_exclusion_filter(asvars)
1802
+ asvars = self._apply_incompatible_specs_filter(asvars)
1774
1803
  return asvars
1775
1804
  # }
1776
1805
 
@@ -1785,6 +1814,7 @@ class Search():
1785
1814
  isvars = [self.param.avail_isvars[i] for i in isvar_select_pos]
1786
1815
  isvars.extend(self.param.ps_isvars)
1787
1816
  isvars = self._apply_mutual_exclusion_filter(isvars)
1817
+ isvars = self._apply_incompatible_specs_filter(isvars)
1788
1818
  return isvars
1789
1819
  # }
1790
1820
 
@@ -2084,6 +2114,9 @@ class Search():
2084
2114
  # ── Mutual-exclusion constraint ──
2085
2115
  self._enforce_mutual_exclusion(solution)
2086
2116
 
2117
+ # ── Incompatible alt-specific specs constraint ──
2118
+ self._enforce_incompatible_specs(solution)
2119
+
2087
2120
  # ── Min-behavioural constraint (soft: at least n from a pool) ──
2088
2121
  self._enforce_min_behavioral(solution)
2089
2122
 
@@ -2176,6 +2209,17 @@ class Search():
2176
2209
  return []
2177
2210
  return self.param.pres_spec_constr.get('mutually_exclusive', [])
2178
2211
 
2212
+ def _get_incompatible_specs_groups(self):
2213
+ """Return the list of incompatible alt-specific variable groups, or [].
2214
+
2215
+ Each group is a list[str] of alt-specific column names (e.g.,
2216
+ ``TIME_Car``, ``COST_Car``); at most one member of each group
2217
+ may appear in a solution.
2218
+ """
2219
+ if not hasattr(self.param, 'pres_spec_constr') or self.param.pres_spec_constr is None:
2220
+ return []
2221
+ return self.param.pres_spec_constr.get('incompatible_specs', [])
2222
+
2179
2223
  def _get_excluded_by_mutual_group(self, already_present: set):
2180
2224
  """Return the set of variables that are blocked because a partner
2181
2225
  from the same mutually-exclusive group is *already_present*.
@@ -2187,6 +2231,37 @@ class Search():
2187
2231
  excluded.update(gset)
2188
2232
  return excluded
2189
2233
 
2234
+ def _get_excluded_by_incompatible_specs(self, already_present: set):
2235
+ """Return the set of alt-specific column names that are blocked
2236
+ because a partner from the same incompatible-spec group is
2237
+ *already_present*.
2238
+ """
2239
+ excluded = set()
2240
+ for group in self._get_incompatible_specs_groups():
2241
+ gset = set(group)
2242
+ if gset & already_present:
2243
+ excluded.update(gset)
2244
+ return excluded
2245
+
2246
+ def _apply_incompatible_specs_filter(self, varlist):
2247
+ """Given a list of variables, keep at most one per incompatible-spec
2248
+ group. For each group the first variable encountered is kept;
2249
+ subsequent members are dropped.
2250
+ """
2251
+ groups = self._get_incompatible_specs_groups()
2252
+ if not groups:
2253
+ return varlist
2254
+ blocked = set()
2255
+ for group in groups:
2256
+ seen_first = False
2257
+ for v in varlist:
2258
+ if v in group:
2259
+ if seen_first:
2260
+ blocked.add(v)
2261
+ else:
2262
+ seen_first = True
2263
+ return [v for v in varlist if v not in blocked]
2264
+
2190
2265
  def _enforce_mutual_exclusion(self, solution):
2191
2266
  """Ensure at most one variable per mutually-exclusive group is present.
2192
2267
 
@@ -2215,6 +2290,34 @@ class Search():
2215
2290
  if 'corvars' in solution and v in solution['corvars']:
2216
2291
  solution['corvars'] = [x for x in solution['corvars'] if x != v]
2217
2292
 
2293
+ def _enforce_incompatible_specs(self, solution):
2294
+ """Ensure at most one alt-specific variable per incompatible group.
2295
+
2296
+ Operates on the alt-specific dummy column names (e.g.
2297
+ ``TIME_Car``, ``COST_Car``). If multiple members of a group
2298
+ are present, keep the first and remove the rest from asvars.
2299
+ """
2300
+ groups = self._get_incompatible_specs_groups()
2301
+ if not groups:
2302
+ return
2303
+ all_v = self._all_vars_in_solution(solution)
2304
+ for group in groups:
2305
+ present = [v for v in group if v in all_v]
2306
+ if len(present) <= 1:
2307
+ continue
2308
+ remove = set(present[1:])
2309
+ for v in remove:
2310
+ if 'asvars' in solution and v in solution['asvars']:
2311
+ solution['asvars'] = [x for x in solution['asvars'] if x != v]
2312
+ if 'isvars' in solution and v in solution['isvars']:
2313
+ solution['isvars'] = [x for x in solution['isvars'] if x != v]
2314
+ if 'randvars' in solution and v in solution['randvars']:
2315
+ del solution['randvars'][v]
2316
+ if 'bcvars' in solution and v in solution['bcvars']:
2317
+ solution['bcvars'] = [x for x in solution['bcvars'] if x != v]
2318
+ if 'corvars' in solution and v in solution['corvars']:
2319
+ solution['corvars'] = [x for x in solution['corvars'] if x != v]
2320
+
2218
2321
  def _cull_attrited_vars(self):
2219
2322
  """Remove variables from the available pools that have exceeded the
2220
2323
  failure threshold. Once removed they stay out for the rest of
@@ -2234,13 +2337,50 @@ class Search():
2234
2337
  print(f" [attrition] '{v}' removed from search after {self._var_failures[v]} failures")
2235
2338
 
2236
2339
  def _apply_latent_class_constraints(self, solution, lc_constraints):
2237
- """Apply constraints specific to latent class models."""
2238
- # Example: force variables to appear only in certain classes
2340
+ """Apply constraints specific to latent class models.
2341
+
2342
+ Populates solution['class_params_spec'] from the constraint builder's
2343
+ class_specific_vars dict, so that the search respects per-class
2344
+ variable assignments.
2345
+ """
2346
+ n_classes = lc_constraints.get('n_classes', 2)
2347
+
2348
+ # --- class-specific variable assignments ---
2239
2349
  if 'class_specific_vars' in lc_constraints:
2240
- # This would require more complex logic depending on latent class implementation
2241
- # For now, just ensure variables are included
2242
- for class_vars in lc_constraints['class_specific_vars'].values():
2243
- solution['asvars'] = list(set(class_vars + solution['asvars']))
2350
+ class_spec_vars = lc_constraints['class_specific_vars']
2351
+ # Build / update class_params_spec
2352
+ existing = solution.data.get('class_params_spec', None)
2353
+ if existing is None or len(existing) != n_classes:
2354
+ existing = np.empty(n_classes, dtype=object)
2355
+ for c in range(n_classes):
2356
+ existing[c] = np.array([], dtype='<U64')
2357
+
2358
+ for c in range(n_classes):
2359
+ key = f'class_{c}'
2360
+ if key in class_spec_vars:
2361
+ vars_c = class_spec_vars[key]
2362
+ current = list(existing[c]) if len(existing[c]) > 0 else []
2363
+ merged = list(set(current + vars_c))
2364
+ existing[c] = np.array(merged, dtype='<U64')
2365
+
2366
+ solution['class_params_spec'] = existing
2367
+
2368
+ # Also ensure all class-specific vars are in asvars (the flat list used
2369
+ # by variable-selection operators for discovery)
2370
+ all_class_vars = []
2371
+ for vars_c in class_spec_vars.values():
2372
+ all_class_vars.extend(vars_c)
2373
+ solution['asvars'] = list(set(all_class_vars + solution['asvars']))
2374
+
2375
+ # --- membership variable constraints ---
2376
+ if 'membership_vars' in lc_constraints:
2377
+ member_vars = list(lc_constraints['membership_vars'].keys())
2378
+ existing_member = solution.data.get('member_params_spec', None)
2379
+ if existing_member is None or len(existing_member) == 0:
2380
+ solution['member_params_spec'] = np.array(member_vars, dtype='<U64')
2381
+ else:
2382
+ merged = list(set(list(existing_member) + member_vars))
2383
+ solution['member_params_spec'] = np.array(merged, dtype='<U64')
2244
2384
 
2245
2385
  def _apply_mixed_model_constraints(self, solution, mm_constraints):
2246
2386
  """Apply constraints specific to mixed models."""
@@ -2562,6 +2702,7 @@ class Search():
2562
2702
  # {
2563
2703
  candidate = [var for var in self.param.asvarnames if var not in solution['asvars']]
2564
2704
  blocked = self._get_excluded_by_mutual_group(set(solution.get('asvars', [])) | set(solution.get('isvars', [])))
2705
+ blocked |= self._get_excluded_by_incompatible_specs(set(solution.get('asvars', [])) | set(solution.get('isvars', [])))
2565
2706
  candidate = [v for v in candidate if v not in blocked]
2566
2707
  if len(candidate) > 0:
2567
2708
  new_asvar = self.random_choice(candidate)
@@ -2637,6 +2778,7 @@ class Search():
2637
2778
  # {
2638
2779
  candidate = [var for var in self.param.isvarnames if var not in solution['isvars']]
2639
2780
  blocked = self._get_excluded_by_mutual_group(set(solution.get('asvars', [])) | set(solution.get('isvars', [])))
2781
+ blocked |= self._get_excluded_by_incompatible_specs(set(solution.get('asvars', [])) | set(solution.get('isvars', [])))
2640
2782
  candidate = [v for v in candidate if v not in blocked]
2641
2783
  if len(candidate) > 0:
2642
2784
  # {
@@ -3883,47 +4025,72 @@ class Search():
3883
4025
  def create_dummy_column(self, asvars):
3884
4026
  # {
3885
4027
  """
3886
- This function generates a random boolean array with the same
3887
- length as 'asvars'. For each 'True' in the array, it creates a
3888
- new dataframe column for each choice alternative where the value
3889
- is the product of the variable and a boolean that indicates if
3890
- the choice alternative is the current alternative. It then adds
3891
- these alternative-specific variables into a new list and extends
3892
- it with the remaining variables from 'asvars' that were not
3893
- chosen for alternative-specific coefficients.
3894
-
3895
- The new asvar includes the new dummy columns created and the remaining
3896
- variables from 'asvars' that were not chosen for alternative-specific coefficients.
4028
+ Generates alternative-specific dummy columns for variables that
4029
+ receive alternative-specific coefficients. For variables listed
4030
+ in the ``asvar_alt_spec`` constraint, dummies are created *only*
4031
+ for the specified alternatives (the original variable is kept
4032
+ for the remaining alternatives as a generic coefficient). For
4033
+ all other variables the existing random-selection behaviour is
4034
+ preserved: a random subset is turned into full alt-specific
4035
+ dummies and the remaining variables stay generic.
4036
+
4037
+ The new asvar list includes the new dummy columns alongside the
4038
+ variables that were not chosen for alternative-specific
4039
+ coefficients.
3897
4040
  """
3898
4041
 
3899
- # Generate a random boolean array with the same length as asvars
3900
- rand_array = np.random.choice([True, False], len(asvars))
4042
+ constraints = getattr(self.param, 'pres_spec_constr', None) or {}
4043
+ alt_spec_map = constraints.get('asvar_alt_spec', {})
4044
+
4045
+ # Separate constrained vars from free vars
4046
+ constrained_asvars = [v for v in asvars if v in alt_spec_map]
4047
+ free_asvars = [v for v in asvars if v not in alt_spec_map]
4048
+
4049
+ # Generate a random boolean array for free vars only
4050
+ rand_array = np.random.choice([True, False], len(free_asvars))
3901
4051
 
3902
- # Initialize a list for storing new alternative-specific variables
3903
4052
  asvars_new = []
3904
4053
 
3905
- # Extract variables randomly chosen to have alternative-specific coefficients
3906
- alt_spec_vars = [var for var, bool in zip(asvars, rand_array) if bool]
4054
+ # 1. Process constrained variables (subset alt-specific)
4055
+ for alt_var in constrained_asvars:
4056
+ # {
4057
+ specified_alts = alt_spec_map[alt_var]
4058
+ for choice in specified_alts:
4059
+ # {
4060
+ col_name = f"{alt_var}_{choice}"
4061
+ if col_name not in self.param.df.columns:
4062
+ self.param.df[col_name] = self.param.df[alt_var] * (self.param.alt_var == choice)
4063
+
4064
+ if self.param.nb_crit > 1:
4065
+ self.param.df_test[col_name] = self.param.df_test[alt_var] * (self.param.test_alt_var == choice)
4066
+
4067
+ asvars_new.append(col_name)
4068
+ # }
4069
+ # Keep the original variable as a generic coefficient for
4070
+ # the alternatives NOT listed in the constraint
4071
+ asvars_new.append(alt_var)
4072
+ # }
4073
+
4074
+ # 2. Process free variables (existing random behaviour)
4075
+ alt_spec_vars = [var for var, bool in zip(free_asvars, rand_array) if bool]
4076
+ generic_vars = [var for var, bool in zip(free_asvars, rand_array) if not bool]
3907
4077
 
3908
- # Create dummy columns for the selected variables
3909
4078
  for alt_var in alt_spec_vars:
3910
4079
  # {
3911
4080
  for choice in self.param.choice_set:
3912
4081
  # {
3913
4082
  col_name = f"{alt_var}_{choice}"
3914
- self.param.df[col_name] = self.param.df[alt_var] * (self.param.alt_var == choice)
4083
+ if col_name not in self.param.df.columns:
4084
+ self.param.df[col_name] = self.param.df[alt_var] * (self.param.alt_var == choice)
3915
4085
 
3916
- if self.param.nb_crit > 1:
3917
- # Must compare against the split TEST alt array, not the
3918
- # full training self.param.alt_var (shape mismatch else).
3919
- self.param.df_test[col_name] = self.param.df_test[alt_var] * (self.param.test_alt_var == choice)
4086
+ if self.param.nb_crit > 1:
4087
+ self.param.df_test[col_name] = self.param.df_test[alt_var] * (self.param.test_alt_var == choice)
3920
4088
 
3921
4089
  asvars_new.append(col_name)
3922
4090
  # }
3923
4091
  # }
4092
+ asvars_new.extend(generic_vars)
3924
4093
 
3925
- # Add the remaining variables that were not selected for alternative-specific coefficients
3926
- asvars_new.extend(var for var, bool in zip(asvars, rand_array) if not bool)
3927
4094
  return asvars_new
3928
4095
 
3929
4096
  # }
@@ -4148,6 +4315,7 @@ class Search():
4148
4315
  avail=avail,
4149
4316
  membership_vars=membership_vars,
4150
4317
  member_params_spec=member_params_spec,
4318
+ class_params_spec=class_params_spec,
4151
4319
  )
4152
4320
  model.fit(em_method="squarem")
4153
4321
  model.get_loglik_null()
@@ -4344,6 +4512,29 @@ class Search():
4344
4512
  member_params_spec = sol.get('member_params_spec', None)
4345
4513
  num_classes = getattr(self.param, 'num_classes', 2)
4346
4514
 
4515
+ # Build all_vars as the UNION of all per-class variable specs
4516
+ # (plus membership vars, plus asvars/isvars fallback).
4517
+ if class_params_spec is not None and len(class_params_spec) > 0:
4518
+ all_vars_set = set()
4519
+ for c in range(len(class_params_spec)):
4520
+ if class_params_spec[c] is not None and len(class_params_spec[c]) > 0:
4521
+ for v in class_params_spec[c]:
4522
+ all_vars_set.add(str(v))
4523
+ # Also include member params
4524
+ if member_params_spec is not None:
4525
+ if hasattr(member_params_spec, 'flat'):
4526
+ for v in member_params_spec.flat:
4527
+ all_vars_set.add(str(v))
4528
+ elif hasattr(member_params_spec, '__iter__'):
4529
+ for v in member_params_spec:
4530
+ all_vars_set.add(str(v))
4531
+ # Fallback to asvars+isvars for any vars not captured
4532
+ for v in (as_vars + is_vars):
4533
+ all_vars_set.add(str(v))
4534
+ all_vars = [v for v in self.param.varnames if v in all_vars_set]
4535
+ else:
4536
+ all_vars = [var for var in self.param.varnames if var in (as_vars + is_vars)]
4537
+
4347
4538
  X = self.param.df[all_vars].values
4348
4539
  y = self.param.choices
4349
4540
  ids = self.param.choice_id if self.param.choice_id is not None else self.param.ind_id
@@ -0,0 +1 @@
1
+ 0.0.152
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SearchLibrium
3
- Version: 0.0.151
3
+ Version: 0.0.152
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>
@@ -53,4 +53,5 @@ src/SearchLibrium.egg-info/top_level.txt
53
53
  src/SearchLibrium/bhhh/minimize.py
54
54
  src/SearchLibrium/data/Swissmetro_final.csv
55
55
  src/SearchLibrium/data/TravelMode.csv
56
- src/SearchLibrium/data/electricity.csv
56
+ src/SearchLibrium/data/electricity.csv
57
+ src/data_generator/latent_class_gen.py
@@ -1,2 +1,3 @@
1
1
  SearchLibrium
2
+ data_generator
2
3
  sa_runs
@@ -0,0 +1,174 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ from scipy.special import softmax
4
+
5
+ class AdvancedLatentClassGenerator:
6
+ def __init__(
7
+ self,
8
+ n_classes=3,
9
+ n_alternatives=3,
10
+ n_individuals=2000,
11
+ n_choice_tasks=5,
12
+ class_probs=None,
13
+ scale_separation=2.5,
14
+ n_noise_vars=5,
15
+ n_weak_vars=3,
16
+ n_collinear_vars=3,
17
+ rare_class=False,
18
+ random_state=42
19
+ ):
20
+ self.rng = np.random.default_rng(random_state)
21
+
22
+ self.K = n_classes
23
+ self.J = n_alternatives
24
+ self.N = n_individuals
25
+ self.T = n_choice_tasks
26
+
27
+ if class_probs is None:
28
+ if rare_class:
29
+ probs = np.ones(n_classes)
30
+ probs[-1] = 0.05
31
+ probs[:-1] = (1 - 0.05) / (n_classes - 1)
32
+ self.class_probs = probs
33
+ else:
34
+ self.class_probs = np.ones(n_classes) / n_classes
35
+ else:
36
+ self.class_probs = np.array(class_probs)
37
+
38
+ self.scale_separation = scale_separation
39
+ self.n_noise_vars = n_noise_vars
40
+ self.n_weak_vars = n_weak_vars
41
+ self.n_collinear_vars = n_collinear_vars
42
+
43
+ self._define_variables()
44
+ self._generate_parameters()
45
+
46
+ # --------------------------------
47
+ # Variable Names
48
+ # --------------------------------
49
+ def _define_variables(self):
50
+
51
+ self.shared_vars = [
52
+ "price",
53
+ "travel_time",
54
+ "waiting_time"
55
+ ]
56
+
57
+ self.class_specific_vars = {
58
+ 0: ["comfort_level", "seat_space"],
59
+ 1: ["brand_reputation", "loyalty_points"],
60
+ 2: ["eco_rating", "carbon_emissions"]
61
+ }
62
+
63
+ self.noise_vars = [f"random_noise_{i}" for i in range(self.n_noise_vars)]
64
+ self.weak_vars = [f"weak_signal_{i}" for i in range(self.n_weak_vars)]
65
+ self.collinear_vars = [f"collinear_var_{i}" for i in range(self.n_collinear_vars)]
66
+
67
+ # --------------------------------
68
+ # Generate True Parameters
69
+ # --------------------------------
70
+ def _generate_parameters(self):
71
+
72
+ self.parameters = {}
73
+
74
+ for k in range(self.K):
75
+ params = {}
76
+
77
+ # Shared variables
78
+ for var in self.shared_vars:
79
+ params[var] = self.rng.normal(
80
+ loc=k * self.scale_separation,
81
+ scale=1.0
82
+ )
83
+
84
+ # Class-specific variables
85
+ for var in self.class_specific_vars.get(k, []):
86
+ params[var] = self.rng.normal(
87
+ loc=(k + 1) * self.scale_separation,
88
+ scale=1.0
89
+ )
90
+
91
+ # Weak effect variables
92
+ for var in self.weak_vars:
93
+ params[var] = self.rng.normal(
94
+ loc=0,
95
+ scale=0.05
96
+ )
97
+
98
+ # Noise variables → TRUE beta = 0
99
+ for var in self.noise_vars:
100
+ params[var] = 0.0
101
+
102
+ self.parameters[k] = params
103
+
104
+ # --------------------------------
105
+ # Feature Generation
106
+ # --------------------------------
107
+ def _generate_features(self):
108
+
109
+ all_vars = set(self.shared_vars)
110
+ for v in self.class_specific_vars.values():
111
+ all_vars.update(v)
112
+
113
+ all_vars.update(self.noise_vars)
114
+ all_vars.update(self.weak_vars)
115
+ all_vars.update(self.collinear_vars)
116
+
117
+ X = {}
118
+
119
+ for var in all_vars:
120
+ X[var] = self.rng.normal(size=(self.N, self.T, self.J))
121
+
122
+ # Create collinearity
123
+ for i, var in enumerate(self.collinear_vars):
124
+ X[var] = 0.8 * X["price"] + 0.2 * self.rng.normal(
125
+ size=(self.N, self.T, self.J)
126
+ )
127
+
128
+ return X
129
+
130
+ # --------------------------------
131
+ # Data Generation
132
+ # --------------------------------
133
+ def generate(self):
134
+
135
+ classes = self.rng.choice(
136
+ self.K,
137
+ size=self.N,
138
+ p=self.class_probs
139
+ )
140
+
141
+ X = self._generate_features()
142
+ rows = []
143
+
144
+ for n in range(self.N):
145
+ k = classes[n]
146
+
147
+ for t in range(self.T):
148
+
149
+ utilities = np.zeros(self.J)
150
+
151
+ for j in range(self.J):
152
+ for var, beta in self.parameters[k].items():
153
+ utilities[j] += beta * X[var][n, t, j]
154
+
155
+ probs = softmax(utilities)
156
+ choice = self.rng.choice(self.J, p=probs)
157
+
158
+ for j in range(self.J):
159
+
160
+ row = {
161
+ "individual": n,
162
+ "choice_task": t,
163
+ "alternative": j,
164
+ "choice": 1 if j == choice else 0,
165
+ "true_class": k
166
+ }
167
+
168
+ for var in X:
169
+ row[var] = X[var][n, t, j]
170
+
171
+ rows.append(row)
172
+
173
+ df = pd.DataFrame(rows)
174
+ return df
@@ -1 +0,0 @@
1
- 0.0.151