huff 1.5.0__py3-none-any.whl → 1.5.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- huff/models.py +574 -141
- huff/tests/data/Wieland2015.xlsx +0 -0
- huff/tests/tests_huff.py +52 -23
- {huff-1.5.0.dist-info → huff-1.5.2.dist-info}/METADATA +6 -11
- {huff-1.5.0.dist-info → huff-1.5.2.dist-info}/RECORD +7 -7
- {huff-1.5.0.dist-info → huff-1.5.2.dist-info}/WHEEL +0 -0
- {huff-1.5.0.dist-info → huff-1.5.2.dist-info}/top_level.txt +0 -0
huff/models.py
CHANGED
@@ -4,8 +4,8 @@
|
|
4
4
|
# Author: Thomas Wieland
|
5
5
|
# ORCID: 0000-0001-5168-9846
|
6
6
|
# mail: geowieland@googlemail.com
|
7
|
-
# Version: 1.5.
|
8
|
-
# Last update: 2025-
|
7
|
+
# Version: 1.5.2
|
8
|
+
# Last update: 2025-07-02 21:09
|
9
9
|
# Copyright (c) 2025 Thomas Wieland
|
10
10
|
#-----------------------------------------------------------------------
|
11
11
|
|
@@ -528,15 +528,23 @@ class InteractionMatrix:
|
|
528
528
|
if supply_locations_metadata["weighting"][0]["func"] is None and supply_locations_metadata["weighting"][0]["param"] is None:
|
529
529
|
print("Attraction not defined")
|
530
530
|
else:
|
531
|
-
|
531
|
+
if supply_locations_metadata["weighting"][0]["param"] is not None:
|
532
|
+
print("Attraction " + str(round(supply_locations_metadata["weighting"][0]["param"],3)) + " (" + supply_locations_metadata["weighting"][0]["func"] + ")")
|
533
|
+
else:
|
534
|
+
print("Attraction NA" + " (" + supply_locations_metadata["weighting"][0]["func"] + ")")
|
532
535
|
|
533
536
|
if customer_origins_metadata["weighting"][0]["func"] is None and customer_origins_metadata["weighting"][0]["param"] is None:
|
534
537
|
print("Transport costs not defined")
|
535
538
|
elif customer_origins_metadata["weighting"][0]["func"] in ["power", "exponential"]:
|
536
|
-
|
539
|
+
if customer_origins_metadata["weighting"][0]["param"] is not None:
|
540
|
+
print("Transport costs " + str(round(customer_origins_metadata["weighting"][0]["param"],3)) + " (" + customer_origins_metadata["weighting"][0]["func"] + ")")
|
541
|
+
else:
|
542
|
+
print("Transport costs NA" + " (" + customer_origins_metadata["weighting"][0]["func"] + ")")
|
537
543
|
elif customer_origins_metadata["weighting"][0]["func"] == "logistic":
|
538
|
-
|
539
|
-
|
544
|
+
if customer_origins_metadata["weighting"][0]["param"] is not None:
|
545
|
+
print("Transport costs " + str(round(customer_origins_metadata["weighting"][0]["param"][0],3)) + ", " + str(round(customer_origins_metadata["weighting"][0]["param"][1],3)) + " (" + customer_origins_metadata["weighting"][0]["func"] + ")")
|
546
|
+
else:
|
547
|
+
print("Transport costs NA" + " (" + customer_origins_metadata["weighting"][0]["func"] + ")")
|
540
548
|
|
541
549
|
attrac_vars = supply_locations_metadata["attraction_col"]
|
542
550
|
attrac_vars_no = len(attrac_vars)
|
@@ -553,12 +561,25 @@ class InteractionMatrix:
|
|
553
561
|
print(f"{attrac_vars[key][:16]:16} not defined")
|
554
562
|
|
555
563
|
else:
|
564
|
+
|
565
|
+
if supply_locations_metadata["weighting"][key]["func"] is None and supply_locations_metadata["weighting"][key]["param"]:
|
566
|
+
|
567
|
+
print(f"{attrac_vars[key][:16]:16} not defined")
|
556
568
|
|
557
|
-
|
558
|
-
|
559
|
-
|
569
|
+
else:
|
570
|
+
|
571
|
+
if supply_locations_metadata["weighting"][key]["param"] is not None:
|
572
|
+
|
573
|
+
name = supply_locations_metadata["weighting"][key]["name"]
|
574
|
+
param = supply_locations_metadata["weighting"][key]["param"]
|
575
|
+
func = supply_locations_metadata["weighting"][key]["func"]
|
576
|
+
|
577
|
+
print(f"{name[:16]:16} {round(param, 3)} ({func})")
|
578
|
+
|
579
|
+
else:
|
580
|
+
|
581
|
+
print(f"{attrac_vars[key][:16]:16} NA" + " (" + supply_locations_metadata["weighting"][0]["func"] + ")")
|
560
582
|
|
561
|
-
print(f"{name[:16]:16} {round(param, 3)} ({func})")
|
562
583
|
|
563
584
|
print("----------------------------------")
|
564
585
|
|
@@ -566,6 +587,8 @@ class InteractionMatrix:
|
|
566
587
|
print("Parameter estimation")
|
567
588
|
print("Fit function " + interaction_matrix_metadata["fit"]["function"])
|
568
589
|
print("Fit by " + interaction_matrix_metadata["fit"]["fit_by"])
|
590
|
+
if interaction_matrix_metadata["fit"]["function"] == "huff_ml_fit":
|
591
|
+
print("Fit method " + interaction_matrix_metadata["fit"]["method"] + " (Converged: " + str(interaction_matrix_metadata["fit"]["minimize_success"]) + ")")
|
569
592
|
|
570
593
|
def transport_costs(
|
571
594
|
self,
|
@@ -688,7 +711,8 @@ class InteractionMatrix:
|
|
688
711
|
vars_funcs = {
|
689
712
|
0: {
|
690
713
|
"name": "A_j",
|
691
|
-
"func": "power"
|
714
|
+
"func": "power",
|
715
|
+
"param": 1
|
692
716
|
},
|
693
717
|
1: {
|
694
718
|
"name": "t_ij",
|
@@ -711,9 +735,13 @@ class InteractionMatrix:
|
|
711
735
|
|
712
736
|
supply_locations_metadata["weighting"][0]["name"] = vars_funcs[0]["name"]
|
713
737
|
supply_locations_metadata["weighting"][0]["func"] = vars_funcs[0]["func"]
|
738
|
+
if "param" in vars_funcs[0]:
|
739
|
+
supply_locations_metadata["weighting"][0]["param"] = vars_funcs[0]["param"]
|
714
740
|
|
715
741
|
customer_origins_metadata["weighting"][0]["name"] = vars_funcs[1]["name"]
|
716
742
|
customer_origins_metadata["weighting"][0]["func"] = vars_funcs[1]["func"]
|
743
|
+
if "param" in vars_funcs[1]:
|
744
|
+
customer_origins_metadata["weighting"][0]["param"] = vars_funcs[1]["param"]
|
717
745
|
|
718
746
|
if len(vars_funcs) > 2:
|
719
747
|
|
@@ -731,7 +759,9 @@ class InteractionMatrix:
|
|
731
759
|
|
732
760
|
supply_locations_metadata["weighting"][key-1]["name"] = var["name"]
|
733
761
|
supply_locations_metadata["weighting"][key-1]["func"] = var["func"]
|
734
|
-
|
762
|
+
|
763
|
+
if "param" in var:
|
764
|
+
supply_locations_metadata["weighting"][key-1]["param"] = var["param"]
|
735
765
|
|
736
766
|
self.supply_locations.metadata = supply_locations_metadata
|
737
767
|
self.customer_origins.metadata = customer_origins_metadata
|
@@ -876,7 +906,8 @@ class InteractionMatrix:
|
|
876
906
|
|
877
907
|
check_vars(
|
878
908
|
df = interaction_matrix_df,
|
879
|
-
cols = ["E_ij"]
|
909
|
+
cols = ["E_ij"],
|
910
|
+
check_zero = False
|
880
911
|
)
|
881
912
|
|
882
913
|
market_areas_df = pd.DataFrame(interaction_matrix_df.groupby("j")["E_ij"].sum())
|
@@ -1057,7 +1088,8 @@ class InteractionMatrix:
|
|
1057
1088
|
if len(params) < 2:
|
1058
1089
|
raise ValueError("Parameter 'params' must be a list or np.ndarray with at least 2 parameter values")
|
1059
1090
|
|
1060
|
-
|
1091
|
+
customer_origins = self.customer_origins
|
1092
|
+
customer_origins_metadata = customer_origins.get_metadata()
|
1061
1093
|
|
1062
1094
|
param_gamma, param_lambda = params[0], params[1]
|
1063
1095
|
|
@@ -1071,10 +1103,7 @@ class InteractionMatrix:
|
|
1071
1103
|
interaction_matrix_df = self.interaction_matrix_df
|
1072
1104
|
|
1073
1105
|
supply_locations = self.supply_locations
|
1074
|
-
supply_locations_metadata = supply_locations.get_metadata()
|
1075
|
-
|
1076
|
-
customer_origins = self.customer_origins
|
1077
|
-
customer_origins_metadata = customer_origins.get_metadata()
|
1106
|
+
supply_locations_metadata = supply_locations.get_metadata()
|
1078
1107
|
|
1079
1108
|
supply_locations_metadata["weighting"][0]["param"] = float(param_gamma)
|
1080
1109
|
supply_locations.metadata = supply_locations_metadata
|
@@ -1119,8 +1148,15 @@ class InteractionMatrix:
|
|
1119
1148
|
|
1120
1149
|
customer_origins.metadata = customer_origins_metadata
|
1121
1150
|
|
1122
|
-
p_ij_emp
|
1123
|
-
|
1151
|
+
if "p_ij_emp" not in interaction_matrix_df.columns:
|
1152
|
+
p_ij_emp = interaction_matrix_df["p_ij"]
|
1153
|
+
else:
|
1154
|
+
p_ij_emp = interaction_matrix_df["p_ij_emp"]
|
1155
|
+
|
1156
|
+
if "E_ij_emp" not in interaction_matrix_df.columns:
|
1157
|
+
E_ij_emp = interaction_matrix_df["E_ij"]
|
1158
|
+
else:
|
1159
|
+
E_ij_emp = interaction_matrix_df["E_ij_emp"]
|
1124
1160
|
|
1125
1161
|
interaction_matrix_copy = copy.deepcopy(self)
|
1126
1162
|
|
@@ -1144,10 +1180,12 @@ class InteractionMatrix:
|
|
1144
1180
|
observed = p_ij_emp
|
1145
1181
|
expected = p_ij
|
1146
1182
|
|
1147
|
-
|
1183
|
+
modelfit_metrics = modelfit(
|
1148
1184
|
observed = observed,
|
1149
1185
|
expected = expected
|
1150
|
-
|
1186
|
+
)
|
1187
|
+
|
1188
|
+
LL = modelfit_metrics[1]["LL"]
|
1151
1189
|
|
1152
1190
|
return -LL
|
1153
1191
|
|
@@ -1171,7 +1209,10 @@ class InteractionMatrix:
|
|
1171
1209
|
params_metadata_customer_origins = 1
|
1172
1210
|
else:
|
1173
1211
|
if customer_origins_metadata["weighting"][0]["param"] is not None:
|
1174
|
-
|
1212
|
+
if isinstance(customer_origins_metadata["weighting"][0]["param"], (int, float)):
|
1213
|
+
params_metadata_customer_origins = 1
|
1214
|
+
else:
|
1215
|
+
params_metadata_customer_origins = len(customer_origins_metadata["weighting"][0]["param"])
|
1175
1216
|
|
1176
1217
|
if customer_origins_metadata["weighting"][0]["func"] == "logistic":
|
1177
1218
|
params_metadata_customer_origins = 2
|
@@ -1251,45 +1292,57 @@ class InteractionMatrix:
|
|
1251
1292
|
print(f"Optimization via {method} algorithm succeeded with parameters: {', '.join(str(round(par, 3)) for par in param_results)}.")
|
1252
1293
|
|
1253
1294
|
else:
|
1254
|
-
|
1255
|
-
param_gamma = None
|
1256
|
-
param_lambda = None
|
1257
|
-
|
1258
|
-
supply_locations_metadata["weighting"][0]["param"] = param_gamma
|
1259
|
-
|
1260
|
-
if customer_origins_metadata["weighting"][0]["func"] == "logistic":
|
1261
|
-
|
1262
|
-
param_lambda2 = None
|
1263
|
-
customer_origins_metadata["weighting"][0]["param"][0] = param_lambda
|
1264
|
-
customer_origins_metadata["weighting"][0]["param"][1] = param_lambda2
|
1265
|
-
|
1266
|
-
else:
|
1267
|
-
|
1268
|
-
customer_origins_metadata["weighting"][0]["param"] = param_lambda
|
1269
1295
|
|
1270
1296
|
print(f"Optimiziation via {method} algorithm failed with error message: '{ml_result.message}'. See https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html for all available algorithms.")
|
1271
1297
|
|
1272
1298
|
self.supply_locations.metadata = supply_locations_metadata
|
1273
|
-
self.customer_origins.metadata = customer_origins_metadata
|
1299
|
+
self.customer_origins.metadata = customer_origins_metadata
|
1274
1300
|
|
1275
|
-
if
|
1276
|
-
|
1277
|
-
|
1278
|
-
"function": "huff_ml_fit",
|
1279
|
-
"fit_by": fit_by,
|
1280
|
-
"initial_params": initial_params,
|
1281
|
-
"method": method,
|
1282
|
-
"bounds": bounds,
|
1283
|
-
"constraints": constraints
|
1284
|
-
}
|
1301
|
+
if update_estimates:
|
1302
|
+
|
1303
|
+
if "p_ij_emp" not in self.interaction_matrix_df.columns:
|
1285
1304
|
|
1286
|
-
|
1287
|
-
|
1288
|
-
|
1289
|
-
|
1290
|
-
|
1291
|
-
|
1292
|
-
|
1305
|
+
self.interaction_matrix_df["p_ij_emp"] = self.interaction_matrix_df["p_ij"]
|
1306
|
+
|
1307
|
+
print("NOTE: Probabilities in interaction matrix are treated as empirical probabilities")
|
1308
|
+
|
1309
|
+
else:
|
1310
|
+
|
1311
|
+
print("NOTE: Interaction matrix contains empirical probabilities")
|
1312
|
+
|
1313
|
+
if "E_ij_emp" not in self.interaction_matrix_df.columns:
|
1314
|
+
|
1315
|
+
self.interaction_matrix_df["E_ij_emp"] = self.interaction_matrix_df["E_ij"]
|
1316
|
+
|
1317
|
+
print("NOTE: Customer interactions in interaction matrix are treated as empirical interactions")
|
1318
|
+
|
1319
|
+
else:
|
1320
|
+
|
1321
|
+
print("NOTE: Interaction matrix contains empirical customer interactions")
|
1322
|
+
|
1323
|
+
if np.isnan(ml_result.x).any():
|
1324
|
+
|
1325
|
+
print("WARNING: No update of estimates because fit parameters contain NaN")
|
1326
|
+
|
1327
|
+
update_estimates = False
|
1328
|
+
|
1329
|
+
else:
|
1330
|
+
|
1331
|
+
self = self.utility()
|
1332
|
+
self = self.probabilities()
|
1333
|
+
self = self.flows()
|
1334
|
+
|
1335
|
+
self.metadata["fit"] = {
|
1336
|
+
"function": "huff_ml_fit",
|
1337
|
+
"fit_by": fit_by,
|
1338
|
+
"initial_params": initial_params,
|
1339
|
+
"method": method,
|
1340
|
+
"bounds": bounds,
|
1341
|
+
"constraints": constraints,
|
1342
|
+
"minimize_success": ml_result.success,
|
1343
|
+
"minimize_fittedparams": ml_result.x,
|
1344
|
+
"update_estimates": update_estimates
|
1345
|
+
}
|
1293
1346
|
|
1294
1347
|
return self
|
1295
1348
|
|
@@ -1331,7 +1384,7 @@ class InteractionMatrix:
|
|
1331
1384
|
|
1332
1385
|
if "transport_costs" not in interaction_matrix_metadata:
|
1333
1386
|
|
1334
|
-
print("New destination(s) included. No transport costs calculation because not defined in original interaction matrix.")
|
1387
|
+
print("WARNING: New destination(s) included. No transport costs calculation because not defined in original interaction matrix.")
|
1335
1388
|
|
1336
1389
|
interaction_matrix_df = pd.concat(
|
1337
1390
|
[
|
@@ -1400,11 +1453,12 @@ class MarketAreas:
|
|
1400
1453
|
|
1401
1454
|
def add_to_model(
|
1402
1455
|
self,
|
1403
|
-
model_object
|
1456
|
+
model_object,
|
1457
|
+
output_model = "Huff"
|
1404
1458
|
):
|
1405
1459
|
|
1406
|
-
if not isinstance(model_object, (HuffModel, MCIModel)):
|
1407
|
-
raise ValueError("Parameter 'interaction_matrix' must be of class HuffModel or
|
1460
|
+
if not isinstance(model_object, (HuffModel, MCIModel, InteractionMatrix)):
|
1461
|
+
raise ValueError("Parameter 'interaction_matrix' must be of class HuffModel, MCIModel, or InteractionMatrix")
|
1408
1462
|
|
1409
1463
|
if isinstance(model_object, MCIModel):
|
1410
1464
|
|
@@ -1415,15 +1469,35 @@ class MarketAreas:
|
|
1415
1469
|
market_areas_df = self.market_areas_df
|
1416
1470
|
)
|
1417
1471
|
|
1418
|
-
|
1472
|
+
elif isinstance(model_object, HuffModel):
|
1419
1473
|
|
1420
1474
|
model = HuffModel(
|
1421
1475
|
interaction_matrix = model_object.interaction_matrix,
|
1422
1476
|
market_areas_df = self.market_areas_df
|
1423
1477
|
)
|
1478
|
+
|
1479
|
+
elif isinstance(model_object, InteractionMatrix):
|
1480
|
+
|
1481
|
+
if output_model not in ["Huff", "MCI"]:
|
1482
|
+
raise ValueError("Parameter 'output_model' must be either 'Huff' or 'MCI'")
|
1424
1483
|
|
1484
|
+
if output_model == "Huff":
|
1485
|
+
|
1486
|
+
model = HuffModel(
|
1487
|
+
interaction_matrix=model_object,
|
1488
|
+
market_areas_df=self.market_areas_df
|
1489
|
+
)
|
1490
|
+
|
1491
|
+
if output_model == "MCI":
|
1492
|
+
|
1493
|
+
model = MCIModel(
|
1494
|
+
coefs=model_object.coefs,
|
1495
|
+
mci_ols_model=model_object.mci_ols_model,
|
1496
|
+
market_areas_df=self.market_areas_df
|
1497
|
+
)
|
1498
|
+
|
1425
1499
|
return model
|
1426
|
-
|
1500
|
+
|
1427
1501
|
class HuffModel:
|
1428
1502
|
|
1429
1503
|
def __init__(
|
@@ -1457,14 +1531,16 @@ class HuffModel:
|
|
1457
1531
|
return customer_origins
|
1458
1532
|
|
1459
1533
|
def get_market_areas_df(self):
|
1534
|
+
|
1460
1535
|
return self.market_areas_df
|
1461
|
-
|
1536
|
+
|
1462
1537
|
def summary(self):
|
1463
1538
|
|
1464
1539
|
interaction_matrix = self.interaction_matrix
|
1465
1540
|
|
1466
1541
|
customer_origins_metadata = interaction_matrix.get_customer_origins().get_metadata()
|
1467
1542
|
supply_locations_metadata = interaction_matrix.get_supply_locations().get_metadata()
|
1543
|
+
interaction_matrix_metadata = interaction_matrix.get_metadata()
|
1468
1544
|
|
1469
1545
|
print("Huff Model")
|
1470
1546
|
print("----------------------------------")
|
@@ -1486,14 +1562,23 @@ class HuffModel:
|
|
1486
1562
|
if supply_locations_metadata["weighting"][0]["func"] is None and supply_locations_metadata["weighting"][0]["param"] is None:
|
1487
1563
|
print("Attraction not defined")
|
1488
1564
|
else:
|
1489
|
-
|
1565
|
+
if supply_locations_metadata["weighting"][0]["param"] is not None:
|
1566
|
+
print("Attraction " + str(round(supply_locations_metadata["weighting"][0]["param"],3)) + " (" + supply_locations_metadata["weighting"][0]["func"] + ")")
|
1567
|
+
else:
|
1568
|
+
print("Attraction NA" + " (" + supply_locations_metadata["weighting"][0]["func"] + ")")
|
1490
1569
|
|
1491
1570
|
if customer_origins_metadata["weighting"][0]["func"] is None and customer_origins_metadata["weighting"][0]["param"] is None:
|
1492
1571
|
print("Transport costs not defined")
|
1493
1572
|
elif customer_origins_metadata["weighting"][0]["func"] in ["power", "exponential"]:
|
1494
|
-
|
1573
|
+
if customer_origins_metadata["weighting"][0]["param"] is not None:
|
1574
|
+
print("Transport costs " + str(round(customer_origins_metadata["weighting"][0]["param"],3)) + " (" + customer_origins_metadata["weighting"][0]["func"] + ")")
|
1575
|
+
else:
|
1576
|
+
print("Transport costs NA" + " (" + customer_origins_metadata["weighting"][0]["func"] + ")")
|
1495
1577
|
elif customer_origins_metadata["weighting"][0]["func"] == "logistic":
|
1496
|
-
|
1578
|
+
if customer_origins_metadata["weighting"][0]["param"] is not None:
|
1579
|
+
print("Transport costs " + str(round(customer_origins_metadata["weighting"][0]["param"][0],3)) + ", " + str(round(customer_origins_metadata["weighting"][0]["param"][1],3)) + " (" + customer_origins_metadata["weighting"][0]["func"] + ")")
|
1580
|
+
else:
|
1581
|
+
print("Transport costs NA" + " (" + customer_origins_metadata["weighting"][0]["func"] + ")")
|
1497
1582
|
|
1498
1583
|
attrac_vars = supply_locations_metadata["attraction_col"]
|
1499
1584
|
attrac_vars_no = len(attrac_vars)
|
@@ -1518,39 +1603,46 @@ class HuffModel:
|
|
1518
1603
|
print(f"{name[:16]:16} {round(param, 3)} ({func})")
|
1519
1604
|
|
1520
1605
|
print("----------------------------------")
|
1521
|
-
|
1522
|
-
|
1523
|
-
|
1606
|
+
|
1607
|
+
if interaction_matrix_metadata != {} and "fit" in interaction_matrix_metadata and interaction_matrix_metadata["fit"]["function"] is not None:
|
1608
|
+
print("Parameter estimation")
|
1609
|
+
print("Fit function " + interaction_matrix_metadata["fit"]["function"])
|
1610
|
+
print("Fit by " + interaction_matrix_metadata["fit"]["fit_by"])
|
1611
|
+
if interaction_matrix_metadata["fit"]["function"] == "huff_ml_fit":
|
1612
|
+
print("Fit method " + interaction_matrix_metadata["fit"]["method"] + " (Converged: " + str(interaction_matrix_metadata["fit"]["minimize_success"]) + ")")
|
1613
|
+
|
1614
|
+
huff_modelfit = self.modelfit(by = interaction_matrix_metadata["fit"]["fit_by"])
|
1524
1615
|
|
1525
|
-
|
1616
|
+
if huff_modelfit is not None:
|
1617
|
+
|
1618
|
+
print ("Goodness-of-fit for " + interaction_matrix_metadata["fit"]["fit_by"])
|
1619
|
+
|
1620
|
+
print("Sum of squared residuals ", round(huff_modelfit[1]["SQR"], 2))
|
1621
|
+
print("R-squared ", round(huff_modelfit[1]["Rsq"], 2))
|
1622
|
+
print("Mean squared error ", round(huff_modelfit[1]["MSE"], 2))
|
1623
|
+
print("Root mean squared error ", round(huff_modelfit[1]["RMSE"], 2))
|
1624
|
+
print("Mean absolute error ", round(huff_modelfit[1]["MAE"], 2))
|
1625
|
+
if huff_modelfit[1]["MAPE"] is not None:
|
1626
|
+
print("Mean absolute percentage error ", round(huff_modelfit[1]["MAPE"], 2))
|
1627
|
+
else:
|
1628
|
+
print("Mean absolute percentage error Not calculated")
|
1629
|
+
print("Symmetric MAPE ", round(huff_modelfit[1]["sMAPE"], 2))
|
1630
|
+
print("Absolute percentage errors")
|
1631
|
+
|
1632
|
+
APE_list = [
|
1633
|
+
["< 5 % ", round(huff_modelfit[1]["APE"]["resid_below5"], 2), " < 30 % ", round(huff_modelfit[1]["APE"]["resid_below30"], 2)],
|
1634
|
+
["< 10 % ", round(huff_modelfit[1]["APE"]["resid_below10"], 2), " < 35 % ", round(huff_modelfit[1]["APE"]["resid_below35"], 2)],
|
1635
|
+
["< 15 % ", round(huff_modelfit[1]["APE"]["resid_below15"], 2), " < 40 % ", round(huff_modelfit[1]["APE"]["resid_below40"], 2)],
|
1636
|
+
["< 20 % ", round(huff_modelfit[1]["APE"]["resid_below20"], 2), " < 45 % ", round(huff_modelfit[1]["APE"]["resid_below45"], 2)],
|
1637
|
+
["< 25% ", round(huff_modelfit[1]["APE"]["resid_below25"], 2), " < 50 % ", round(huff_modelfit[1]["APE"]["resid_below50"], 2)]
|
1638
|
+
]
|
1639
|
+
APE_df = pd.DataFrame(
|
1640
|
+
APE_list,
|
1641
|
+
columns=["Resid.", "%", "Resid.", "%"]
|
1642
|
+
)
|
1643
|
+
print(APE_df.to_string(index=False))
|
1526
1644
|
|
1527
|
-
|
1528
|
-
print("Sum of squares ", round(huff_modelfit[1]["SQT"], 2))
|
1529
|
-
print("R-squared ", round(huff_modelfit[1]["Rsq"], 2))
|
1530
|
-
print("Mean squared error ", round(huff_modelfit[1]["MSE"], 2))
|
1531
|
-
print("Root mean squared error ", round(huff_modelfit[1]["RMSE"], 2))
|
1532
|
-
print("Mean absolute error ", round(huff_modelfit[1]["MAE"], 2))
|
1533
|
-
if huff_modelfit[1]["MAPE"] is not None:
|
1534
|
-
print("Mean absolute percentage error ", round(huff_modelfit[1]["MAPE"], 2))
|
1535
|
-
else:
|
1536
|
-
print("Mean absolute percentage error Not calculated")
|
1537
|
-
print("Symmetric MAPE ", round(huff_modelfit[1]["sMAPE"], 2))
|
1538
|
-
print("Absolute percentage errors")
|
1539
|
-
|
1540
|
-
APE_list = [
|
1541
|
-
["< 5 % ", round(huff_modelfit[1]["APE"]["resid_below5"], 2), " < 30 % ", round(huff_modelfit[1]["APE"]["resid_below30"], 2)],
|
1542
|
-
["< 10 % ", round(huff_modelfit[1]["APE"]["resid_below10"], 2), " < 35 % ", round(huff_modelfit[1]["APE"]["resid_below35"], 2)],
|
1543
|
-
["< 15 % ", round(huff_modelfit[1]["APE"]["resid_below15"], 2), " < 40 % ", round(huff_modelfit[1]["APE"]["resid_below40"], 2)],
|
1544
|
-
["< 20 % ", round(huff_modelfit[1]["APE"]["resid_below20"], 2), " < 45 % ", round(huff_modelfit[1]["APE"]["resid_below45"], 2)],
|
1545
|
-
["< 25% ", round(huff_modelfit[1]["APE"]["resid_below25"], 2), " < 50 % ", round(huff_modelfit[1]["APE"]["resid_below50"], 2)]
|
1546
|
-
]
|
1547
|
-
APE_df = pd.DataFrame(
|
1548
|
-
APE_list,
|
1549
|
-
columns=["Resid.", "%", "Resid.", "%"]
|
1550
|
-
)
|
1551
|
-
print(APE_df.to_string(index=False))
|
1552
|
-
|
1553
|
-
print("----------------------------------")
|
1645
|
+
print("----------------------------------")
|
1554
1646
|
|
1555
1647
|
def mci_fit(
|
1556
1648
|
self,
|
@@ -1642,6 +1734,287 @@ class HuffModel:
|
|
1642
1734
|
|
1643
1735
|
return mci_model
|
1644
1736
|
|
1737
|
+
def loglik(
|
1738
|
+
self,
|
1739
|
+
params
|
1740
|
+
):
|
1741
|
+
|
1742
|
+
if not isinstance(params, list):
|
1743
|
+
if isinstance(params, np.ndarray):
|
1744
|
+
params = params.tolist()
|
1745
|
+
else:
|
1746
|
+
raise ValueError("Parameter 'params' must be a list or np.ndarray with at least 2 parameter values")
|
1747
|
+
|
1748
|
+
if len(params) < 2:
|
1749
|
+
raise ValueError("Parameter 'params' must be a list or np.ndarray with at least 2 parameter values")
|
1750
|
+
|
1751
|
+
market_areas_df = self.market_areas_df
|
1752
|
+
|
1753
|
+
customer_origins = self.interaction_matrix.customer_origins
|
1754
|
+
customer_origins_metadata = customer_origins.get_metadata()
|
1755
|
+
|
1756
|
+
param_gamma, param_lambda = params[0], params[1]
|
1757
|
+
|
1758
|
+
if customer_origins_metadata["weighting"][0]["func"] == "logistic":
|
1759
|
+
|
1760
|
+
if len(params) < 3:
|
1761
|
+
raise ValueError("When using logistic weighting, parameter 'params' must be a list or np.ndarray with at least 3 parameter values")
|
1762
|
+
|
1763
|
+
param_gamma, param_lambda, param_lambda2 = params[0], params[1], params[2]
|
1764
|
+
|
1765
|
+
supply_locations = self.interaction_matrix.supply_locations
|
1766
|
+
supply_locations_metadata = supply_locations.get_metadata()
|
1767
|
+
|
1768
|
+
supply_locations_metadata["weighting"][0]["param"] = float(param_gamma)
|
1769
|
+
supply_locations.metadata = supply_locations_metadata
|
1770
|
+
|
1771
|
+
if customer_origins_metadata["weighting"][0]["func"] in ["power", "exponential"]:
|
1772
|
+
|
1773
|
+
if len(params) >= 2:
|
1774
|
+
|
1775
|
+
customer_origins_metadata["weighting"][0]["param"] = float(param_lambda)
|
1776
|
+
|
1777
|
+
else:
|
1778
|
+
|
1779
|
+
raise ValueError ("Huff Model with transport cost weighting of type " + customer_origins_metadata["weighting"][0]["func"] + " must have >= 2 input parameters")
|
1780
|
+
|
1781
|
+
elif customer_origins_metadata["weighting"][0]["func"] == "logistic":
|
1782
|
+
|
1783
|
+
if len(params) >= 3:
|
1784
|
+
|
1785
|
+
customer_origins_metadata["weighting"][0]["param"] = [float(param_lambda), float(param_lambda2)]
|
1786
|
+
|
1787
|
+
else:
|
1788
|
+
|
1789
|
+
raise ValueError("Huff Model with transport cost weightig of type " + customer_origins_metadata["weighting"][0]["func"] + " must have >= 3 input parameters")
|
1790
|
+
|
1791
|
+
if (customer_origins_metadata["weighting"][0]["func"] in ["power", "exponential"] and len(params) > 2):
|
1792
|
+
|
1793
|
+
for key, param in enumerate(params):
|
1794
|
+
|
1795
|
+
if key <= 1:
|
1796
|
+
continue
|
1797
|
+
|
1798
|
+
supply_locations_metadata["weighting"][key-1]["param"] = float(param)
|
1799
|
+
|
1800
|
+
if (customer_origins_metadata["weighting"][0]["func"] == "logistic" and len(params) > 3):
|
1801
|
+
|
1802
|
+
for key, param in enumerate(params):
|
1803
|
+
|
1804
|
+
if key <= 2:
|
1805
|
+
continue
|
1806
|
+
|
1807
|
+
supply_locations_metadata["weighting"][key-2]["param"] = float(param)
|
1808
|
+
|
1809
|
+
customer_origins.metadata = customer_origins_metadata
|
1810
|
+
|
1811
|
+
if "T_j_emp" not in market_areas_df.columns:
|
1812
|
+
T_j_emp = market_areas_df["T_j"]
|
1813
|
+
else:
|
1814
|
+
T_j_emp = market_areas_df["T_j_emp"]
|
1815
|
+
|
1816
|
+
|
1817
|
+
huff_model_copy = copy.deepcopy(self)
|
1818
|
+
|
1819
|
+
interaction_matrix_copy = copy.deepcopy(huff_model_copy.interaction_matrix)
|
1820
|
+
|
1821
|
+
interaction_matrix_copy = interaction_matrix_copy.utility()
|
1822
|
+
interaction_matrix_copy = interaction_matrix_copy.probabilities()
|
1823
|
+
interaction_matrix_copy = interaction_matrix_copy.flows()
|
1824
|
+
|
1825
|
+
huff_model_copy = interaction_matrix_copy.marketareas()
|
1826
|
+
|
1827
|
+
market_areas_df_copy = huff_model_copy.market_areas_df
|
1828
|
+
|
1829
|
+
observed = T_j_emp
|
1830
|
+
expected = market_areas_df_copy["T_j"]
|
1831
|
+
|
1832
|
+
modelfit_metrics = modelfit(
|
1833
|
+
observed = observed,
|
1834
|
+
expected = expected
|
1835
|
+
)
|
1836
|
+
|
1837
|
+
LL = modelfit_metrics[1]["LL"]
|
1838
|
+
|
1839
|
+
return -LL
|
1840
|
+
|
1841
|
+
def ml_fit(
|
1842
|
+
self,
|
1843
|
+
initial_params: list = [1.0, -2.0],
|
1844
|
+
method: str = "L-BFGS-B",
|
1845
|
+
bounds: list = [(0.5, 1), (-3, -1)],
|
1846
|
+
constraints: list = [],
|
1847
|
+
fit_by = "probabilities",
|
1848
|
+
update_estimates: bool = True,
|
1849
|
+
check_numbers: bool = True
|
1850
|
+
):
|
1851
|
+
|
1852
|
+
if fit_by in ["probabilities", "flows"]:
|
1853
|
+
|
1854
|
+
self.interaction_matrix.huff_ml_fit(
|
1855
|
+
initial_params = initial_params,
|
1856
|
+
method = method,
|
1857
|
+
bounds = bounds,
|
1858
|
+
constraints = constraints,
|
1859
|
+
fit_by = fit_by,
|
1860
|
+
update_estimates = update_estimates
|
1861
|
+
)
|
1862
|
+
|
1863
|
+
elif fit_by == "totals":
|
1864
|
+
|
1865
|
+
if check_numbers:
|
1866
|
+
|
1867
|
+
market_areas_df = self.market_areas_df
|
1868
|
+
interaction_matrix_df = self.get_interaction_matrix_df()
|
1869
|
+
T_j_market_areas_df = sum(market_areas_df["T_j"])
|
1870
|
+
T_j_interaction_matrix_df = sum(interaction_matrix_df["E_ij"])
|
1871
|
+
|
1872
|
+
if T_j_market_areas_df != T_j_interaction_matrix_df:
|
1873
|
+
print("WARNING: Sum of total market areas (" + str(int(T_j_market_areas_df)) + ") is not equal to sum of customer flows (" + str(int(T_j_interaction_matrix_df)) + ")")
|
1874
|
+
|
1875
|
+
supply_locations = self.interaction_matrix.supply_locations
|
1876
|
+
supply_locations_metadata = supply_locations.get_metadata()
|
1877
|
+
|
1878
|
+
customer_origins = self.interaction_matrix.customer_origins
|
1879
|
+
customer_origins_metadata = customer_origins.get_metadata()
|
1880
|
+
|
1881
|
+
if customer_origins_metadata["weighting"][0]["param"] is None:
|
1882
|
+
params_metadata_customer_origins = 1
|
1883
|
+
else:
|
1884
|
+
if customer_origins_metadata["weighting"][0]["param"] is not None:
|
1885
|
+
if isinstance(customer_origins_metadata["weighting"][0]["param"], (int, float)):
|
1886
|
+
params_metadata_customer_origins = 1
|
1887
|
+
else:
|
1888
|
+
params_metadata_customer_origins = len(customer_origins_metadata["weighting"][0]["param"])
|
1889
|
+
|
1890
|
+
if customer_origins_metadata["weighting"][0]["func"] == "logistic":
|
1891
|
+
params_metadata_customer_origins = 2
|
1892
|
+
else:
|
1893
|
+
params_metadata_customer_origins = 1
|
1894
|
+
|
1895
|
+
params_metadata_supply_locations = len(supply_locations_metadata["weighting"])
|
1896
|
+
|
1897
|
+
params_metadata = params_metadata_customer_origins+params_metadata_supply_locations
|
1898
|
+
|
1899
|
+
if len(initial_params) < 2 or len(initial_params) != params_metadata:
|
1900
|
+
raise ValueError("Parameter 'initial_params' must be a list with " + str(params_metadata) + " entries (Attaction: " + str(params_metadata_supply_locations) + ", Transport costs: " + str(params_metadata_customer_origins) + ")")
|
1901
|
+
|
1902
|
+
if len(bounds) != len(initial_params):
|
1903
|
+
raise ValueError("Parameter 'bounds' must have the same length as parameter 'initial_params' (" + str(len(bounds)) + ", " + str(len(initial_params)) + ")")
|
1904
|
+
|
1905
|
+
ml_result = minimize(
|
1906
|
+
self.loglik,
|
1907
|
+
initial_params,
|
1908
|
+
method = method,
|
1909
|
+
bounds = bounds,
|
1910
|
+
constraints = constraints,
|
1911
|
+
options={'disp': 3}
|
1912
|
+
)
|
1913
|
+
|
1914
|
+
attrac_vars = len(supply_locations_metadata["weighting"])
|
1915
|
+
|
1916
|
+
if ml_result.success:
|
1917
|
+
|
1918
|
+
fitted_params = ml_result.x
|
1919
|
+
|
1920
|
+
param_gamma = fitted_params[0]
|
1921
|
+
supply_locations_metadata["weighting"][0]["param"] = float(param_gamma)
|
1922
|
+
|
1923
|
+
if customer_origins_metadata["weighting"][0]["func"] in ["power", "exponential"]:
|
1924
|
+
|
1925
|
+
param_lambda = fitted_params[1]
|
1926
|
+
param_results = [
|
1927
|
+
float(param_gamma),
|
1928
|
+
float(param_lambda)
|
1929
|
+
]
|
1930
|
+
|
1931
|
+
customer_origins_metadata["weighting"][0]["param"] = float(param_lambda)
|
1932
|
+
|
1933
|
+
elif customer_origins_metadata["weighting"][0]["func"] == "logistic":
|
1934
|
+
|
1935
|
+
param_lambda = fitted_params[1]
|
1936
|
+
param_lambda2 = fitted_params[2]
|
1937
|
+
param_results = [
|
1938
|
+
float(param_gamma),
|
1939
|
+
float(param_lambda),
|
1940
|
+
float(param_lambda2)
|
1941
|
+
]
|
1942
|
+
|
1943
|
+
customer_origins_metadata["weighting"][0]["param"][0] = float(param_lambda)
|
1944
|
+
customer_origins_metadata["weighting"][0]["param"][1] = float(param_lambda2)
|
1945
|
+
|
1946
|
+
if attrac_vars > 1:
|
1947
|
+
|
1948
|
+
if customer_origins_metadata["weighting"][0]["func"] == "logistic":
|
1949
|
+
fitted_params_add = 3
|
1950
|
+
else:
|
1951
|
+
fitted_params_add = 2
|
1952
|
+
|
1953
|
+
for key, var in supply_locations_metadata["weighting"].items():
|
1954
|
+
|
1955
|
+
if key > len(supply_locations_metadata["weighting"])-fitted_params_add:
|
1956
|
+
break
|
1957
|
+
|
1958
|
+
param = float(fitted_params[key+fitted_params_add])
|
1959
|
+
|
1960
|
+
param_results = param_results + [param]
|
1961
|
+
|
1962
|
+
supply_locations_metadata["weighting"][(key+1)]["param"] = float(param)
|
1963
|
+
|
1964
|
+
print(f"Optimization via {method} algorithm succeeded with parameters: {', '.join(str(round(par, 3)) for par in param_results)}.")
|
1965
|
+
|
1966
|
+
else:
|
1967
|
+
|
1968
|
+
print(f"Optimiziation via {method} algorithm failed with error message: '{ml_result.message}'. See https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html for all available algorithms.")
|
1969
|
+
|
1970
|
+
self.interaction_matrix.supply_locations.metadata = supply_locations_metadata
|
1971
|
+
self.interaction_matrix.customer_origins.metadata = customer_origins_metadata
|
1972
|
+
|
1973
|
+
if update_estimates:
|
1974
|
+
|
1975
|
+
if "T_j_emp" not in self.market_areas_df.columns:
|
1976
|
+
|
1977
|
+
self.market_areas_df["T_j_emp"] = self.market_areas_df["T_j"]
|
1978
|
+
|
1979
|
+
print("NOTE: Total values in market areas df are treated as empirical total values")
|
1980
|
+
|
1981
|
+
else:
|
1982
|
+
|
1983
|
+
print("NOTE: Total market areas df contains empirical total values")
|
1984
|
+
|
1985
|
+
if np.isnan(ml_result.x).any():
|
1986
|
+
|
1987
|
+
print("WARNING: No update of estimates because fit parameters contain NaN")
|
1988
|
+
|
1989
|
+
update_estimates = False
|
1990
|
+
|
1991
|
+
else:
|
1992
|
+
|
1993
|
+
self.interaction_matrix.utility()
|
1994
|
+
self.interaction_matrix.probabilities()
|
1995
|
+
self.interaction_matrix.flows()
|
1996
|
+
|
1997
|
+
huff_model_new_marketareas = self.interaction_matrix.marketareas()
|
1998
|
+
self.market_areas_df["T_j"] = huff_model_new_marketareas.get_market_areas_df()["T_j"]
|
1999
|
+
|
2000
|
+
self.interaction_matrix.metadata["fit"] = {
|
2001
|
+
"function": "huff_ml_fit",
|
2002
|
+
"fit_by": fit_by,
|
2003
|
+
"initial_params": initial_params,
|
2004
|
+
"method": method,
|
2005
|
+
"bounds": bounds,
|
2006
|
+
"constraints": constraints,
|
2007
|
+
"minimize_success": ml_result.success,
|
2008
|
+
"minimize_fittedparams": ml_result.x,
|
2009
|
+
"update_estimates": update_estimates
|
2010
|
+
}
|
2011
|
+
|
2012
|
+
else:
|
2013
|
+
|
2014
|
+
raise ValueError("Parameter 'fit_by' must be 'probabilities', 'flows' or 'totals'")
|
2015
|
+
|
2016
|
+
return self
|
2017
|
+
|
1645
2018
|
def update(self):
|
1646
2019
|
|
1647
2020
|
self.interaction_matrix = self.interaction_matrix.update()
|
@@ -1652,31 +2025,92 @@ class HuffModel:
|
|
1652
2025
|
|
1653
2026
|
def modelfit(
|
1654
2027
|
self,
|
1655
|
-
by = "
|
1656
|
-
):
|
1657
|
-
|
1658
|
-
interaction_matrix = self.interaction_matrix
|
1659
|
-
interaction_matrix_df = interaction_matrix.get_interaction_matrix_df()
|
2028
|
+
by = "probabilities"
|
2029
|
+
):
|
1660
2030
|
|
1661
|
-
if
|
2031
|
+
if by == "probabilities":
|
2032
|
+
|
2033
|
+
interaction_matrix = self.interaction_matrix
|
2034
|
+
interaction_matrix_df = interaction_matrix.get_interaction_matrix_df()
|
2035
|
+
|
2036
|
+
if ("p_ij" in interaction_matrix_df.columns and "p_ij_emp" in interaction_matrix_df.columns):
|
2037
|
+
|
2038
|
+
try:
|
2039
|
+
|
2040
|
+
huff_modelfit = modelfit(
|
2041
|
+
interaction_matrix_df["p_ij_emp"],
|
2042
|
+
interaction_matrix_df["p_ij"]
|
2043
|
+
)
|
2044
|
+
|
2045
|
+
return huff_modelfit
|
2046
|
+
|
2047
|
+
except:
|
2048
|
+
|
2049
|
+
print("WARNING: Goodness-of-fit metrics could not be calculated due to NaN values.")
|
2050
|
+
return None
|
1662
2051
|
|
1663
|
-
|
2052
|
+
else:
|
2053
|
+
|
2054
|
+
print("WARNING: Goodness-of-fit metrics could not be calculated. No empirical values of probabilities in interaction matrix.")
|
2055
|
+
|
2056
|
+
return None
|
1664
2057
|
|
1665
|
-
|
1666
|
-
|
1667
|
-
|
1668
|
-
|
2058
|
+
elif by == "flows":
|
2059
|
+
|
2060
|
+
interaction_matrix = self.interaction_matrix
|
2061
|
+
interaction_matrix_df = interaction_matrix.get_interaction_matrix_df()
|
2062
|
+
|
2063
|
+
if ("E_ij" in interaction_matrix_df.columns and "E_ij_emp" in interaction_matrix_df.columns):
|
1669
2064
|
|
1670
|
-
|
2065
|
+
try:
|
1671
2066
|
|
1672
|
-
|
2067
|
+
huff_modelfit = modelfit(
|
2068
|
+
interaction_matrix_df["E_ij_emp"],
|
2069
|
+
interaction_matrix_df["E_ij"]
|
2070
|
+
)
|
2071
|
+
|
2072
|
+
return huff_modelfit
|
2073
|
+
|
2074
|
+
except:
|
2075
|
+
|
2076
|
+
print("WARNING: Goodness-of-fit metrics could not be calculated due to NaN values.")
|
2077
|
+
return None
|
2078
|
+
|
2079
|
+
else:
|
1673
2080
|
|
1674
|
-
print("Goodness-of-fit metrics could not be calculated
|
2081
|
+
print("WARNING: Goodness-of-fit metrics could not be calculated. No empirical values of customer flows in interaction matrix.")
|
2082
|
+
|
1675
2083
|
return None
|
1676
|
-
|
1677
|
-
else:
|
1678
2084
|
|
1679
|
-
|
2085
|
+
elif by == "totals":
|
2086
|
+
|
2087
|
+
market_areas_df = self.market_areas_df
|
2088
|
+
|
2089
|
+
if ("T_j" in market_areas_df.columns and "T_j_emp" in market_areas_df.columns):
|
2090
|
+
|
2091
|
+
try:
|
2092
|
+
|
2093
|
+
huff_modelfit = modelfit(
|
2094
|
+
market_areas_df["T_j_emp"],
|
2095
|
+
market_areas_df["T_j"]
|
2096
|
+
)
|
2097
|
+
|
2098
|
+
return huff_modelfit
|
2099
|
+
|
2100
|
+
except:
|
2101
|
+
|
2102
|
+
print("WARNING: Goodness-of-fit metrics could not be calculated due to NaN values.")
|
2103
|
+
return None
|
2104
|
+
|
2105
|
+
else:
|
2106
|
+
|
2107
|
+
print("WARNING: Goodness-of-fit metrics could not be calculated. No empirical values of T_j in market areas data.")
|
2108
|
+
|
2109
|
+
return None
|
2110
|
+
|
2111
|
+
else:
|
2112
|
+
|
2113
|
+
raise ValueError("Parameter 'by' must be 'probabilities', 'flows', or 'totals'")
|
1680
2114
|
|
1681
2115
|
class MCIModel:
|
1682
2116
|
|
@@ -1744,7 +2178,7 @@ class MCIModel:
|
|
1744
2178
|
|
1745
2179
|
except:
|
1746
2180
|
|
1747
|
-
print("Goodness-of-fit metrics could not be calculated due to NaN values.")
|
2181
|
+
print("WARNING: Goodness-of-fit metrics could not be calculated due to NaN values.")
|
1748
2182
|
return None
|
1749
2183
|
|
1750
2184
|
else:
|
@@ -1794,7 +2228,6 @@ class MCIModel:
|
|
1794
2228
|
print ("Goodness-of-fit for probabilities")
|
1795
2229
|
|
1796
2230
|
print("Sum of squared residuals ", round(mci_modelfit[1]["SQR"], 2))
|
1797
|
-
print("Sum of squares ", round(mci_modelfit[1]["SQT"], 2))
|
1798
2231
|
print("R-squared ", round(mci_modelfit[1]["Rsq"], 2))
|
1799
2232
|
print("Mean squared error ", round(mci_modelfit[1]["MSE"], 2))
|
1800
2233
|
print("Root mean squared error ", round(mci_modelfit[1]["RMSE"], 2))
|
@@ -1890,8 +2323,11 @@ class MCIModel:
|
|
1890
2323
|
interaction_matrix = self.interaction_matrix
|
1891
2324
|
interaction_matrix_df = interaction_matrix.get_interaction_matrix_df()
|
1892
2325
|
|
1893
|
-
if "p_ij" in interaction_matrix_df.columns:
|
2326
|
+
if "p_ij" in interaction_matrix_df.columns and "p_ij_emp" not in interaction_matrix_df.columns:
|
2327
|
+
print("NOTE: Probabilities in interaction matrix are treated as empirical probabilities")
|
1894
2328
|
interaction_matrix_df["p_ij_emp"] = interaction_matrix_df["p_ij"]
|
2329
|
+
else:
|
2330
|
+
print("NOTE: Interaction matrix contains empirical probabilities")
|
1895
2331
|
|
1896
2332
|
if "U_ij" not in interaction_matrix_df.columns:
|
1897
2333
|
self.utility(transformation = transformation)
|
@@ -2666,7 +3102,8 @@ def get_isochrones(
|
|
2666
3102
|
def modelfit(
|
2667
3103
|
observed,
|
2668
3104
|
expected,
|
2669
|
-
remove_nan: bool = True
|
3105
|
+
remove_nan: bool = True,
|
3106
|
+
verbose: bool = False
|
2670
3107
|
):
|
2671
3108
|
|
2672
3109
|
observed_no = len(observed)
|
@@ -2692,6 +3129,10 @@ def modelfit(
|
|
2692
3129
|
)
|
2693
3130
|
|
2694
3131
|
obs_exp_clean = obs_exp.dropna(subset=["observed", "expected"])
|
3132
|
+
|
3133
|
+
if len(obs_exp_clean) < len(observed) or len(obs_exp_clean) < len(expected):
|
3134
|
+
if verbose:
|
3135
|
+
print("NOTE: Vectors 'observed' and/or 'expected' contain zeros which are dropped.")
|
2695
3136
|
|
2696
3137
|
observed = obs_exp_clean["observed"].to_numpy()
|
2697
3138
|
expected = obs_exp_clean["expected"].to_numpy()
|
@@ -2708,7 +3149,8 @@ def modelfit(
|
|
2708
3149
|
residuals_abs = abs(residuals)
|
2709
3150
|
|
2710
3151
|
if any(observed == 0):
|
2711
|
-
|
3152
|
+
if verbose:
|
3153
|
+
print ("Vector 'observed' contains values equal to zero. No APE/MAPE calculated.")
|
2712
3154
|
APE = np.full_like(observed, np.nan)
|
2713
3155
|
MAPE = None
|
2714
3156
|
else:
|
@@ -2735,6 +3177,7 @@ def modelfit(
|
|
2735
3177
|
MSE = float(SQR/observed_no)
|
2736
3178
|
RMSE = float(sqrt(MSE))
|
2737
3179
|
MAE = float(SAR/observed_no)
|
3180
|
+
LL = np.sum(np.log(residuals_sq))
|
2738
3181
|
|
2739
3182
|
sMAPE = float(np.mean(sAPE))
|
2740
3183
|
|
@@ -2759,6 +3202,7 @@ def modelfit(
|
|
2759
3202
|
"MAE": MAE,
|
2760
3203
|
"MAPE": MAPE,
|
2761
3204
|
"sMAPE": sMAPE,
|
3205
|
+
"LL": -LL,
|
2762
3206
|
"APE": {
|
2763
3207
|
"resid_below5": resid_below5,
|
2764
3208
|
"resid_below10": resid_below10,
|
@@ -2780,34 +3224,23 @@ def modelfit(
|
|
2780
3224
|
|
2781
3225
|
return modelfit_results
|
2782
3226
|
|
2783
|
-
def loglik(
|
2784
|
-
observed,
|
2785
|
-
expected
|
2786
|
-
):
|
2787
|
-
|
2788
|
-
model_fit = modelfit(
|
2789
|
-
observed,
|
2790
|
-
expected
|
2791
|
-
)
|
2792
|
-
residuals_sq = model_fit[0]["residuals_sq"]
|
2793
|
-
|
2794
|
-
LL = np.sum(np.log(residuals_sq))
|
2795
|
-
|
2796
|
-
return -LL
|
2797
|
-
|
2798
3227
|
def check_vars(
|
2799
3228
|
df: pd.DataFrame,
|
2800
|
-
cols: list
|
3229
|
+
cols: list,
|
3230
|
+
check_numeric: bool = True,
|
3231
|
+
check_zero: bool = True
|
2801
3232
|
):
|
2802
3233
|
|
2803
3234
|
for col in cols:
|
2804
3235
|
if col not in df.columns:
|
2805
3236
|
raise KeyError(f"Column '{col}' not in dataframe.")
|
2806
3237
|
|
2807
|
-
|
2808
|
-
|
2809
|
-
|
3238
|
+
if check_numeric:
|
3239
|
+
for col in cols:
|
3240
|
+
if not pd.api.types.is_numeric_dtype(df[col]):
|
3241
|
+
raise ValueError(f"Column '{col}' is not numeric. All stated columns must be numeric.")
|
2810
3242
|
|
2811
|
-
|
2812
|
-
|
2813
|
-
|
3243
|
+
if check_zero:
|
3244
|
+
for col in cols:
|
3245
|
+
if (df[col] <= 0).any():
|
3246
|
+
raise ValueError(f"Column '{col}' includes values <= 0. All values must be numeric and positive.")
|
huff/tests/data/Wieland2015.xlsx
CHANGED
Binary file
|
huff/tests/tests_huff.py
CHANGED
@@ -4,18 +4,17 @@
|
|
4
4
|
# Author: Thomas Wieland
|
5
5
|
# ORCID: 0000-0001-5168-9846
|
6
6
|
# mail: geowieland@googlemail.com
|
7
|
-
# Version: 1.5.
|
8
|
-
# Last update: 2025-
|
7
|
+
# Version: 1.5.2
|
8
|
+
# Last update: 2025-07-02 21:10
|
9
9
|
# Copyright (c) 2025 Thomas Wieland
|
10
10
|
#-----------------------------------------------------------------------
|
11
11
|
|
12
|
-
|
13
12
|
from huff.models import create_interaction_matrix, get_isochrones, load_geodata, load_interaction_matrix, load_marketareas, market_shares, modelfit
|
14
13
|
from huff.osm import map_with_basemap
|
15
14
|
from huff.gistools import buffers, point_spatial_join
|
16
15
|
|
17
16
|
|
18
|
-
#
|
17
|
+
# Dealing with customer origins (statistical districts):
|
19
18
|
|
20
19
|
Haslach = load_geodata(
|
21
20
|
"data/Haslach.shp",
|
@@ -39,10 +38,8 @@ Haslach.define_marketsize("pop")
|
|
39
38
|
# Definition of market size variable
|
40
39
|
|
41
40
|
Haslach.define_transportcosts_weighting(
|
42
|
-
|
43
|
-
#
|
44
|
-
param_lambda = [10, -0.5],
|
45
|
-
func="logistic"
|
41
|
+
param_lambda = -2.2,
|
42
|
+
# one weighting parameter for power function (default)
|
46
43
|
# two weighting parameters for logistic function
|
47
44
|
)
|
48
45
|
# Definition of transport costs weighting (lambda)
|
@@ -51,7 +48,7 @@ Haslach.summary()
|
|
51
48
|
# Summary after update
|
52
49
|
|
53
50
|
|
54
|
-
#
|
51
|
+
# Dealing with upply locations (supermarkets):
|
55
52
|
|
56
53
|
Haslach_supermarkets = load_geodata(
|
57
54
|
"data/Haslach_supermarkets.shp",
|
@@ -87,9 +84,7 @@ Haslach_supermarkets.summary()
|
|
87
84
|
# Summary of updated customer origins
|
88
85
|
|
89
86
|
Haslach_supermarkets_isochrones = Haslach_supermarkets.get_isochrones_gdf()
|
90
|
-
# Extracting isochrones
|
91
|
-
|
92
|
-
print(Haslach_supermarkets_isochrones)
|
87
|
+
# Extracting isochrones as gdf
|
93
88
|
|
94
89
|
|
95
90
|
# Using customer origins and supply locations for building interaction matrix:
|
@@ -101,8 +96,8 @@ haslach_interactionmatrix = create_interaction_matrix(
|
|
101
96
|
# Creating interaction matrix
|
102
97
|
|
103
98
|
haslach_interactionmatrix.transport_costs(
|
104
|
-
|
105
|
-
network=
|
99
|
+
ors_auth="5b3ce3597851110001cf62480a15aafdb5a64f4d91805929f8af6abd",
|
100
|
+
network=True,
|
106
101
|
#distance_unit="meters",
|
107
102
|
# set network = True to calculate transport costs matrix via ORS API (default)
|
108
103
|
)
|
@@ -132,11 +127,9 @@ print(huff_model.get_market_areas_df())
|
|
132
127
|
# Maximum Likelihood fit for Huff Model:
|
133
128
|
|
134
129
|
haslach_interactionmatrix.huff_ml_fit(
|
135
|
-
|
136
|
-
initial_params=[1, 9, -0.6],
|
130
|
+
initial_params=[1, -2],
|
137
131
|
method="trust-constr",
|
138
|
-
|
139
|
-
bounds = [(0.8, 0.9999),(7, 11),(-0.9, -0.4)],
|
132
|
+
bounds = [(0.8, 0.9999),(-2.5, -1.5)]
|
140
133
|
)
|
141
134
|
# Maximum Likelihood fit for Huff Model
|
142
135
|
|
@@ -218,10 +211,14 @@ Wieland2015_interaction_matrix = load_interaction_matrix(
|
|
218
211
|
probabilities_col="MA_Anb1",
|
219
212
|
data_type="xlsx"
|
220
213
|
)
|
214
|
+
# Loading interaction matrix from XLSX file
|
221
215
|
|
222
216
|
Wieland2015_interaction_matrix.summary()
|
223
217
|
# Summary of interaction matrix
|
224
218
|
|
219
|
+
|
220
|
+
# Parameter estimation via MCI model:
|
221
|
+
|
225
222
|
Wieland2015_fit = Wieland2015_interaction_matrix.mci_fit(
|
226
223
|
cols=[
|
227
224
|
"A_j",
|
@@ -289,15 +286,16 @@ Wieland2015_interaction_matrix2.define_weightings(
|
|
289
286
|
|
290
287
|
Wieland2015_interaction_matrix2.huff_ml_fit(
|
291
288
|
# Power TC function
|
292
|
-
initial_params=[0.9, -
|
293
|
-
bounds=[(0.5, 1), (-
|
289
|
+
initial_params=[0.9, -1.5, 0.5, 0.3],
|
290
|
+
bounds=[(0.5, 1), (-2, -1), (0.2, 0.7), (0.2, 0.7)],
|
294
291
|
# # Logistic TC function:
|
295
292
|
# initial_params=[0.9, 10, -0.5, 0.5, 0.3],
|
296
293
|
# bounds=[(0.5, 1), (8, 12), (-0.7, -0.2), (0.2, 0.7), (0.2, 0.7)],
|
297
|
-
fit_by="
|
298
|
-
method = "trust-constr"
|
294
|
+
fit_by="probabilities",
|
295
|
+
#method = "trust-constr"
|
299
296
|
)
|
300
|
-
# ML fit with power transport cost function
|
297
|
+
# ML fit with power transport cost function based on probabilities
|
298
|
+
# from InteractionMatrix object
|
301
299
|
|
302
300
|
Wieland2015_interaction_matrix2.summary()
|
303
301
|
# Summary of interaction matrix
|
@@ -305,6 +303,19 @@ Wieland2015_interaction_matrix2.summary()
|
|
305
303
|
huff_model_fit2 = Wieland2015_interaction_matrix2.marketareas()
|
306
304
|
# Calculation of market areas
|
307
305
|
|
306
|
+
huff_model_fit2 = huff_model_fit2.ml_fit(
|
307
|
+
# Power TC function
|
308
|
+
initial_params=[0.9, -1.5, 0.5, 0.3],
|
309
|
+
bounds=[(0.5, 1), (-2, -1), (0.2, 0.7), (0.2, 0.7)],
|
310
|
+
# # Logistic TC function:
|
311
|
+
# initial_params=[0.9, 10, -0.5, 0.5, 0.3],
|
312
|
+
# bounds=[(0.5, 1), (8, 12), (-0.7, -0.2), (0.2, 0.7), (0.2, 0.7)],
|
313
|
+
fit_by="probabilities",
|
314
|
+
#method = "trust-constr"
|
315
|
+
)
|
316
|
+
# ML fit with power transport cost function based on probabilities
|
317
|
+
# from HuffModel object
|
318
|
+
|
308
319
|
huff_model_fit2.summary()
|
309
320
|
# Summary of Hudd model
|
310
321
|
|
@@ -328,6 +339,24 @@ huff_model_fit2 = Wieland2025_totalmarketareas.add_to_model(
|
|
328
339
|
print(huff_model_fit2.get_market_areas_df())
|
329
340
|
# Showing total market areas of HuffModel object
|
330
341
|
|
342
|
+
huff_model_fit3 = huff_model_fit2.ml_fit(
|
343
|
+
# Power TC function
|
344
|
+
initial_params=[0.9, -1.5, 0.5, 0.3],
|
345
|
+
bounds=[(0.5, 1), (-2, -1), (0.2, 0.7), (0.2, 0.7)],
|
346
|
+
# # Logistic TC function:
|
347
|
+
# initial_params=[0.9, 10, -0.5, 0.5, 0.3],
|
348
|
+
# bounds=[(0.5, 1), (8, 12), (-0.7, -0.2), (0.2, 0.7), (0.2, 0.7)],
|
349
|
+
fit_by="totals",
|
350
|
+
#method = "trust-constr"
|
351
|
+
)
|
352
|
+
# Fit Huff model by totals
|
353
|
+
|
354
|
+
huff_model_fit3.summary()
|
355
|
+
# Show summary
|
356
|
+
|
357
|
+
print(huff_model_fit3.get_market_areas_df())
|
358
|
+
# Show market areas df
|
359
|
+
|
331
360
|
|
332
361
|
# Buffer analysis:
|
333
362
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: huff
|
3
|
-
Version: 1.5.
|
3
|
+
Version: 1.5.2
|
4
4
|
Summary: huff: Huff Model Market Area Analysis
|
5
5
|
Author: Thomas Wieland
|
6
6
|
Author-email: geowieland@googlemail.com
|
@@ -28,16 +28,11 @@ Thomas Wieland [ORCID](https://orcid.org/0000-0001-5168-9846) [EMail](mailto:geo
|
|
28
28
|
See the /tests directory for usage examples of most of the included functions.
|
29
29
|
|
30
30
|
|
31
|
-
## Updates v1.5.
|
32
|
-
- Extensions:
|
33
|
-
- Huff model utility via InteractionMatrix.utility() extended to >2 variables
|
34
|
-
- Huff Model Maximum Likelihood fit via InteractionMatrix.huff_ml_fit() extended: >2 variables, fit by flows or probabilities
|
35
|
-
- Loading total market areas data as class MarketAreas
|
36
|
-
- Extended output of InteractionMatrix.summary()
|
31
|
+
## Updates v1.5.2
|
37
32
|
- Bugfixes:
|
38
|
-
-
|
39
|
-
-
|
40
|
-
|
33
|
+
- HuffModel.ml_fit(): Correct values of expected T_j, corrected calculation of model fit metrices when fit_by="totals"
|
34
|
+
- HuffModel.ml_fit(): Check if sum of E_ij != sum of T_j
|
35
|
+
|
41
36
|
|
42
37
|
## Features
|
43
38
|
|
@@ -45,7 +40,7 @@ See the /tests directory for usage examples of most of the included functions.
|
|
45
40
|
- Defining origins and destinations with weightings
|
46
41
|
- Creating interaction matrix from origins and destinations
|
47
42
|
- Different function types: power, exponential, logistic
|
48
|
-
- Huff model parameter estimation via Maximum Likelihood (ML) by probalities and
|
43
|
+
- Huff model parameter estimation via Maximum Likelihood (ML) by probalities, customer flows, and total market areas
|
49
44
|
- Huff model market simulation
|
50
45
|
- **Multiplicative Competitive Interaction Model**:
|
51
46
|
- Log-centering transformation of interaction matrix
|
@@ -1,10 +1,10 @@
|
|
1
1
|
huff/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
2
|
huff/gistools.py,sha256=fgeE1IsUO7UIaawb23kuiz_Rlxn7T18iLLTA5yvgp74,7038
|
3
|
-
huff/models.py,sha256=
|
3
|
+
huff/models.py,sha256=e8aILi45qcJ9tvHJfKIFKWfD-DYXjZQ0gXOS4MpG7Ks,125430
|
4
4
|
huff/ors.py,sha256=JlO2UEishQX87PIiktksOrVT5QdB-GEWgjXcxoR_KuA,11929
|
5
5
|
huff/osm.py,sha256=9A-7hxeZyjA2r8w2_IqqwH14qq2Y9AS1GxVKOD7utqs,7747
|
6
6
|
huff/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
7
|
-
huff/tests/tests_huff.py,sha256=
|
7
|
+
huff/tests/tests_huff.py,sha256=xHCR087rqLNWDFfZhi1giKDzffCx3IemWQmHrAUYxFw,12956
|
8
8
|
huff/tests/data/Haslach.cpg,sha256=OtMDH1UDpEBK-CUmLugjLMBNTqZoPULF3QovKiesmCQ,5
|
9
9
|
huff/tests/data/Haslach.dbf,sha256=GVPIt05OzDO7UrRDcsMhiYWvyXAPg6Z-qkiysFzj-fc,506
|
10
10
|
huff/tests/data/Haslach.prj,sha256=2Jy1Vlzh7UxQ1MXpZ9UYLs2SxfrObj2xkEkZyLqmGTY,437
|
@@ -23,8 +23,8 @@ huff/tests/data/Haslach_supermarkets.prj,sha256=2Jy1Vlzh7UxQ1MXpZ9UYLs2SxfrObj2x
|
|
23
23
|
huff/tests/data/Haslach_supermarkets.qmd,sha256=JlcOYzG4vI1NH1IuOpxwIPnJsCyC-pDRAI00TzEvNf0,2522
|
24
24
|
huff/tests/data/Haslach_supermarkets.shp,sha256=X7QbQ0BTMag_B-bDRbpr-go2BQIXo3Y8zMAKpYZmlps,324
|
25
25
|
huff/tests/data/Haslach_supermarkets.shx,sha256=j23QHX-SmdAeN04rw0x8nUOran-OCg_T6r_LvzzEPWs,164
|
26
|
-
huff/tests/data/Wieland2015.xlsx,sha256=
|
27
|
-
huff-1.5.
|
28
|
-
huff-1.5.
|
29
|
-
huff-1.5.
|
30
|
-
huff-1.5.
|
26
|
+
huff/tests/data/Wieland2015.xlsx,sha256=H4rxCFlctn44-O6mIyeFf67FlgvznLX7xZqpoWYS41A,25788
|
27
|
+
huff-1.5.2.dist-info/METADATA,sha256=XnlmcfscK8c1P3EN40W8JcQnFE7AkWDT4NqLR9skTIY,5956
|
28
|
+
huff-1.5.2.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
|
29
|
+
huff-1.5.2.dist-info/top_level.txt,sha256=nlzX-PxZNFmIxANIJMySuIFPihd6qOBkRlhIC28NEsQ,5
|
30
|
+
huff-1.5.2.dist-info/RECORD,,
|
File without changes
|
File without changes
|