muoblpanalyzers 0.0.1__tar.gz

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.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: muoblpanalyzers
3
+ Version: 0.0.1
4
+ Summary: Analysis metrics for solved multi objective linear programs
5
+ License-Expression: MIT
6
+ Author: Piotr Skowron
7
+ Author-email: p.skowron@mimuw.edu.pl
8
+ Requires-Python: >=3.13
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Classifier: Programming Language :: Python :: 3.15
13
+ Provides-Extra: native
14
+ Requires-Dist: muoblp (>=1.0.5,<2.0)
15
+ Requires-Dist: muoblpbindings (>=0.0.20,<0.1) ; extra == "native"
16
+ Requires-Dist: muoblpsolvers (>=0.0.15,<0.1)
17
+ Requires-Dist: pulp (==3.3.2)
18
+ Project-URL: Documentation, https://jasieksz.github.io/multiobjective-lp
19
+ Project-URL: Repository, https://github.com/algorithmic-econ/multiobjective-lp
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Analyzers
23
+ Metrics for analysing solved multi objective linear programs
24
+ (`muoblp.MultiObjectiveLpProblem` with variable values set, e.g. by a solver
25
+ from `muoblpsolvers`).
26
+
27
+ ---
28
+
29
+ ## Installation
30
+ ```shell
31
+ pip install muoblpanalyzers # python metrics
32
+ pip install "muoblpanalyzers[native]" # + C++ native metrics (muoblpbindings)
33
+ ```
34
+
35
+ ## Usage
36
+ ```python
37
+ from muoblpanalyzers import Metric, get_metrics, ejr_plus
38
+
39
+ problem.solve(solver) # any solved MultiObjectiveLpProblem
40
+
41
+ get_metrics([Metric.EXCLUSION_RATIO, Metric.EJR_PLUS_NATIVE], problem)
42
+ # {"EXCLUSION_RATIO": {"exclusion_ratio": 0.25}, "EJR_PLUS_NATIVE": {"ejr_plus": 0}}
43
+
44
+ ejr_plus(problem) # single metric -> {"ejr_plus": 0}
45
+ ```
46
+
47
+ ## Metrics
48
+ `Metric` values are stable keys (used in persisted results), never renamed.
49
+
50
+ | `Metric` | Function | Result | Notes |
51
+ |---|---|---|---|
52
+ | `EXCLUSION_RATIO` | `exclusion_ratio` | `{"exclusion_ratio": float}` | share of objectives (voters) with value 0 |
53
+ | `SUM_OBJECTIVES` | `sum_objectives` | `{"sum": float}` | sum of objective values |
54
+ | `EJR_PLUS` | `ejr_plus` | `{"ejr_plus": int}` | unelected projects violating EJR+ (up to one) |
55
+ | `CONSTRAINTS` | `invalid_constraints` | `{"pb_constraint": bool, "invalid_count": int}` | needs PB constraint |
56
+ | `INSTANCE_SIZE` | `instance_size` | `{"size": int}` | number of variables |
57
+ | `TOTAL_COST` | `total_cost` | `{"total_cost": float}` | needs PB constraint |
58
+ | `EXCLUSION_RATIO_NATIVE` | native | same as `EXCLUSION_RATIO` | needs `[native]` |
59
+ | `EJR_PLUS_NATIVE` | native | same as `EJR_PLUS` | needs `[native]` |
60
+
61
+ PB constraint = single `<=` constraint covering all candidates
62
+ (`muoblpsolvers.election_solver.validate_pb_constraint`); missing or ambiguous
63
+ raises.
64
+
65
+ ## Native metrics
66
+ C++ implementations from `muoblpbindings.metrics`, each a twin of a python
67
+ metric (`PYTHON_COUNTERPART`) with identical results (parity-tested, exact).
68
+ * Imports are lazy: python metrics work without bindings; a native metric
69
+ without bindings raises `ImportError` (no silent python fallback).
70
+ * `get_metrics` builds one `SolvedInstance` handle per problem, shared by all
71
+ requested native metrics; a snapshot of variable values at build time.
72
+
73
+ ## Adding a metric
74
+ * Python: function `(MultiObjectiveLpProblem) -> dict` in `metrics.py`,
75
+ `Metric` member in `metric.py`, entry in `PYTHON_STRATEGIES`.
76
+ * Native: follow "Adding new metric" in the
77
+ [bindings README](../bindings/README.md), then register strategy in
78
+ `NATIVE_STRATEGIES` and its python oracle in `PYTHON_COUNTERPART`
79
+ (`native_metrics.py`). Parity tests pick it up from `PYTHON_COUNTERPART`.
80
+ * `experiments` consumers: add matching `AnalyzerResult` field
81
+ (`experiments/src/helpers/analyzers/model.py`).
82
+
83
+ ## Development
84
+ ```shell
85
+ $ poetry install --all-extras
86
+ $ poetry run pytest
87
+ $ poetry run pyright
88
+ ```
89
+ C++ edits need a reinstall of the bindings into this venv:
90
+ `poetry run pip install --force-reinstall --no-deps ../bindings`.
91
+
@@ -0,0 +1,69 @@
1
+ # Analyzers
2
+ Metrics for analysing solved multi objective linear programs
3
+ (`muoblp.MultiObjectiveLpProblem` with variable values set, e.g. by a solver
4
+ from `muoblpsolvers`).
5
+
6
+ ---
7
+
8
+ ## Installation
9
+ ```shell
10
+ pip install muoblpanalyzers # python metrics
11
+ pip install "muoblpanalyzers[native]" # + C++ native metrics (muoblpbindings)
12
+ ```
13
+
14
+ ## Usage
15
+ ```python
16
+ from muoblpanalyzers import Metric, get_metrics, ejr_plus
17
+
18
+ problem.solve(solver) # any solved MultiObjectiveLpProblem
19
+
20
+ get_metrics([Metric.EXCLUSION_RATIO, Metric.EJR_PLUS_NATIVE], problem)
21
+ # {"EXCLUSION_RATIO": {"exclusion_ratio": 0.25}, "EJR_PLUS_NATIVE": {"ejr_plus": 0}}
22
+
23
+ ejr_plus(problem) # single metric -> {"ejr_plus": 0}
24
+ ```
25
+
26
+ ## Metrics
27
+ `Metric` values are stable keys (used in persisted results), never renamed.
28
+
29
+ | `Metric` | Function | Result | Notes |
30
+ |---|---|---|---|
31
+ | `EXCLUSION_RATIO` | `exclusion_ratio` | `{"exclusion_ratio": float}` | share of objectives (voters) with value 0 |
32
+ | `SUM_OBJECTIVES` | `sum_objectives` | `{"sum": float}` | sum of objective values |
33
+ | `EJR_PLUS` | `ejr_plus` | `{"ejr_plus": int}` | unelected projects violating EJR+ (up to one) |
34
+ | `CONSTRAINTS` | `invalid_constraints` | `{"pb_constraint": bool, "invalid_count": int}` | needs PB constraint |
35
+ | `INSTANCE_SIZE` | `instance_size` | `{"size": int}` | number of variables |
36
+ | `TOTAL_COST` | `total_cost` | `{"total_cost": float}` | needs PB constraint |
37
+ | `EXCLUSION_RATIO_NATIVE` | native | same as `EXCLUSION_RATIO` | needs `[native]` |
38
+ | `EJR_PLUS_NATIVE` | native | same as `EJR_PLUS` | needs `[native]` |
39
+
40
+ PB constraint = single `<=` constraint covering all candidates
41
+ (`muoblpsolvers.election_solver.validate_pb_constraint`); missing or ambiguous
42
+ raises.
43
+
44
+ ## Native metrics
45
+ C++ implementations from `muoblpbindings.metrics`, each a twin of a python
46
+ metric (`PYTHON_COUNTERPART`) with identical results (parity-tested, exact).
47
+ * Imports are lazy: python metrics work without bindings; a native metric
48
+ without bindings raises `ImportError` (no silent python fallback).
49
+ * `get_metrics` builds one `SolvedInstance` handle per problem, shared by all
50
+ requested native metrics; a snapshot of variable values at build time.
51
+
52
+ ## Adding a metric
53
+ * Python: function `(MultiObjectiveLpProblem) -> dict` in `metrics.py`,
54
+ `Metric` member in `metric.py`, entry in `PYTHON_STRATEGIES`.
55
+ * Native: follow "Adding new metric" in the
56
+ [bindings README](../bindings/README.md), then register strategy in
57
+ `NATIVE_STRATEGIES` and its python oracle in `PYTHON_COUNTERPART`
58
+ (`native_metrics.py`). Parity tests pick it up from `PYTHON_COUNTERPART`.
59
+ * `experiments` consumers: add matching `AnalyzerResult` field
60
+ (`experiments/src/helpers/analyzers/model.py`).
61
+
62
+ ## Development
63
+ ```shell
64
+ $ poetry install --all-extras
65
+ $ poetry run pytest
66
+ $ poetry run pyright
67
+ ```
68
+ C++ edits need a reinstall of the bindings into this venv:
69
+ `poetry run pip install --force-reinstall --no-deps ../bindings`.
@@ -0,0 +1,64 @@
1
+ [project]
2
+ version = "0.0.1"
3
+ name = "muoblpanalyzers"
4
+ description = "Analysis metrics for solved multi objective linear programs"
5
+
6
+ authors = [
7
+ { name = "Piotr Skowron", email = "p.skowron@mimuw.edu.pl" },
8
+ { name = "Jan Sznajd", email = "j.sznajd@uw.edu.pl" }
9
+ ]
10
+ license = "MIT"
11
+ readme = "README.md"
12
+
13
+ requires-python = ">=3.13"
14
+
15
+ dependencies = [
16
+ "muoblp>=1.0.5,<2.0",
17
+ "muoblpsolvers>=0.0.15,<0.1",
18
+ "pulp==3.3.2",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ # native (C++) metrics: muoblpbindings.metrics, imported lazily
23
+ native = ["muoblpbindings>=0.0.20,<0.1"]
24
+
25
+ [project.urls]
26
+ repository = "https://github.com/algorithmic-econ/multiobjective-lp"
27
+ documentation = "https://jasieksz.github.io/multiobjective-lp"
28
+
29
+ [tool.poetry]
30
+ packages = [{include = "muoblpanalyzers", from = "src"}]
31
+
32
+ # Single-style policy: static [project.dependencies] holds the
33
+ # published version pins; this table ONLY enriches them with local path
34
+ # sources for dev installs. Poetry 2.x uses [project] for wheel METADATA
35
+ # and this table for install/lock -- do not move deps back here alone or
36
+ # wheels regain broken file:// refs.
37
+ [tool.poetry.dependencies]
38
+ muoblp = {path = "../core", develop = true}
39
+ muoblpsolvers = {path = "../solvers", develop = true}
40
+ # explicit path wiring: solvers' path enrichment for muoblpbindings
41
+ # doesn't propagate transitively on relock
42
+ muoblpbindings = {path = "../bindings", develop = false, optional = true}
43
+
44
+ [build-system]
45
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
46
+ build-backend = "poetry.core.masonry.api"
47
+
48
+ [tool.ruff]
49
+ line-length = 79
50
+ preview = true
51
+
52
+ [tool.pytest.ini_options]
53
+ minversion = "8.0"
54
+ addopts = "-ra -q --import-mode=importlib"
55
+ testpaths = [
56
+ "tests",
57
+ ]
58
+
59
+ [dependency-groups]
60
+ dev = [
61
+ "pytest (>=8.4,<9.0)",
62
+ "ruff (==0.15.1)", # = CI (ruff.yml); preview rules drift across patches
63
+ "pyright (>=1.1,<2.0)"
64
+ ]
@@ -0,0 +1,39 @@
1
+ import logging
2
+
3
+ from muoblpanalyzers.metric import Metric
4
+ from muoblpanalyzers.metrics import (
5
+ PYTHON_STRATEGIES,
6
+ ejr_plus,
7
+ exclusion_ratio,
8
+ get_metric_strategy,
9
+ get_metrics,
10
+ instance_size,
11
+ invalid_constraints,
12
+ sum_objectives,
13
+ total_cost,
14
+ )
15
+ from muoblpanalyzers.native_metrics import (
16
+ NATIVE_STRATEGIES,
17
+ PYTHON_COUNTERPART,
18
+ build_solved_instance,
19
+ )
20
+
21
+ __all__ = [
22
+ "NATIVE_STRATEGIES",
23
+ "PYTHON_COUNTERPART",
24
+ "PYTHON_STRATEGIES",
25
+ "Metric",
26
+ "build_solved_instance",
27
+ "ejr_plus",
28
+ "exclusion_ratio",
29
+ "get_metric_strategy",
30
+ "get_metrics",
31
+ "instance_size",
32
+ "invalid_constraints",
33
+ "sum_objectives",
34
+ "total_cost",
35
+ ]
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ logger.addHandler(logging.NullHandler())
@@ -0,0 +1,15 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class Metric(StrEnum):
5
+ # values are on-disk keys (metrics.json, goldens): never rename
6
+ EXCLUSION_RATIO = "EXCLUSION_RATIO"
7
+ SUM_OBJECTIVES = "SUM_OBJECTIVES"
8
+ EJR_PLUS = "EJR_PLUS"
9
+ CONSTRAINTS = "CONSTRAINTS"
10
+ INSTANCE_SIZE = "INSTANCE_SIZE"
11
+ TOTAL_COST = "TOTAL_COST"
12
+ # native (C++ bindings) twin of EXCLUSION_RATIO
13
+ EXCLUSION_RATIO_NATIVE = "EXCLUSION_RATIO_NATIVE"
14
+ # native (C++ bindings) twin of EJR_PLUS
15
+ EJR_PLUS_NATIVE = "EJR_PLUS_NATIVE"
@@ -0,0 +1,124 @@
1
+ from collections.abc import Callable
2
+ from typing import cast
3
+
4
+ from muoblp.model.multi_objective_lp import MultiObjectiveLpProblem
5
+ from muoblpsolvers.election_solver import validate_pb_constraint
6
+
7
+ from muoblpanalyzers import native_metrics
8
+ from muoblpanalyzers.metric import Metric
9
+
10
+
11
+ def get_metrics(
12
+ metrics: list[Metric], problem: MultiObjectiveLpProblem
13
+ ) -> dict[str, dict]:
14
+ # native handle built once per problem, only if a native metric is asked
15
+ instance = (
16
+ native_metrics.build_solved_instance(problem)
17
+ if any(m in native_metrics.NATIVE_STRATEGIES for m in metrics)
18
+ else None
19
+ )
20
+ results: dict[str, dict] = {}
21
+ for metric in metrics:
22
+ if instance is not None and metric in native_metrics.NATIVE_STRATEGIES:
23
+ results[metric] = native_metrics.NATIVE_STRATEGIES[metric](
24
+ instance
25
+ )
26
+ else:
27
+ results[metric] = get_metric_strategy(metric)(problem)
28
+ return results
29
+
30
+
31
+ def get_metric_strategy(
32
+ metric: Metric,
33
+ ) -> Callable[[MultiObjectiveLpProblem], dict]:
34
+ # native metrics have no python strategy (see NATIVE_STRATEGIES)
35
+ try:
36
+ return PYTHON_STRATEGIES[metric]
37
+ except KeyError:
38
+ raise NotImplementedError("Metric not implemented") from None
39
+
40
+
41
+ def exclusion_ratio(problem: MultiObjectiveLpProblem) -> dict:
42
+ zero_count = len([1 for obj in problem.objectives if obj.value() == 0])
43
+ return {
44
+ "exclusion_ratio": zero_count / len(problem.objectives),
45
+ }
46
+
47
+
48
+ def sum_objectives(problem: MultiObjectiveLpProblem) -> dict:
49
+ # expects a solved problem: every variable has a value, so value() is
50
+ # never None here
51
+ return {
52
+ "sum": sum(cast(float, obj.value()) for obj in problem.objectives),
53
+ }
54
+
55
+
56
+ def total_cost(problem: MultiObjectiveLpProblem) -> dict:
57
+ pb_constraint = validate_pb_constraint(problem)
58
+ costs = {candidate.name: cost for candidate, cost in pb_constraint.items()}
59
+ selected = [v for v in problem.variables() if v.value() == 1.0]
60
+ return {
61
+ "total_cost": sum(costs[c.name] for c in selected),
62
+ }
63
+
64
+
65
+ def invalid_constraints(problem: MultiObjectiveLpProblem) -> dict:
66
+ total_budget_constraint = validate_pb_constraint(problem)
67
+ return {
68
+ "pb_constraint": total_budget_constraint.valid(),
69
+ "invalid_count": sum(
70
+ 1 if not c.valid() else 0 for c in problem.constraints.values()
71
+ ),
72
+ }
73
+
74
+
75
+ def instance_size(problem: MultiObjectiveLpProblem) -> dict:
76
+ return {"size": len(problem.variables())}
77
+
78
+
79
+ def ejr_plus(problem: MultiObjectiveLpProblem) -> dict:
80
+ # TODO: Check if utility was COST based
81
+ # TODO: Introduce metric options, i.e., pass up_to_one as config parameter
82
+ up_to_one = True
83
+
84
+ costs: dict[str, int] = {
85
+ candidate.name: cost
86
+ for constraint in problem.constraints.values()
87
+ for candidate, cost in constraint.items()
88
+ }
89
+
90
+ total_budget = abs(validate_pb_constraint(problem).constant)
91
+
92
+ voter_satisfaction = sorted(
93
+ [(voter, cast(float, voter.value())) for voter in problem.objectives],
94
+ key=lambda v_sat: v_sat[1],
95
+ )
96
+ voter_count = len(problem.objectives)
97
+ failures = []
98
+
99
+ for project in problem.variables():
100
+ if project.value() == 1: # skip elected
101
+ continue
102
+ coalition_size = 0
103
+ for voter, satisfaction in voter_satisfaction:
104
+ # if voter approved of project
105
+ if project in voter:
106
+ coalition_size += 1
107
+ ejr_satisfied = satisfaction >= (
108
+ coalition_size / voter_count
109
+ ) * total_budget - (costs[project.name] if up_to_one else 0)
110
+ if not ejr_satisfied:
111
+ failures.append(project.name)
112
+ break
113
+
114
+ return {"ejr_plus": len(failures)}
115
+
116
+
117
+ PYTHON_STRATEGIES: dict[Metric, Callable[[MultiObjectiveLpProblem], dict]] = {
118
+ Metric.EXCLUSION_RATIO: exclusion_ratio,
119
+ Metric.SUM_OBJECTIVES: sum_objectives,
120
+ Metric.EJR_PLUS: ejr_plus,
121
+ Metric.CONSTRAINTS: invalid_constraints,
122
+ Metric.INSTANCE_SIZE: instance_size,
123
+ Metric.TOTAL_COST: total_cost,
124
+ }
@@ -0,0 +1,50 @@
1
+ """Native metrics: C++ implementations from `muoblpbindings.metrics`.
2
+
3
+ Each has its own Metric name and a python counterpart (oracle) for parity
4
+ tests. Imports are lazy: python-only analysis works without the metrics
5
+ module, and a native request fails loudly with ImportError.
6
+ """
7
+
8
+ from collections.abc import Callable
9
+ from typing import TYPE_CHECKING
10
+
11
+ from muoblp.model.multi_objective_lp import MultiObjectiveLpProblem
12
+
13
+ from muoblpanalyzers.metric import Metric
14
+
15
+ if TYPE_CHECKING:
16
+ from muoblpbindings.metrics import SolvedInstance
17
+
18
+
19
+ def build_solved_instance(
20
+ problem: MultiObjectiveLpProblem,
21
+ ) -> "SolvedInstance":
22
+ from muoblpbindings.metrics import SolvedInstance
23
+
24
+ return SolvedInstance(problem)
25
+
26
+
27
+ def exclusion_ratio_native(instance: "SolvedInstance") -> dict:
28
+ from muoblpbindings.metrics import exclusion_ratio
29
+
30
+ # same inner key as python metric -> values compare directly
31
+ return {"exclusion_ratio": exclusion_ratio(instance)}
32
+
33
+
34
+ def ejr_plus_native(instance: "SolvedInstance") -> dict:
35
+ from muoblpbindings.metrics import ejr_plus
36
+
37
+ # up_to_one=True, same as the python metric
38
+ return {"ejr_plus": ejr_plus(instance, up_to_one=True)}
39
+
40
+
41
+ NATIVE_STRATEGIES: dict[Metric, Callable[["SolvedInstance"], dict]] = {
42
+ Metric.EXCLUSION_RATIO_NATIVE: exclusion_ratio_native,
43
+ Metric.EJR_PLUS_NATIVE: ejr_plus_native,
44
+ }
45
+
46
+ # parity tests iterate this map: every native metric must be registered here
47
+ PYTHON_COUNTERPART: dict[Metric, Metric] = {
48
+ Metric.EXCLUSION_RATIO_NATIVE: Metric.EXCLUSION_RATIO,
49
+ Metric.EJR_PLUS_NATIVE: Metric.EJR_PLUS,
50
+ }
File without changes