Skip to content

Commit 3bcc811

Browse files
dhenslejpn--Copilot
authored
Speed Up Nearest Zone Calculation in Disaggregate Accessibilities (ActivitySim#1031)
* optimize nearest_zone from skims function * increase speed of skim dict _lookup mapping function * blacken * handle empty inputs for nearest zone calc * maz span not dependent on numbering Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * map based on index instead of number * batching skim lookup for disagg access nearest zone * Revert "Merge branch 'disagg_access_nearest_zone_speed_up' of https://github.com/RSGInc/activitysim into disagg_access_nearest_zone_speed_up" This reverts commit a9b7f69, reversing changes made to 66ae28c. * Revert "map based on index instead of number" This reverts commit 66ae28c. * Reapply "map based on index instead of number" This reverts commit f1e135e. * Reapply "Merge branch 'disagg_access_nearest_zone_speed_up' of https://github.com/RSGInc/activitysim into disagg_access_nearest_zone_speed_up" This reverts commit 6c6367f. --------- Co-authored-by: Jeffrey Newman <jeff@driftless.xyz> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent bf80d5a commit 3bcc811

2 files changed

Lines changed: 81 additions & 9 deletions

File tree

activitysim/abm/tables/disaggregate_accessibility.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,41 @@ def find_nearest_accessibility_zone(
2727
def weighted_average(df, values, weights):
2828
return df[values].T.dot(df[weights]) / df[weights].sum()
2929

30-
def nearest_skim(oz, zones):
31-
# need to pass equal # of origins and destinations to skim_dict
32-
orig_zones = np.full(shape=len(zones), fill_value=oz, dtype=int)
33-
return (
34-
oz,
35-
zones[np.argmin(skim_dict.lookup(orig_zones, zones, "DIST"))],
36-
)
30+
def find_nearest_zones_via_skims(origin_zones, dest_zones, skim_dict):
31+
"""
32+
Vectorized lookup to find nearest destination zone for each origin zone.
33+
Performs a single batched skim lookup instead of one per origin zone.
34+
"""
35+
origin_zones = np.asarray(origin_zones)
36+
dest_zones = np.asarray(dest_zones)
37+
n_origins = len(origin_zones)
38+
n_dests = len(dest_zones)
39+
40+
# handle empty input case
41+
if n_origins == 0 or n_dests == 0:
42+
return []
43+
44+
# Process in batches to avoid allocating arrays of size n_origins * n_dests
45+
max_pairs = 5_000_000
46+
batch_size = max(1, int(max_pairs // n_dests))
47+
48+
results = []
49+
for start in range(0, n_origins, batch_size):
50+
batch_orig = origin_zones[start : start + batch_size]
51+
52+
# create all origin-destination pairs for this batch
53+
all_orig = np.repeat(batch_orig, n_dests)
54+
all_dest = np.tile(dest_zones, len(batch_orig))
55+
56+
# single skim lookup for all pairs in the batch
57+
all_dists = skim_dict.lookup(all_orig, all_dest, "DIST")
58+
dist_matrix = np.asarray(all_dists).reshape(len(batch_orig), n_dests)
59+
60+
# find the index of the nearest destination zone for each origin in the batch
61+
nearest_indices = np.argmin(dist_matrix, axis=1)
62+
results.extend(zip(batch_orig, dest_zones[nearest_indices]))
63+
64+
return results
3765

3866
def nearest_node(oz, zones_df):
3967
_idx = util.nearest_node_index(_centroids.loc[oz].XY, zones_df.to_list())
@@ -70,7 +98,10 @@ def nearest_node(oz, zones_df):
7098

7199
else:
72100
skim_dict = state.get_injectable("skim_dict")
73-
nearest = [nearest_skim(Oz, accessibility_zones) for Oz in unmatched_zones]
101+
# Vectorized lookup: single skim call for all origin-destination pairs
102+
nearest = find_nearest_zones_via_skims(
103+
unmatched_zones, accessibility_zones, skim_dict
104+
)
74105

75106
# Add the nearest zones to the matched zones
76107
matched = [(x, x) for x in matched_zones]

activitysim/core/skim_dictionary.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class OffsetMapper(object):
3939

4040
def __init__(self, offset_int=None, offset_list=None, offset_series=None):
4141
self.offset_int = self.offset_series = None
42+
self._offset_array = None # numpy array for fast O(1) lookups
4243

4344
assert (offset_int is not None) + (offset_list is not None) + (
4445
offset_series is not None
@@ -72,6 +73,28 @@ def set_offset_series(self, offset_series):
7273
self.offset_series = offset_series
7374
self.offset_int = None
7475

76+
# Build numpy lookup array for fast O(1) mapping
77+
# This replaces slow pandas Series.map() with direct numpy indexing
78+
index_vals = offset_series.index.values
79+
if len(index_vals) > 0:
80+
min_zone = int(index_vals.min())
81+
max_zone = int(index_vals.max())
82+
span = max_zone - min_zone + 1
83+
# Only build array if zone IDs are non-negative and range is reasonable
84+
# (avoid huge arrays for sparse zone IDs)
85+
if min_zone >= 0 and span <= len(index_vals) * 10:
86+
self._offset_array_min_zone = min_zone
87+
self._offset_array = np.full(span, NOT_IN_SKIM_ZONE_ID, dtype=np.int32)
88+
self._offset_array[
89+
index_vals.astype(int) - min_zone
90+
] = offset_series.values.astype(np.int32)
91+
else:
92+
self._offset_array = None
93+
self._offset_array_min_zone = 0
94+
else:
95+
self._offset_array = None
96+
self._offset_array_min_zone = 0
97+
7598
def set_offset_list(self, offset_list):
7699
"""
77100
Convenience method to set offset_series using an integer list the same size as target skim dimension
@@ -110,6 +133,7 @@ def set_offset_int(self, offset_int):
110133

111134
self.offset_int = int(offset_int)
112135
self.offset_series = None
136+
self._offset_array = None # not needed for simple int offset
113137

114138
def map(self, zone_ids):
115139
"""
@@ -124,7 +148,24 @@ def map(self, zone_ids):
124148
offsets : numpy array of int
125149
"""
126150

127-
if self.offset_series is not None:
151+
if self._offset_array is not None:
152+
# Fast path: use numpy array indexing (O(1) per element)
153+
zone_ids = np.asanyarray(zone_ids).astype(int)
154+
min_zone = getattr(self, "_offset_array_min_zone", 0)
155+
idx = zone_ids - min_zone
156+
# Clip to valid range to avoid index errors, then mark out-of-range as NOT_IN_SKIM
157+
max_valid = len(self._offset_array) - 1
158+
valid_mask = (idx >= 0) & (idx <= max_valid)
159+
# Use clip to safely index, then apply mask
160+
clipped_idx = np.clip(idx, 0, max_valid)
161+
offsets = np.where(
162+
valid_mask,
163+
self._offset_array[clipped_idx],
164+
NOT_IN_SKIM_ZONE_ID,
165+
)
166+
return offsets
167+
168+
elif self.offset_series is not None:
128169
assert self.offset_int is None
129170
assert isinstance(self.offset_series, pd.Series)
130171

0 commit comments

Comments
 (0)