Skip to content

Commit 059b0f4

Browse files
committed
Refactor for clarity, add comments.
We were confused by the algorithm, and this seemed generally undesirable, so we have attempted a refactor for greater clarity. We've also used the more standard RNG instance pattern for the random number generation. The tests are failing, but this seems also to be true of `main`.
1 parent 277a2d4 commit 059b0f4

File tree

4 files changed

+86
-48
lines changed

4 files changed

+86
-48
lines changed

requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
numpy
2+
requests
3+
pyaml

scripts/project_selection.py

Lines changed: 47 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import numpy as np
22

33

4+
class Proposal:
5+
6+
def __init__(self, name, requested_amount, previous_funding):
7+
self.name = str(name)
8+
self.requested_amount = abs(requested_amount)
9+
self.previous_funding = previous_funding
10+
11+
412
def select_proposals_to_fund(budget, funding_limit, proposals, seed=None):
513
"""
614
Randomly selects which proposals to fund in a round.
@@ -11,67 +19,76 @@ def select_proposals_to_fund(budget, funding_limit, proposals, seed=None):
1119
1220
`proposals` is a list of tuples of the form `(name, requested_amount, previous_funding)`
1321
where `name` is the name of the proposal, `requested_amount` is the amount of funding
14-
that proposal requests and `previos_funding` is the amount of funding the project
15-
associated with that proposal has received that year.
22+
that proposal requests and `previous_funding` is the amount of funding the
23+
project associated with that proposal has received that year.
1624
1725
This function will throw and not produce any results if any of its inputs are invalid.
1826
"""
1927

20-
np.random.seed(seed)
28+
# `seed` can be a seed (integer) or a random number generator.
29+
rng = np.random.default_rng(seed)
2130

22-
for p in proposals:
23-
if len(p) != 3:
24-
raise ValueError("Malformed proposal")
25-
if p[1] + p[2] > funding_limit:
26-
raise ValueError(
27-
f'If proposal "{p[0]}" were funded it would receive more than the funding limit this year.'
28-
)
31+
proposals = [Proposal(*p) for p in proposals]
32+
n_proposals = len(proposals)
2933

30-
names = [str(p[0]) for p in proposals]
31-
weights = [(funding_limit - p[2]) / p[1] for p in proposals]
32-
33-
for w in weights:
34-
assert w >= 1 # this should be redundant with the validation check above.
35-
36-
if len(set(names)) != len(names):
34+
if len(set(p.name for p in proposals)) != n_proposals:
3735
raise ValueError("Proposal names are not unique")
3836

37+
weights = np.zeros(n_proposals)
38+
for i, p in enumerate(proposals):
39+
remaining_limit = funding_limit - p.previous_funding
40+
if p.requested_amount > remaining_limit:
41+
raise ValueError(
42+
f'If proposal "{p.name}" were funded it would receive more '
43+
'than the per-project funding limit this year.'
44+
)
45+
# Decrease weight for projects that have already had previous funding.
46+
weights[i] = remaining_limit / p.requested_amount
47+
assert weights[i] >= 1 # this should be redundant with the validation check above.
48+
3949
funded = []
4050
budget_remaining = budget
41-
temp_budget_remaining = budget + 0
42-
while budget_remaining > 0 and len(funded) < len(proposals):
43-
total_weight = sum(weights)
51+
while budget_remaining > 0 and len(funded) < n_proposals:
52+
total_weight = np.sum(weights)
4453
if total_weight == 0:
4554
# When all the proposals have been evaluated and there's still budget
4655
break
47-
i = np.random.choice(range(len(weights)), p=[w / total_weight for w in weights])
56+
# Select one project using weights.
57+
i = rng.choice(n_proposals, p=weights / total_weight)
58+
# Implement selection (but dependent on deficit check below).
4859
weights[i] = 0
4960
proposal = proposals[i]
50-
proposal_budget = proposal[1]
51-
temp_budget_remaining -= proposal_budget
52-
if temp_budget_remaining > -proposal_budget / 2:
53-
funded.append(proposal)
54-
budget_remaining = temp_budget_remaining
61+
budget_remaining -= proposal.requested_amount
62+
deficit = -budget_remaining if budget_remaining < 0 else 0
63+
if deficit > proposal.requested_amount / 2:
64+
# Deficit is too great, add back project budget, and find
65+
# another project to try.
66+
budget_remaining += proposal.requested_amount
67+
continue
68+
# Confirm selection.
69+
funded.append(proposal)
5570

5671
print("Inputs:")
5772
print(f"Budget: ${budget}")
5873
print(f"Per-project funding limit: ${funding_limit}")
5974
print("Proposals in the drawing:")
6075
for p in proposals:
6176
print(
62-
f'"{p[0]}" requests ${p[1]} and is proposed by a project that has previously received ${p[2]} this year.'
77+
f'"{p.name}" requests ${p.requested_amount} and is proposed by a '
78+
f'project that has previously received ${p.previous_funding} this year.'
6379
)
6480

6581
print()
6682
print("Random Outputs:")
6783
print(
6884
f"Allocated: ${round(budget - budget_remaining, 2)} (${round(abs(budget_remaining), 2)} {'over' if budget_remaining < 0 else 'under'} budget)"
6985
)
70-
print(f"{len(funded)} proposals funded out of {len(proposals)} total proposals in the drawing")
86+
print(f"{len(funded)} proposals funded out of {n_proposals} total proposals in the drawing")
7187
print()
7288
print("Funded the following projects")
7389

7490
for p in funded:
75-
print(f'Fund "{p[0]}" for ${p[1]} bringing its project\'s annual total to ${p[1] + p[2]}.')
91+
print(f'Fund "{p.name}" for ${p.requested_amount} bringing its '
92+
f'project\'s annual total to ${p.requested_amount + p.previous_funding}.')
7693

77-
return [f[0] for f in funded]
94+
return [f.name for f in funded]

scripts/test_project_selection.py

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,37 @@
11
import pytest
2-
import numpy as np
32

43
from project_selection import select_proposals_to_fund
54

65

76
@pytest.mark.parametrize(
87
"proposals, errmessage",
98
[
10-
([("A", 2, 0, 3)], r"Malformed proposal"),
119
(
1210
[("A", 3, 1)],
13-
r'If proposal "A" were funded it would receive more than the funding limit this year.',
11+
r'If proposal "A" were funded it would receive more than '
12+
'the per-project funding limit this year.',
1413
),
1514
(
1615
[("A", 2, 1)],
17-
r'If proposal "A" were funded it would receive more than the funding limit this year.',
16+
r'If proposal "A" were funded it would receive more than '
17+
'the per-project funding limit this year.',
1818
),
1919
([("A", 0.5, 1), ("A", 0.5, 1)], r"Proposal names are not unique"),
2020
],
2121
)
2222
def test_select_proposals_wrong_input(proposals, errmessage):
23-
np.random.seed(2025)
2423
budget = 5
2524
funding_limit = 2
2625
with pytest.raises(ValueError, match=errmessage):
27-
select_proposals_to_fund(budget, funding_limit, proposals)
26+
select_proposals_to_fund(budget, funding_limit, proposals, seed=2025)
27+
28+
29+
def test_malformed():
30+
with pytest.raises(TypeError):
31+
select_proposals_to_fund(5, 2, [("A", 2, 0, 3)])
2832

2933

3034
def test_select_proposals_all_funds(capfd):
31-
np.random.seed(2025)
3235
budget = 5
3336
funding_limit = 2
3437
proposals = [("A", 2, 0), ("B", 1, 0), ("C", 1, 0), ("D", 0.5, 0), ("E", 0.5, 0)]
@@ -55,15 +58,17 @@ def test_select_proposals_all_funds(capfd):
5558
'Fund "C" for $1 bringing its project\'s annual total to $1.\n'
5659
'Fund "A" for $2 bringing its project\'s annual total to $2.\n'
5760
)
58-
result = select_proposals_to_fund(budget, funding_limit, proposals)
61+
result = select_proposals_to_fund(budget,
62+
funding_limit,
63+
proposals,
64+
seed=2025)
5965
captured = capfd.readouterr()
6066

6167
assert set(result) == expected_result
6268
assert captured.out == expected_captured
6369

6470

6571
def test_select_proposals_more_than_funds(capfd):
66-
np.random.seed(2025)
6772
budget = 5
6873
funding_limit = 2
6974
proposals = [
@@ -103,15 +108,17 @@ def test_select_proposals_more_than_funds(capfd):
103108
'Fund "B" for $1 bringing its project\'s annual total to $1.\n'
104109
'Fund "A" for $2 bringing its project\'s annual total to $2.\n'
105110
)
106-
result = select_proposals_to_fund(budget, funding_limit, proposals)
111+
result = select_proposals_to_fund(budget,
112+
funding_limit,
113+
proposals,
114+
seed=2025)
107115
captured = capfd.readouterr()
108116

109117
assert set(result) == expected_result
110118
assert captured.out == expected_captured
111119

112120

113121
def test_select_proposals_more_than_funds_under(capfd):
114-
np.random.seed(2025)
115122
budget = 4.1
116123
funding_limit = 2
117124
proposals = [
@@ -150,15 +157,17 @@ def test_select_proposals_more_than_funds_under(capfd):
150157
'Fund "D" for $0.5 bringing its project\'s annual total to $0.5.\n'
151158
'Fund "B" for $1 bringing its project\'s annual total to $1.\n'
152159
)
153-
result = select_proposals_to_fund(budget, funding_limit, proposals)
160+
result = select_proposals_to_fund(budget,
161+
funding_limit,
162+
proposals,
163+
seed=2025)
154164
captured = capfd.readouterr()
155165

156166
assert set(result) == expected_result
157167
assert captured.out == expected_captured
158168

159169

160170
def test_select_proposals_more_than_funds_eqweight_zero(capfd):
161-
np.random.seed(2025)
162171
budget = 6
163172
funding_limit = 2
164173
proposals = [
@@ -198,15 +207,17 @@ def test_select_proposals_more_than_funds_eqweight_zero(capfd):
198207
'Fund "C" for $1 bringing its project\'s annual total to $1.\n'
199208
'Fund "A" for $1 bringing its project\'s annual total to $1.\n'
200209
)
201-
result = select_proposals_to_fund(budget, funding_limit, proposals)
210+
result = select_proposals_to_fund(budget,
211+
funding_limit,
212+
proposals,
213+
seed=2025)
202214
captured = capfd.readouterr()
203215

204216
assert set(result) == expected_result
205217
assert captured.out == expected_captured
206218

207219

208220
def test_select_proposals_more_than_funds_eqweight_under(capfd):
209-
np.random.seed(2025)
210221
budget = 5.4
211222
funding_limit = 2
212223
proposals = [
@@ -245,15 +256,17 @@ def test_select_proposals_more_than_funds_eqweight_under(capfd):
245256
'Fund "D" for $1 bringing its project\'s annual total to $1.\n'
246257
'Fund "C" for $1 bringing its project\'s annual total to $1.\n'
247258
)
248-
result = select_proposals_to_fund(budget, funding_limit, proposals)
259+
result = select_proposals_to_fund(budget,
260+
funding_limit,
261+
proposals,
262+
seed=2025)
249263
captured = capfd.readouterr()
250264

251265
assert set(result) == expected_result
252266
assert captured.out == expected_captured
253267

254268

255269
def test_select_proposals_more_than_funds_eqweight_over(capfd):
256-
np.random.seed(2025)
257270
budget = 6.6
258271
funding_limit = 2
259272
proposals = [
@@ -294,7 +307,10 @@ def test_select_proposals_more_than_funds_eqweight_over(capfd):
294307
'Fund "A" for $1 bringing its project\'s annual total to $1.\n'
295308
'Fund "F" for $1 bringing its project\'s annual total to $1.\n'
296309
)
297-
result = select_proposals_to_fund(budget, funding_limit, proposals)
310+
result = select_proposals_to_fund(budget,
311+
funding_limit,
312+
proposals,
313+
seed=2025)
298314
captured = capfd.readouterr()
299315

300316
assert set(result) == expected_result

test-requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-r requirements.txt
2+
pytest

0 commit comments

Comments
 (0)