agriflow-api / benchmarks /_metrics.py
masterAAA123's picture
Space deploy v5: orphan snapshot of main, zero binary files
b81a86b
Raw
History Blame Contribute Delete
7.8 kB
"""
benchmarks/_metrics.py — Pure equity metrics helpers.
No engine imports. All functions are pure (no side effects, no I/O).
These are the five metrics columns in the baseline comparison table.
Metric definitions
------------------
total_deficit_covered
Volume-weighted coverage: sum of min(matched, demanded) across all
(kab, commodity, segment) keys divided by total demand volume.
This is tons fulfilled / tons demanded, NOT count of kab covered.
A 200-ton deficit that is 50% filled counts more than a 10-ton
deficit that is 100% filled.
gini
Weighted Gini coefficient from the Lorenz curve formulation:
G = Σᵢ Σⱼ wᵢ wⱼ |rᵢ − rⱼ| / (2 (Σwᵢ)² r̄)
where:
rᵢ = fulfillment ratio for node i (matched_tons / demand_tons)
wᵢ = demand volume weight (demand_tons for node i)
r̄ = weighted mean fulfillment ratio
Verification invariants:
gini_weighted([1,1,1,1], equal weights) ≈ 0.0 (perfectly equal)
gini_weighted([1,0,0,0], equal weights) > 0.6 (maximum inequality)
uniform allocation → Gini ≈ 0 (everyone gets same ratio)
pure greedy → Gini highest (best nodes get everything first)
atkinson
Atkinson index A(ε) = 1 − (1/μ) * (Σᵢ wᵢ rᵢ^(1−ε) / Σᵢ wᵢ)^(1/(1−ε))
for ε ≠ 1. For ε = 1: A(1) = 1 − exp(Σᵢ wᵢ ln(rᵢ) / Σᵢ wᵢ) / μ.
Reported at ε=0.5 (moderate inequality aversion) and ε=1.0 (strong).
wᵢ = demand_tons weight. rᵢ values of 0 handled by clamping to 1e-9
before log, consistent with standard practice.
min_fulfillment
Leximin gap = the single worst fulfillment ratio across all nodes.
One number per strategy; higher is better.
kab_fulfillment
Volume-weighted fulfillment ratio for a single kabupaten across all
its (commodity, segment) demand nodes.
Used for Sampang (3527) and Bangkalan (3526) headline numbers.
"""
from __future__ import annotations
import math
from typing import Dict, Tuple
# Key type used throughout: (kab_id, commodity_code, segment_value) -> float
_Key = Tuple[str, str, str]
def fulfillment_by_node(
matched_tons: Dict[_Key, float],
demand_tons: Dict[_Key, float],
) -> Dict[_Key, float]:
"""
Per-node fulfillment ratio capped at 1.0.
Args:
matched_tons: dict key -> tons matched (may be missing for unmatched nodes)
demand_tons: dict key -> tons demanded (positive values only)
Returns:
dict key -> ratio in [0.0, 1.0] for every key with demand_tons > 0.
"""
return {
k: min(1.0, matched_tons.get(k, 0.0) / v)
for k, v in demand_tons.items()
if v > 0
}
def total_deficit_covered(
matched_tons: Dict[_Key, float],
demand_tons: Dict[_Key, float],
) -> float:
"""
Volume-weighted coverage ratio: tons fulfilled / tons demanded.
This is NOT a count of kabupaten covered — it weights each node by
its demand volume so a 200-ton deficit that is half-filled (100t)
contributes more than a 10-ton deficit that is fully filled (10t).
Returns a float in [0.0, 1.0].
"""
total = sum(demand_tons.values())
if total == 0:
return 0.0
covered = sum(
min(matched_tons.get(k, 0.0), v) for k, v in demand_tons.items()
)
return covered / total
def gini(
matched_tons: Dict[_Key, float],
demand_tons: Dict[_Key, float],
) -> float:
"""
Weighted Gini from the Lorenz curve mean-absolute-difference formula.
G = Σᵢ Σⱼ wᵢ wⱼ |rᵢ − rⱼ| / (2 (Σwᵢ)² r̄)
wᵢ = demand_tons[i] (volume weight)
rᵢ = fulfillment ratio for node i, capped at 1.0
Returns 0.0 when all fulfillment ratios are identical (uniform allocation).
Returns near-maximum when one node gets everything and others get nothing.
Verification:
gini({k: 1 for all k}, equal weights) == 0.0
gini({k: 0 for all k except one}, equal weights) > 0.6
uniform allocation produces Gini ≈ 0 (all ratios equal)
"""
nodes = [(k, v) for k, v in demand_tons.items() if v > 0]
if not nodes:
return 0.0
ratios = [min(1.0, matched_tons.get(k, 0.0) / v) for k, v in nodes]
weights = [v for _, v in nodes]
w_sum = sum(weights)
if w_sum == 0:
return 0.0
# Weighted mean fulfillment
r_bar = sum(w * r for w, r in zip(weights, ratios)) / w_sum
if r_bar == 0:
return 0.0
# Double-sum formulation: O(n²) but n is at most ~38*19*4 ≈ 2888 for Jatim.
# Formula: G = Σ_all_ij wᵢwⱼ|rᵢ−rⱼ| / (2 w_sum² r̄)
# Σ_all_ij = 2 * Σ_{i<j} (since |rᵢ−rⱼ| is symmetric and diagonal is 0).
# Therefore: G = 2*Σ_{i<j} wᵢwⱼ|rᵢ−rⱼ| / (2 w_sum² r̄) = Σ_{i<j} / (w_sum² r̄).
total = 0.0
n = len(ratios)
for i in range(n):
for j in range(i + 1, n):
total += weights[i] * weights[j] * abs(ratios[i] - ratios[j])
return total / (w_sum ** 2 * r_bar)
def atkinson(
matched_tons: Dict[_Key, float],
demand_tons: Dict[_Key, float],
epsilon: float = 0.5,
) -> float:
"""
Atkinson inequality index A(ε).
For ε ≠ 1:
A(ε) = 1 − (1/μ) * (Σᵢ wᵢ rᵢ^(1−ε) / Σᵢ wᵢ)^(1/(1−ε))
For ε = 1 (limiting case):
A(1) = 1 − exp(Σᵢ wᵢ ln(rᵢ) / Σᵢ wᵢ) / μ
wᵢ = demand_tons weight.
rᵢ = fulfillment ratio capped at 1.0.
rᵢ = 0 clamped to 1e-9 before log (standard handling).
Returns value in [0.0, 1.0]; higher means more inequality.
"""
nodes = [(k, v) for k, v in demand_tons.items() if v > 0]
if not nodes:
return 0.0
ratios = [min(1.0, matched_tons.get(k, 0.0) / v) for k, v in nodes]
weights = [v for _, v in nodes]
w_sum = sum(weights)
if w_sum == 0:
return 0.0
mu = sum(w * r for w, r in zip(weights, ratios)) / w_sum
if mu == 0:
return 1.0 # everyone gets nothing → maximum inequality by convention
if abs(epsilon - 1.0) < 1e-9:
# Geometric mean formulation
log_sum = sum(
w * math.log(max(r, 1e-9)) for w, r in zip(weights, ratios)
)
geom_mean = math.exp(log_sum / w_sum)
return 1.0 - geom_mean / mu
else:
power = 1.0 - epsilon
moment = sum(
w * (max(r, 1e-9) ** power) for w, r in zip(weights, ratios)
) / w_sum
ede = moment ** (1.0 / power)
return 1.0 - ede / mu
def min_fulfillment(
matched_tons: Dict[_Key, float],
demand_tons: Dict[_Key, float],
) -> float:
"""
Leximin gap: fulfillment ratio of the single worst-served demand node.
A strategy that sacrifices one kab entirely will score 0.0 here.
Higher is better.
Returns 0.0 if there are no demand nodes.
"""
nodes = [(k, v) for k, v in demand_tons.items() if v > 0]
if not nodes:
return 0.0
ratios = [min(1.0, matched_tons.get(k, 0.0) / v) for k, v in nodes]
return min(ratios)
def kab_fulfillment(
matched_tons: Dict[_Key, float],
demand_tons: Dict[_Key, float],
kab_id: str,
) -> float:
"""
Volume-weighted fulfillment ratio for a single kabupaten across all
its (commodity, segment) demand nodes.
Used for Sampang (3527) and Bangkalan (3526) headline pitch numbers.
Returns 0.0 if kab_id has no demand entries.
"""
kab_demand = {k: v for k, v in demand_tons.items() if k[0] == kab_id}
if not kab_demand:
return 0.0
total_demand = sum(kab_demand.values())
if total_demand == 0:
return 0.0
total_matched = sum(
min(matched_tons.get(k, 0.0), v) for k, v in kab_demand.items()
)
return total_matched / total_demand