22
33from __future__ import annotations
44
5+ from collections .abc import Callable , Sequence
6+ from dataclasses import dataclass
57from typing import TYPE_CHECKING
68
79try :
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+
1991def _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
88197def 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
93202def 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