3.2 Sorting, Searching, and Conditional Selection
An analytical table is a collection of relationships: an order ID, its delivery time, and its zone belong to the same observation. Sorting is safe only when those relationships move together. This section develops a reusable pattern: compute positions first, then apply those positions to every aligned array.
import numpy as np
order_ids = np.array(["A104", "A105", "A106", "A107", "A108"])
minutes = np.array([42, 27, 42, 35, 51])
zones = np.array(["North", "South", "West", "Central", "North"])Sorting values versus sorting records
np.sort returns sorted values and normally leaves the input unchanged:
sorted_minutes = np.sort(minutes)
print(sorted_minutes) # [27 35 42 42 51]
print(minutes) # original order remainsIf you independently sort order_ids, minutes, and zones, each column may look orderly while the rows become fictional. Instead, indirect sorting uses np.argsort to return source positions:
order = np.argsort(minutes, kind="stable")
print(order) # positions such as [1 3 0 2 4]
print(order_ids[order]) # aligned IDs
print(minutes[order]) # aligned times
print(zones[order]) # aligned zonesThe output of argsort is not the sorted data. It is a permutation: a sequence of integer positions that can be reused for every array aligned along the same axis.
For descending order, reverse an ascending permutation or sort the negated numeric key:
descending = np.argsort(-minutes, kind="stable")A stable sort preserves original order among equal keys. The two 42-minute records remain in their input order. Stability is useful when input order has meaning or when you sort by several keys in stages. It does not define a meaningful tie-breaker by itself; if ties must be ordered by ID or timestamp, state that rule explicitly. np.lexsort can build a permutation from multiple keys:
# Last key is primary: minutes first, then ID breaks ties.
order = np.lexsort((order_ids, minutes))For a large array when you need only the smallest or largest items, np.argpartition can avoid fully ordering everything:
k = 3
candidate_positions = np.argpartition(minutes, k - 1)[:k]
top_k_order = candidate_positions[
np.argsort(minutes[candidate_positions], kind="stable")
]The partitioned candidates are not guaranteed to be sorted, so the second step orders the small candidate set.
Sorting along an axis
For a two-dimensional array, axis identifies the independent lines being sorted:
matrix = np.array([
[8, 3, 5],
[2, 9, 4],
])
np.sort(matrix, axis=1) # sort within each row
np.sort(matrix, axis=0) # sort within each column
np.sort(matrix, axis=None) # flatten, then sort all six valuesSorting each column of a table independently usually destroys records. Axis-wise sorting is appropriate when each row or column is an independent numerical sequence, not when several columns form one observation.
Search ordered boundaries
np.searchsorted finds insertion positions in an already sorted one-dimensional array. It uses binary search, making repeated boundary lookups efficient:
boundaries = np.array([36, 50])
observed = np.array([24, 35, 36, 50, 61])
bucket = np.searchsorted(boundaries, observed, side="right")
print(bucket) # [0 0 1 2 2]Here the first boundary is 36 because the measurements are whole minutes and values through 35 are on-time. With side="right", a value equal to a boundary is inserted after that boundary, so 36 is late and 50 is critical. The three buckets represent on-time, late, and critical. With side="left", equality would belong to the lower bucket instead. Neither choice is universally correct: the domain rule must decide who owns each exact boundary.
The input boundaries must be sorted. searchsorted does not validate their business meaning or sort them for you. Check monotonically increasing order when boundaries come from configuration:
assert np.all(boundaries[:-1] <= boundaries[1:])Choose values with vectorized conditions
The three-argument form of np.where selects one of two values at each position:
status = np.where(minutes <= 35, "on-time", "late")The condition and both choices follow broadcasting rules, and the result has their broadcast shape. This form is different from np.where(condition) with one argument, which returns index arrays. For readable positions in one-dimensional data, np.flatnonzero(condition) is often clearer:
late_positions = np.flatnonzero(minutes > 35)
late_ids = order_ids[late_positions]For more than two outcomes, use np.select:
conditions = [
minutes >= 50,
minutes > 35,
]
choices = ["critical", "late"]
status = np.select(conditions, choices, default="on-time")np.select uses the first true condition. Therefore overlapping rules must be ordered from the most specific or highest-priority rule to the broadest. If minutes > 35 came first, every value at least 50 would take the late branch and critical would be unreachable.
When constructing conditions, remember these rules:
- Combine array conditions with
&,|, and~, not Python's scalarand,or, andnot.
- Parenthesize each comparison:
(minutes > 35) & (zones == "North").
- Decide explicitly how missing values should be routed; comparisons with
np.nanare usually false.
- Test exact boundary values, not only values comfortably inside each category.
Sorting and conditional selection are deterministic: the same inputs and rules produce the same result. The next section introduces controlled randomness, where reproducibility requires recording both generator state and the sequence of operations.