forked from iSchool-597PR/2022Fall_projects
-
Notifications
You must be signed in to change notification settings - Fork 0
/
FoodPantry.py
462 lines (445 loc) · 23.5 KB
/
FoodPantry.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
"""This file implements the FoodPantry class"""
import math
import time
from typing import Dict, Tuple
import pandas as pd
import cython
from Global import *
from utils import Food, mod_beta_random
@cython.cclass
class FoodPantry:
"""Simulates the operation of the food pantry and the behavior of clients"""
@cython.ccall
def __init__(self, parent, num_households=100, config=Global.config):
"""
Initialize basic attributes of a food pantry, including its clients, food inventory, operation day, etc.
:param parent: a reference to the FoodBank object it belongs to
:param num_households: the number of households served by the food pantry
:param config: a dictionary of boolean options for hypothesis testing
"""
self.parent = parent
self.num_households = num_households
self.config = config
self.clients = self.generate_clients()
self.num_people = self.clients[("num_people", "")].sum()
self.mean_demand = {k: (v["mean"] * self.num_people * DEMAND_RATIO) for k, v in PERSONAL_WEEKLY_DEMAND.items()}
self.food = Food()
self.operation_day = rng.integers(0, 7)
self.previous_record = []
@cython.ccall
def generate_clients(self) -> pd.DataFrame:
"""For each client's family, generate the weekly physical demand for food, and the baseline level of proportion
of demand they can already secure through purchasing.
:return: a dataframe storing the information of families, including fields that will be used later.
>>> pantry = FoodPantry(None, num_households=3)
>>> demo = pantry.generate_clients()
>>> demo.iloc[:] = 0
>>> demo # doctest: +NORMALIZE_WHITESPACE
num_people staples ... protein
total base_secured ... demand_alt purchased_fresh purchased_packaged
0 0 0 0 ... 0 0 0
1 0 0 0 ... 0 0 0
2 0 0 0 ... 0 0 0
<BLANKLINE>
[3 rows x 20 columns]
>>> demo[FV]
total base_secured ... purchased_fresh purchased_packaged
0 0 0 ... 0 0
1 0 0 ... 0 0
2 0 0 ... 0 0
<BLANKLINE>
[3 rows x 7 columns]
>>> pantry = FoodPantry(None)
>>> df = pantry.generate_clients().round(2)
>>> ((1 <= df[("num_people", "")]) & (df[("num_people", "")] <= 10)).all()
True
>>> bools = []
>>> for typ, stat in PERSONAL_WEEKLY_DEMAND.items():
... mean = stat["mean"] * pantry.num_households * 2.4503
... bools.append(0.85 * mean < df[(typ, "total")].sum() < 1.15 * mean)
>>> all(bools)
True
"""
columns = [("num_people", ""), (STP, "total"), (STP, "base_secured"), (STP, "secured"), (STP, "demand"),
(STP, "purchased"),
(FV, "total"), (FV, "base_secured"), (FV, "secured"), (FV, "demand"), (FV, "demand_alt"),
(FV, "purchased_fresh"), (FV, "purchased_packaged"),
(PT, "total"), (PT, "base_secured"), (PT, "secured"), (PT, "demand"), (PT, "demand_alt"),
(PT, "purchased_fresh"), (PT, "purchased_packaged")]
clients = pd.DataFrame(columns=pd.MultiIndex.from_tuples(columns)).astype("float")
clients[("num_people", "")] = rng.choice(range(1, 11), self.num_households, p=FAMILY_DISTRIBUTION)
clients.loc[:, (slice(None), "base_secured")] = rng.uniform(0.3, 0.9, (self.num_households, 3))
for typ, stat in PERSONAL_WEEKLY_DEMAND.items():
mean, std = stat["mean"], stat["std"]
low, high = 0.5 * mean, 2 * mean
clients[(typ, "total")] = mod_beta_random(low, high, mean, std, self.num_households) * clients[
("num_people", "")]
return clients
@cython.ccall
def initialize_weekly_demand(self):
"""Generate each client's proportion of food secured this week in response to price fluctuation, and their
demand to the food bank.
:return:
>>> pantry = FoodPantry(None)
>>> pantry.initialize_weekly_demand()
>>> (pantry.clients.loc[:, (slice(None), ["demand_alt", "purchased", "purchased_fresh", "purchased_packaged"])]\
== 0).all(axis=None)
True
"""
price_ratio = {STP: 1.1, FV: 1.2, PT: 0.9}
## get the ratio of p/p0. We use the current prices for fresh food types, because the baseline prices for
## packaged food types are not available
# current_prices = Global.base_prices()
# price_ratio = {STP: current_prices[STP]/BASELINE_PRICE[STP], FV: current_prices[FFV]/BASELINE_PRICE[FV], PT:
# current_prices[FPT]/BASELINE_PRICE[PT]}
factor = {k: (v ** ELASTICITY[k]) for k, v in price_ratio.items()}
for typ in ELASTICITY.keys():
self.clients[(typ, "secured")] = self.clients[(typ, "base_secured")] * factor[typ]
self.clients[(typ, "demand")] = self.clients[(typ, "total")] * (
1 - self.clients[(typ, "secured")]) + rng.normal(0, 1, self.num_households)
if "demand_alt" in self.clients[typ]:
self.clients[(typ, "demand_alt")] = 0.
# remove the purchase record of the previous week
self.clients.loc[:, (slice(None), ["purchased", "purchased_fresh", "purchased_packaged"])] = 0.
@cython.ccall
def estimate_demand(self) -> Dict[str, float]:
"""Predict client demand this week based on prior experience
:return: A dictionary storing the quantity needed for each type of food.
"""
if not self.config["pantry"]["use_real_demand"]:
if not self.previous_record:
est_demand = self.mean_demand
else:
est_demand = self.previous_record[-1]
else:
# est_demand = {k: v * 1 for k, v in est_demand.items()} # scale the estimation?
est_demand = dict()
for typ in PERSONAL_WEEKLY_DEMAND:
est_demand[typ] = self.clients[(typ, "demand")].sum().item()
return est_demand
@cython.ccall
def set_order_and_limit(self, demand: Dict[str, float], stock: Dict[str, float], bank_stock: Dict[str, float]) -> \
Tuple[dict, dict]:
"""Make an order to the food bank based on estimated demand and the current inventory of the pantry and the
foodbank. Request fresh food first, and request packaged food to meet the remaining demand. Set limits on fresh
food per client based on the demand and the replenished inventory.
:param demand: A dictionary storing the demand (or its estimation) for each type of food.
:param stock: the current inventory of the food pantry
:param bank_stock: the current inventory of the food bank
:return: A dictionary storing the quantity of each type of food requested to the food bank, and a dictionary
storing the limit on fresh food per type.
>>> pantry = FoodPantry(None, num_households=8)
>>> demand = {STP: 100, FV: 100, PT: 100}
>>> stock = {STP: 30, FFV: 5, PFV: 30, FPT: 5, PPT: 20}
>>> bank_stock1 = {STP: 500, FFV: 500, PFV: 500, FPT: 500, PPT: 500}
>>> pantry.set_order_and_limit(demand, stock, bank_stock1)[0] # doctest: +NORMALIZE_WHITESPACE
{'staples': 70, 'fresh_fruits_and_vegetables': 65, 'packaged_fruits_and_vegetables': 0, 'fresh_protein': 75,
'packaged_protein': 0}
>>> bank_stock2 = {STP: 500, FFV: 10, PFV: 500, FPT: 15, PPT: 500}
>>> pantry.set_order_and_limit(demand, stock, bank_stock2)[0] # doctest: +NORMALIZE_WHITESPACE
{'staples': 70, 'fresh_fruits_and_vegetables': 10, 'packaged_fruits_and_vegetables': 55, 'fresh_protein': 15,
'packaged_protein': 60}
>>> stock2 = {STP: 100, FFV: 0, PFV: 100, FPT: 0, PPT: 100}
>>> ordr = pantry.set_order_and_limit(demand, stock2, bank_stock1)[0]
>>> list(ordr.values())
[0, 0, 0, 0, 0]
"""
types = {FV: [FFV, PFV], PT: [FPT, PPT]}
stock[FV] = stock[FFV] + stock[PFV]
stock[PT] = stock[FPT] + stock[PPT]
gap = {typ: max(demand - stock[typ], 0) for typ, demand in demand.items()}
limits = dict()
order = dict()
order[STP] = min(gap[STP], bank_stock[STP])
for typ, subtypes in types.items():
fresh, packaged = subtypes
order[fresh] = min(gap[typ], bank_stock[fresh])
order[packaged] = min(gap[typ] - order[fresh], bank_stock[packaged])
# Calculate the limit on fresh food of this type
fresh_qty = stock[fresh] + order[fresh]
limits[typ] = float("inf")
if fresh_qty < demand[typ]:
if self.config["pantry"]["set_limit"]:
limits[typ] = fresh_qty * 1.3 / self.num_people
return order, limits
@cython.ccall
@classmethod
def func(cls, data: pd.Series, typ="exp", param=0.7) -> pd.Series:
"""
Food utility as a function of the proportion of demand satisfied. It should map 0 to 1, 1 to 1, and be concave
to reflect diminished marginal effect.
:param data: a pd.Series or np.ndarray storing the proportion of food demand that is satisfied per household
:param typ: the type of the function, either exponential, logarithm or quadratic.
:param param: one additional parameter to decide the shape of the curve, e.g. the power in exponential function,
the quadracitc coefficient in a quadratic function.
:return: The element-wise utility value
>>> portion = pd.Series(np.arange(0, 1.2, 0.2))
>>> portion.round(2).values.tolist()
[0.0, 0.2, 0.4, 0.6, 0.8, 1.0]
>>> FoodPantry.func(portion).round(2).values.tolist()
[0.0, 0.32, 0.53, 0.7, 0.86, 1.0]
>>> FoodPantry.func(portion, "quad", -0.5).round(2).values.tolist()
[0.0, 0.28, 0.52, 0.72, 0.88, 1.0]
>>> FoodPantry.func(portion, "log", 3).round(2).values.tolist()
[0.0, 0.34, 0.57, 0.74, 0.88, 1.0]
"""
assert typ in ["exp", "log", "quad"]
if typ == "exp":
return np.power(data, param)
elif typ == "log":
return np.log(data * param + 1) / math.log(param + 1)
elif typ == "quad":
assert -1 <= param < 0, "The quadratic coefficient should be between -1 and 0 for quadratic functions!"
return param * np.square(data) + (1 - param) * data
@cython.ccall
def utility_one_type(self, typ: str) -> pd.Series:
"""After a pantry activity, estimate the increment in utility of some type of food for each household.
:param typ: The type of food for which to calculate utility increment
:return:
>>> pantry = FoodPantry(None)
>>> pantry.initialize_weekly_demand()
>>> all(pantry.utility_one_type(typ).round(2).sum()==0 for typ in Global.get_food_demand_types())
True
"""
assert typ in [STP, FV, PT]
family_size = self.clients[("num_people", "")]
total = self.clients[(typ, "total")]
secured = self.clients[(typ, "secured")]
if typ == STP:
delta = self.clients[(STP, "purchased")] / total
inc = (FoodPantry.func(secured + delta) - FoodPantry.func(secured)) * family_size
else:
fresh_pct = self.clients[(typ, "purchased_fresh")] / self.clients[(typ, "total")]
pckg_pct = self.clients[(typ, "purchased_packaged")] / self.clients[(typ, "total")]
pct_with_fresh = secured + fresh_pct
pct_total = pct_with_fresh + pckg_pct
inc = (0.7 * FoodPantry.func(pct_total) + 0.3 * FoodPantry.func(pct_with_fresh) - FoodPantry.func(secured)) \
* family_size
return inc
@cython.ccall
def get_utility(self) -> float:
"""Estimate the increment in total food utility after a pantry activity
:return: a float number of the total utility
>>> pantry = FoodPantry(None)
>>> pantry.initialize_weekly_demand()
>>> round(pantry.get_utility(), 3) == 0
True
>>> pantry.clients[(STP, "purchased")] = pantry.clients[(STP, "demand")]
>>> 0.2 < (pantry.get_utility() / pantry.num_households) < 0.6
True
>>> pantry.initialize_weekly_demand()
>>> pantry.clients[(FV, "purchased_fresh")] = pantry.clients[(FV, "demand")]
>>> u1 = pantry.get_utility()
>>> pantry.initialize_weekly_demand()
>>> pantry.clients[(FV, "purchased_packaged")] = pantry.clients[(FV, "demand")]
>>> u2 = pantry.get_utility()
>>> (0.65 * u1) < u2 < (0.75 * u1) # utility of packaged food should be about 0.7 of fresh food
True
"""
tot_util = pd.Series(np.zeros(self.num_households))
for typ in [STP, FV, PT]:
tot_util += self.utility_one_type(typ)
return tot_util.sum() / 3 # / (self.num_people * 3)
@cython.ccall
def allocate_food(self, food: pd.DataFrame, demand: pd.Series) -> Tuple[pd.Series, pd.DataFrame, int]:
"""Clients line up to purchase one type of food. Record their purchase and update the pantry inventory.
:param food: the dataframe of some type of food
:param demand: a pd.Series object storing the demand of clients in the queue
:return: a pd.Series storing the amount purchased by clients, a pd.DataFrame storing the remaining food, and the
number of clients who get enough food.
>>> pantry = FoodPantry(None)
>>> demand = pd.Series([10.] * 5)
>>> total = demand.sum() / TYPES[STP]["proportion"]
>>> food = Food(total + 2.0).select(STP).df # a bit more food than needed
>>> purchased, remain, served = pantry.allocate_food(food, demand)
>>> list(purchased.round(2))
[10.0, 10.0, 10.0, 10.0, 10.0]
>>> remain.round(2)
type remaining_days quantity
177 staples 178 0.04
178 staples 179 0.28
179 staples 180 0.28
>>> served
5
>>> food2 = Food(total - 40.0).select(STP).df # less food than needed
>>> purchased2, remain2, served2 = pantry.allocate_food(food2, demand)
>>> list(purchased2.round(2))
[10.0, 10.0, 10.0, 8.0, 0.0]
>>> remain2.empty
True
>>> served2
3
>>> food0 = Food().select(STP).df
>>> purchased0, remain0, served0 = pantry.allocate_food(food0, demand)
>>> purchased0.sum() == 0 and remain0.empty
True
>>> served0
0
>>> demand0 = pd.Series([0.] * 5)
>>> purchased_0, remain_0, served_0 = pantry.allocate_food(food, demand0)
>>> purchased_0.sum() == 0
True
>>> served_0
5
"""
if isinstance(food, Food):
food = food.df
num_households = len(demand)
cum_stock = food["quantity"].cumsum()
tot_stock = cum_stock.iat[-1] if len(cum_stock) >= 1 else 0
cum_demand = demand.cumsum()
tot_demand = cum_demand.iat[-1] if len(cum_demand) >= 1 else 0
purchased = pd.Series(np.zeros(num_households))
if tot_stock >= tot_demand:
# Get the index of the last batch of food before all demand is satisfied
pivot = cum_stock.ge(tot_demand - 1e-7).idxmax() # Due to float precision, loosen the condition a bit
food.loc[pivot, "quantity"] = cum_stock[pivot] - tot_demand
food = food[pivot:]
purchased = demand
served = num_households
else:
food = Food().df
# Get the index of the first client who cannot get enough food
pivot = cum_demand.gt(tot_stock).idxmax()
purchased[:pivot] = demand[:pivot]
purchased[pivot] = demand[pivot] - (cum_demand[pivot] - tot_stock)
served = pivot # (served+1) clients get some food
return purchased, food, served
@cython.ccall
def hold_pantry(self, limits: Dict[str, float]) -> Tuple[int, int]:
"""Hold a pantry activity. Although in reality one client shops multiple types of food at once, to avoid
unnecessary computation, we transform it to the equivalent process of allocating food multiple times, once for
each type of food.
:param limit: a dictionary that maps each food type to a quantity
:return: a tuple where the first element is the number of clients who get all their demand satisfied (either
fresh or packaged), and the second is the number of clients who get at least some food.
>>> pantry = FoodPantry(None)
>>> pantry.food = Food(1000)
>>> limits = {FV: 0, PT: 0}
>>> pantry.initialize_weekly_demand()
>>> tup1 = pantry.hold_pantry(limits)
>>> tup1[1] == 100
True
>>> pantry.food = Food(1000)
>>> pantry.initialize_weekly_demand()
>>> limits2 = {FV: 10000, PT: 10000}
>>> tup2 = pantry.hold_pantry(limits2)
>>> tup1[0] < tup2[0]
True
>>> tup1[1] > tup2[1]
True
"""
types = {STP: [STP], FV: [FFV, PFV], PT: [FPT, PPT]}
remains = []
served_per_type = []
est_demand = dict()
for typ, options in types.items():
if len(options) == 1:
purchased, remain, served = self.allocate_food(self.food.select(options[0]),
self.clients[(typ, "demand")])
self.clients[(typ, "purchased")] = purchased
remains.append(remain)
served_per_type.append(served)
if served < min(20, 0.2 * self.num_households):
# Sample size too small for a good estimation. Use statistics instead
est_demand[typ] = self.mean_demand[typ]
elif served == self.num_households:
est_demand[typ] = purchased.sum().item()
elif served < self.num_households:
est_demand[typ] = purchased.sum().item() / served * self.num_households
else:
raise ValueError
elif len(options) == 2:
fresh, packaged = options
# Transfer out-of-limit demand for fresh food to packaged food
quota = self.clients[("num_people", "")] * limits[typ]
mask = (self.clients[(typ, "demand")] > quota)
self.clients.loc[mask, (typ, "demand_alt")] = self.clients.loc[mask, (typ, "demand")] - quota
self.clients.loc[mask, (typ, "demand")] = quota
purchased_fresh, remain, served = self.allocate_food(self.food.select(fresh),
self.clients[(typ, "demand")])
self.clients[(typ, "purchased_fresh")] = purchased_fresh
remains.append(remain)
served_per_type.append(served)
# Add the unmet demand on fresh food to packaged food
self.clients[(typ, "demand_alt")] += (
self.clients[(typ, "demand")] - self.clients[(typ, "purchased_fresh")])
purchased_pckg, remain, served = self.allocate_food(self.food.select(packaged),
self.clients[(typ, "demand_alt")])
self.clients[(typ, "purchased_packaged")] = purchased_pckg
remains.append(remain)
served_per_type.append(served)
# Demand on fresh food is not observable due to the quota, but those who get enough packaged food must
# have fulfilled their demand in this type.
if served < min(20, 0.2 * self.num_households):
est_demand[typ] = self.mean_demand[typ]
elif served == self.num_households:
est_demand[typ] = (purchased_fresh + purchased_pckg).sum().item()
elif served < self.num_households:
est_demand[typ] = (purchased_fresh + purchased_pckg)[
:served].sum().item() / served * self.num_households
else:
raise ValueError
self.food.df = pd.concat(remains).reset_index(drop=True)
self.previous_record.append(est_demand)
all_served = min(served_per_type[0], served_per_type[2], served_per_type[4])
partly_served = min(max(served_per_type) + 1, self.num_households)
return all_served, partly_served
@cython.ccall
def run_one_day(self, debug=False) -> Tuple[Dict[str, float], Dict[str, float], float, Tuple[int, int], Dict[str, float]]:
""" Run the simulation for one day.
:param debug: a boolean of whether the method is called in debug mode
:return: a tuple consisting of a dictionary of food waste per type, a dictionary of an order of food, a float
of total utility, a tuple of the number of clients served, and a dictionary of the best knowledge about the
client demand from the pantry owner.
>>> pantry = FoodPantry(None)
>>> waste, order, utility, num_served, _ = pantry.run_one_day(debug=True)
>>> sum(waste.values()) == 0
True
>>> num_served[1] > 80
True
"""
if (not debug) and (Global.get_day() % 7) != self.operation_day:
return
self.initialize_weekly_demand()
waste = self.food.quality_control(num_days=7)
est_demand = self.estimate_demand()
if debug:
order, limits = self.set_order_and_limit(est_demand, self.food.get_quantity(), Food(1500).get_quantity())
suppl = Food(1500).subtract(order)
else:
order, limits = self.set_order_and_limit(est_demand, self.food.get_quantity(), self.parent.get_food_quantity())
suppl = self.parent.get_food_order(order)
self.food.add(suppl)
self.food.sort_by_freshness()
self.clients = self.clients.sample(frac=1).reset_index(drop=True)
num_served = self.hold_pantry(limits)
utility = self.get_utility()
return waste, order, utility, num_served, est_demand
if __name__ == '__main__':
# We may need to drastically reduce the number of pantries to make it computationally feasible
# There are about 10 million households in total
utilities = []
wastes = []
served_ls = []
households = 100
num_days = 100
pantry = FoodPantry(None, num_households=households)
start = time.time()
for i in range(num_days):
waste, order, utility, num_served, _ = pantry.run_one_day(debug=True)
utilities.append(utility)
wastes.append(waste)
served_ls.append(num_served)
all_served, partly_served = list((zip(*served_ls)))
end = time.time()
print(f"It took {end - start} seconds")
print(f"Total average utility {sum(utilities) / len(utilities)}")
print("{:.2%} of clients get all demand satisfied".format(sum(all_served) / (households * num_days)))
print("{:.2%} of clients get at least some food".format(sum(partly_served) / (households * num_days)))
waste_per_type = dict()
for typ in TYPES:
waste_per_type[typ] = sum(w[typ] for w in wastes) / num_days
waste_qty = sum(v for v in waste_per_type.values())
print(f"Daily waste per type: {waste_per_type}")
print(f"Daily total waste: {waste_qty}")