Skip to content

Commit 1cfcb83

Browse files
oedokumaciclaude
andcommitted
feat(numeric): add gale_shapley_traced with proposal stats and selector hook
Expose proposal counts and a pluggable proposer-selection rule on the numpy-backed M-W loop. Motivated by downstream RL work that needs to benchmark "fewer proposals" strategies against deferred-acceptance baselines. Added: - GSStats: frozen dataclass with match, total proposals, per-proposer counts. - Selector type alias + lifo_selector / fifo_selector / random_selector helpers. - gale_shapley_traced: same M-W loop, returns GSStats, accepts a selector. - men_optimal_traced / women_optimal_traced: convenience wrappers that mirror the existing men_optimal_gs / women_optimal_gs orientation conventions. - gale_shapley refactored to a thin wrapper: returns gale_shapley_traced(...).match. Tests cover Knuth invariance of the match (and proposal count) under selector choice, the canonical n*(n+1)/2 worst case for shared preferences, women_optimal_traced orientation, and validation parity. Coverage on numeric/gs.py: 100%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e21b549 commit 1cfcb83

3 files changed

Lines changed: 336 additions & 8 deletions

File tree

src/gale_shapley_algorithm/numeric/__init__.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,19 @@
3030
>>> lattice = enumerate_stable_matchings(proposer_rank, responder_rank)
3131
"""
3232

33-
from gale_shapley_algorithm.numeric.gs import gale_shapley, men_optimal_gs, women_optimal_gs
33+
from gale_shapley_algorithm.numeric.gs import (
34+
GSStats,
35+
Selector,
36+
fifo_selector,
37+
gale_shapley,
38+
gale_shapley_traced,
39+
lifo_selector,
40+
men_optimal_gs,
41+
men_optimal_traced,
42+
random_selector,
43+
women_optimal_gs,
44+
women_optimal_traced,
45+
)
3446
from gale_shapley_algorithm.numeric.lattice import (
3547
apply_rotation,
3648
enumerate_stable_matchings,
@@ -39,12 +51,20 @@
3951
from gale_shapley_algorithm.numeric.stability import find_blocking_pairs, is_stable
4052

4153
__all__ = [
54+
"GSStats",
55+
"Selector",
4256
"apply_rotation",
4357
"enumerate_stable_matchings",
4458
"exposed_rotations",
59+
"fifo_selector",
4560
"find_blocking_pairs",
4661
"gale_shapley",
62+
"gale_shapley_traced",
4763
"is_stable",
64+
"lifo_selector",
4865
"men_optimal_gs",
66+
"men_optimal_traced",
67+
"random_selector",
4968
"women_optimal_gs",
69+
"women_optimal_traced",
5070
]

src/gale_shapley_algorithm/numeric/gs.py

Lines changed: 170 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
from collections.abc import Callable, Sequence
6+
from dataclasses import dataclass
57
from typing import TYPE_CHECKING
68

79
try:
@@ -16,6 +18,76 @@
1618
from numpy.typing import NDArray
1719

1820

21+
type Selector = Callable[[Sequence[int]], int]
22+
"""Picks which free proposer goes next.
23+
24+
Receives the current free list of proposer ids and returns an index into
25+
that list (``0..len(free)-1``; negative indices supported per ``list.pop``
26+
semantics). The default is :func:`lifo_selector`, which matches the
27+
historical behavior of :func:`gale_shapley` (``free.pop()``).
28+
"""
29+
30+
31+
def lifo_selector(free: Sequence[int]) -> int:
32+
"""Return the last index — equivalent to ``list.pop()`` (LIFO)."""
33+
del free
34+
return -1
35+
36+
37+
def fifo_selector(free: Sequence[int]) -> int:
38+
"""Return the first index — pops in arrival order (FIFO)."""
39+
del free
40+
return 0
41+
42+
43+
def random_selector(rng: np.random.Generator) -> Selector:
44+
"""Build a uniform-random selector backed by a numpy ``Generator``.
45+
46+
Roth-Vande Vate-style: pick a free proposer uniformly at random each step.
47+
Final matching is invariant to selector choice (Knuth's order independence)
48+
but the proposal count is not — random order is a useful baseline against
49+
LIFO/FIFO for amortized analysis.
50+
51+
Args:
52+
rng: a numpy ``Generator`` (use ``np.random.default_rng(seed)`` for
53+
determinism).
54+
55+
Returns:
56+
A :data:`Selector` closure that draws ``rng.integers(len(free))`` per call.
57+
"""
58+
59+
def _selector(free: Sequence[int]) -> int:
60+
return int(rng.integers(len(free)))
61+
62+
return _selector
63+
64+
65+
@dataclass(slots=True, frozen=True, eq=False)
66+
class GSStats:
67+
"""Result of a traced Gale-Shapley run.
68+
69+
``eq=False`` because :attr:`match` and :attr:`proposals_per_proposer` are
70+
numpy arrays whose ``__eq__`` returns an array, not a bool — so the
71+
dataclass-generated ``__eq__`` would raise. Compare fields directly.
72+
73+
Attributes:
74+
match: shape ``(n,)``, dtype ``int16``. Proposer-indexed:
75+
``match[p]`` is the responder paired with proposer ``p``. The
76+
wrapper :func:`women_optimal_traced` re-indexes this to be
77+
men-indexed; see its docstring.
78+
proposals: total number of proposal events. Equals
79+
``proposals_per_proposer.sum()``.
80+
proposals_per_proposer: shape ``(n,)``, dtype ``int16``.
81+
``proposals_per_proposer[p]`` is the number of proposals made by
82+
proposer ``p``, which also equals the rank (1-indexed) of ``p``'s
83+
final match in ``p``'s preference list.
84+
"""
85+
86+
match: NDArray[np.int16]
87+
proposals: int
88+
proposals_per_proposer: NDArray[np.int16]
89+
90+
1991
def _validate_rank_matrices(
2092
proposer_rank: NDArray[np.integer],
2193
responder_rank: NDArray[np.integer],
@@ -47,31 +119,44 @@ def _validate_rank_matrices(
47119
)
48120

49121

50-
def gale_shapley(proposer_rank: NDArray[np.integer], responder_rank: NDArray[np.integer]) -> NDArray[np.int16]:
51-
"""Run proposer-optimal deferred acceptance.
122+
def gale_shapley_traced(
123+
proposer_rank: NDArray[np.integer],
124+
responder_rank: NDArray[np.integer],
125+
*,
126+
selector: Selector = lifo_selector,
127+
) -> GSStats:
128+
"""Run proposer-optimal deferred acceptance and return per-proposer statistics.
129+
130+
Mechanically identical to :func:`gale_shapley` (sequential McVitie-Wilson
131+
on the free-proposer pool), but exposes the proposal count and a
132+
pluggable proposer-selection rule. The final matching is invariant under
133+
selector choice (Knuth); the proposal count is not.
52134
53135
Args:
54136
proposer_rank: ``(n, n)`` array where ``proposer_rank[i, j]`` is
55137
the 1-indexed position of responder ``j`` in proposer ``i``'s
56138
preference list. Each row must be a permutation of ``1..n``.
57139
responder_rank: ``(n, n)`` array with the symmetric convention.
140+
selector: picks which free proposer acts next; defaults to
141+
:func:`lifo_selector`. See :data:`Selector`.
58142
59143
Returns:
60-
``match`` of shape ``(n,)``, dtype ``int16``, where ``match[i]`` is
61-
the responder paired with proposer ``i``.
144+
:class:`GSStats` with the matching, total proposal count, and
145+
per-proposer counts.
62146
63147
Raises:
64148
ValueError: if the two arrays don't have the same square shape, or
65149
any row is not a permutation of ``1..n``.
150+
IndexError: if ``selector`` returns an index outside the free list.
66151
"""
67152
_validate_rank_matrices(proposer_rank, responder_rank)
68153
n = proposer_rank.shape[0]
69154
next_proposal = np.zeros(n, dtype=np.int16)
70155
responder_match = np.full(n, -1, dtype=np.int16)
71156
proposer_pref = np.argsort(proposer_rank, axis=1).astype(np.int16)
72-
free = list(range(n))
157+
free: list[int] = list(range(n))
73158
while free:
74-
p = free.pop()
159+
p = free.pop(selector(free))
75160
r = int(proposer_pref[p, next_proposal[p]])
76161
next_proposal[p] += 1
77162
current = int(responder_match[r])
@@ -82,7 +167,31 @@ def gale_shapley(proposer_rank: NDArray[np.integer], responder_rank: NDArray[np.
82167
free.append(current)
83168
else:
84169
free.append(p)
85-
return np.argsort(responder_match).astype(np.int16)
170+
return GSStats(
171+
match=np.argsort(responder_match).astype(np.int16),
172+
proposals=int(next_proposal.sum()),
173+
proposals_per_proposer=next_proposal.copy(),
174+
)
175+
176+
177+
def gale_shapley(proposer_rank: NDArray[np.integer], responder_rank: NDArray[np.integer]) -> NDArray[np.int16]:
178+
"""Run proposer-optimal deferred acceptance.
179+
180+
Args:
181+
proposer_rank: ``(n, n)`` array where ``proposer_rank[i, j]`` is
182+
the 1-indexed position of responder ``j`` in proposer ``i``'s
183+
preference list. Each row must be a permutation of ``1..n``.
184+
responder_rank: ``(n, n)`` array with the symmetric convention.
185+
186+
Returns:
187+
``match`` of shape ``(n,)``, dtype ``int16``, where ``match[i]`` is
188+
the responder paired with proposer ``i``.
189+
190+
Raises:
191+
ValueError: if the two arrays don't have the same square shape, or
192+
any row is not a permutation of ``1..n``.
193+
"""
194+
return gale_shapley_traced(proposer_rank, responder_rank).match
86195

87196

88197
def men_optimal_gs(men_rank: NDArray[np.integer], women_rank: NDArray[np.integer]) -> NDArray[np.int16]:
@@ -93,3 +202,57 @@ def men_optimal_gs(men_rank: NDArray[np.integer], women_rank: NDArray[np.integer
93202
def women_optimal_gs(men_rank: NDArray[np.integer], women_rank: NDArray[np.integer]) -> NDArray[np.int16]:
94203
"""Return the women-optimal stable matching, still in men-indexed form ``match[m] = w``."""
95204
return np.argsort(gale_shapley(women_rank, men_rank)).astype(np.int16)
205+
206+
207+
def men_optimal_traced(
208+
men_rank: NDArray[np.integer],
209+
women_rank: NDArray[np.integer],
210+
*,
211+
selector: Selector = lifo_selector,
212+
) -> GSStats:
213+
"""Men-optimal stable matching with per-man proposal stats.
214+
215+
Equivalent to ``gale_shapley_traced(men_rank, women_rank, selector=...)``;
216+
provided for symmetry with :func:`men_optimal_gs`.
217+
218+
Args:
219+
men_rank: ``(n, n)`` rank matrix for men (proposers).
220+
women_rank: ``(n, n)`` rank matrix for women (responders).
221+
selector: see :data:`Selector`.
222+
223+
Returns:
224+
:class:`GSStats` where ``match`` and ``proposals_per_proposer`` are
225+
both men-indexed.
226+
"""
227+
return gale_shapley_traced(men_rank, women_rank, selector=selector)
228+
229+
230+
def women_optimal_traced(
231+
men_rank: NDArray[np.integer],
232+
women_rank: NDArray[np.integer],
233+
*,
234+
selector: Selector = lifo_selector,
235+
) -> GSStats:
236+
"""Women-optimal stable matching with per-woman proposal stats.
237+
238+
Runs :func:`gale_shapley_traced` with women as the proposing side. The
239+
returned ``match`` is re-indexed to men-indexed form for consistency with
240+
:func:`women_optimal_gs`. ``proposals_per_proposer`` remains
241+
**women-indexed**, since women are the proposers in this run.
242+
243+
Args:
244+
men_rank: ``(n, n)`` rank matrix for men.
245+
women_rank: ``(n, n)`` rank matrix for women.
246+
selector: see :data:`Selector`.
247+
248+
Returns:
249+
:class:`GSStats` with ``match[m] = w`` (men-indexed) and
250+
``proposals_per_proposer[w] = number of proposals woman w made``
251+
(women-indexed). ``proposals`` is the unambiguous total.
252+
"""
253+
stats = gale_shapley_traced(women_rank, men_rank, selector=selector)
254+
return GSStats(
255+
match=np.argsort(stats.match).astype(np.int16),
256+
proposals=stats.proposals,
257+
proposals_per_proposer=stats.proposals_per_proposer,
258+
)

0 commit comments

Comments
 (0)