mirror of
https://github.com/tgorordo/carousel.git
synced 2026-06-05 10:12:13 -07:00
sync
This commit is contained in:
parent
6cdc579270
commit
cbedbbeb48
12 changed files with 1582 additions and 591 deletions
58
README.md
58
README.md
|
|
@ -3,7 +3,7 @@ bibliography: REFERENCES.bib
|
|||
...
|
||||
|
||||
# Carousel
|
||||
*A simple Stable Matching Solver.*
|
||||
*A simple Stable Matching solver.*
|
||||
|
||||
`carousel` is a solver for the
|
||||
[Envy-free](https://en.wikipedia.org/wiki/Envy-free_matching)
|
||||
|
|
@ -14,30 +14,29 @@ bibliography: REFERENCES.bib
|
|||
|
||||
### Gale-Shapley Deferred Acceptance
|
||||
|
||||
The most basic version(s) of the stable matching problem was outlined and solved in [@gale&shapley1962] and won Shapley the
|
||||
[2012 Nobel Prize in Economics](https://www.nobelprize.org/prizes/economic-sciences/2012/popular-information/).
|
||||
- Gives a solution to the basic problem.
|
||||
|
||||
The basic problem being solved is as follows:
|
||||
### GS + Rotation Enumeration of Solutions
|
||||
|
||||
#### Stable Marriage Problem
|
||||
The "Stable Marriage problem" TODO
|
||||
- Can implement post-selection measures/constraints/criteria.
|
||||
|
||||
TODO
|
||||
### Integer Programming (Huang++)
|
||||
|
||||
#### College Admissions Problem
|
||||
Solving the stable marriage problem also provides a solution to the "College Admissions Problem",
|
||||
with just a little more work.
|
||||
### Polytope Solution Sampling (Large Problems)
|
||||
|
||||
TODO
|
||||
### Brute-Force Combinatoric Search
|
||||
A benchmark/testbed implementation. Beware.
|
||||
|
||||
### More?
|
||||
Contribute!
|
||||
|
||||
## Data - Input/Output
|
||||
All [input table formats supported by `polars`](https://docs.pola.rs/user-guide/io/) are supported by `carousel` (`csv`, `excel`, `json` to name a few),
|
||||
which accepts a few inter-related tabular schemes for the input/output data.
|
||||
|
||||
### Input
|
||||
Input describes the preferences/rankings of the "applicants" of "reviewers" to which they will be matched (possibly many-to-one, as in the "College Admission Problem"),
|
||||
as well as the preferences/rankings for the reviwers of applicants.
|
||||
Input should be in one of three forms:
|
||||
Input describes the preferences/rankings of the "applicants" of "reviewers" to which they will be matched (possibly many-to-one, as in the "College Admission Problem"), as well as the preferences/rankings for the reviwers of applicants.
|
||||
Input should be in one of two forms:
|
||||
|
||||
#### Preferences
|
||||
Preferences enumerate by-name some preferences in descending order,
|
||||
|
|
@ -61,10 +60,13 @@ e.g. Alice, Bob and Charlie rank the fruit apples, bananas and cherries as:
|
|||
| banana | 3 | 1 | 2 |
|
||||
| cherry | 2 | 3 | 1 |
|
||||
|
||||
### Intermediate
|
||||
|
||||
#### Ranking Matrix
|
||||
In order to perform a matching, `carousel` either needs a pair of preferences
|
||||
(e.g. a set of doctor's preferences for residencies, and a set of residencies' preferences for doctors),
|
||||
a pair of corresponding rankings, *or* a matrix encoding both rankings at once:
|
||||
a pair of corresponding rankings. A ranking matrix is a concise way to express
|
||||
a pair of rankings - used to display rankings:
|
||||
|
||||
| names | Alice | Bob | Charlie |
|
||||
|---------|------------|---------|-----------|
|
||||
|
|
@ -72,6 +74,25 @@ a pair of corresponding rankings, *or* a matrix encoding both rankings at once:
|
|||
| CaseMed | (3, 2) | (1, 1) | (2, 3) |
|
||||
| Emory | (2, 1) | (3, 3) | (1, 2) |
|
||||
|
||||
|
||||
#### Priorities
|
||||
Internally, preferences/rankings are often serialized to rows as a priority listing of the form:
|
||||
|
||||
| App. | Rank | Rev |
|
||||
|-------|---|--------|
|
||||
| Alice | 1 | apple |
|
||||
| Alice | 2 | cherry |
|
||||
| Alice | 3 | banana |
|
||||
| Bob | 1 | banana |
|
||||
| Bob | 2 | apple |
|
||||
| Bob | 3 | cherry |
|
||||
| Charlie | 1 | cherry |
|
||||
| Charlie | 2 | banana |
|
||||
| Charlie | 3 | apple |
|
||||
|
||||
(in no specific row order). This is not a supported input format in the frontends, but may be relevant
|
||||
when interacting with the package programmatically - priorities can be followed as edges in a graph.
|
||||
|
||||
### Output
|
||||
|
||||
#### Matching
|
||||
|
|
@ -112,6 +133,9 @@ using the [pages.uoregon.edu CGI feature](https://service.uoregon.edu/TDClient/2
|
|||
Submit your applicant and reviewer preferences in tabular form,
|
||||
or as excel uploads and the server will return a table of matches for you to choose from.
|
||||
|
||||
This resource has *very* limited compute, so excessive usage might result in limitations or restricted access.
|
||||
Try not to ruin a good thing! A moderate number of problems on the scale of those in the Examples section below should be sustainable.
|
||||
|
||||
### Command Line Binary
|
||||
|
||||
TODO
|
||||
|
|
@ -141,13 +165,13 @@ TODO
|
|||
It's often desirable to enforce additional criteria on solutions
|
||||
that are not well-posed within the core optimization problem.
|
||||
Since the solver itself is stochastic to some extent, these are often most easily implemented
|
||||
by a post-selection.
|
||||
by a post-selection on a sampling of solutions.
|
||||
|
||||
|
||||
## Examples
|
||||
Here are some usage examples:
|
||||
|
||||
### Departmental TA Assignments
|
||||
### University of Oregon Physics Department Graduate TA Assignments
|
||||
TODO
|
||||
|
||||
### Caltech Housing Rotation
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
@article{gale&shapley1962,
|
||||
ISSN = {0002989, 19300972},
|
||||
URL = {https://www.jstor.org/stable/2312726},
|
||||
|
|
@ -14,3 +12,64 @@
|
|||
year = {1962},
|
||||
}
|
||||
|
||||
@article{doi:10.1137/0215048,
|
||||
author = {Irving, Robert W. and Leather, Paul},
|
||||
title = {The Complexity of Counting Stable Marriages},
|
||||
journal = {SIAM Journal on Computing},
|
||||
volume = {15},
|
||||
number = {3},
|
||||
pages = {655-667},
|
||||
year = {1986},
|
||||
doi = {10.1137/0215048},
|
||||
URL = {https://doi.org/10.1137/0215048},
|
||||
eprint = {https://doi.org/10.1137/0215048}
|
||||
}
|
||||
|
||||
@article{doi:10.1137/0216010,
|
||||
author = {Gusfield, Dan},
|
||||
title = {Three Fast Algorithms for Four Problems in Stable Marriage},
|
||||
journal = {SIAM Journal on Computing},
|
||||
volume = {16},
|
||||
number = {1},
|
||||
pages = {111-128},
|
||||
year = {1987},
|
||||
doi = {10.1137/0216010},
|
||||
URL = { https://doi.org/10.1137/0216010},
|
||||
eprint = { https://doi.org/10.1137/0216010 }
|
||||
}
|
||||
|
||||
@article{https://doi.org/10.3982/TE4830,
|
||||
author = {Huang, Chao},
|
||||
title = {Stable matching: An integer programming approach},
|
||||
journal = {Theoretical Economics},
|
||||
volume = {18},
|
||||
number = {1},
|
||||
pages = {37-63},
|
||||
keywords = {Two-sided matching, stability, integer programming, many-to-one matching, complementarity, total unimodularity, demand type, C61, C78, D47, D63},
|
||||
doi = {https://doi.org/10.3982/TE4830},
|
||||
url = {https://onlinelibrary.wiley.com/doi/abs/10.3982/TE4830},
|
||||
eprint = {https://onlinelibrary.wiley.com/doi/pdf/10.3982/TE4830}
|
||||
}
|
||||
|
||||
@article{DELORME2019426,
|
||||
title = {Mathematical models for stable matching problems with ties and incomplete lists},
|
||||
journal = {European Journal of Operational Research},
|
||||
volume = {277},
|
||||
number = {2},
|
||||
pages = {426-441},
|
||||
year = {2019},
|
||||
issn = {0377-2217},
|
||||
doi = {https://doi.org/10.1016/j.ejor.2019.03.017},
|
||||
url = {https://www.sciencedirect.com/science/article/pii/S0377221719302565},
|
||||
author = {Maxence Delorme and Sergio García and Jacek Gondzio and Jörg Kalcsics and David Manlove and William Pettersson},
|
||||
}
|
||||
|
||||
@misc{gutin2024findingstablematchingsassignment,
|
||||
title={Finding all stable matchings with assignment constraints},
|
||||
author={Gregory Gutin and Philip R. Neary and Anders Yeo},
|
||||
year={2024},
|
||||
eprint={2204.03989},
|
||||
archivePrefix={arXiv},
|
||||
primaryClass={econ.TH},
|
||||
url={https://arxiv.org/abs/2204.03989},
|
||||
}
|
||||
|
|
|
|||
22
justfile
22
justfile
|
|
@ -1,14 +1,11 @@
|
|||
list:
|
||||
just --list
|
||||
|
||||
run:
|
||||
uv run carousel
|
||||
#run:
|
||||
# uv run carousel
|
||||
|
||||
python *arguments:
|
||||
uv run python -c "import code; from rich import pretty; pretty.install(); code.interact()" {{arguments}}
|
||||
|
||||
check:
|
||||
uv run pyright src
|
||||
uv run python -c {{arguments}}
|
||||
|
||||
test:
|
||||
uv run pytest -vvv --tb=short --log-cli-level=INFO
|
||||
|
|
@ -16,7 +13,16 @@ test:
|
|||
format:
|
||||
uv run ruff format src test
|
||||
|
||||
compile:
|
||||
check:
|
||||
uv run pyright src
|
||||
|
||||
sync:
|
||||
uv sync --upgrade
|
||||
|
||||
lock:
|
||||
uv pip compile pyproject.toml -o requirements.txt --group dev
|
||||
|
||||
build:
|
||||
uv run pyinstaller src/cli.py
|
||||
uv run pyinstaller src/gui.py
|
||||
|
||||
|
|
@ -29,5 +35,3 @@ wipe:
|
|||
just clean
|
||||
rm -rf .venv
|
||||
|
||||
lock:
|
||||
uv pip compile pyproject.toml -o requirements.txt --group dev
|
||||
|
|
|
|||
153
requirements.txt
153
requirements.txt
|
|
@ -1,42 +1,137 @@
|
|||
# This file was autogenerated by uv via the following command:
|
||||
# uv pip compile pyproject.toml -o requirements.txt --group dev
|
||||
altair==5.5.0
|
||||
# via marimo
|
||||
altgraph==0.17.4
|
||||
# via pyinstaller
|
||||
annotated-types==0.7.0
|
||||
# via pydantic
|
||||
anyio==4.10.0
|
||||
# via
|
||||
# httpx
|
||||
# openai
|
||||
# starlette
|
||||
attrs==25.3.0
|
||||
# via hypothesis
|
||||
# via
|
||||
# hypothesis
|
||||
# jsonschema
|
||||
# referencing
|
||||
certifi==2025.8.3
|
||||
# via
|
||||
# httpcore
|
||||
# httpx
|
||||
click==8.1.8
|
||||
# via carousel (pyproject.toml)
|
||||
# via
|
||||
# carousel (pyproject.toml)
|
||||
# marimo
|
||||
# uvicorn
|
||||
distro==1.9.0
|
||||
# via openai
|
||||
docutils==0.22
|
||||
# via marimo
|
||||
duckdb==1.3.2
|
||||
# via marimo
|
||||
faker==37.6.0
|
||||
# via carousel (pyproject.toml:dev)
|
||||
fastjsonschema==2.21.2
|
||||
# via nbformat
|
||||
h11==0.16.0
|
||||
# via
|
||||
# httpcore
|
||||
# uvicorn
|
||||
httpcore==1.0.9
|
||||
# via httpx
|
||||
httpx==0.28.1
|
||||
# via openai
|
||||
hypothesis==6.131.5
|
||||
# via carousel (pyproject.toml:dev)
|
||||
idna==3.10
|
||||
# via
|
||||
# anyio
|
||||
# httpx
|
||||
iniconfig==2.1.0
|
||||
# via pytest
|
||||
itsdangerous==2.2.0
|
||||
# via marimo
|
||||
jedi==0.19.2
|
||||
# via marimo
|
||||
jinja2==3.1.6
|
||||
# via altair
|
||||
jiter==0.10.0
|
||||
# via openai
|
||||
jsonschema==4.25.1
|
||||
# via
|
||||
# altair
|
||||
# nbformat
|
||||
jsonschema-specifications==2025.4.1
|
||||
# via jsonschema
|
||||
jupyter-core==5.8.1
|
||||
# via nbformat
|
||||
loro==1.6.0
|
||||
# via marimo
|
||||
marimo==0.15.2
|
||||
# via carousel (pyproject.toml:dev)
|
||||
markdown==3.9
|
||||
# via
|
||||
# marimo
|
||||
# pymdown-extensions
|
||||
markdown-it-py==3.0.0
|
||||
# via rich
|
||||
markupsafe==3.0.2
|
||||
# via jinja2
|
||||
mdurl==0.1.2
|
||||
# via markdown-it-py
|
||||
narwhals==2.3.0
|
||||
# via
|
||||
# altair
|
||||
# marimo
|
||||
nbformat==5.10.4
|
||||
# via marimo
|
||||
nodeenv==1.9.1
|
||||
# via pyright
|
||||
numpy==2.2.4
|
||||
# via carousel (pyproject.toml)
|
||||
openai==1.106.1
|
||||
# via marimo
|
||||
packaging==24.2
|
||||
# via
|
||||
# altair
|
||||
# marimo
|
||||
# pyinstaller
|
||||
# pyinstaller-hooks-contrib
|
||||
# pytest
|
||||
parso==0.8.5
|
||||
# via jedi
|
||||
platformdirs==4.4.0
|
||||
# via jupyter-core
|
||||
pluggy==1.5.0
|
||||
# via pytest
|
||||
polars==1.27.1
|
||||
# via carousel (pyproject.toml)
|
||||
# via
|
||||
# carousel (pyproject.toml)
|
||||
# marimo
|
||||
psutil==7.0.0
|
||||
# via marimo
|
||||
py-cpuinfo==9.0.0
|
||||
# via pytest-benchmark
|
||||
pyarrow==21.0.0
|
||||
# via polars
|
||||
pyclean==3.1.0
|
||||
# via carousel (pyproject.toml:dev)
|
||||
pydantic==2.11.7
|
||||
# via openai
|
||||
pydantic-core==2.33.2
|
||||
# via pydantic
|
||||
pygments==2.19.1
|
||||
# via rich
|
||||
# via
|
||||
# marimo
|
||||
# rich
|
||||
pyinstaller==6.13.0
|
||||
# via carousel (pyproject.toml:dev)
|
||||
pyinstaller-hooks-contrib==2025.3
|
||||
# via pyinstaller
|
||||
pymdown-extensions==10.16.1
|
||||
# via marimo
|
||||
pyright==1.1.399
|
||||
# via carousel (pyproject.toml:dev)
|
||||
pyside6==6.9.0
|
||||
|
|
@ -53,10 +148,24 @@ pytest==8.3.5
|
|||
# pytest-benchmark
|
||||
pytest-benchmark==5.1.0
|
||||
# via carousel (pyproject.toml:dev)
|
||||
pyyaml==6.0.2
|
||||
# via
|
||||
# marimo
|
||||
# pymdown-extensions
|
||||
referencing==0.36.2
|
||||
# via
|
||||
# jsonschema
|
||||
# jsonschema-specifications
|
||||
rich==14.0.0
|
||||
# via carousel (pyproject.toml)
|
||||
rpds-py==0.27.1
|
||||
# via
|
||||
# jsonschema
|
||||
# referencing
|
||||
ruff==0.11.6
|
||||
# via carousel (pyproject.toml:dev)
|
||||
# via
|
||||
# carousel (pyproject.toml:dev)
|
||||
# marimo
|
||||
setuptools==78.1.0
|
||||
# via
|
||||
# pyinstaller
|
||||
|
|
@ -66,7 +175,39 @@ shiboken6==6.9.0
|
|||
# pyside6
|
||||
# pyside6-addons
|
||||
# pyside6-essentials
|
||||
sniffio==1.3.1
|
||||
# via
|
||||
# anyio
|
||||
# openai
|
||||
sortedcontainers==2.4.0
|
||||
# via hypothesis
|
||||
sqlglot==27.12.0
|
||||
# via marimo
|
||||
starlette==0.47.3
|
||||
# via marimo
|
||||
tomlkit==0.13.3
|
||||
# via marimo
|
||||
tqdm==4.67.1
|
||||
# via openai
|
||||
traitlets==5.14.3
|
||||
# via
|
||||
# jupyter-core
|
||||
# nbformat
|
||||
ty==0.0.1a20
|
||||
# via carousel (pyproject.toml:dev)
|
||||
typing-extensions==4.13.2
|
||||
# via pyright
|
||||
# via
|
||||
# altair
|
||||
# openai
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# pyright
|
||||
# typing-inspection
|
||||
typing-inspection==0.4.1
|
||||
# via pydantic
|
||||
tzdata==2025.2
|
||||
# via faker
|
||||
uvicorn==0.35.0
|
||||
# via marimo
|
||||
websockets==15.0.1
|
||||
# via marimo
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import logging, rich
|
||||
from rich.logging import RichHandler
|
||||
|
||||
rich.traceback.install()
|
||||
|
||||
import itertools as it
|
||||
import numpy as np
|
||||
|
||||
import polars as pl
|
||||
import polars.selectors as pls
|
||||
|
||||
rich.traceback.install()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(message)s",
|
||||
|
|
@ -24,126 +24,7 @@ logging.basicConfig(
|
|||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def rank_to_pref(ranking):
|
||||
"""Converts a ranking to a preference."""
|
||||
id_col_name = ranking.select(pls.by_index(0)).to_series().name
|
||||
preferences = ranking.select(
|
||||
[
|
||||
pl.col(id_col_name).sort_by(c).alias(c)
|
||||
for c in ranking.columns
|
||||
if c != id_col_name
|
||||
]
|
||||
)
|
||||
return preferences
|
||||
|
||||
|
||||
def pref_to_rank(preferences):
|
||||
"""Converts a preference to a ranking."""
|
||||
o = preferences.select(
|
||||
pl.concat_list(preferences.columns).explode().unique().sort().alias("")
|
||||
) # .with_row_index(offset=1)
|
||||
|
||||
ranking = pl.concat(
|
||||
[
|
||||
o.join(
|
||||
preferences.with_row_index(offset=1),
|
||||
how="full",
|
||||
left_on="",
|
||||
right_on=c,
|
||||
maintain_order="left",
|
||||
).select(pl.col("index").alias(c))
|
||||
for c in preferences.columns
|
||||
],
|
||||
how="horizontal",
|
||||
)
|
||||
return pl.concat([o, ranking], how="horizontal")
|
||||
|
||||
|
||||
""""
|
||||
def ranking_matrix(A, B):
|
||||
T = pl.concat([A, B], how="horizontal")
|
||||
|
||||
TT = T.with_columns(pl.concat_list(A.columns[0], B.columns[0]))
|
||||
for ab in zip(A.columns[1:], B.columns[1:]):
|
||||
TT = TT.with_columns(pl.concat_list(*ab))
|
||||
TTT = TT.select(pl.col(A.columns))
|
||||
|
||||
return TTT.insert_column(0, pl.Series("names", B.columns))
|
||||
"""
|
||||
|
||||
|
||||
def check_valid_pref(preferences):
|
||||
"""A valid set of preferences has all unique entries in each column, TODO"""
|
||||
repeats = preferences.select(
|
||||
(~pl.all_horizontal((pl.all().is_unique() | pl.all().is_null()).all())).alias(
|
||||
"repeats"
|
||||
)
|
||||
).get_column("repeats")[0]
|
||||
return not repeats
|
||||
|
||||
|
||||
def check_valid_rank(ranking):
|
||||
"""A valid ranking has no ties, TODO"""
|
||||
ties = ranking.select(
|
||||
(~pl.all_horizontal((pl.all().is_unique() | pl.all().is_null()).all())).alias(
|
||||
"ties"
|
||||
)
|
||||
).get_column("ties")[0]
|
||||
return not ties
|
||||
|
||||
|
||||
def check_valid_match(match, applicants, reviewers):
|
||||
# TODO
|
||||
pass
|
||||
|
||||
|
||||
def check_valid_assgn(assgn, applicants, reviewers):
|
||||
# TODO
|
||||
pass
|
||||
|
||||
|
||||
def assgn_to_match(assgn):
|
||||
# TODO
|
||||
pass
|
||||
|
||||
|
||||
def match_to_assgn(match):
|
||||
# TODO
|
||||
pass
|
||||
|
||||
|
||||
def get_rank(ranking, ranker, ranked):
|
||||
idx = ranking.select(pl.arg_where(pl.col("") == ranked)).item()
|
||||
return ranking[ranker][idx]
|
||||
|
||||
|
||||
def check_unstable(match, applicant_ranking, reviewer_ranking):
|
||||
applicants = applicant_ranking.columns[1:] # assume unique applicants
|
||||
for a, b in it.permutations(applicants, 2):
|
||||
A = (
|
||||
match.select(c for c in match.iter_columns() if a in c).to_series().name
|
||||
) # the reviewer a is matched to
|
||||
B = (
|
||||
match.select(c for c in match.iter_columns() if b in c).to_series().name
|
||||
) # the reviewer b is matched to
|
||||
|
||||
b_prefers_A = get_rank(applicant_ranking, b, A) < get_rank(
|
||||
applicant_ranking, b, B
|
||||
)
|
||||
A_prefers_b = get_rank(reviewer_ranking, A, b) < get_rank(
|
||||
reviewer_ranking, A, a
|
||||
)
|
||||
if b_prefers_A and A_prefers_b:
|
||||
return True
|
||||
# else
|
||||
return False
|
||||
|
||||
|
||||
def check_stable(*args, **kwargs):
|
||||
return not check_unstable(*args, **kwargs)
|
||||
|
||||
|
||||
from .util import *
|
||||
from .brute import *
|
||||
from .def_acc import *
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
def brute(applicant_rankings, reviewer_rankings):
|
||||
"""Brute force search for stable matches."""
|
||||
def brute_match(applicant_rankings, reviewer_rankings):
|
||||
"""Brute force combinatoric search for stable matches."""
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -1,5 +1,126 @@
|
|||
def deferred_acceptance(applicant_rankings, reviewer_rankings):
|
||||
"""Find Gale-Shapley deferred-acceptance stable matchings for preferences A, R."""
|
||||
from .util import *
|
||||
|
||||
|
||||
def preparefor_def_acc(applicant_rankings, reviewer_rankings):
|
||||
"""Sanitize/Format applicant and reviewer rankings for the deferred acceptance solver."""
|
||||
pass
|
||||
# return app_ranks, rev_ranks
|
||||
|
||||
|
||||
def matchby_deferred_acceptance(applicant_rankings, reviewer_rankings, revfirst=False):
|
||||
"""Find the Gale-Shapley deferred-acceptance stable matching for rankings A, R. Default to A-first unless `revfirst=true`."""
|
||||
|
||||
app_prio = rank_to_prio(
|
||||
applicant_rankings, prioritizer="applicant", priority="reviewer"
|
||||
)
|
||||
rev_prio = rank_to_prio(
|
||||
reviewer_rankings, prioritizer="reviewer", priority="applicant"
|
||||
)
|
||||
|
||||
state = app_prio.select(
|
||||
[
|
||||
pl.col("applicant").unique().alias("applicant"),
|
||||
]
|
||||
).with_columns([pl.lit(0).alias("next_rank"), pl.lit(True).alias("is_free")])
|
||||
|
||||
matches = pl.DataFrame(
|
||||
{
|
||||
"applicant": pl.Series([]),
|
||||
"reviewer": pl.Series([]),
|
||||
"current": pl.Series([], dtype=pl.Int64),
|
||||
}
|
||||
)
|
||||
|
||||
max_iters = len(state) * len(rev_prio.select("applicant").unique())
|
||||
|
||||
for _ in range(max_iters):
|
||||
props = (
|
||||
state.filter(pl.col("is_free"))
|
||||
.join(app_prio, on="applicant")
|
||||
.filter(pl.col("rank") == pl.col("next_rank"))
|
||||
.select(["applicant", "reviewer"])
|
||||
)
|
||||
|
||||
if len(props) == 0:
|
||||
break
|
||||
|
||||
props = props.join(rev_prio, on=["reviewer", "applicant"], how="left")
|
||||
|
||||
props = props.join(
|
||||
matches.select(
|
||||
["reviewer", pl.col("applicant").alias("proposer"), pl.col("current")]
|
||||
),
|
||||
on="reviewer",
|
||||
how="left",
|
||||
)
|
||||
|
||||
props = props.with_columns(
|
||||
[
|
||||
(
|
||||
pl.col("proposer").is_null() | (pl.col("rank") < pl.col("current"))
|
||||
).alias("accepted")
|
||||
]
|
||||
)
|
||||
|
||||
accepted = proposals.filter(pl.col("accepted"))
|
||||
rejected = proposals.filter(~pl.col("accepted"))
|
||||
|
||||
displaced = accepted.filter(pl.col("proposer").is_not_null()).select(
|
||||
pl.col("proposer").alias("applicant")
|
||||
)
|
||||
|
||||
matches = matches.join(accepted.select("reviewer"), on="reviewer", how="anti")
|
||||
|
||||
matches = pl.concat(
|
||||
[
|
||||
matches,
|
||||
accepted.select(
|
||||
["reviewer", "applicant", pl.col("rank").alias("current")]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
rejected_apps = rejected.select("applicant")
|
||||
accepted_apps = accepted.select("applicant")
|
||||
|
||||
state = (
|
||||
state.join(
|
||||
rejected_apps.with_columns(pl.lit(True).alias("was_rejected")),
|
||||
on="applicant",
|
||||
how="left",
|
||||
)
|
||||
.join(
|
||||
accepted_apps.with_columns(pl.lit(True).alias("was_accepted")),
|
||||
on="applicant",
|
||||
how="left",
|
||||
)
|
||||
.join(
|
||||
displaced.with_columns(pl.lit(True).alias("was_displaced")),
|
||||
on="applicant",
|
||||
how="left",
|
||||
)
|
||||
.with_columns(
|
||||
[
|
||||
pl.when(pl.col("was_rejected").fill_null(False))
|
||||
.then(pl.col("next_rank") + 1)
|
||||
.otherwise(pl.col("next_rank"))
|
||||
.alias("next_rank"),
|
||||
pl.when(pl.col("was_accepted").fill_null(False))
|
||||
.then(False)
|
||||
.when(pl.col("was_displaced").full_null(False))
|
||||
.then(True)
|
||||
.otherwise(pl.col("is_free"))
|
||||
.alias("is_free"),
|
||||
]
|
||||
)
|
||||
.select(["applicant", "next_rank", "is_free"])
|
||||
)
|
||||
|
||||
return matches.select(["applicant", "reviewer"])
|
||||
|
||||
|
||||
def deferred_acceptance_match(applicant_rankings, reviewer_rankings):
|
||||
"""Find Gale-Shapley deferred-acceptance stable matching for rankings A, R."""
|
||||
reviewer_rankings = reviewer_rankings.rename(
|
||||
{reviewer_rankings.columns[0]: "applicant"}
|
||||
)
|
||||
|
|
|
|||
152
src/carousel/util.py
Normal file
152
src/carousel/util.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import itertools as it
|
||||
import numpy as np
|
||||
|
||||
import polars as pl
|
||||
import polars.selectors as pls
|
||||
|
||||
# Stability
|
||||
|
||||
|
||||
def get_rank(ranking, ranker, ranked):
|
||||
idx = ranking.select(pl.arg_where(pl.col("") == ranked)).item()
|
||||
return ranking[ranker][idx]
|
||||
|
||||
|
||||
def check_match_unstable(match, applicant_ranking, reviewer_ranking):
|
||||
applicants = applicant_ranking.columns[1:] # assume unique applicants
|
||||
for a, b in it.permutations(applicants, 2):
|
||||
A = (
|
||||
match.select(c for c in match.iter_columns() if a in c).to_series().name
|
||||
) # the reviewer a is matched to
|
||||
B = (
|
||||
match.select(c for c in match.iter_columns() if b in c).to_series().name
|
||||
) # the reviewer b is matched to
|
||||
|
||||
b_prefers_A = get_rank(applicant_ranking, b, A) < get_rank(
|
||||
applicant_ranking, b, B
|
||||
)
|
||||
A_prefers_b = get_rank(reviewer_ranking, A, b) < get_rank(
|
||||
reviewer_ranking, A, a
|
||||
)
|
||||
if b_prefers_A and A_prefers_b:
|
||||
return True
|
||||
# else
|
||||
return False
|
||||
|
||||
|
||||
def check_match_stable(*args, **kwargs):
|
||||
return not check_match_unstable(*args, **kwargs)
|
||||
|
||||
|
||||
# Conversions
|
||||
|
||||
|
||||
def rank_to_pref(ranking):
|
||||
"""Converts a ranking to a preference."""
|
||||
id_col_name = ranking.select(pls.by_index(0)).to_series().name
|
||||
preferences = ranking.select(
|
||||
[
|
||||
pl.col(id_col_name).sort_by(c).alias(c)
|
||||
for c in ranking.columns
|
||||
if c != id_col_name
|
||||
]
|
||||
)
|
||||
return preferences
|
||||
|
||||
|
||||
def pref_to_rank(preferences):
|
||||
"""Converts a preference to a ranking."""
|
||||
o = preferences.select(
|
||||
pl.concat_list(preferences.columns).explode().unique().sort().alias("")
|
||||
) # .with_row_index(offset=1)
|
||||
|
||||
ranking = pl.concat(
|
||||
[
|
||||
o.join(
|
||||
preferences.with_row_index(offset=1),
|
||||
how="full",
|
||||
left_on="",
|
||||
right_on=c,
|
||||
maintain_order="left",
|
||||
).select(pl.col("index").alias(c))
|
||||
for c in preferences.columns
|
||||
],
|
||||
how="horizontal",
|
||||
)
|
||||
return pl.concat([o, ranking], how="horizontal")
|
||||
|
||||
|
||||
def ranks_to_mat(rankA, rankB):
|
||||
return prefs_to_mat(rank_to_pref(rankA), rank_to_pref(rankB))
|
||||
|
||||
|
||||
def prefs_to_mat(prefA, prefB):
|
||||
return rank_to_mat(pref_to_rank(preferences), pref_to_rank(preferences[0]))
|
||||
|
||||
|
||||
def mat_to_ranks(matrix):
|
||||
pass # TODO
|
||||
|
||||
|
||||
def mat_to_prefs(matrix):
|
||||
rankA, rankB = mat_to_ranks(matrix)
|
||||
return rank_to_pref(rankA), rank_to_preft(rankB)
|
||||
|
||||
|
||||
def pref_to_prio(pref, prioritizer="prioritizer", priority="subject"):
|
||||
return pref.with_row_index("rank").unpivot(
|
||||
index="rank", variable_name=prioritizer, value_name=priority
|
||||
)
|
||||
|
||||
|
||||
def rank_to_prio(rank, **kwargs):
|
||||
return pref_to_prio(rank_to_pref(rank), **kwargs)
|
||||
|
||||
|
||||
""""
|
||||
def ranking_matrix(A, B):
|
||||
T = pl.concat([A, B], how="horizontal")
|
||||
|
||||
TT = T.with_columns(pl.concat_list(A.columns[0], B.columns[0]))
|
||||
for ab in zip(A.columns[1:], B.columns[1:]):
|
||||
TT = TT.with_columns(pl.concat_list(*ab))
|
||||
TTT = TT.select(pl.col(A.columns))
|
||||
|
||||
return TTT.insert_column(0, pl.Series("names", B.columns))
|
||||
"""
|
||||
|
||||
## Output
|
||||
|
||||
|
||||
def match_to_assignment(matching):
|
||||
pass
|
||||
|
||||
|
||||
def assgn_to_match(assignment):
|
||||
pass
|
||||
|
||||
|
||||
## Internal
|
||||
|
||||
|
||||
# Validity
|
||||
|
||||
|
||||
def check_pref_allunique(preferences):
|
||||
"""A valid set of preferences has all unique entries in each column."""
|
||||
repeats = preferences.select(
|
||||
(~pl.all_horizontal((pl.all().is_unique() | pl.all().is_null()).all())).alias(
|
||||
"repeats"
|
||||
)
|
||||
).get_column("repeats")[0]
|
||||
return not repeats
|
||||
|
||||
|
||||
def check_rank_noties(ranking):
|
||||
"""A valid ranking has no ties."""
|
||||
ties = ranking.select(
|
||||
(~pl.all_horizontal((pl.all().is_unique() | pl.all().is_null()).all())).alias(
|
||||
"ties"
|
||||
)
|
||||
).get_column("ties")[0]
|
||||
return not ties
|
||||
|
|
@ -40,7 +40,7 @@ def test_invalid_pref():
|
|||
pp = pl.DataFrame(
|
||||
{"a": ["A", "A", "B"], "b": ["B", "A", "C"], "c": ["C", "B", "A"]}
|
||||
)
|
||||
assert crsl.check_valid_pref(pp) is False
|
||||
assert crsl.check_pref_allunique(pp) is False
|
||||
|
||||
|
||||
def test_pref_to_rank():
|
||||
|
|
@ -51,7 +51,7 @@ def test_invalid_rank():
|
|||
rr = pl.DataFrame(
|
||||
{"": ["A", "B", "C"], "a": [1, 1, 2], "b": [2, 1, 3], "c": [3, 2, 1]}
|
||||
)
|
||||
assert crsl.check_valid_pref(rr) is False
|
||||
assert crsl.check_pref_allunique(rr) is False
|
||||
|
||||
|
||||
def test_rank_to_pref():
|
||||
|
|
@ -60,7 +60,7 @@ def test_rank_to_pref():
|
|||
|
||||
@given(rankings())
|
||||
def test_valid_rank(R):
|
||||
assert crsl.check_valid_rank(R)
|
||||
assert crsl.check_rank_noties(R)
|
||||
|
||||
|
||||
@given(rankings())
|
||||
|
|
@ -70,7 +70,7 @@ def test_ranks_tofrom_prefs(R):
|
|||
|
||||
@given(preferences())
|
||||
def test_valid_pref(P):
|
||||
assert crsl.check_valid_pref(P)
|
||||
assert crsl.check_pref_allunique(P)
|
||||
|
||||
|
||||
@given(preferences())
|
||||
|
|
@ -99,7 +99,7 @@ def test_eg2_unstable():
|
|||
)
|
||||
match = pl.DataFrame({"A": ["a"], "B": ["b"], "C": ["c"], "D": ["d"]})
|
||||
|
||||
assert crsl.check_unstable(match, ar, rr)
|
||||
assert crsl.check_match_unstable(match, ar, rr)
|
||||
|
||||
|
||||
def test_eg2_isstable():
|
||||
|
|
@ -123,7 +123,7 @@ def test_eg2_isstable():
|
|||
)
|
||||
match = pl.DataFrame({"A": ["c"], "B": ["d"], "C": ["a"], "D": ["b"]})
|
||||
|
||||
assert crsl.check_stable(match, ar, rr)
|
||||
assert crsl.check_match_stable(match, ar, rr)
|
||||
|
||||
|
||||
@given(
|
||||
|
|
@ -131,5 +131,5 @@ def test_eg2_isstable():
|
|||
rankings(names=["A", "B", "C", "D"], choices=["a", "b", "c", "d"]),
|
||||
)
|
||||
def test_defacc_isstable(applicant_rankings, reviewer_rankings):
|
||||
match = crsl.deferred_acceptance(applicant_rankings, reviewer_rankings)
|
||||
assert crsl.check_stable(match, applicant_rankings, reviewer_rankings)
|
||||
match = crsl.matchby_deferred_acceptance(applicant_rankings, reviewer_rankings)
|
||||
assert crsl.check_match_stable(match, applicant_rankings, reviewer_rankings)
|
||||
|
|
|
|||
124
test/nbs/__marimo__/session/hovses.py.json
Normal file
124
test/nbs/__marimo__/session/hovses.py.json
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
{
|
||||
"version": "1",
|
||||
"metadata": {
|
||||
"marimo_version": "0.18.3"
|
||||
},
|
||||
"cells": [
|
||||
{
|
||||
"id": "Hbol",
|
||||
"code_hash": "bc65c35c6fad59890b50c502bb8affa4",
|
||||
"outputs": [
|
||||
{
|
||||
"type": "data",
|
||||
"data": {
|
||||
"text/markdown": "<span class=\"markdown prose dark:prose-invert contents\"><h1 id=\"caltech-hovse-rotation-example\">Caltech Hovse Rotation Example</h1></span>"
|
||||
}
|
||||
}
|
||||
],
|
||||
"console": []
|
||||
},
|
||||
{
|
||||
"id": "MJUe",
|
||||
"code_hash": "ccb62cc29f2cec640b063832a90adfec",
|
||||
"outputs": [
|
||||
{
|
||||
"type": "data",
|
||||
"data": {
|
||||
"text/markdown": "<span class=\"markdown prose dark:prose-invert contents\"><h2 id=\"imports\">Imports</h2></span>"
|
||||
}
|
||||
}
|
||||
],
|
||||
"console": []
|
||||
},
|
||||
{
|
||||
"id": "vblA",
|
||||
"code_hash": "9588e2b4004a32c73b7ecc0a968b666f",
|
||||
"outputs": [
|
||||
{
|
||||
"type": "data",
|
||||
"data": {
|
||||
"text/plain": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"console": []
|
||||
},
|
||||
{
|
||||
"id": "bkHC",
|
||||
"code_hash": "4821038400db1f5dc63daf7ca5b26279",
|
||||
"outputs": [
|
||||
{
|
||||
"type": "data",
|
||||
"data": {
|
||||
"text/markdown": "<span class=\"markdown prose dark:prose-invert contents\"><h2 id=\"generating-rotation-rankings\">Generating Rotation Rankings</h2></span>"
|
||||
}
|
||||
}
|
||||
],
|
||||
"console": []
|
||||
},
|
||||
{
|
||||
"id": "lEQa",
|
||||
"code_hash": "ecc9ae14c8c8f3875656fc620665c6b4",
|
||||
"outputs": [
|
||||
{
|
||||
"type": "data",
|
||||
"data": {
|
||||
"text/html": "<pre class='text-xs'>True</pre>"
|
||||
}
|
||||
}
|
||||
],
|
||||
"console": []
|
||||
},
|
||||
{
|
||||
"id": "NdeS",
|
||||
"code_hash": "c582dec943ff7b743aa0691df291cea6",
|
||||
"outputs": [
|
||||
{
|
||||
"type": "data",
|
||||
"data": {
|
||||
"text/html": "<marimo-ui-element object-id='NdeS-0' random-id='7040b6c9-c598-beaa-38c6-9f1afe159be0'><marimo-table data-initial-value='[]' data-label='null' data-data='"[{\"\":\"A\",\"a\":1,\"b\":1,\"c\":2,\"d\":4},{\"\":\"B\",\"a\":2,\"b\":4,\"c\":1,\"d\":2},{\"\":\"C\",\"a\":3,\"b\":3,\"c\":3,\"d\":3},{\"\":\"D\",\"a\":4,\"b\":2,\"c\":4,\"d\":1}]"' data-total-rows='4' data-total-columns='5' data-max-columns='50' data-banner-text='""' data-pagination='true' data-page-size='10' data-field-types='[["",["string","str"]],["a",["integer","i64"]],["b",["integer","i64"]],["c",["integer","i64"]],["d",["integer","i64"]]]' data-show-filters='true' data-show-download='true' data-show-column-summaries='false' data-show-data-types='true' data-show-page-size-selector='false' data-show-column-explorer='true' data-show-chart-builder='true' data-row-headers='[]' data-has-stable-row-id='false' data-lazy='false' data-preload='false'></marimo-table></marimo-ui-element>"
|
||||
}
|
||||
}
|
||||
],
|
||||
"console": []
|
||||
},
|
||||
{
|
||||
"id": "PKri",
|
||||
"code_hash": "b9f0f3a28a20d94d4b173bbbd49db37f",
|
||||
"outputs": [],
|
||||
"console": []
|
||||
},
|
||||
{
|
||||
"id": "Xref",
|
||||
"code_hash": "ccc8305d08e563d4fb1c9df31e9b7b69",
|
||||
"outputs": [
|
||||
{
|
||||
"type": "data",
|
||||
"data": {
|
||||
"text/html": "<marimo-ui-element object-id='Xref-0' random-id='b6beae57-9d1c-9561-11cf-94cfd5b3aa06'><marimo-table data-initial-value='[]' data-label='null' data-data='"[{\"a\":\"A\",\"b\":\"A\",\"c\":\"B\",\"d\":\"D\"},{\"a\":\"B\",\"b\":\"D\",\"c\":\"A\",\"d\":\"B\"},{\"a\":\"C\",\"b\":\"C\",\"c\":\"C\",\"d\":\"C\"},{\"a\":\"D\",\"b\":\"B\",\"c\":\"D\",\"d\":\"A\"}]"' data-total-rows='4' data-total-columns='4' data-max-columns='50' data-banner-text='""' data-pagination='true' data-page-size='10' data-field-types='[["a",["string","str"]],["b",["string","str"]],["c",["string","str"]],["d",["string","str"]]]' data-show-filters='true' data-show-download='true' data-show-column-summaries='false' data-show-data-types='true' data-show-page-size-selector='false' data-show-column-explorer='true' data-show-chart-builder='true' data-row-headers='[]' data-has-stable-row-id='false' data-lazy='false' data-preload='false'></marimo-table></marimo-ui-element>"
|
||||
}
|
||||
}
|
||||
],
|
||||
"console": []
|
||||
},
|
||||
{
|
||||
"id": "taaO",
|
||||
"code_hash": "b712cc6f3d50763308dd5bc1c6703f77",
|
||||
"outputs": [
|
||||
{
|
||||
"type": "data",
|
||||
"data": {
|
||||
"text/html": "<marimo-ui-element object-id='taaO-0' random-id='1a8ede2c-9d90-fbec-5f1c-6258f3189f72'><marimo-table data-initial-value='[]' data-label='null' data-data='"[{\"rank\":0,\"prioritizer\":\"A\",\"subject\":\"d\"},{\"rank\":1,\"prioritizer\":\"A\",\"subject\":\"c\"},{\"rank\":2,\"prioritizer\":\"A\",\"subject\":\"a\"},{\"rank\":3,\"prioritizer\":\"A\",\"subject\":\"b\"},{\"rank\":0,\"prioritizer\":\"B\",\"subject\":\"b\"},{\"rank\":1,\"prioritizer\":\"B\",\"subject\":\"d\"},{\"rank\":2,\"prioritizer\":\"B\",\"subject\":\"a\"},{\"rank\":3,\"prioritizer\":\"B\",\"subject\":\"c\"},{\"rank\":0,\"prioritizer\":\"C\",\"subject\":\"d\"},{\"rank\":1,\"prioritizer\":\"C\",\"subject\":\"a\"}]"' data-total-rows='16' data-total-columns='3' data-max-columns='50' data-banner-text='""' data-pagination='true' data-page-size='10' data-field-types='[["rank",["integer","u32"]],["prioritizer",["string","str"]],["subject",["string","str"]]]' data-show-filters='true' data-show-download='true' data-show-column-summaries='true' data-show-data-types='true' data-show-page-size-selector='true' data-show-column-explorer='true' data-show-chart-builder='true' data-row-headers='[]' data-has-stable-row-id='false' data-lazy='false' data-preload='false'></marimo-table></marimo-ui-element>"
|
||||
}
|
||||
}
|
||||
],
|
||||
"console": []
|
||||
},
|
||||
{
|
||||
"id": "wJzy",
|
||||
"code_hash": null,
|
||||
"outputs": [],
|
||||
"console": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,18 +1,22 @@
|
|||
import marimo
|
||||
|
||||
__generated_with = "0.13.6"
|
||||
__generated_with = "0.18.3"
|
||||
app = marimo.App(width="medium")
|
||||
|
||||
|
||||
@app.cell(hide_code=True)
|
||||
def _(mo):
|
||||
mo.md(r"""# Caltech Hovse Rotation Example""")
|
||||
mo.md(r"""
|
||||
# Caltech Hovse Rotation Example
|
||||
""")
|
||||
return
|
||||
|
||||
|
||||
@app.cell(hide_code=True)
|
||||
def _(mo):
|
||||
mo.md(r"""## Imports""")
|
||||
mo.md(r"""
|
||||
## Imports
|
||||
""")
|
||||
return
|
||||
|
||||
|
||||
|
|
@ -22,27 +26,73 @@ def _():
|
|||
import polars as pl, polars.selectors as pls
|
||||
import numpy as np, faker as fk
|
||||
import carousel as crsl
|
||||
|
||||
return crsl, mo, pl
|
||||
|
||||
|
||||
@app.cell(hide_code=True)
|
||||
def _(mo):
|
||||
mo.md(r"""## Generating Rotation Rankings""")
|
||||
mo.md(r"""
|
||||
## Generating Rotation Rankings
|
||||
""")
|
||||
return
|
||||
|
||||
|
||||
@app.cell
|
||||
def _(crsl, pl):
|
||||
ar = pl.DataFrame({ "": ["A", "B", "C", "D"], "a": [1, 2, 3, 4], "b": [1, 4, 3, 2], "c": [2, 1, 3, 4], "d": [4, 2, 3, 1]})
|
||||
rr = pl.DataFrame({ "": ["a", "b", "c", "d"], "A": [3, 4, 2, 1], "B": [3, 1, 4, 2], "C": [2, 3, 4, 1], "D": [3, 2, 1, 4]})
|
||||
ar = pl.DataFrame(
|
||||
{
|
||||
"": ["A", "B", "C", "D"],
|
||||
"a": [1, 2, 3, 4],
|
||||
"b": [1, 4, 3, 2],
|
||||
"c": [2, 1, 3, 4],
|
||||
"d": [4, 2, 3, 1],
|
||||
}
|
||||
)
|
||||
rr = pl.DataFrame(
|
||||
{
|
||||
"": ["a", "b", "c", "d"],
|
||||
"A": [3, 4, 2, 1],
|
||||
"B": [3, 1, 4, 2],
|
||||
"C": [2, 3, 4, 1],
|
||||
"D": [3, 2, 1, 4],
|
||||
}
|
||||
)
|
||||
m = pl.DataFrame({"A": ["c"], "B": ["d"], "C": ["a"], "D": ["b"]})
|
||||
crsl.check_stable(m, ar, rr)
|
||||
crsl.check_match_stable(m, ar, rr)
|
||||
return ar, rr
|
||||
|
||||
|
||||
@app.cell
|
||||
def _(ar):
|
||||
ar
|
||||
return
|
||||
|
||||
|
||||
@app.cell
|
||||
def _():
|
||||
hovses = ["Blacker", "Dabney", "Ricketts", "Fleming", "Page", "Lloyd", "Venerable", "Avery"]
|
||||
hovses = [
|
||||
"Blacker",
|
||||
"Dabney",
|
||||
"Ricketts",
|
||||
"Fleming",
|
||||
"Page",
|
||||
"Lloyd",
|
||||
"Venerable",
|
||||
"Avery",
|
||||
]
|
||||
return
|
||||
|
||||
|
||||
@app.cell
|
||||
def _(ar, crsl):
|
||||
crsl.rank_to_pref(ar)
|
||||
return
|
||||
|
||||
|
||||
@app.cell
|
||||
def _(crsl, rr):
|
||||
crsl.pref_to_prio(crsl.rank_to_pref(rr))
|
||||
return
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue