Skip to content

Commit dc0581c

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 dc0581c

File tree

4 files changed

+60
-38
lines changed

4 files changed

+60
-38
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: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,47 +11,54 @@ def select_proposals_to_fund(budget, funding_limit, proposals, seed=None):
1111
1212
`proposals` is a list of tuples of the form `(name, requested_amount, previous_funding)`
1313
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.
14+
that proposal requests and `previous_funding` is the amount of funding the
15+
project associated with that proposal has received that year.
1616
1717
This function will throw and not produce any results if any of its inputs are invalid.
1818
"""
1919

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

22-
for p in proposals:
23-
if len(p) != 3:
24-
raise ValueError("Malformed proposal")
25-
if p[1] + p[2] > funding_limit:
23+
names = [str(p[0]) for p in proposals]
24+
n_proposals = len(proposals)
25+
weights = np.zeros(n_proposals)
26+
for i, (p_name, p_fund_ask, p_fund_previous) in enumerate(proposals):
27+
remaining_limit = funding_limit - p_fund_previous
28+
if p_fund_ask > remaining_limit:
2629
raise ValueError(
27-
f'If proposal "{p[0]}" were funded it would receive more than the funding limit this year.'
30+
f'If proposal "{p_name}" were funded it would receive more '
31+
'than the per-project funding limit this year.'
2832
)
29-
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.
33+
# Decrease weight for projects that have already had previous funding.
34+
weights[i] = remaining_limit / p_fund_ask
35+
assert weights[i] >= 1 # this should be redundant with the validation check above.
3536

3637
if len(set(names)) != len(names):
3738
raise ValueError("Proposal names are not unique")
3839

3940
funded = []
4041
budget_remaining = budget
41-
temp_budget_remaining = budget + 0
42-
while budget_remaining > 0 and len(funded) < len(proposals):
43-
total_weight = sum(weights)
42+
while budget_remaining > 0 and len(funded) < n_proposals:
43+
total_weight = np.sum(weights)
4444
if total_weight == 0:
4545
# When all the proposals have been evaluated and there's still budget
4646
break
47-
i = np.random.choice(range(len(weights)), p=[w / total_weight for w in weights])
47+
# Select one project using weights.
48+
i = rng.choice(n_proposals, p=weights / total_weight)
49+
# Implement selection (but dependent on deficit check below).
4850
weights[i] = 0
4951
proposal = proposals[i]
5052
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
53+
remainder_or_deficit = budget_remaining - proposal_budget
54+
if remainder_or_deficit < 0: # We've gone into deficit.
55+
if abs(remainder_or_deficit) > proposal_budget / 2:
56+
# Deficit is too great, discard project, search for another
57+
# causing less deficit.
58+
continue
59+
# Confirm selection.
60+
funded.append(proposal)
61+
budget_remaining = budget_remaining - proposal_budget
5562

5663
print("Inputs:")
5764
print(f"Budget: ${budget}")
@@ -67,7 +74,7 @@ def select_proposals_to_fund(budget, funding_limit, proposals, seed=None):
6774
print(
6875
f"Allocated: ${round(budget - budget_remaining, 2)} (${round(abs(budget_remaining), 2)} {'over' if budget_remaining < 0 else 'under'} budget)"
6976
)
70-
print(f"{len(funded)} proposals funded out of {len(proposals)} total proposals in the drawing")
77+
print(f"{len(funded)} proposals funded out of {n_proposals} total proposals in the drawing")
7178
print()
7279
print("Funded the following projects")
7380

scripts/test_project_selection.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import pytest
2-
import numpy as np
32

43
from project_selection import select_proposals_to_fund
54

@@ -20,15 +19,13 @@
2019
],
2120
)
2221
def test_select_proposals_wrong_input(proposals, errmessage):
23-
np.random.seed(2025)
2422
budget = 5
2523
funding_limit = 2
2624
with pytest.raises(ValueError, match=errmessage):
27-
select_proposals_to_fund(budget, funding_limit, proposals)
25+
select_proposals_to_fund(budget, funding_limit, proposals, seed=2025)
2826

2927

3028
def test_select_proposals_all_funds(capfd):
31-
np.random.seed(2025)
3229
budget = 5
3330
funding_limit = 2
3431
proposals = [("A", 2, 0), ("B", 1, 0), ("C", 1, 0), ("D", 0.5, 0), ("E", 0.5, 0)]
@@ -55,15 +52,17 @@ def test_select_proposals_all_funds(capfd):
5552
'Fund "C" for $1 bringing its project\'s annual total to $1.\n'
5653
'Fund "A" for $2 bringing its project\'s annual total to $2.\n'
5754
)
58-
result = select_proposals_to_fund(budget, funding_limit, proposals)
55+
result = select_proposals_to_fund(budget,
56+
funding_limit,
57+
proposals,
58+
seed=2025)
5959
captured = capfd.readouterr()
6060

6161
assert set(result) == expected_result
6262
assert captured.out == expected_captured
6363

6464

6565
def test_select_proposals_more_than_funds(capfd):
66-
np.random.seed(2025)
6766
budget = 5
6867
funding_limit = 2
6968
proposals = [
@@ -103,15 +102,17 @@ def test_select_proposals_more_than_funds(capfd):
103102
'Fund "B" for $1 bringing its project\'s annual total to $1.\n'
104103
'Fund "A" for $2 bringing its project\'s annual total to $2.\n'
105104
)
106-
result = select_proposals_to_fund(budget, funding_limit, proposals)
105+
result = select_proposals_to_fund(budget,
106+
funding_limit,
107+
proposals,
108+
seed=2025)
107109
captured = capfd.readouterr()
108110

109111
assert set(result) == expected_result
110112
assert captured.out == expected_captured
111113

112114

113115
def test_select_proposals_more_than_funds_under(capfd):
114-
np.random.seed(2025)
115116
budget = 4.1
116117
funding_limit = 2
117118
proposals = [
@@ -150,15 +151,17 @@ def test_select_proposals_more_than_funds_under(capfd):
150151
'Fund "D" for $0.5 bringing its project\'s annual total to $0.5.\n'
151152
'Fund "B" for $1 bringing its project\'s annual total to $1.\n'
152153
)
153-
result = select_proposals_to_fund(budget, funding_limit, proposals)
154+
result = select_proposals_to_fund(budget,
155+
funding_limit,
156+
proposals,
157+
seed=2025)
154158
captured = capfd.readouterr()
155159

156160
assert set(result) == expected_result
157161
assert captured.out == expected_captured
158162

159163

160164
def test_select_proposals_more_than_funds_eqweight_zero(capfd):
161-
np.random.seed(2025)
162165
budget = 6
163166
funding_limit = 2
164167
proposals = [
@@ -198,15 +201,17 @@ def test_select_proposals_more_than_funds_eqweight_zero(capfd):
198201
'Fund "C" for $1 bringing its project\'s annual total to $1.\n'
199202
'Fund "A" for $1 bringing its project\'s annual total to $1.\n'
200203
)
201-
result = select_proposals_to_fund(budget, funding_limit, proposals)
204+
result = select_proposals_to_fund(budget,
205+
funding_limit,
206+
proposals,
207+
seed=2025)
202208
captured = capfd.readouterr()
203209

204210
assert set(result) == expected_result
205211
assert captured.out == expected_captured
206212

207213

208214
def test_select_proposals_more_than_funds_eqweight_under(capfd):
209-
np.random.seed(2025)
210215
budget = 5.4
211216
funding_limit = 2
212217
proposals = [
@@ -245,15 +250,17 @@ def test_select_proposals_more_than_funds_eqweight_under(capfd):
245250
'Fund "D" for $1 bringing its project\'s annual total to $1.\n'
246251
'Fund "C" for $1 bringing its project\'s annual total to $1.\n'
247252
)
248-
result = select_proposals_to_fund(budget, funding_limit, proposals)
253+
result = select_proposals_to_fund(budget,
254+
funding_limit,
255+
proposals,
256+
seed=2025)
249257
captured = capfd.readouterr()
250258

251259
assert set(result) == expected_result
252260
assert captured.out == expected_captured
253261

254262

255263
def test_select_proposals_more_than_funds_eqweight_over(capfd):
256-
np.random.seed(2025)
257264
budget = 6.6
258265
funding_limit = 2
259266
proposals = [
@@ -294,7 +301,10 @@ def test_select_proposals_more_than_funds_eqweight_over(capfd):
294301
'Fund "A" for $1 bringing its project\'s annual total to $1.\n'
295302
'Fund "F" for $1 bringing its project\'s annual total to $1.\n'
296303
)
297-
result = select_proposals_to_fund(budget, funding_limit, proposals)
304+
result = select_proposals_to_fund(budget,
305+
funding_limit,
306+
proposals,
307+
seed=2025)
298308
captured = capfd.readouterr()
299309

300310
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)