Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions activitysim/abm/models/joint_tour_participation.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,18 +219,18 @@ def participants_chooser(
# anybody with probability > 0 is forced to join the joint tour
if state.settings.use_explicit_error_terms:
# need "is valid choice" such that we certainly choose those with non-zero values,
# and do not choose others. Let's use 3.0 as large value here.
# and do not choose others.
probs_or_utils[choice_col] = np.where(
probs_or_utils[choice_col] > logit.UTIL_MIN,
3.0,
logit.UTIL_LARGE_ENOUGH,
logit.UTIL_UNAVAILABLE,
)
non_choice_col = [
col for col in probs_or_utils.columns if col != choice_col
][0]
probs_or_utils[non_choice_col] = np.where(
probs_or_utils[choice_col] <= logit.UTIL_MIN,
3.0,
logit.UTIL_LARGE_ENOUGH,
logit.UTIL_UNAVAILABLE,
)
else:
Expand Down
343 changes: 162 additions & 181 deletions activitysim/core/interaction_sample.py

Large diffs are not rendered by default.

7 changes: 1 addition & 6 deletions activitysim/core/interaction_sample_simulate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -337,9 +337,7 @@ def _interaction_sample_simulate(
if zero_probs.any():
# copied from proabability below, fix when that gets fixed
# FIXME this is kind of gnarly, but we force choice of first alt
utilities_df.loc[
zero_probs, 0
] = 3.0 # arbitrary value much larger than UTIL_UNAVAILABLE
utilities_df.loc[zero_probs, 0] = logit.UTIL_LARGE_ENOUGH

# positions is series with the chosen alternative represented as a column index in utilities_df
# which is an integer between zero and num alternatives in the alternative sample
Expand DownExpand Up@@ -430,9 +428,6 @@ def _interaction_sample_simulate(
# resulting pandas Int64Index has one element per chooser row and is in same order as choosers
choices = alternatives[choice_column].take(positions + first_row_offsets)

# resulting pandas Int64Index has one element per chooser row and is in same order as choosers
choices = alternatives[choice_column].take(positions + first_row_offsets)

# create a series with index from choosers and the index of the chosen alternative
choices = pd.Series(choices, index=choosers.index)

Expand Down
45 changes: 40 additions & 5 deletions activitysim/core/logit.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,13 @@
UTIL_MIN = np.log(EXP_UTIL_MIN, dtype=np.float64)
UTIL_UNAVAILABLE = 1000.0 * (UTIL_MIN - 1.0)

# Utility assigned to an alternative that must be chosen over UTIL_UNAVAILABLE
# alternatives, e.g. when forcing a choice for a chooser that would otherwise
# have no available alternative. Any finite value works: the gap to
# UTIL_UNAVAILABLE is many orders of magnitude larger than the EV1 error terms
# added by the explicit error term choice methods.
UTIL_LARGE_ENOUGH = 3.0

PROB_MIN = 0.0
PROB_MAX = 1.0

Expand DownExpand Up@@ -640,6 +647,7 @@ def make_choices_utility_based(
nest_spec=None,
alts_context: AltsContext | None = None,
alt_nrs_df: pd.DataFrame | None = None,
allow_zero_probs: bool = False,
) -> tuple[pd.Series, pd.Series]:
"""
Make choices for each chooser from among a set of alternatives based on utilities by adding
Expand All@@ -666,6 +674,11 @@ def make_choices_utility_based(
the alt_nrs for each alternative for each chooser. This is used to index into the random numbers when sampling
EET terms for multinomial logit models, and should contain -999 for any alternatives that are not available
for a given chooser. Should be provided along with `alts_context`.
allow_zero_probs : bool
If False, report choosers that have no available alternative at all. This is the
utility-space counterpart of `allow_bad_probs` in `make_choices`, and takes the
name `allow_zero_probs` because the condition it suppresses is the same one that
`validate_utils` and `utils_to_probs` describe by that name.

Returns
-------
Expand All@@ -677,14 +690,36 @@ def make_choices_utility_based(

Notes
-----
Bad-row reporting (e.g., a chooser whose alternatives are all `UTIL_UNAVAILABLE`) is the
responsibility of `validate_utils()`, which is invoked at every EET call site
(interaction_sample, interaction_sample_simulate, simulate.eval_mnl, simulate.eval_nl)
BEFORE this function is called. EET argmax always returns a valid integer position;
we do not re-check here.
An argmax always returns a position, so a chooser with no available alternative gets a
choice here rather than an error: with every utility at `UTIL_UNAVAILABLE` the alternatives
are tied and the error terms decide, and with every utility at `-inf` the first column wins.
The Monte Carlo path does not go quiet in that situation -- `make_choices` reports it unless
`allow_bad_probs` is set -- so this function reports it too. Most callers reach here having
already run `validate_utils`, which makes the same check; the duplication is deliberate and
mirrors `make_choices` re-checking what `utils_to_probs` has already looked at.
"""
trace_label = tracing.extend_trace_label(trace_label, "make_choices_utility_based")

if not allow_zero_probs:
# An alternative counts as available when its utility exceeds UTIL_MIN, which is the
# same threshold validate_utils uses before clamping to UTIL_UNAVAILABLE. Testing the
# threshold directly rather than testing the row sum against a multiple of
# UTIL_UNAVAILABLE means this also holds for callers that arrive without having run
# validate_utils, where unavailable alternatives may still carry their raw value.
no_available_alts = ~(utilities.to_numpy() > UTIL_MIN).any(axis=1)
if no_available_alts.any():
report_bad_choices(
state,
no_available_alts,
utilities,
state.settings.skip_failed_choices,
trace_label=tracing.extend_trace_label(
trace_label, "no_available_alts"
),
msg="no alternative is available",
trace_choosers=trace_choosers,
)

if nest_spec is None:
choices = make_choices_explicit_error_term_mnl(
state,
Expand Down
39 changes: 26 additions & 13 deletions activitysim/core/random.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,7 +441,9 @@ def gumbel_choice_positions_for_df(
alt_nrs_df : pandas.DataFrame, optional
DataFrame aligned to `utilities` whose values identify which dense alternative
each utility column corresponds to. Use `MASKED_ALT_ID` (-999) for masked or
unavailable positions; any other negative value raises ValueError.
unavailable positions; any other negative value raises ValueError. A row that
is masked in every column is taken to mean the chooser has no alternative to
choose from, and returns position 0.
n_rands : int, optional
Number of EV1 draws to generate per chooser row. Required when `alt_nrs_df`
is provided and may exceed the visible number of utility columns.
Expand All@@ -461,7 +463,12 @@ def gumbel_choice_positions_for_df(
positions = np.empty(n_rows, dtype=np.int64)

if alt_nrs_df is not None:
assert alt_nrs_df.shape == utilities.shape
assert alt_nrs_df.index.equals(
utilities.index
), "alt_nrs_df and utilities must share the same index"
assert alt_nrs_df.columns.equals(
utilities.columns
), "alt_nrs_df and utilities must share the same columns"
if n_rands is None:
raise ValueError("n_rands is required when alt_nrs_df is provided")
alt_nr_values = alt_nrs_df.to_numpy()
Expand All@@ -474,6 +481,7 @@ def gumbel_choice_positions_for_df(
f"{MASKED_ALT_ID} sentinel: {offenders}"
)
masked = alt_nr_values == MASKED_ALT_ID
active_mask = ~masked
safe_alt_nrs = np.where(masked, 0, alt_nr_values)
else:
if n_rands is None:
Expand All@@ -482,7 +490,7 @@ def gumbel_choice_positions_for_df(
raise ValueError(
"n_rands must equal utilities.shape[1] when alt_nrs_df is omitted"
)
alt_nr_values = masked = safe_alt_nrs = None
alt_nr_values = masked = active_mask = safe_alt_nrs = None

generators = self._generators_for_df(utilities)

Expand All@@ -495,16 +503,21 @@ def gumbel_choice_positions_for_df(
utility_row - np.log(-np.log(row_randoms))
)
else:
# Masked positions are set to -inf so they cannot win argmax,
# and the gumbel transform is skipped for them entirely.
row_mask = masked[row_num]
candidate_values = np.full(n_alts, -np.inf, dtype=np.float64)
active = ~row_mask
if active.any():
candidate_values[active] = utility_row[active] - np.log(
-np.log(row_randoms[safe_alt_nrs[row_num, active]])
)
positions[row_num] = np.argmax(candidate_values)
# Masked positions can never be chosen, so apply the gumbel transform and argmax
# only to the active ones. flatnonzero returns ascending indices, so ties resolve
# to the lowest column position, which is what a full-width argmax would have done.
active = np.flatnonzero(active_mask[row_num])
if active.size == 0:
# The chooser has no alternative available at all. Return the first
# column by convention. Note that logit.make_choices_utility_based filters
# out choosers with no available alternatives, so this case will only occur
# when explicitly allowed by the caller.
positions[row_num] = 0
continue
gumbel = utility_row[active] - np.log(
-np.log(row_randoms[safe_alt_nrs[row_num, active]])
)
positions[row_num] = active[np.argmax(gumbel)]

self.row_states.loc[utilities.index, "offset"] += n_rands
return positions
Expand Down
2 changes: 1 addition & 1 deletion activitysim/core/simulate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1553,7 +1553,7 @@ def eval_nl(

if state.settings.use_explicit_error_terms:
raw_utilities = logit.validate_utils(
state, raw_utilities, allow_zero_probs=True, trace_label=trace_label
state, raw_utilities, trace_label=trace_label
)

if custom_chooser:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions activitysim/abm/models/joint_tour_participation.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,18 +219,18 @@ def participants_chooser(
# anybody with probability > 0 is forced to join the joint tour
if state.settings.use_explicit_error_terms:
# need "is valid choice" such that we certainly choose those with non-zero values,
# and do not choose others. Let's use 3.0 as large value here.
# and do not choose others.
probs_or_utils[choice_col] = np.where(
probs_or_utils[choice_col] > logit.UTIL_MIN,
3.0,
logit.UTIL_LARGE_ENOUGH,
logit.UTIL_UNAVAILABLE,
)
non_choice_col = [
col for col in probs_or_utils.columns if col != choice_col
][0]
probs_or_utils[non_choice_col] = np.where(
probs_or_utils[choice_col] <= logit.UTIL_MIN,
3.0,
logit.UTIL_LARGE_ENOUGH,
logit.UTIL_UNAVAILABLE,
)
else:
Expand Down
343 changes: 162 additions & 181 deletions activitysim/core/interaction_sample.py

Large diffs are not rendered by default.

7 changes: 1 addition & 6 deletions activitysim/core/interaction_sample_simulate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -337,9 +337,7 @@ def _interaction_sample_simulate(
if zero_probs.any():
# copied from proabability below, fix when that gets fixed
# FIXME this is kind of gnarly, but we force choice of first alt
utilities_df.loc[
zero_probs, 0
] = 3.0 # arbitrary value much larger than UTIL_UNAVAILABLE
utilities_df.loc[zero_probs, 0] = logit.UTIL_LARGE_ENOUGH

# positions is series with the chosen alternative represented as a column index in utilities_df
# which is an integer between zero and num alternatives in the alternative sample
Expand DownExpand Up@@ -430,9 +428,6 @@ def _interaction_sample_simulate(
# resulting pandas Int64Index has one element per chooser row and is in same order as choosers
choices = alternatives[choice_column].take(positions + first_row_offsets)

# resulting pandas Int64Index has one element per chooser row and is in same order as choosers
choices = alternatives[choice_column].take(positions + first_row_offsets)

# create a series with index from choosers and the index of the chosen alternative
choices = pd.Series(choices, index=choosers.index)

Expand Down
45 changes: 40 additions & 5 deletions activitysim/core/logit.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,13 @@
UTIL_MIN = np.log(EXP_UTIL_MIN, dtype=np.float64)
UTIL_UNAVAILABLE = 1000.0 * (UTIL_MIN - 1.0)

# Utility assigned to an alternative that must be chosen over UTIL_UNAVAILABLE
# alternatives, e.g. when forcing a choice for a chooser that would otherwise
# have no available alternative. Any finite value works: the gap to
# UTIL_UNAVAILABLE is many orders of magnitude larger than the EV1 error terms
# added by the explicit error term choice methods.
UTIL_LARGE_ENOUGH = 3.0

PROB_MIN = 0.0
PROB_MAX = 1.0

Expand DownExpand Up@@ -640,6 +647,7 @@ def make_choices_utility_based(
nest_spec=None,
alts_context: AltsContext | None = None,
alt_nrs_df: pd.DataFrame | None = None,
allow_zero_probs: bool = False,
) -> tuple[pd.Series, pd.Series]:
"""
Make choices for each chooser from among a set of alternatives based on utilities by adding
Expand All@@ -666,6 +674,11 @@ def make_choices_utility_based(
the alt_nrs for each alternative for each chooser. This is used to index into the random numbers when sampling
EET terms for multinomial logit models, and should contain -999 for any alternatives that are not available
for a given chooser. Should be provided along with `alts_context`.
allow_zero_probs : bool
If False, report choosers that have no available alternative at all. This is the
utility-space counterpart of `allow_bad_probs` in `make_choices`, and takes the
name `allow_zero_probs` because the condition it suppresses is the same one that
`validate_utils` and `utils_to_probs` describe by that name.

Returns
-------
Expand All@@ -677,14 +690,36 @@ def make_choices_utility_based(

Notes
-----
Bad-row reporting (e.g., a chooser whose alternatives are all `UTIL_UNAVAILABLE`) is the
responsibility of `validate_utils()`, which is invoked at every EET call site
(interaction_sample, interaction_sample_simulate, simulate.eval_mnl, simulate.eval_nl)
BEFORE this function is called. EET argmax always returns a valid integer position;
we do not re-check here.
An argmax always returns a position, so a chooser with no available alternative gets a
choice here rather than an error: with every utility at `UTIL_UNAVAILABLE` the alternatives
are tied and the error terms decide, and with every utility at `-inf` the first column wins.
The Monte Carlo path does not go quiet in that situation -- `make_choices` reports it unless
`allow_bad_probs` is set -- so this function reports it too. Most callers reach here having
already run `validate_utils`, which makes the same check; the duplication is deliberate and
mirrors `make_choices` re-checking what `utils_to_probs` has already looked at.
"""
trace_label = tracing.extend_trace_label(trace_label, "make_choices_utility_based")

if not allow_zero_probs:
# An alternative counts as available when its utility exceeds UTIL_MIN, which is the
# same threshold validate_utils uses before clamping to UTIL_UNAVAILABLE. Testing the
# threshold directly rather than testing the row sum against a multiple of
# UTIL_UNAVAILABLE means this also holds for callers that arrive without having run
# validate_utils, where unavailable alternatives may still carry their raw value.
no_available_alts = ~(utilities.to_numpy() > UTIL_MIN).any(axis=1)
if no_available_alts.any():
report_bad_choices(
state,
no_available_alts,
utilities,
state.settings.skip_failed_choices,
trace_label=tracing.extend_trace_label(
trace_label, "no_available_alts"
),
msg="no alternative is available",
trace_choosers=trace_choosers,
)

if nest_spec is None:
choices = make_choices_explicit_error_term_mnl(
state,
Expand Down
39 changes: 26 additions & 13 deletions activitysim/core/random.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,7 +441,9 @@ def gumbel_choice_positions_for_df(
alt_nrs_df : pandas.DataFrame, optional
DataFrame aligned to `utilities` whose values identify which dense alternative
each utility column corresponds to. Use `MASKED_ALT_ID` (-999) for masked or
unavailable positions; any other negative value raises ValueError.
unavailable positions; any other negative value raises ValueError. A row that
is masked in every column is taken to mean the chooser has no alternative to
choose from, and returns position 0.
n_rands : int, optional
Number of EV1 draws to generate per chooser row. Required when `alt_nrs_df`
is provided and may exceed the visible number of utility columns.
Expand All@@ -461,7 +463,12 @@ def gumbel_choice_positions_for_df(
positions = np.empty(n_rows, dtype=np.int64)

if alt_nrs_df is not None:
assert alt_nrs_df.shape == utilities.shape
assert alt_nrs_df.index.equals(
utilities.index
), "alt_nrs_df and utilities must share the same index"
assert alt_nrs_df.columns.equals(
utilities.columns
), "alt_nrs_df and utilities must share the same columns"
if n_rands is None:
raise ValueError("n_rands is required when alt_nrs_df is provided")
alt_nr_values = alt_nrs_df.to_numpy()
Expand All@@ -474,6 +481,7 @@ def gumbel_choice_positions_for_df(
f"{MASKED_ALT_ID} sentinel: {offenders}"
)
masked = alt_nr_values == MASKED_ALT_ID
active_mask = ~masked
safe_alt_nrs = np.where(masked, 0, alt_nr_values)
else:
if n_rands is None:
Expand All@@ -482,7 +490,7 @@ def gumbel_choice_positions_for_df(
raise ValueError(
"n_rands must equal utilities.shape[1] when alt_nrs_df is omitted"
)
alt_nr_values = masked = safe_alt_nrs = None
alt_nr_values = masked = active_mask = safe_alt_nrs = None

generators = self._generators_for_df(utilities)

Expand All@@ -495,16 +503,21 @@ def gumbel_choice_positions_for_df(
utility_row - np.log(-np.log(row_randoms))
)
else:
# Masked positions are set to -inf so they cannot win argmax,
# and the gumbel transform is skipped for them entirely.
row_mask = masked[row_num]
candidate_values = np.full(n_alts, -np.inf, dtype=np.float64)
active = ~row_mask
if active.any():
candidate_values[active] = utility_row[active] - np.log(
-np.log(row_randoms[safe_alt_nrs[row_num, active]])
)
positions[row_num] = np.argmax(candidate_values)
# Masked positions can never be chosen, so apply the gumbel transform and argmax
# only to the active ones. flatnonzero returns ascending indices, so ties resolve
# to the lowest column position, which is what a full-width argmax would have done.
active = np.flatnonzero(active_mask[row_num])
if active.size == 0:
# The chooser has no alternative available at all. Return the first
# column by convention. Note that logit.make_choices_utility_based filters
# out choosers with no available alternatives, so this case will only occur
# when explicitly allowed by the caller.
positions[row_num] = 0
continue
gumbel = utility_row[active] - np.log(
-np.log(row_randoms[safe_alt_nrs[row_num, active]])
)
positions[row_num] = active[np.argmax(gumbel)]

self.row_states.loc[utilities.index, "offset"] += n_rands
return positions
Expand Down
2 changes: 1 addition & 1 deletion activitysim/core/simulate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1553,7 +1553,7 @@ def eval_nl(

if state.settings.use_explicit_error_terms:
raw_utilities = logit.validate_utils(
state, raw_utilities, allow_zero_probs=True, trace_label=trace_label
state, raw_utilities, trace_label=trace_label
)

if custom_chooser:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions activitysim/abm/models/joint_tour_participation.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,18 +219,18 @@ def participants_chooser(
# anybody with probability > 0 is forced to join the joint tour
if state.settings.use_explicit_error_terms:
# need "is valid choice" such that we certainly choose those with non-zero values,
# and do not choose others. Let's use 3.0 as large value here.
# and do not choose others.
probs_or_utils[choice_col] = np.where(
probs_or_utils[choice_col] > logit.UTIL_MIN,
3.0,
logit.UTIL_LARGE_ENOUGH,
logit.UTIL_UNAVAILABLE,
)
non_choice_col = [
col for col in probs_or_utils.columns if col != choice_col
][0]
probs_or_utils[non_choice_col] = np.where(
probs_or_utils[choice_col] <= logit.UTIL_MIN,
3.0,
logit.UTIL_LARGE_ENOUGH,
logit.UTIL_UNAVAILABLE,
)
else:
Expand Down
343 changes: 162 additions & 181 deletions activitysim/core/interaction_sample.py

Large diffs are not rendered by default.

7 changes: 1 addition & 6 deletions activitysim/core/interaction_sample_simulate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -337,9 +337,7 @@ def _interaction_sample_simulate(
if zero_probs.any():
# copied from proabability below, fix when that gets fixed
# FIXME this is kind of gnarly, but we force choice of first alt
utilities_df.loc[
zero_probs, 0
] = 3.0 # arbitrary value much larger than UTIL_UNAVAILABLE
utilities_df.loc[zero_probs, 0] = logit.UTIL_LARGE_ENOUGH

# positions is series with the chosen alternative represented as a column index in utilities_df
# which is an integer between zero and num alternatives in the alternative sample
Expand DownExpand Up@@ -430,9 +428,6 @@ def _interaction_sample_simulate(
# resulting pandas Int64Index has one element per chooser row and is in same order as choosers
choices = alternatives[choice_column].take(positions + first_row_offsets)

# resulting pandas Int64Index has one element per chooser row and is in same order as choosers
choices = alternatives[choice_column].take(positions + first_row_offsets)

# create a series with index from choosers and the index of the chosen alternative
choices = pd.Series(choices, index=choosers.index)

Expand Down
45 changes: 40 additions & 5 deletions activitysim/core/logit.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,13 @@
UTIL_MIN = np.log(EXP_UTIL_MIN, dtype=np.float64)
UTIL_UNAVAILABLE = 1000.0 * (UTIL_MIN - 1.0)

# Utility assigned to an alternative that must be chosen over UTIL_UNAVAILABLE
# alternatives, e.g. when forcing a choice for a chooser that would otherwise
# have no available alternative. Any finite value works: the gap to
# UTIL_UNAVAILABLE is many orders of magnitude larger than the EV1 error terms
# added by the explicit error term choice methods.
UTIL_LARGE_ENOUGH = 3.0

PROB_MIN = 0.0
PROB_MAX = 1.0

Expand DownExpand Up@@ -640,6 +647,7 @@ def make_choices_utility_based(
nest_spec=None,
alts_context: AltsContext | None = None,
alt_nrs_df: pd.DataFrame | None = None,
allow_zero_probs: bool = False,
) -> tuple[pd.Series, pd.Series]:
"""
Make choices for each chooser from among a set of alternatives based on utilities by adding
Expand All@@ -666,6 +674,11 @@ def make_choices_utility_based(
the alt_nrs for each alternative for each chooser. This is used to index into the random numbers when sampling
EET terms for multinomial logit models, and should contain -999 for any alternatives that are not available
for a given chooser. Should be provided along with `alts_context`.
allow_zero_probs : bool
If False, report choosers that have no available alternative at all. This is the
utility-space counterpart of `allow_bad_probs` in `make_choices`, and takes the
name `allow_zero_probs` because the condition it suppresses is the same one that
`validate_utils` and `utils_to_probs` describe by that name.

Returns
-------
Expand All@@ -677,14 +690,36 @@ def make_choices_utility_based(

Notes
-----
Bad-row reporting (e.g., a chooser whose alternatives are all `UTIL_UNAVAILABLE`) is the
responsibility of `validate_utils()`, which is invoked at every EET call site
(interaction_sample, interaction_sample_simulate, simulate.eval_mnl, simulate.eval_nl)
BEFORE this function is called. EET argmax always returns a valid integer position;
we do not re-check here.
An argmax always returns a position, so a chooser with no available alternative gets a
choice here rather than an error: with every utility at `UTIL_UNAVAILABLE` the alternatives
are tied and the error terms decide, and with every utility at `-inf` the first column wins.
The Monte Carlo path does not go quiet in that situation -- `make_choices` reports it unless
`allow_bad_probs` is set -- so this function reports it too. Most callers reach here having
already run `validate_utils`, which makes the same check; the duplication is deliberate and
mirrors `make_choices` re-checking what `utils_to_probs` has already looked at.
"""
trace_label = tracing.extend_trace_label(trace_label, "make_choices_utility_based")

if not allow_zero_probs:
# An alternative counts as available when its utility exceeds UTIL_MIN, which is the
# same threshold validate_utils uses before clamping to UTIL_UNAVAILABLE. Testing the
# threshold directly rather than testing the row sum against a multiple of
# UTIL_UNAVAILABLE means this also holds for callers that arrive without having run
# validate_utils, where unavailable alternatives may still carry their raw value.
no_available_alts = ~(utilities.to_numpy() > UTIL_MIN).any(axis=1)
if no_available_alts.any():
report_bad_choices(
state,
no_available_alts,
utilities,
state.settings.skip_failed_choices,
trace_label=tracing.extend_trace_label(
trace_label, "no_available_alts"
),
msg="no alternative is available",
trace_choosers=trace_choosers,
)

if nest_spec is None:
choices = make_choices_explicit_error_term_mnl(
state,
Expand Down
39 changes: 26 additions & 13 deletions activitysim/core/random.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,7 +441,9 @@ def gumbel_choice_positions_for_df(
alt_nrs_df : pandas.DataFrame, optional
DataFrame aligned to `utilities` whose values identify which dense alternative
each utility column corresponds to. Use `MASKED_ALT_ID` (-999) for masked or
unavailable positions; any other negative value raises ValueError.
unavailable positions; any other negative value raises ValueError. A row that
is masked in every column is taken to mean the chooser has no alternative to
choose from, and returns position 0.
n_rands : int, optional
Number of EV1 draws to generate per chooser row. Required when `alt_nrs_df`
is provided and may exceed the visible number of utility columns.
Expand All@@ -461,7 +463,12 @@ def gumbel_choice_positions_for_df(
positions = np.empty(n_rows, dtype=np.int64)

if alt_nrs_df is not None:
assert alt_nrs_df.shape == utilities.shape
assert alt_nrs_df.index.equals(
utilities.index
), "alt_nrs_df and utilities must share the same index"
assert alt_nrs_df.columns.equals(
utilities.columns
), "alt_nrs_df and utilities must share the same columns"
if n_rands is None:
raise ValueError("n_rands is required when alt_nrs_df is provided")
alt_nr_values = alt_nrs_df.to_numpy()
Expand All@@ -474,6 +481,7 @@ def gumbel_choice_positions_for_df(
f"{MASKED_ALT_ID} sentinel: {offenders}"
)
masked = alt_nr_values == MASKED_ALT_ID
active_mask = ~masked
safe_alt_nrs = np.where(masked, 0, alt_nr_values)
else:
if n_rands is None:
Expand All@@ -482,7 +490,7 @@ def gumbel_choice_positions_for_df(
raise ValueError(
"n_rands must equal utilities.shape[1] when alt_nrs_df is omitted"
)
alt_nr_values = masked = safe_alt_nrs = None
alt_nr_values = masked = active_mask = safe_alt_nrs = None

generators = self._generators_for_df(utilities)

Expand All@@ -495,16 +503,21 @@ def gumbel_choice_positions_for_df(
utility_row - np.log(-np.log(row_randoms))
)
else:
# Masked positions are set to -inf so they cannot win argmax,
# and the gumbel transform is skipped for them entirely.
row_mask = masked[row_num]
candidate_values = np.full(n_alts, -np.inf, dtype=np.float64)
active = ~row_mask
if active.any():
candidate_values[active] = utility_row[active] - np.log(
-np.log(row_randoms[safe_alt_nrs[row_num, active]])
)
positions[row_num] = np.argmax(candidate_values)
# Masked positions can never be chosen, so apply the gumbel transform and argmax
# only to the active ones. flatnonzero returns ascending indices, so ties resolve
# to the lowest column position, which is what a full-width argmax would have done.
active = np.flatnonzero(active_mask[row_num])
if active.size == 0:
# The chooser has no alternative available at all. Return the first
# column by convention. Note that logit.make_choices_utility_based filters
# out choosers with no available alternatives, so this case will only occur
# when explicitly allowed by the caller.
positions[row_num] = 0
continue
gumbel = utility_row[active] - np.log(
-np.log(row_randoms[safe_alt_nrs[row_num, active]])
)
positions[row_num] = active[np.argmax(gumbel)]

self.row_states.loc[utilities.index, "offset"] += n_rands
return positions
Expand Down
2 changes: 1 addition & 1 deletion activitysim/core/simulate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1553,7 +1553,7 @@ def eval_nl(

if state.settings.use_explicit_error_terms:
raw_utilities = logit.validate_utils(
state, raw_utilities, allow_zero_probs=True, trace_label=trace_label
state, raw_utilities, trace_label=trace_label
)

if custom_chooser:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions activitysim/abm/models/joint_tour_participation.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,18 +219,18 @@ def participants_chooser(
# anybody with probability > 0 is forced to join the joint tour
if state.settings.use_explicit_error_terms:
# need "is valid choice" such that we certainly choose those with non-zero values,
# and do not choose others. Let's use 3.0 as large value here.
# and do not choose others.
probs_or_utils[choice_col] = np.where(
probs_or_utils[choice_col] > logit.UTIL_MIN,
3.0,
logit.UTIL_LARGE_ENOUGH,
logit.UTIL_UNAVAILABLE,
)
non_choice_col = [
col for col in probs_or_utils.columns if col != choice_col
][0]
probs_or_utils[non_choice_col] = np.where(
probs_or_utils[choice_col] <= logit.UTIL_MIN,
3.0,
logit.UTIL_LARGE_ENOUGH,
logit.UTIL_UNAVAILABLE,
)
else:
Expand Down
343 changes: 162 additions & 181 deletions activitysim/core/interaction_sample.py

Large diffs are not rendered by default.

7 changes: 1 addition & 6 deletions activitysim/core/interaction_sample_simulate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -337,9 +337,7 @@ def _interaction_sample_simulate(
if zero_probs.any():
# copied from proabability below, fix when that gets fixed
# FIXME this is kind of gnarly, but we force choice of first alt
utilities_df.loc[
zero_probs, 0
] = 3.0 # arbitrary value much larger than UTIL_UNAVAILABLE
utilities_df.loc[zero_probs, 0] = logit.UTIL_LARGE_ENOUGH

# positions is series with the chosen alternative represented as a column index in utilities_df
# which is an integer between zero and num alternatives in the alternative sample
Expand DownExpand Up@@ -430,9 +428,6 @@ def _interaction_sample_simulate(
# resulting pandas Int64Index has one element per chooser row and is in same order as choosers
choices = alternatives[choice_column].take(positions + first_row_offsets)

# resulting pandas Int64Index has one element per chooser row and is in same order as choosers
choices = alternatives[choice_column].take(positions + first_row_offsets)

# create a series with index from choosers and the index of the chosen alternative
choices = pd.Series(choices, index=choosers.index)

Expand Down
45 changes: 40 additions & 5 deletions activitysim/core/logit.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,13 @@
UTIL_MIN = np.log(EXP_UTIL_MIN, dtype=np.float64)
UTIL_UNAVAILABLE = 1000.0 * (UTIL_MIN - 1.0)

# Utility assigned to an alternative that must be chosen over UTIL_UNAVAILABLE
# alternatives, e.g. when forcing a choice for a chooser that would otherwise
# have no available alternative. Any finite value works: the gap to
# UTIL_UNAVAILABLE is many orders of magnitude larger than the EV1 error terms
# added by the explicit error term choice methods.
UTIL_LARGE_ENOUGH = 3.0

PROB_MIN = 0.0
PROB_MAX = 1.0

Expand DownExpand Up@@ -640,6 +647,7 @@ def make_choices_utility_based(
nest_spec=None,
alts_context: AltsContext | None = None,
alt_nrs_df: pd.DataFrame | None = None,
allow_zero_probs: bool = False,
) -> tuple[pd.Series, pd.Series]:
"""
Make choices for each chooser from among a set of alternatives based on utilities by adding
Expand All@@ -666,6 +674,11 @@ def make_choices_utility_based(
the alt_nrs for each alternative for each chooser. This is used to index into the random numbers when sampling
EET terms for multinomial logit models, and should contain -999 for any alternatives that are not available
for a given chooser. Should be provided along with `alts_context`.
allow_zero_probs : bool
If False, report choosers that have no available alternative at all. This is the
utility-space counterpart of `allow_bad_probs` in `make_choices`, and takes the
name `allow_zero_probs` because the condition it suppresses is the same one that
`validate_utils` and `utils_to_probs` describe by that name.

Returns
-------
Expand All@@ -677,14 +690,36 @@ def make_choices_utility_based(

Notes
-----
Bad-row reporting (e.g., a chooser whose alternatives are all `UTIL_UNAVAILABLE`) is the
responsibility of `validate_utils()`, which is invoked at every EET call site
(interaction_sample, interaction_sample_simulate, simulate.eval_mnl, simulate.eval_nl)
BEFORE this function is called. EET argmax always returns a valid integer position;
we do not re-check here.
An argmax always returns a position, so a chooser with no available alternative gets a
choice here rather than an error: with every utility at `UTIL_UNAVAILABLE` the alternatives
are tied and the error terms decide, and with every utility at `-inf` the first column wins.
The Monte Carlo path does not go quiet in that situation -- `make_choices` reports it unless
`allow_bad_probs` is set -- so this function reports it too. Most callers reach here having
already run `validate_utils`, which makes the same check; the duplication is deliberate and
mirrors `make_choices` re-checking what `utils_to_probs` has already looked at.
"""
trace_label = tracing.extend_trace_label(trace_label, "make_choices_utility_based")

if not allow_zero_probs:
# An alternative counts as available when its utility exceeds UTIL_MIN, which is the
# same threshold validate_utils uses before clamping to UTIL_UNAVAILABLE. Testing the
# threshold directly rather than testing the row sum against a multiple of
# UTIL_UNAVAILABLE means this also holds for callers that arrive without having run
# validate_utils, where unavailable alternatives may still carry their raw value.
no_available_alts = ~(utilities.to_numpy() > UTIL_MIN).any(axis=1)
if no_available_alts.any():
report_bad_choices(
state,
no_available_alts,
utilities,
state.settings.skip_failed_choices,
trace_label=tracing.extend_trace_label(
trace_label, "no_available_alts"
),
msg="no alternative is available",
trace_choosers=trace_choosers,
)

if nest_spec is None:
choices = make_choices_explicit_error_term_mnl(
state,
Expand Down
39 changes: 26 additions & 13 deletions activitysim/core/random.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,7 +441,9 @@ def gumbel_choice_positions_for_df(
alt_nrs_df : pandas.DataFrame, optional
DataFrame aligned to `utilities` whose values identify which dense alternative
each utility column corresponds to. Use `MASKED_ALT_ID` (-999) for masked or
unavailable positions; any other negative value raises ValueError.
unavailable positions; any other negative value raises ValueError. A row that
is masked in every column is taken to mean the chooser has no alternative to
choose from, and returns position 0.
n_rands : int, optional
Number of EV1 draws to generate per chooser row. Required when `alt_nrs_df`
is provided and may exceed the visible number of utility columns.
Expand All@@ -461,7 +463,12 @@ def gumbel_choice_positions_for_df(
positions = np.empty(n_rows, dtype=np.int64)

if alt_nrs_df is not None:
assert alt_nrs_df.shape == utilities.shape
assert alt_nrs_df.index.equals(
utilities.index
), "alt_nrs_df and utilities must share the same index"
assert alt_nrs_df.columns.equals(
utilities.columns
), "alt_nrs_df and utilities must share the same columns"
if n_rands is None:
raise ValueError("n_rands is required when alt_nrs_df is provided")
alt_nr_values = alt_nrs_df.to_numpy()
Expand All@@ -474,6 +481,7 @@ def gumbel_choice_positions_for_df(
f"{MASKED_ALT_ID} sentinel: {offenders}"
)
masked = alt_nr_values == MASKED_ALT_ID
active_mask = ~masked
safe_alt_nrs = np.where(masked, 0, alt_nr_values)
else:
if n_rands is None:
Expand All@@ -482,7 +490,7 @@ def gumbel_choice_positions_for_df(
raise ValueError(
"n_rands must equal utilities.shape[1] when alt_nrs_df is omitted"
)
alt_nr_values = masked = safe_alt_nrs = None
alt_nr_values = masked = active_mask = safe_alt_nrs = None

generators = self._generators_for_df(utilities)

Expand All@@ -495,16 +503,21 @@ def gumbel_choice_positions_for_df(
utility_row - np.log(-np.log(row_randoms))
)
else:
# Masked positions are set to -inf so they cannot win argmax,
# and the gumbel transform is skipped for them entirely.
row_mask = masked[row_num]
candidate_values = np.full(n_alts, -np.inf, dtype=np.float64)
active = ~row_mask
if active.any():
candidate_values[active] = utility_row[active] - np.log(
-np.log(row_randoms[safe_alt_nrs[row_num, active]])
)
positions[row_num] = np.argmax(candidate_values)
# Masked positions can never be chosen, so apply the gumbel transform and argmax
# only to the active ones. flatnonzero returns ascending indices, so ties resolve
# to the lowest column position, which is what a full-width argmax would have done.
active = np.flatnonzero(active_mask[row_num])
if active.size == 0:
# The chooser has no alternative available at all. Return the first
# column by convention. Note that logit.make_choices_utility_based filters
# out choosers with no available alternatives, so this case will only occur
# when explicitly allowed by the caller.
positions[row_num] = 0
continue
gumbel = utility_row[active] - np.log(
-np.log(row_randoms[safe_alt_nrs[row_num, active]])
)
positions[row_num] = active[np.argmax(gumbel)]

self.row_states.loc[utilities.index, "offset"] += n_rands
return positions
Expand Down
2 changes: 1 addition & 1 deletion activitysim/core/simulate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1553,7 +1553,7 @@ def eval_nl(

if state.settings.use_explicit_error_terms:
raw_utilities = logit.validate_utils(
state, raw_utilities, allow_zero_probs=True, trace_label=trace_label
state, raw_utilities, trace_label=trace_label
)

if custom_chooser:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions activitysim/abm/models/joint_tour_participation.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,18 +219,18 @@ def participants_chooser(
# anybody with probability > 0 is forced to join the joint tour
if state.settings.use_explicit_error_terms:
# need "is valid choice" such that we certainly choose those with non-zero values,
# and do not choose others. Let's use 3.0 as large value here.
# and do not choose others.
probs_or_utils[choice_col] = np.where(
probs_or_utils[choice_col] > logit.UTIL_MIN,
3.0,
logit.UTIL_LARGE_ENOUGH,
logit.UTIL_UNAVAILABLE,
)
non_choice_col = [
col for col in probs_or_utils.columns if col != choice_col
][0]
probs_or_utils[non_choice_col] = np.where(
probs_or_utils[choice_col] <= logit.UTIL_MIN,
3.0,
logit.UTIL_LARGE_ENOUGH,
logit.UTIL_UNAVAILABLE,
)
else:
Expand Down
343 changes: 162 additions & 181 deletions activitysim/core/interaction_sample.py

Large diffs are not rendered by default.

7 changes: 1 addition & 6 deletions activitysim/core/interaction_sample_simulate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -337,9 +337,7 @@ def _interaction_sample_simulate(
if zero_probs.any():
# copied from proabability below, fix when that gets fixed
# FIXME this is kind of gnarly, but we force choice of first alt
utilities_df.loc[
zero_probs, 0
] = 3.0 # arbitrary value much larger than UTIL_UNAVAILABLE
utilities_df.loc[zero_probs, 0] = logit.UTIL_LARGE_ENOUGH

# positions is series with the chosen alternative represented as a column index in utilities_df
# which is an integer between zero and num alternatives in the alternative sample
Expand DownExpand Up@@ -430,9 +428,6 @@ def _interaction_sample_simulate(
# resulting pandas Int64Index has one element per chooser row and is in same order as choosers
choices = alternatives[choice_column].take(positions + first_row_offsets)

# resulting pandas Int64Index has one element per chooser row and is in same order as choosers
choices = alternatives[choice_column].take(positions + first_row_offsets)

# create a series with index from choosers and the index of the chosen alternative
choices = pd.Series(choices, index=choosers.index)

Expand Down
45 changes: 40 additions & 5 deletions activitysim/core/logit.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,13 @@
UTIL_MIN = np.log(EXP_UTIL_MIN, dtype=np.float64)
UTIL_UNAVAILABLE = 1000.0 * (UTIL_MIN - 1.0)

# Utility assigned to an alternative that must be chosen over UTIL_UNAVAILABLE
# alternatives, e.g. when forcing a choice for a chooser that would otherwise
# have no available alternative. Any finite value works: the gap to
# UTIL_UNAVAILABLE is many orders of magnitude larger than the EV1 error terms
# added by the explicit error term choice methods.
UTIL_LARGE_ENOUGH = 3.0

PROB_MIN = 0.0
PROB_MAX = 1.0

Expand DownExpand Up@@ -640,6 +647,7 @@ def make_choices_utility_based(
nest_spec=None,
alts_context: AltsContext | None = None,
alt_nrs_df: pd.DataFrame | None = None,
allow_zero_probs: bool = False,
) -> tuple[pd.Series, pd.Series]:
"""
Make choices for each chooser from among a set of alternatives based on utilities by adding
Expand All@@ -666,6 +674,11 @@ def make_choices_utility_based(
the alt_nrs for each alternative for each chooser. This is used to index into the random numbers when sampling
EET terms for multinomial logit models, and should contain -999 for any alternatives that are not available
for a given chooser. Should be provided along with `alts_context`.
allow_zero_probs : bool
If False, report choosers that have no available alternative at all. This is the
utility-space counterpart of `allow_bad_probs` in `make_choices`, and takes the
name `allow_zero_probs` because the condition it suppresses is the same one that
`validate_utils` and `utils_to_probs` describe by that name.

Returns
-------
Expand All@@ -677,14 +690,36 @@ def make_choices_utility_based(

Notes
-----
Bad-row reporting (e.g., a chooser whose alternatives are all `UTIL_UNAVAILABLE`) is the
responsibility of `validate_utils()`, which is invoked at every EET call site
(interaction_sample, interaction_sample_simulate, simulate.eval_mnl, simulate.eval_nl)
BEFORE this function is called. EET argmax always returns a valid integer position;
we do not re-check here.
An argmax always returns a position, so a chooser with no available alternative gets a
choice here rather than an error: with every utility at `UTIL_UNAVAILABLE` the alternatives
are tied and the error terms decide, and with every utility at `-inf` the first column wins.
The Monte Carlo path does not go quiet in that situation -- `make_choices` reports it unless
`allow_bad_probs` is set -- so this function reports it too. Most callers reach here having
already run `validate_utils`, which makes the same check; the duplication is deliberate and
mirrors `make_choices` re-checking what `utils_to_probs` has already looked at.
"""
trace_label = tracing.extend_trace_label(trace_label, "make_choices_utility_based")

if not allow_zero_probs:
# An alternative counts as available when its utility exceeds UTIL_MIN, which is the
# same threshold validate_utils uses before clamping to UTIL_UNAVAILABLE. Testing the
# threshold directly rather than testing the row sum against a multiple of
# UTIL_UNAVAILABLE means this also holds for callers that arrive without having run
# validate_utils, where unavailable alternatives may still carry their raw value.
no_available_alts = ~(utilities.to_numpy() > UTIL_MIN).any(axis=1)
if no_available_alts.any():
report_bad_choices(
state,
no_available_alts,
utilities,
state.settings.skip_failed_choices,
trace_label=tracing.extend_trace_label(
trace_label, "no_available_alts"
),
msg="no alternative is available",
trace_choosers=trace_choosers,
)

if nest_spec is None:
choices = make_choices_explicit_error_term_mnl(
state,
Expand Down
39 changes: 26 additions & 13 deletions activitysim/core/random.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,7 +441,9 @@ def gumbel_choice_positions_for_df(
alt_nrs_df : pandas.DataFrame, optional
DataFrame aligned to `utilities` whose values identify which dense alternative
each utility column corresponds to. Use `MASKED_ALT_ID` (-999) for masked or
unavailable positions; any other negative value raises ValueError.
unavailable positions; any other negative value raises ValueError. A row that
is masked in every column is taken to mean the chooser has no alternative to
choose from, and returns position 0.
n_rands : int, optional
Number of EV1 draws to generate per chooser row. Required when `alt_nrs_df`
is provided and may exceed the visible number of utility columns.
Expand All@@ -461,7 +463,12 @@ def gumbel_choice_positions_for_df(
positions = np.empty(n_rows, dtype=np.int64)

if alt_nrs_df is not None:
assert alt_nrs_df.shape == utilities.shape
assert alt_nrs_df.index.equals(
utilities.index
), "alt_nrs_df and utilities must share the same index"
assert alt_nrs_df.columns.equals(
utilities.columns
), "alt_nrs_df and utilities must share the same columns"
if n_rands is None:
raise ValueError("n_rands is required when alt_nrs_df is provided")
alt_nr_values = alt_nrs_df.to_numpy()
Expand All@@ -474,6 +481,7 @@ def gumbel_choice_positions_for_df(
f"{MASKED_ALT_ID} sentinel: {offenders}"
)
masked = alt_nr_values == MASKED_ALT_ID
active_mask = ~masked
safe_alt_nrs = np.where(masked, 0, alt_nr_values)
else:
if n_rands is None:
Expand All@@ -482,7 +490,7 @@ def gumbel_choice_positions_for_df(
raise ValueError(
"n_rands must equal utilities.shape[1] when alt_nrs_df is omitted"
)
alt_nr_values = masked = safe_alt_nrs = None
alt_nr_values = masked = active_mask = safe_alt_nrs = None

generators = self._generators_for_df(utilities)

Expand All@@ -495,16 +503,21 @@ def gumbel_choice_positions_for_df(
utility_row - np.log(-np.log(row_randoms))
)
else:
# Masked positions are set to -inf so they cannot win argmax,
# and the gumbel transform is skipped for them entirely.
row_mask = masked[row_num]
candidate_values = np.full(n_alts, -np.inf, dtype=np.float64)
active = ~row_mask
if active.any():
candidate_values[active] = utility_row[active] - np.log(
-np.log(row_randoms[safe_alt_nrs[row_num, active]])
)
positions[row_num] = np.argmax(candidate_values)
# Masked positions can never be chosen, so apply the gumbel transform and argmax
# only to the active ones. flatnonzero returns ascending indices, so ties resolve
# to the lowest column position, which is what a full-width argmax would have done.
active = np.flatnonzero(active_mask[row_num])
if active.size == 0:
# The chooser has no alternative available at all. Return the first
# column by convention. Note that logit.make_choices_utility_based filters
# out choosers with no available alternatives, so this case will only occur
# when explicitly allowed by the caller.
positions[row_num] = 0
continue
gumbel = utility_row[active] - np.log(
-np.log(row_randoms[safe_alt_nrs[row_num, active]])
)
positions[row_num] = active[np.argmax(gumbel)]

self.row_states.loc[utilities.index, "offset"] += n_rands
return positions
Expand Down
2 changes: 1 addition & 1 deletion activitysim/core/simulate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1553,7 +1553,7 @@ def eval_nl(

if state.settings.use_explicit_error_terms:
raw_utilities = logit.validate_utils(
state, raw_utilities, allow_zero_probs=True, trace_label=trace_label
state, raw_utilities, trace_label=trace_label
)

if custom_chooser:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions activitysim/abm/models/joint_tour_participation.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,18 +219,18 @@ def participants_chooser(
# anybody with probability > 0 is forced to join the joint tour
if state.settings.use_explicit_error_terms:
# need "is valid choice" such that we certainly choose those with non-zero values,
# and do not choose others. Let's use 3.0 as large value here.
# and do not choose others.
probs_or_utils[choice_col] = np.where(
probs_or_utils[choice_col] > logit.UTIL_MIN,
3.0,
logit.UTIL_LARGE_ENOUGH,
logit.UTIL_UNAVAILABLE,
)
non_choice_col = [
col for col in probs_or_utils.columns if col != choice_col
][0]
probs_or_utils[non_choice_col] = np.where(
probs_or_utils[choice_col] <= logit.UTIL_MIN,
3.0,
logit.UTIL_LARGE_ENOUGH,
logit.UTIL_UNAVAILABLE,
)
else:
Expand Down
343 changes: 162 additions & 181 deletions activitysim/core/interaction_sample.py

Large diffs are not rendered by default.

7 changes: 1 addition & 6 deletions activitysim/core/interaction_sample_simulate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -337,9 +337,7 @@ def _interaction_sample_simulate(
if zero_probs.any():
# copied from proabability below, fix when that gets fixed
# FIXME this is kind of gnarly, but we force choice of first alt
utilities_df.loc[
zero_probs, 0
] = 3.0 # arbitrary value much larger than UTIL_UNAVAILABLE
utilities_df.loc[zero_probs, 0] = logit.UTIL_LARGE_ENOUGH

# positions is series with the chosen alternative represented as a column index in utilities_df
# which is an integer between zero and num alternatives in the alternative sample
Expand DownExpand Up@@ -430,9 +428,6 @@ def _interaction_sample_simulate(
# resulting pandas Int64Index has one element per chooser row and is in same order as choosers
choices = alternatives[choice_column].take(positions + first_row_offsets)

# resulting pandas Int64Index has one element per chooser row and is in same order as choosers
choices = alternatives[choice_column].take(positions + first_row_offsets)

# create a series with index from choosers and the index of the chosen alternative
choices = pd.Series(choices, index=choosers.index)

Expand Down
45 changes: 40 additions & 5 deletions activitysim/core/logit.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,13 @@
UTIL_MIN = np.log(EXP_UTIL_MIN, dtype=np.float64)
UTIL_UNAVAILABLE = 1000.0 * (UTIL_MIN - 1.0)

# Utility assigned to an alternative that must be chosen over UTIL_UNAVAILABLE
# alternatives, e.g. when forcing a choice for a chooser that would otherwise
# have no available alternative. Any finite value works: the gap to
# UTIL_UNAVAILABLE is many orders of magnitude larger than the EV1 error terms
# added by the explicit error term choice methods.
UTIL_LARGE_ENOUGH = 3.0

PROB_MIN = 0.0
PROB_MAX = 1.0

Expand DownExpand Up@@ -640,6 +647,7 @@ def make_choices_utility_based(
nest_spec=None,
alts_context: AltsContext | None = None,
alt_nrs_df: pd.DataFrame | None = None,
allow_zero_probs: bool = False,
) -> tuple[pd.Series, pd.Series]:
"""
Make choices for each chooser from among a set of alternatives based on utilities by adding
Expand All@@ -666,6 +674,11 @@ def make_choices_utility_based(
the alt_nrs for each alternative for each chooser. This is used to index into the random numbers when sampling
EET terms for multinomial logit models, and should contain -999 for any alternatives that are not available
for a given chooser. Should be provided along with `alts_context`.
allow_zero_probs : bool
If False, report choosers that have no available alternative at all. This is the
utility-space counterpart of `allow_bad_probs` in `make_choices`, and takes the
name `allow_zero_probs` because the condition it suppresses is the same one that
`validate_utils` and `utils_to_probs` describe by that name.

Returns
-------
Expand All@@ -677,14 +690,36 @@ def make_choices_utility_based(

Notes
-----
Bad-row reporting (e.g., a chooser whose alternatives are all `UTIL_UNAVAILABLE`) is the
responsibility of `validate_utils()`, which is invoked at every EET call site
(interaction_sample, interaction_sample_simulate, simulate.eval_mnl, simulate.eval_nl)
BEFORE this function is called. EET argmax always returns a valid integer position;
we do not re-check here.
An argmax always returns a position, so a chooser with no available alternative gets a
choice here rather than an error: with every utility at `UTIL_UNAVAILABLE` the alternatives
are tied and the error terms decide, and with every utility at `-inf` the first column wins.
The Monte Carlo path does not go quiet in that situation -- `make_choices` reports it unless
`allow_bad_probs` is set -- so this function reports it too. Most callers reach here having
already run `validate_utils`, which makes the same check; the duplication is deliberate and
mirrors `make_choices` re-checking what `utils_to_probs` has already looked at.
"""
trace_label = tracing.extend_trace_label(trace_label, "make_choices_utility_based")

if not allow_zero_probs:
# An alternative counts as available when its utility exceeds UTIL_MIN, which is the
# same threshold validate_utils uses before clamping to UTIL_UNAVAILABLE. Testing the
# threshold directly rather than testing the row sum against a multiple of
# UTIL_UNAVAILABLE means this also holds for callers that arrive without having run
# validate_utils, where unavailable alternatives may still carry their raw value.
no_available_alts = ~(utilities.to_numpy() > UTIL_MIN).any(axis=1)
if no_available_alts.any():
report_bad_choices(
state,
no_available_alts,
utilities,
state.settings.skip_failed_choices,
trace_label=tracing.extend_trace_label(
trace_label, "no_available_alts"
),
msg="no alternative is available",
trace_choosers=trace_choosers,
)

if nest_spec is None:
choices = make_choices_explicit_error_term_mnl(
state,
Expand Down
39 changes: 26 additions & 13 deletions activitysim/core/random.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,7 +441,9 @@ def gumbel_choice_positions_for_df(
alt_nrs_df : pandas.DataFrame, optional
DataFrame aligned to `utilities` whose values identify which dense alternative
each utility column corresponds to. Use `MASKED_ALT_ID` (-999) for masked or
unavailable positions; any other negative value raises ValueError.
unavailable positions; any other negative value raises ValueError. A row that
is masked in every column is taken to mean the chooser has no alternative to
choose from, and returns position 0.
n_rands : int, optional
Number of EV1 draws to generate per chooser row. Required when `alt_nrs_df`
is provided and may exceed the visible number of utility columns.
Expand All@@ -461,7 +463,12 @@ def gumbel_choice_positions_for_df(
positions = np.empty(n_rows, dtype=np.int64)

if alt_nrs_df is not None:
assert alt_nrs_df.shape == utilities.shape
assert alt_nrs_df.index.equals(
utilities.index
), "alt_nrs_df and utilities must share the same index"
assert alt_nrs_df.columns.equals(
utilities.columns
), "alt_nrs_df and utilities must share the same columns"
if n_rands is None:
raise ValueError("n_rands is required when alt_nrs_df is provided")
alt_nr_values = alt_nrs_df.to_numpy()
Expand All@@ -474,6 +481,7 @@ def gumbel_choice_positions_for_df(
f"{MASKED_ALT_ID} sentinel: {offenders}"
)
masked = alt_nr_values == MASKED_ALT_ID
active_mask = ~masked
safe_alt_nrs = np.where(masked, 0, alt_nr_values)
else:
if n_rands is None:
Expand All@@ -482,7 +490,7 @@ def gumbel_choice_positions_for_df(
raise ValueError(
"n_rands must equal utilities.shape[1] when alt_nrs_df is omitted"
)
alt_nr_values = masked = safe_alt_nrs = None
alt_nr_values = masked = active_mask = safe_alt_nrs = None

generators = self._generators_for_df(utilities)

Expand All@@ -495,16 +503,21 @@ def gumbel_choice_positions_for_df(
utility_row - np.log(-np.log(row_randoms))
)
else:
# Masked positions are set to -inf so they cannot win argmax,
# and the gumbel transform is skipped for them entirely.
row_mask = masked[row_num]
candidate_values = np.full(n_alts, -np.inf, dtype=np.float64)
active = ~row_mask
if active.any():
candidate_values[active] = utility_row[active] - np.log(
-np.log(row_randoms[safe_alt_nrs[row_num, active]])
)
positions[row_num] = np.argmax(candidate_values)
# Masked positions can never be chosen, so apply the gumbel transform and argmax
# only to the active ones. flatnonzero returns ascending indices, so ties resolve
# to the lowest column position, which is what a full-width argmax would have done.
active = np.flatnonzero(active_mask[row_num])
if active.size == 0:
# The chooser has no alternative available at all. Return the first
# column by convention. Note that logit.make_choices_utility_based filters
# out choosers with no available alternatives, so this case will only occur
# when explicitly allowed by the caller.
positions[row_num] = 0
continue
gumbel = utility_row[active] - np.log(
-np.log(row_randoms[safe_alt_nrs[row_num, active]])
)
positions[row_num] = active[np.argmax(gumbel)]

self.row_states.loc[utilities.index, "offset"] += n_rands
return positions
Expand Down
2 changes: 1 addition & 1 deletion activitysim/core/simulate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1553,7 +1553,7 @@ def eval_nl(

if state.settings.use_explicit_error_terms:
raw_utilities = logit.validate_utils(
state, raw_utilities, allow_zero_probs=True, trace_label=trace_label
state, raw_utilities, trace_label=trace_label
)

if custom_chooser:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions activitysim/abm/models/joint_tour_participation.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,18 +219,18 @@ def participants_chooser(
# anybody with probability > 0 is forced to join the joint tour
if state.settings.use_explicit_error_terms:
# need "is valid choice" such that we certainly choose those with non-zero values,
# and do not choose others. Let's use 3.0 as large value here.
# and do not choose others.
probs_or_utils[choice_col] = np.where(
probs_or_utils[choice_col] > logit.UTIL_MIN,
3.0,
logit.UTIL_LARGE_ENOUGH,
logit.UTIL_UNAVAILABLE,
)
non_choice_col = [
col for col in probs_or_utils.columns if col != choice_col
][0]
probs_or_utils[non_choice_col] = np.where(
probs_or_utils[choice_col] <= logit.UTIL_MIN,
3.0,
logit.UTIL_LARGE_ENOUGH,
logit.UTIL_UNAVAILABLE,
)
else:
Expand Down
343 changes: 162 additions & 181 deletions activitysim/core/interaction_sample.py

Large diffs are not rendered by default.

7 changes: 1 addition & 6 deletions activitysim/core/interaction_sample_simulate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -337,9 +337,7 @@ def _interaction_sample_simulate(
if zero_probs.any():
# copied from proabability below, fix when that gets fixed
# FIXME this is kind of gnarly, but we force choice of first alt
utilities_df.loc[
zero_probs, 0
] = 3.0 # arbitrary value much larger than UTIL_UNAVAILABLE
utilities_df.loc[zero_probs, 0] = logit.UTIL_LARGE_ENOUGH

# positions is series with the chosen alternative represented as a column index in utilities_df
# which is an integer between zero and num alternatives in the alternative sample
Expand DownExpand Up@@ -430,9 +428,6 @@ def _interaction_sample_simulate(
# resulting pandas Int64Index has one element per chooser row and is in same order as choosers
choices = alternatives[choice_column].take(positions + first_row_offsets)

# resulting pandas Int64Index has one element per chooser row and is in same order as choosers
choices = alternatives[choice_column].take(positions + first_row_offsets)

# create a series with index from choosers and the index of the chosen alternative
choices = pd.Series(choices, index=choosers.index)

Expand Down
45 changes: 40 additions & 5 deletions activitysim/core/logit.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,13 @@
UTIL_MIN = np.log(EXP_UTIL_MIN, dtype=np.float64)
UTIL_UNAVAILABLE = 1000.0 * (UTIL_MIN - 1.0)

# Utility assigned to an alternative that must be chosen over UTIL_UNAVAILABLE
# alternatives, e.g. when forcing a choice for a chooser that would otherwise
# have no available alternative. Any finite value works: the gap to
# UTIL_UNAVAILABLE is many orders of magnitude larger than the EV1 error terms
# added by the explicit error term choice methods.
UTIL_LARGE_ENOUGH = 3.0

PROB_MIN = 0.0
PROB_MAX = 1.0

Expand DownExpand Up@@ -640,6 +647,7 @@ def make_choices_utility_based(
nest_spec=None,
alts_context: AltsContext | None = None,
alt_nrs_df: pd.DataFrame | None = None,
allow_zero_probs: bool = False,
) -> tuple[pd.Series, pd.Series]:
"""
Make choices for each chooser from among a set of alternatives based on utilities by adding
Expand All@@ -666,6 +674,11 @@ def make_choices_utility_based(
the alt_nrs for each alternative for each chooser. This is used to index into the random numbers when sampling
EET terms for multinomial logit models, and should contain -999 for any alternatives that are not available
for a given chooser. Should be provided along with `alts_context`.
allow_zero_probs : bool
If False, report choosers that have no available alternative at all. This is the
utility-space counterpart of `allow_bad_probs` in `make_choices`, and takes the
name `allow_zero_probs` because the condition it suppresses is the same one that
`validate_utils` and `utils_to_probs` describe by that name.

Returns
-------
Expand All@@ -677,14 +690,36 @@ def make_choices_utility_based(

Notes
-----
Bad-row reporting (e.g., a chooser whose alternatives are all `UTIL_UNAVAILABLE`) is the
responsibility of `validate_utils()`, which is invoked at every EET call site
(interaction_sample, interaction_sample_simulate, simulate.eval_mnl, simulate.eval_nl)
BEFORE this function is called. EET argmax always returns a valid integer position;
we do not re-check here.
An argmax always returns a position, so a chooser with no available alternative gets a
choice here rather than an error: with every utility at `UTIL_UNAVAILABLE` the alternatives
are tied and the error terms decide, and with every utility at `-inf` the first column wins.
The Monte Carlo path does not go quiet in that situation -- `make_choices` reports it unless
`allow_bad_probs` is set -- so this function reports it too. Most callers reach here having
already run `validate_utils`, which makes the same check; the duplication is deliberate and
mirrors `make_choices` re-checking what `utils_to_probs` has already looked at.
"""
trace_label = tracing.extend_trace_label(trace_label, "make_choices_utility_based")

if not allow_zero_probs:
# An alternative counts as available when its utility exceeds UTIL_MIN, which is the
# same threshold validate_utils uses before clamping to UTIL_UNAVAILABLE. Testing the
# threshold directly rather than testing the row sum against a multiple of
# UTIL_UNAVAILABLE means this also holds for callers that arrive without having run
# validate_utils, where unavailable alternatives may still carry their raw value.
no_available_alts = ~(utilities.to_numpy() > UTIL_MIN).any(axis=1)
if no_available_alts.any():
report_bad_choices(
state,
no_available_alts,
utilities,
state.settings.skip_failed_choices,
trace_label=tracing.extend_trace_label(
trace_label, "no_available_alts"
),
msg="no alternative is available",
trace_choosers=trace_choosers,
)

if nest_spec is None:
choices = make_choices_explicit_error_term_mnl(
state,
Expand Down
39 changes: 26 additions & 13 deletions activitysim/core/random.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,7 +441,9 @@ def gumbel_choice_positions_for_df(
alt_nrs_df : pandas.DataFrame, optional
DataFrame aligned to `utilities` whose values identify which dense alternative
each utility column corresponds to. Use `MASKED_ALT_ID` (-999) for masked or
unavailable positions; any other negative value raises ValueError.
unavailable positions; any other negative value raises ValueError. A row that
is masked in every column is taken to mean the chooser has no alternative to
choose from, and returns position 0.
n_rands : int, optional
Number of EV1 draws to generate per chooser row. Required when `alt_nrs_df`
is provided and may exceed the visible number of utility columns.
Expand All@@ -461,7 +463,12 @@ def gumbel_choice_positions_for_df(
positions = np.empty(n_rows, dtype=np.int64)

if alt_nrs_df is not None:
assert alt_nrs_df.shape == utilities.shape
assert alt_nrs_df.index.equals(
utilities.index
), "alt_nrs_df and utilities must share the same index"
assert alt_nrs_df.columns.equals(
utilities.columns
), "alt_nrs_df and utilities must share the same columns"
if n_rands is None:
raise ValueError("n_rands is required when alt_nrs_df is provided")
alt_nr_values = alt_nrs_df.to_numpy()
Expand All@@ -474,6 +481,7 @@ def gumbel_choice_positions_for_df(
f"{MASKED_ALT_ID} sentinel: {offenders}"
)
masked = alt_nr_values == MASKED_ALT_ID
active_mask = ~masked
safe_alt_nrs = np.where(masked, 0, alt_nr_values)
else:
if n_rands is None:
Expand All@@ -482,7 +490,7 @@ def gumbel_choice_positions_for_df(
raise ValueError(
"n_rands must equal utilities.shape[1] when alt_nrs_df is omitted"
)
alt_nr_values = masked = safe_alt_nrs = None
alt_nr_values = masked = active_mask = safe_alt_nrs = None

generators = self._generators_for_df(utilities)

Expand All@@ -495,16 +503,21 @@ def gumbel_choice_positions_for_df(
utility_row - np.log(-np.log(row_randoms))
)
else:
# Masked positions are set to -inf so they cannot win argmax,
# and the gumbel transform is skipped for them entirely.
row_mask = masked[row_num]
candidate_values = np.full(n_alts, -np.inf, dtype=np.float64)
active = ~row_mask
if active.any():
candidate_values[active] = utility_row[active] - np.log(
-np.log(row_randoms[safe_alt_nrs[row_num, active]])
)
positions[row_num] = np.argmax(candidate_values)
# Masked positions can never be chosen, so apply the gumbel transform and argmax
# only to the active ones. flatnonzero returns ascending indices, so ties resolve
# to the lowest column position, which is what a full-width argmax would have done.
active = np.flatnonzero(active_mask[row_num])
if active.size == 0:
# The chooser has no alternative available at all. Return the first
# column by convention. Note that logit.make_choices_utility_based filters
# out choosers with no available alternatives, so this case will only occur
# when explicitly allowed by the caller.
positions[row_num] = 0
continue
gumbel = utility_row[active] - np.log(
-np.log(row_randoms[safe_alt_nrs[row_num, active]])
)
positions[row_num] = active[np.argmax(gumbel)]

self.row_states.loc[utilities.index, "offset"] += n_rands
return positions
Expand Down
2 changes: 1 addition & 1 deletion activitysim/core/simulate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1553,7 +1553,7 @@ def eval_nl(

if state.settings.use_explicit_error_terms:
raw_utilities = logit.validate_utils(
state, raw_utilities, allow_zero_probs=True, trace_label=trace_label
state, raw_utilities, trace_label=trace_label
)

if custom_chooser:
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions activitysim/abm/models/joint_tour_participation.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -219,18 +219,18 @@ def participants_chooser(
# anybody with probability > 0 is forced to join the joint tour
if state.settings.use_explicit_error_terms:
# need "is valid choice" such that we certainly choose those with non-zero values,
# and do not choose others. Let's use 3.0 as large value here.
# and do not choose others.
probs_or_utils[choice_col] = np.where(
probs_or_utils[choice_col] > logit.UTIL_MIN,
3.0,
logit.UTIL_LARGE_ENOUGH,
logit.UTIL_UNAVAILABLE,
)
non_choice_col = [
col for col in probs_or_utils.columns if col != choice_col
][0]
probs_or_utils[non_choice_col] = np.where(
probs_or_utils[choice_col] <= logit.UTIL_MIN,
3.0,
logit.UTIL_LARGE_ENOUGH,
logit.UTIL_UNAVAILABLE,
)
else:
Expand Down
343 changes: 162 additions & 181 deletions activitysim/core/interaction_sample.py

Large diffs are not rendered by default.

7 changes: 1 addition & 6 deletions activitysim/core/interaction_sample_simulate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -337,9 +337,7 @@ def _interaction_sample_simulate(
if zero_probs.any():
# copied from proabability below, fix when that gets fixed
# FIXME this is kind of gnarly, but we force choice of first alt
utilities_df.loc[
zero_probs, 0
] = 3.0 # arbitrary value much larger than UTIL_UNAVAILABLE
utilities_df.loc[zero_probs, 0] = logit.UTIL_LARGE_ENOUGH

# positions is series with the chosen alternative represented as a column index in utilities_df
# which is an integer between zero and num alternatives in the alternative sample
Expand DownExpand Up@@ -430,9 +428,6 @@ def _interaction_sample_simulate(
# resulting pandas Int64Index has one element per chooser row and is in same order as choosers
choices = alternatives[choice_column].take(positions + first_row_offsets)

# resulting pandas Int64Index has one element per chooser row and is in same order as choosers
choices = alternatives[choice_column].take(positions + first_row_offsets)

# create a series with index from choosers and the index of the chosen alternative
choices = pd.Series(choices, index=choosers.index)

Expand Down
45 changes: 40 additions & 5 deletions activitysim/core/logit.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,13 @@
UTIL_MIN = np.log(EXP_UTIL_MIN, dtype=np.float64)
UTIL_UNAVAILABLE = 1000.0 * (UTIL_MIN - 1.0)

# Utility assigned to an alternative that must be chosen over UTIL_UNAVAILABLE
# alternatives, e.g. when forcing a choice for a chooser that would otherwise
# have no available alternative. Any finite value works: the gap to
# UTIL_UNAVAILABLE is many orders of magnitude larger than the EV1 error terms
# added by the explicit error term choice methods.
UTIL_LARGE_ENOUGH = 3.0

PROB_MIN = 0.0
PROB_MAX = 1.0

Expand DownExpand Up@@ -640,6 +647,7 @@ def make_choices_utility_based(
nest_spec=None,
alts_context: AltsContext | None = None,
alt_nrs_df: pd.DataFrame | None = None,
allow_zero_probs: bool = False,
) -> tuple[pd.Series, pd.Series]:
"""
Make choices for each chooser from among a set of alternatives based on utilities by adding
Expand All@@ -666,6 +674,11 @@ def make_choices_utility_based(
the alt_nrs for each alternative for each chooser. This is used to index into the random numbers when sampling
EET terms for multinomial logit models, and should contain -999 for any alternatives that are not available
for a given chooser. Should be provided along with `alts_context`.
allow_zero_probs : bool
If False, report choosers that have no available alternative at all. This is the
utility-space counterpart of `allow_bad_probs` in `make_choices`, and takes the
name `allow_zero_probs` because the condition it suppresses is the same one that
`validate_utils` and `utils_to_probs` describe by that name.

Returns
-------
Expand All@@ -677,14 +690,36 @@ def make_choices_utility_based(

Notes
-----
Bad-row reporting (e.g., a chooser whose alternatives are all `UTIL_UNAVAILABLE`) is the
responsibility of `validate_utils()`, which is invoked at every EET call site
(interaction_sample, interaction_sample_simulate, simulate.eval_mnl, simulate.eval_nl)
BEFORE this function is called. EET argmax always returns a valid integer position;
we do not re-check here.
An argmax always returns a position, so a chooser with no available alternative gets a
choice here rather than an error: with every utility at `UTIL_UNAVAILABLE` the alternatives
are tied and the error terms decide, and with every utility at `-inf` the first column wins.
The Monte Carlo path does not go quiet in that situation -- `make_choices` reports it unless
`allow_bad_probs` is set -- so this function reports it too. Most callers reach here having
already run `validate_utils`, which makes the same check; the duplication is deliberate and
mirrors `make_choices` re-checking what `utils_to_probs` has already looked at.
"""
trace_label = tracing.extend_trace_label(trace_label, "make_choices_utility_based")

if not allow_zero_probs:
# An alternative counts as available when its utility exceeds UTIL_MIN, which is the
# same threshold validate_utils uses before clamping to UTIL_UNAVAILABLE. Testing the
# threshold directly rather than testing the row sum against a multiple of
# UTIL_UNAVAILABLE means this also holds for callers that arrive without having run
# validate_utils, where unavailable alternatives may still carry their raw value.
no_available_alts = ~(utilities.to_numpy() > UTIL_MIN).any(axis=1)
if no_available_alts.any():
report_bad_choices(
state,
no_available_alts,
utilities,
state.settings.skip_failed_choices,
trace_label=tracing.extend_trace_label(
trace_label, "no_available_alts"
),
msg="no alternative is available",
trace_choosers=trace_choosers,
)

if nest_spec is None:
choices = make_choices_explicit_error_term_mnl(
state,
Expand Down
39 changes: 26 additions & 13 deletions activitysim/core/random.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -441,7 +441,9 @@ def gumbel_choice_positions_for_df(
alt_nrs_df : pandas.DataFrame, optional
DataFrame aligned to `utilities` whose values identify which dense alternative
each utility column corresponds to. Use `MASKED_ALT_ID` (-999) for masked or
unavailable positions; any other negative value raises ValueError.
unavailable positions; any other negative value raises ValueError. A row that
is masked in every column is taken to mean the chooser has no alternative to
choose from, and returns position 0.
n_rands : int, optional
Number of EV1 draws to generate per chooser row. Required when `alt_nrs_df`
is provided and may exceed the visible number of utility columns.
Expand All@@ -461,7 +463,12 @@ def gumbel_choice_positions_for_df(
positions = np.empty(n_rows, dtype=np.int64)

if alt_nrs_df is not None:
assert alt_nrs_df.shape == utilities.shape
assert alt_nrs_df.index.equals(
utilities.index
), "alt_nrs_df and utilities must share the same index"
assert alt_nrs_df.columns.equals(
utilities.columns
), "alt_nrs_df and utilities must share the same columns"
if n_rands is None:
raise ValueError("n_rands is required when alt_nrs_df is provided")
alt_nr_values = alt_nrs_df.to_numpy()
Expand All@@ -474,6 +481,7 @@ def gumbel_choice_positions_for_df(
f"{MASKED_ALT_ID} sentinel: {offenders}"
)
masked = alt_nr_values == MASKED_ALT_ID
active_mask = ~masked
safe_alt_nrs = np.where(masked, 0, alt_nr_values)
else:
if n_rands is None:
Expand All@@ -482,7 +490,7 @@ def gumbel_choice_positions_for_df(
raise ValueError(
"n_rands must equal utilities.shape[1] when alt_nrs_df is omitted"
)
alt_nr_values = masked = safe_alt_nrs = None
alt_nr_values = masked = active_mask = safe_alt_nrs = None

generators = self._generators_for_df(utilities)

Expand All@@ -495,16 +503,21 @@ def gumbel_choice_positions_for_df(
utility_row - np.log(-np.log(row_randoms))
)
else:
# Masked positions are set to -inf so they cannot win argmax,
# and the gumbel transform is skipped for them entirely.
row_mask = masked[row_num]
candidate_values = np.full(n_alts, -np.inf, dtype=np.float64)
active = ~row_mask
if active.any():
candidate_values[active] = utility_row[active] - np.log(
-np.log(row_randoms[safe_alt_nrs[row_num, active]])
)
positions[row_num] = np.argmax(candidate_values)
# Masked positions can never be chosen, so apply the gumbel transform and argmax
# only to the active ones. flatnonzero returns ascending indices, so ties resolve
# to the lowest column position, which is what a full-width argmax would have done.
active = np.flatnonzero(active_mask[row_num])
if active.size == 0:
# The chooser has no alternative available at all. Return the first
# column by convention. Note that logit.make_choices_utility_based filters
# out choosers with no available alternatives, so this case will only occur
# when explicitly allowed by the caller.
positions[row_num] = 0
continue
gumbel = utility_row[active] - np.log(
-np.log(row_randoms[safe_alt_nrs[row_num, active]])
)
positions[row_num] = active[np.argmax(gumbel)]

self.row_states.loc[utilities.index, "offset"] += n_rands
return positions
Expand Down
2 changes: 1 addition & 1 deletion activitysim/core/simulate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1553,7 +1553,7 @@ def eval_nl(

if state.settings.use_explicit_error_terms:
raw_utilities = logit.validate_utils(
state, raw_utilities, allow_zero_probs=True, trace_label=trace_label
state, raw_utilities, trace_label=trace_label
)

if custom_chooser:
Expand Down
Loading
Loading