mdpsolver 0.10.1__tar.gz → 0.10.3__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.
- {mdpsolver-0.10.1 → mdpsolver-0.10.3}/LICENSE +1 -1
- {mdpsolver-0.10.1 → mdpsolver-0.10.3}/PKG-INFO +3 -3
- {mdpsolver-0.10.1 → mdpsolver-0.10.3}/README.md +1 -1
- {mdpsolver-0.10.1 → mdpsolver-0.10.3}/pyproject.toml +2 -2
- {mdpsolver-0.10.1 → mdpsolver-0.10.3}/src/mdpsolver/model.py +57 -7
- mdpsolver-0.10.3/tests/knownsolutions.py +300 -0
- mdpsolver-0.10.3/tests/loadmdpsolver.py +148 -0
- mdpsolver-0.10.3/tests/test_imports.py +491 -0
- mdpsolver-0.10.3/tests/test_large.py +285 -0
- mdpsolver-0.10.3/tests/test_mpi.py +440 -0
- mdpsolver-0.10.3/tests/test_pi.py +431 -0
- mdpsolver-0.10.3/tests/test_random.py +318 -0
- mdpsolver-0.10.3/tests/test_reuse.py +287 -0
- mdpsolver-0.10.3/tests/test_vi.py +419 -0
- mdpsolver-0.10.1/tests/example_model.py +0 -139
- mdpsolver-0.10.1/tests/rewards_example2.csv +0 -403
- mdpsolver-0.10.1/tests/test1.py +0 -717
- mdpsolver-0.10.1/tests/test1_pip.py +0 -713
- mdpsolver-0.10.1/tests/test2.py +0 -94
- mdpsolver-0.10.1/tests/test2_pip.py +0 -90
- mdpsolver-0.10.1/tests/transitions_example2.csv +0 -41205
- {mdpsolver-0.10.1 → mdpsolver-0.10.3}/MANIFEST.in +0 -0
- {mdpsolver-0.10.1 → mdpsolver-0.10.3}/src/mdpsolver/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c)
|
|
3
|
+
Copyright (c) 2026 Anders Reenberg Andersen and Jesper Fink Andersen
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: mdpsolver
|
|
3
|
-
Version: 0.10.
|
|
4
|
-
Summary: A fast solver for Markov Decision Processes
|
|
3
|
+
Version: 0.10.3
|
|
4
|
+
Summary: A fast Python solver for Markov Decision Processes
|
|
5
5
|
Author-Email: Anders Reenberg Andersen <andersra@live.dk>, Jesper Fink Andersen <jesperfinkandersen@gmail.com>
|
|
6
6
|
Classifier: Development Status :: 4 - Beta
|
|
7
7
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -31,7 +31,7 @@ MDPSolver is a Python package for large Markov Decision Processes (MDPs) with in
|
|
|
31
31
|
|
|
32
32
|
# Installation
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
Install directly from PyPI with:
|
|
35
35
|
|
|
36
36
|
```
|
|
37
37
|
pip install mdpsolver
|
|
@@ -13,7 +13,7 @@ MDPSolver is a Python package for large Markov Decision Processes (MDPs) with in
|
|
|
13
13
|
|
|
14
14
|
# Installation
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
Install directly from PyPI with:
|
|
17
17
|
|
|
18
18
|
```
|
|
19
19
|
pip install mdpsolver
|
|
@@ -15,12 +15,12 @@ CMAKE_BUILD_TYPE = "Release"
|
|
|
15
15
|
|
|
16
16
|
[project]
|
|
17
17
|
name = "mdpsolver"
|
|
18
|
-
version = "0.10.
|
|
18
|
+
version = "0.10.3"
|
|
19
19
|
authors = [
|
|
20
20
|
{ name="Anders Reenberg Andersen", email="andersra@live.dk" },
|
|
21
21
|
{ name="Jesper Fink Andersen", email="jesperfinkandersen@gmail.com" },
|
|
22
22
|
]
|
|
23
|
-
description = "A fast solver for Markov Decision Processes"
|
|
23
|
+
description = "A fast Python solver for Markov Decision Processes"
|
|
24
24
|
readme = "README.md"
|
|
25
25
|
requires-python = ">=3.10"
|
|
26
26
|
classifiers = [
|
|
@@ -24,6 +24,9 @@ class model:
|
|
|
24
24
|
def initialize(self):
|
|
25
25
|
"""Create and initialize the solver module object."""
|
|
26
26
|
self.mdl = solvermodule.Model()
|
|
27
|
+
# A model object contains only one model
|
|
28
|
+
self._modelDefined = False
|
|
29
|
+
self._solved = False
|
|
27
30
|
|
|
28
31
|
def solve(
|
|
29
32
|
self,
|
|
@@ -45,35 +48,63 @@ class model:
|
|
|
45
48
|
|
|
46
49
|
Args:
|
|
47
50
|
algorithm (str): Algorithm to use.
|
|
48
|
-
tolerance (float): Convergence threshold for the algorithm.
|
|
49
|
-
update (str): The value-update method.
|
|
51
|
+
tolerance (float): Convergence threshold for the algorithm (0 < tolerance < 1).
|
|
52
|
+
update (str): The value-update method ('standard', 'gs' or 'sor'; only 'standard' with the average reward criterion).
|
|
50
53
|
criterion (str): The optimality criterion.
|
|
51
54
|
parIterLim (int): The partial evaluation limit employed in the modified policy iteration algorithm.
|
|
52
55
|
SORrelaxation (float): Relaxation parameter for the Successive Over-Relaxation method.
|
|
53
|
-
initPolicy (list): A 1D-list defining the initial policy. This option can be used for "warm starting" the optimization procedure.
|
|
54
|
-
initValueVector (list): A 1D-list defining the initial value vector. This option can be used for "warm starting" the optimization procedure.
|
|
56
|
+
initPolicy (list): A 1D-list defining the initial policy, one valid action index per state. This option can be used for "warm starting" the optimization procedure.
|
|
57
|
+
initValueVector (list): A 1D-list defining the initial value vector, one value per state. This option can be used for "warm starting" the optimization procedure.
|
|
55
58
|
verbose (bool): If True, prints solver progress to console.
|
|
56
59
|
postProcessing (bool): If True, performs post-processing after solving.
|
|
57
60
|
makeFinalCheck (bool): If True, makes a final check of the value vector. This process checks if the resulting values are reasonable.
|
|
58
61
|
parallel (bool): If True, enables parallel computation for faster solving. This option is only available for the custom MDP model with standard updates.
|
|
59
62
|
|
|
63
|
+
Solving the same model object again starts from the policy and the
|
|
64
|
+
value vector of the previous run, unless initPolicy or initValueVector
|
|
65
|
+
replace them. Create a new model object to start over.
|
|
66
|
+
|
|
60
67
|
Returns:
|
|
61
68
|
None
|
|
62
69
|
"""
|
|
63
70
|
|
|
64
71
|
if not (algorithm == "mpi" or algorithm == "pi" or algorithm == "vi"):
|
|
65
72
|
sys.exit("Error: Algorithm type not recognized. Select either: 'mpi', 'pi', or 'vi'.")
|
|
66
|
-
if tolerance <= 0.0:
|
|
67
|
-
sys.exit("Error: The tolerance needs to be
|
|
73
|
+
if tolerance <= 0.0 or tolerance >= 1.0:
|
|
74
|
+
sys.exit("Error: The tolerance needs to be between 0.0 and 1.0 (i.e. 0 < tolerance < 1).")
|
|
68
75
|
if not (update == "standard" or update == "gs" or update == "sor"):
|
|
69
76
|
sys.exit("Error: Update method not recognized. Select either: 'standard', 'gs', or 'sor'.")
|
|
70
77
|
if not (criterion == "discounted" or criterion == "average"):
|
|
71
78
|
sys.exit("Error: Optimality criterion not recognized. Select either: 'discounted' or 'average'.")
|
|
79
|
+
if criterion == "average" and (update == "gs" or update == "sor"):
|
|
80
|
+
sys.exit("Error: Gauss-Seidel and SOR updates are not available with the average reward criterion. Select update='standard'.")
|
|
72
81
|
if parIterLim <= 0 and algorithm == "mpi":
|
|
73
82
|
sys.exit("Error: The partial evaluation limit needs to be a positive number.")
|
|
74
83
|
if (SORrelaxation <= 0.0 or SORrelaxation >= 2.0) and update == "sor":
|
|
75
84
|
sys.exit("Error: The relaxation parameter needs to be between 0.0 and 2.0 (i.e. 0 < SORrelaxation < 2).")
|
|
76
|
-
|
|
85
|
+
if len(initPolicy) > 0 or len(initValueVector) > 0:
|
|
86
|
+
numberOfStates = self.mdl.getNumberOfStates()
|
|
87
|
+
if numberOfStates >= 0 and len(initPolicy) > 0 and len(initPolicy) != numberOfStates:
|
|
88
|
+
sys.exit("Error: The initial policy needs one action index per state (the model has %d states, initPolicy has %d elements)." % (numberOfStates, len(initPolicy)))
|
|
89
|
+
if numberOfStates >= 0 and len(initValueVector) > 0 and len(initValueVector) != numberOfStates:
|
|
90
|
+
sys.exit("Error: The initial value vector needs one value per state (the model has %d states, initValueVector has %d elements)." % (numberOfStates, len(initValueVector)))
|
|
91
|
+
if numberOfStates >= 0 and len(initPolicy) > 0:
|
|
92
|
+
for stateIndex, action in enumerate(initPolicy):
|
|
93
|
+
numberOfActions = self.mdl.getNumberOfActions(stateIndex=stateIndex)
|
|
94
|
+
if not 0 <= action < numberOfActions:
|
|
95
|
+
sys.exit("Error: The initial policy assigns action %s to state %d, which has %d actions (indices 0 to %d)." % (action, stateIndex, numberOfActions, numberOfActions - 1))
|
|
96
|
+
|
|
97
|
+
# The solver keeps the policy and the value vector of the previous run,
|
|
98
|
+
# unless initPolicy or initValueVector replace them.
|
|
99
|
+
|
|
100
|
+
if self._solved:
|
|
101
|
+
# Value iteration never uses the policy it starts from
|
|
102
|
+
reusesPolicy = len(initPolicy) == 0 and algorithm != "vi"
|
|
103
|
+
reusesValueVector = len(initValueVector) == 0
|
|
104
|
+
if reusesPolicy or reusesValueVector:
|
|
105
|
+
print("Note: This model has been solved before. Create a new model object to run solver with initial values.", flush=True)
|
|
106
|
+
self._solved = True
|
|
107
|
+
|
|
77
108
|
self.mdl.solve(
|
|
78
109
|
algorithm=algorithm,
|
|
79
110
|
tolerance=tolerance,
|
|
@@ -98,6 +129,17 @@ class model:
|
|
|
98
129
|
"""
|
|
99
130
|
return self.mdl.getRuntime()
|
|
100
131
|
|
|
132
|
+
def getIterations(self) -> int:
|
|
133
|
+
"""
|
|
134
|
+
Get the number of iterations of the last solver execution.
|
|
135
|
+
An iteration is one policy improvement step, i.e. one update that
|
|
136
|
+
chooses the best action in every state.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
int: Number of iterations.
|
|
140
|
+
"""
|
|
141
|
+
return self.mdl.getIterations()
|
|
142
|
+
|
|
101
143
|
def printPolicy(self) -> None:
|
|
102
144
|
"""Print the entire policy to the terminal."""
|
|
103
145
|
self.mdl.printPolicy()
|
|
@@ -191,13 +233,21 @@ class model:
|
|
|
191
233
|
tranMatColumns (list): Sparse transition probabilities (option 2, part 2). A 3D-list containing the columns of the non-zero transition probabilities.
|
|
192
234
|
tranMatFromFile (str): Load the transition probabilities from a comma-separated (,) file.
|
|
193
235
|
|
|
236
|
+
A model object holds one model, so mdp() can be called only once per
|
|
237
|
+
model object. Create a new model object to define another model.
|
|
238
|
+
|
|
194
239
|
Returns:
|
|
195
240
|
None
|
|
196
241
|
"""
|
|
197
242
|
|
|
243
|
+
if self._modelDefined:
|
|
244
|
+
sys.exit("Error: A model has already been defined for this model object, and mdp() can only be called once per model object. Create a new model object (e.g. mdl = mdpsolver.model()) to define another model.")
|
|
198
245
|
if discount <= 0.0 or discount >= 1.0:
|
|
199
246
|
sys.exit("Error: The discount needs to be in the interval between 0.0 and 1.0 (i.e. 0 < discount < 1).")
|
|
200
247
|
|
|
248
|
+
# Set before loading, so that a load that fails halfway cannot be
|
|
249
|
+
# followed by another into the same object
|
|
250
|
+
self._modelDefined = True
|
|
201
251
|
self.mdl.mdp(
|
|
202
252
|
discount=discount,
|
|
203
253
|
rewards=rewards,
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
'''
|
|
2
|
+
PROBLEMS WITH KNOWN SOLUTIONS, AND EXACT SOLUTIONS, FOR THE TESTS IN THIS FOLDER.
|
|
3
|
+
|
|
4
|
+
A problem is given as rewards[s][a] and transitions[s][a] = {next state:
|
|
5
|
+
probability}, from which the tests build the arguments of mdp().
|
|
6
|
+
|
|
7
|
+
The hub-and-spoke problem. In the hub (state 0) one either rests, for a reward
|
|
8
|
+
REST per period, or starts one of three ventures, which succeeds with
|
|
9
|
+
probability 1 - FAIL. Venture k (state k) pays R_k per period and ends with
|
|
10
|
+
probability q_k per period, back in the hub; two of the ventures can also be
|
|
11
|
+
quit, for nothing, back to the hub. All rewards are non-negative. With discount
|
|
12
|
+
factor lambda, the hub is worth m/(1 - lambda), where m is the best of REST and
|
|
13
|
+
lambda(1 - FAIL)R_k/(1 + lambda(q_k - FAIL)), the per-period worth of resting or
|
|
14
|
+
of venture k; a venture is continued if R_k >= lambda(1 - q_k)m, and is then
|
|
15
|
+
worth (R_k + lambda q_k v_hub)/(1 - lambda(1 - q_k)), and is quit otherwise,
|
|
16
|
+
worth lambda v_hub. The constants are chosen so that the best choice in the hub
|
|
17
|
+
moves from resting, to the short venture, to the long one as lambda grows, and
|
|
18
|
+
the poor venture goes from continued to quit. Under the average-reward criterion
|
|
19
|
+
the gain is the best of REST and (1 - FAIL)R_k/(1 + q_k - FAIL) (a renewal
|
|
20
|
+
argument), and with the bias of the hub set to 0, a continued venture has bias
|
|
21
|
+
(R_k - g)/q_k and a quit one -g.
|
|
22
|
+
|
|
23
|
+
The secretary problem (Ferguson, 1989). NCANDIDATES candidates are seen one at
|
|
24
|
+
a time in random order, and each can be selected, ending the search, or passed
|
|
25
|
+
over for good; the aim is to select the best of all. The optimal rule passes
|
|
26
|
+
over the first CUTOFF - 1 candidates and then selects the first one that is
|
|
27
|
+
better than all before it, which wins with probability about 1/e (0.398690 for
|
|
28
|
+
ten candidates). The states are a candidate's position i and whether it is the
|
|
29
|
+
best so far, plus an absorbing state once a candidate is selected or none is
|
|
30
|
+
left. The classical problem is undiscounted and MDPSolver discounts every step,
|
|
31
|
+
but every candidate takes exactly one step, so a reward of (i/n)/lambda^(i - 1)
|
|
32
|
+
for selecting candidate i undoes the discount exactly: the start state is worth
|
|
33
|
+
the classical probability of winning at every lambda, and candidate i's states
|
|
34
|
+
are worth their classical values divided by lambda^(i - 1).
|
|
35
|
+
|
|
36
|
+
The exact solutions of any problem: exactdiscounted() and exactaverage() run
|
|
37
|
+
policy iteration with every policy evaluated exactly, by solving its linear
|
|
38
|
+
equations, so they share no arithmetic with MDPSolver's iterative evaluation.
|
|
39
|
+
checkoptimality() checks a solution against the optimality equation, with a
|
|
40
|
+
margin for the optimal actions, and unichainaperiodic() the condition under
|
|
41
|
+
which value iteration converges under the average-reward criterion (Puterman,
|
|
42
|
+
1994, Section 8.5).
|
|
43
|
+
|
|
44
|
+
References:
|
|
45
|
+
Ferguson, T. S. (1989). Who solved the secretary problem? Statistical
|
|
46
|
+
Science 4(3), 282-289. https://doi.org/10.1214/ss/1177012493
|
|
47
|
+
Puterman, M. L. (1994). Markov Decision Processes: Discrete Stochastic
|
|
48
|
+
Dynamic Programming. Wiley. https://doi.org/10.1002/9780470316887
|
|
49
|
+
'''
|
|
50
|
+
|
|
51
|
+
import itertools
|
|
52
|
+
import sys
|
|
53
|
+
|
|
54
|
+
import numpy as np
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
# THE HUB-AND-SPOKE PROBLEM
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
# The reward for resting in the hub, the probability that starting a venture
|
|
62
|
+
# fails, and the ventures as (reward per period, probability of ending per
|
|
63
|
+
# period, whether it can be quit): a short one, a long one and a poor one.
|
|
64
|
+
REST = 2.0
|
|
65
|
+
FAIL = 0.1
|
|
66
|
+
VENTURES = ((6.0, 0.6, False),
|
|
67
|
+
(4.2, 0.05, True),
|
|
68
|
+
(1.5, 0.3, True))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def hubproblem():
|
|
72
|
+
'''The rewards and transitions of the hub-and-spoke problem.'''
|
|
73
|
+
nventures = len(VENTURES)
|
|
74
|
+
rewards = [[REST] + [0.0] * nventures]
|
|
75
|
+
transitions = [[{0: 1.0}] + [{0: FAIL, k: 1.0 - FAIL} for k in range(1, nventures + 1)]]
|
|
76
|
+
for k, (reward, end, quittable) in enumerate(VENTURES, start=1):
|
|
77
|
+
rewards.append([reward] + ([0.0] if quittable else []))
|
|
78
|
+
transitions.append([{0: end, k: 1.0 - end}] + ([{0: 1.0}] if quittable else []))
|
|
79
|
+
return rewards, transitions
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def hubdiscounted(discount):
|
|
83
|
+
'''The optimal policy and values of the hub-and-spoke problem under discounting.'''
|
|
84
|
+
worths = [REST] + [discount * (1.0 - FAIL) * reward / (1.0 + discount * (end - FAIL))
|
|
85
|
+
for reward, end, _ in VENTURES]
|
|
86
|
+
best = max(worths)
|
|
87
|
+
hub = best / (1.0 - discount)
|
|
88
|
+
policy, values = [int(np.argmax(worths))], [hub]
|
|
89
|
+
for reward, end, quittable in VENTURES:
|
|
90
|
+
if quittable and reward < discount * (1.0 - end) * best:
|
|
91
|
+
policy.append(1)
|
|
92
|
+
values.append(discount * hub)
|
|
93
|
+
else:
|
|
94
|
+
policy.append(0)
|
|
95
|
+
values.append((reward + discount * end * hub) / (1.0 - discount * (1.0 - end)))
|
|
96
|
+
return policy, np.array(values)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def hubaverage():
|
|
100
|
+
'''The gain-optimal policy, the gain and the bias (0 in the hub) of the hub-and-spoke problem.'''
|
|
101
|
+
gains = [REST] + [(1.0 - FAIL) * reward / (1.0 + end - FAIL) for reward, end, _ in VENTURES]
|
|
102
|
+
gain = max(gains)
|
|
103
|
+
policy, bias = [int(np.argmax(gains))], [0.0]
|
|
104
|
+
for reward, end, quittable in VENTURES:
|
|
105
|
+
if quittable and reward < gain * (1.0 - end):
|
|
106
|
+
policy.append(1)
|
|
107
|
+
bias.append(-gain)
|
|
108
|
+
else:
|
|
109
|
+
policy.append(0)
|
|
110
|
+
bias.append((reward - gain) / end)
|
|
111
|
+
return policy, gain, np.array(bias)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
# THE SECRETARY PROBLEM
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
NCANDIDATES = 10
|
|
119
|
+
WINPROBABILITY = 0.398690 # for ten candidates (Ferguson, 1989), to six decimals
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def secretaryproblem(discount, compensated=True):
|
|
123
|
+
'''
|
|
124
|
+
The rewards and transitions of the secretary problem. State 2(i - 1) is
|
|
125
|
+
candidate i being the best so far, state 2(i - 1) + 1 it not being so, and
|
|
126
|
+
state 2n the end. A best-so-far candidate can be passed over (action 0) or
|
|
127
|
+
selected (action 1); any other is passed over. Compensated, the reward for
|
|
128
|
+
selecting candidate i undoes the discount of the i - 1 steps to it;
|
|
129
|
+
otherwise it is i/n whatever the discount.
|
|
130
|
+
'''
|
|
131
|
+
n = NCANDIDATES
|
|
132
|
+
end = 2 * n
|
|
133
|
+
rewards, transitions = [], []
|
|
134
|
+
for i in range(1, n + 1):
|
|
135
|
+
onward = {end: 1.0} if i == n else {2 * i: 1.0 / (i + 1), 2 * i + 1: i / (i + 1)}
|
|
136
|
+
rewards.append([0.0, (i / n) / (discount ** (i - 1) if compensated else 1.0)])
|
|
137
|
+
transitions.append([onward, {end: 1.0}])
|
|
138
|
+
rewards.append([0.0])
|
|
139
|
+
transitions.append([dict(onward)])
|
|
140
|
+
rewards.append([0.0])
|
|
141
|
+
transitions.append([{end: 1.0}])
|
|
142
|
+
return rewards, transitions
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def secretarysolution(discount):
|
|
146
|
+
'''
|
|
147
|
+
The optimal policy and values of the compensated secretary problem, from
|
|
148
|
+
the classical solution: W(m) = (m/n) sum_{k=m}^{n-1} 1/k is the probability
|
|
149
|
+
of winning by selecting the next best-so-far candidate after the m-th, and
|
|
150
|
+
the cutoff is the first candidate from which selecting a best-so-far one is
|
|
151
|
+
at least as good as going on. Returns the cutoff too.
|
|
152
|
+
'''
|
|
153
|
+
n = NCANDIDATES
|
|
154
|
+
|
|
155
|
+
def W(m):
|
|
156
|
+
return (m / n) * sum(1.0 / k for k in range(m, n))
|
|
157
|
+
|
|
158
|
+
cutoff = min(r for r in range(1, n + 1) if sum(1.0 / k for k in range(r, n)) <= 1.0)
|
|
159
|
+
policy, values = [], []
|
|
160
|
+
for i in range(1, n + 1):
|
|
161
|
+
onward = W(max(i, cutoff - 1))
|
|
162
|
+
scale = discount ** (i - 1)
|
|
163
|
+
policy += [1 if i >= cutoff else 0, 0]
|
|
164
|
+
values += [max(i / n, onward) / scale, onward / scale]
|
|
165
|
+
policy.append(0)
|
|
166
|
+
values.append(0.0)
|
|
167
|
+
return cutoff, policy, np.array(values)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# ANY PROBLEM
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def densetranmat(transitions):
|
|
175
|
+
'''The transition probabilities as a 3D list with zeros, as tranMatWithZeros.'''
|
|
176
|
+
n = len(transitions)
|
|
177
|
+
return [[[probs.get(j, 0.0) for j in range(n)] for probs in state] for state in transitions]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def elementwisetranmat(transitions):
|
|
181
|
+
'''The non-zero transition probabilities as rows [s, a, j, probability], as tranMatElementwise.'''
|
|
182
|
+
return [[s, a, j, p] for s, state in enumerate(transitions)
|
|
183
|
+
for a, probs in enumerate(state) for j, p in probs.items()]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def matrices(rewards, transitions):
|
|
187
|
+
'''Per state, the rewards of its actions and the rows of their transition probabilities.'''
|
|
188
|
+
n = len(rewards)
|
|
189
|
+
R = [np.array(state, dtype=float) for state in rewards]
|
|
190
|
+
P = []
|
|
191
|
+
for state in transitions:
|
|
192
|
+
rows = np.zeros((len(state), n))
|
|
193
|
+
for a, probs in enumerate(state):
|
|
194
|
+
for j, p in probs.items():
|
|
195
|
+
rows[a, j] = p
|
|
196
|
+
P.append(rows)
|
|
197
|
+
return R, P
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def improve(R, P, policy, values, discount):
|
|
201
|
+
'''The greedy policy for the values, keeping an action that ties with the best.'''
|
|
202
|
+
improved = []
|
|
203
|
+
for s in range(len(R)):
|
|
204
|
+
q = R[s] + discount * (P[s] @ values)
|
|
205
|
+
best = int(np.argmax(q))
|
|
206
|
+
tie = q[policy[s]] >= q[best] - 1e-12 * max(1.0, abs(q[best]))
|
|
207
|
+
improved.append(policy[s] if tie else best)
|
|
208
|
+
return improved
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def exactdiscounted(rewards, transitions, discount):
|
|
212
|
+
'''The optimal policy and values under discounting, by policy iteration with exact evaluation.'''
|
|
213
|
+
R, P = matrices(rewards, transitions)
|
|
214
|
+
n = len(R)
|
|
215
|
+
policy = [int(np.argmax(r)) for r in R]
|
|
216
|
+
while True:
|
|
217
|
+
Ppolicy = np.array([P[s][policy[s]] for s in range(n)])
|
|
218
|
+
rpolicy = np.array([R[s][policy[s]] for s in range(n)])
|
|
219
|
+
values = np.linalg.solve(np.eye(n) - discount * Ppolicy, rpolicy)
|
|
220
|
+
improved = improve(R, P, policy, values, discount)
|
|
221
|
+
if improved == policy:
|
|
222
|
+
return policy, values
|
|
223
|
+
policy = improved
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def exactaverage(rewards, transitions):
|
|
227
|
+
'''
|
|
228
|
+
The gain-optimal policy, the gain and the bias (0 in state 0) of a unichain
|
|
229
|
+
problem, by policy iteration with exact evaluation: h + g = r + P h, h(0) = 0.
|
|
230
|
+
'''
|
|
231
|
+
R, P = matrices(rewards, transitions)
|
|
232
|
+
n = len(R)
|
|
233
|
+
policy = [int(np.argmax(r)) for r in R]
|
|
234
|
+
while True:
|
|
235
|
+
Ppolicy = np.array([P[s][policy[s]] for s in range(n)])
|
|
236
|
+
rpolicy = np.array([R[s][policy[s]] for s in range(n)])
|
|
237
|
+
A = np.zeros((n + 1, n + 1))
|
|
238
|
+
A[:n, :n] = np.eye(n) - Ppolicy
|
|
239
|
+
A[:n, n] = 1.0
|
|
240
|
+
A[n, 0] = 1.0
|
|
241
|
+
solution = np.linalg.solve(A, np.r_[rpolicy, 0.0])
|
|
242
|
+
bias, gain = solution[:n], solution[n]
|
|
243
|
+
improved = improve(R, P, policy, bias, 1.0)
|
|
244
|
+
if improved == policy:
|
|
245
|
+
return policy, gain, bias
|
|
246
|
+
policy = improved
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def qvalues(rewards, transitions, values, discount):
|
|
250
|
+
'''r(s, a) + discount * sum_j p(j | s, a) v(j) for every state and action.'''
|
|
251
|
+
R, P = matrices(rewards, transitions)
|
|
252
|
+
return [list(R[s] + discount * (P[s] @ np.asarray(values))) for s in range(len(R))]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def checkoptimality(name, rewards, transitions, policy, values, discount, mingap, gain=0.0):
|
|
256
|
+
'''
|
|
257
|
+
Ends the test unless the values solve the optimality equation
|
|
258
|
+
v + g = max_a (r + discount P v), with the policy as the maximizer and every
|
|
259
|
+
maximum ahead of the runner-up by mingap.
|
|
260
|
+
'''
|
|
261
|
+
q = qvalues(rewards, transitions, values, discount)
|
|
262
|
+
scale = max(1.0, float(np.max(np.abs(values))))
|
|
263
|
+
for s in range(len(rewards)):
|
|
264
|
+
if abs(values[s] + gain - max(q[s])) > 1e-11 * scale:
|
|
265
|
+
sys.exit("Validation test failed at initialization: the known solution of the %s does not solve the optimality equation in state %d." % (name, s))
|
|
266
|
+
if int(np.argmax(q[s])) != policy[s]:
|
|
267
|
+
sys.exit("Validation test failed at initialization: the known policy of the %s does not maximize in state %d." % (name, s))
|
|
268
|
+
if len(q[s]) > 1 and sorted(q[s])[-1] - sorted(q[s])[-2] < mingap:
|
|
269
|
+
sys.exit("Validation test failed at initialization: in the %s the best action of state %d wins by only %.3e, so the comparison of policies would hinge on rounding." % (name, s, sorted(q[s])[-1] - sorted(q[s])[-2]))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def smallestgap(rewards, transitions, values, discount):
|
|
273
|
+
'''The smallest margin by which the best action of a state beats its runner-up.'''
|
|
274
|
+
q = qvalues(rewards, transitions, values, discount)
|
|
275
|
+
gaps = [sorted(qs)[-1] - sorted(qs)[-2] for qs in q if len(qs) > 1]
|
|
276
|
+
return min(gaps) if gaps else np.inf
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def unichainaperiodic(rewards, transitions):
|
|
280
|
+
'''The first policy that is not unichain and aperiodic, or None. Enumerates every policy.'''
|
|
281
|
+
n = len(rewards)
|
|
282
|
+
for policy in itertools.product(*[range(len(r)) for r in rewards]):
|
|
283
|
+
step = np.zeros((n, n), dtype=np.int64)
|
|
284
|
+
for s in range(n):
|
|
285
|
+
for j, p in transitions[s][policy[s]].items():
|
|
286
|
+
step[s, j] = p > 0.0
|
|
287
|
+
reach = np.eye(n, dtype=np.int64)
|
|
288
|
+
for _ in range(n):
|
|
289
|
+
reach = np.minimum(reach + reach @ step, 1)
|
|
290
|
+
recurrent = [s for s in range(n) if all(reach[j, s] for j in range(n) if reach[s, j])]
|
|
291
|
+
classes = {frozenset(j for j in recurrent if reach[s, j] and reach[j, s]) for s in recurrent}
|
|
292
|
+
if len(classes) != 1:
|
|
293
|
+
return policy
|
|
294
|
+
members = sorted(next(iter(classes)))
|
|
295
|
+
power = sub = step[np.ix_(members, members)]
|
|
296
|
+
for _ in range(len(members) ** 2): # primitive: some power is positive
|
|
297
|
+
power = np.minimum(power @ sub, 1)
|
|
298
|
+
if not np.all(power > 0):
|
|
299
|
+
return policy
|
|
300
|
+
return None
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'''
|
|
2
|
+
LOADS MDPSOLVER FOR THE TESTS IN THIS FOLDER.
|
|
3
|
+
|
|
4
|
+
Every test imports it before anything else from mdpsolver:
|
|
5
|
+
|
|
6
|
+
from loadmdpsolver import mdpsolver, finalline, quietly
|
|
7
|
+
|
|
8
|
+
The tests run the Python code of mdpsolver in Python/src, so that they can be
|
|
9
|
+
run from a clone without installing anything. The compiled solvermodule does
|
|
10
|
+
not have to be copied there; it is loaded from the first of these places that
|
|
11
|
+
has one:
|
|
12
|
+
1. Python/src/mdpsolver, if it has been copied there;
|
|
13
|
+
2. CPP_Source_Code/build, or CPP_Source_Code/build/Release on Windows,
|
|
14
|
+
where the CMake build of the wiki's Contribution guidelines puts it,
|
|
15
|
+
unless it is older than one of the C++ sources (which is when CMake
|
|
16
|
+
would rebuild it);
|
|
17
|
+
3. the installed mdpsolver package (pip install mdpsolver, or
|
|
18
|
+
pip install ./Python), whose C++ may be older than the working tree's.
|
|
19
|
+
Only modules this Python can load are considered, so a build for another Python
|
|
20
|
+
version or platform in a shared folder is passed over. The module must have
|
|
21
|
+
every method that the Python code calls on it, or it is older than that code.
|
|
22
|
+
When the module is not the one in Python/src, the final line of a test says
|
|
23
|
+
where it came from. Copied away from the repository, as for a test of a built
|
|
24
|
+
wheel, the tests find no Python/src and test the installed package as a whole.
|
|
25
|
+
|
|
26
|
+
Also here: finalline(), the line a test prints when it has passed, and
|
|
27
|
+
quietly(), which runs a function with everything the process prints captured,
|
|
28
|
+
the output of the compiled module included.
|
|
29
|
+
'''
|
|
30
|
+
|
|
31
|
+
import ctypes
|
|
32
|
+
import glob
|
|
33
|
+
import importlib.machinery
|
|
34
|
+
import importlib.metadata
|
|
35
|
+
import importlib.util
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import sys
|
|
39
|
+
import tempfile
|
|
40
|
+
|
|
41
|
+
TESTDIR = os.path.dirname(os.path.abspath(__file__))
|
|
42
|
+
SRCDIR = os.path.normpath(os.path.join(TESTDIR, "..", "src"))
|
|
43
|
+
CPPDIR = os.path.normpath(os.path.join(TESTDIR, "..", "..", "CPP_Source_Code"))
|
|
44
|
+
BUILDDIRS = (os.path.join(CPPDIR, "build"), os.path.join(CPPDIR, "build", "Release"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def compiledmodules(directory):
|
|
48
|
+
'''The files in a directory that this Python can load as solvermodule.'''
|
|
49
|
+
paths = [os.path.join(directory, "solvermodule" + suffix)
|
|
50
|
+
for suffix in importlib.machinery.EXTENSION_SUFFIXES]
|
|
51
|
+
return [path for path in paths if os.path.isfile(path)]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
SOLVERMODULENOTE = None # said in the final line when the module is not in Python/src
|
|
55
|
+
|
|
56
|
+
if not os.path.isdir(os.path.join(SRCDIR, "mdpsolver")):
|
|
57
|
+
SOLVERMODULENOTE = "Python/src was not found next to the test, so the installed mdpsolver was tested as a whole."
|
|
58
|
+
|
|
59
|
+
elif not compiledmodules(os.path.join(SRCDIR, "mdpsolver")):
|
|
60
|
+
sources = glob.glob(os.path.join(CPPDIR, "*.cpp")) + glob.glob(os.path.join(CPPDIR, "*.h"))
|
|
61
|
+
newestsource = max(map(os.path.getmtime, sources), default=0.0)
|
|
62
|
+
builds = [path for directory in BUILDDIRS for path in compiledmodules(directory)]
|
|
63
|
+
currentbuilds = [path for path in builds if os.path.getmtime(path) >= newestsource]
|
|
64
|
+
|
|
65
|
+
if currentbuilds:
|
|
66
|
+
spec = importlib.util.spec_from_file_location("mdpsolver.solvermodule", currentbuilds[0])
|
|
67
|
+
solvermodule = importlib.util.module_from_spec(spec)
|
|
68
|
+
spec.loader.exec_module(solvermodule)
|
|
69
|
+
SOLVERMODULENOTE = "The compiled solvermodule was loaded from %s." % currentbuilds[0]
|
|
70
|
+
|
|
71
|
+
else:
|
|
72
|
+
# The installed package is imported as a whole, before Python/src is on
|
|
73
|
+
# the path, so that whatever it does to find the libraries its module
|
|
74
|
+
# links to is done. Everything but its compiled module is then forgotten.
|
|
75
|
+
try:
|
|
76
|
+
import mdpsolver
|
|
77
|
+
except ImportError:
|
|
78
|
+
sys.exit("Validation test failed at initialization: there is no compiled solvermodule for this Python in Python/src/mdpsolver or in %s%s, and mdpsolver is not installed. Build it as described in the wiki's Contribution guidelines." % (BUILDDIRS[0], " (only one older than the C++ sources)" if builds else ""))
|
|
79
|
+
solvermodule = mdpsolver.solvermodule
|
|
80
|
+
for name in [name for name in sys.modules if name.split(".")[0] == "mdpsolver"]:
|
|
81
|
+
del sys.modules[name]
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
version = importlib.metadata.version("mdpsolver")
|
|
85
|
+
except importlib.metadata.PackageNotFoundError:
|
|
86
|
+
version = "(version unknown)"
|
|
87
|
+
SOLVERMODULENOTE = "The compiled solvermodule was that of the installed mdpsolver %s, %s, whose C++ may be older than that of the working tree." % (version, solvermodule.__file__)
|
|
88
|
+
if builds:
|
|
89
|
+
SOLVERMODULENOTE += " The one in %s is older than the C++ sources and was passed over." % os.path.dirname(builds[0])
|
|
90
|
+
|
|
91
|
+
# the Python code in Python/src imports the module as mdpsolver.solvermodule
|
|
92
|
+
sys.modules["mdpsolver.solvermodule"] = solvermodule
|
|
93
|
+
|
|
94
|
+
sys.path.insert(0, SRCDIR)
|
|
95
|
+
|
|
96
|
+
import mdpsolver
|
|
97
|
+
|
|
98
|
+
if os.path.isdir(os.path.join(SRCDIR, "mdpsolver")) and not os.path.samefile(
|
|
99
|
+
os.path.dirname(os.path.abspath(mdpsolver.__file__)), os.path.join(SRCDIR, "mdpsolver")):
|
|
100
|
+
sys.exit("Validation test failed at initialization: mdpsolver was loaded from %s rather than from Python/src." % os.path.dirname(mdpsolver.__file__))
|
|
101
|
+
|
|
102
|
+
# the compiled module must have every method that the Python code calls on it
|
|
103
|
+
with open(sys.modules["mdpsolver.model"].__file__) as file:
|
|
104
|
+
called = sorted(set(re.findall(r"self\.mdl\.(\w+)", file.read())))
|
|
105
|
+
missing = [name for name in called if not hasattr(sys.modules["mdpsolver.solvermodule"].Model, name)]
|
|
106
|
+
if missing:
|
|
107
|
+
sys.exit("Validation test failed at initialization: the compiled solvermodule has no %s, which the Python code of mdpsolver calls, so it is older than that code. Rebuild it. (%s)" % (", ".join(missing), SOLVERMODULENOTE or "It was loaded from Python/src/mdpsolver."))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def finalline(testfile):
|
|
111
|
+
'''The line a test prints when it has passed: its file name, and where the compiled module came from, if not from Python/src.'''
|
|
112
|
+
name = os.path.splitext(os.path.basename(testfile))[0]
|
|
113
|
+
if SOLVERMODULENOTE is None:
|
|
114
|
+
return "%s: All tests passed." % name
|
|
115
|
+
return "%s: All tests passed. (%s)" % (name, SOLVERMODULENOTE)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
LIBC = ctypes.CDLL(None) # the C library, to flush the output of the compiled module
|
|
120
|
+
except (OSError, TypeError):
|
|
121
|
+
LIBC = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def flushall():
|
|
125
|
+
sys.stdout.flush()
|
|
126
|
+
sys.stderr.flush()
|
|
127
|
+
if LIBC is not None:
|
|
128
|
+
LIBC.fflush(None)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def quietly(function):
|
|
132
|
+
'''Runs a function with everything the process prints captured; returns its result and the output.'''
|
|
133
|
+
flushall()
|
|
134
|
+
with tempfile.TemporaryFile() as capture:
|
|
135
|
+
saved = (os.dup(1), os.dup(2))
|
|
136
|
+
os.dup2(capture.fileno(), 1)
|
|
137
|
+
os.dup2(capture.fileno(), 2)
|
|
138
|
+
try:
|
|
139
|
+
result = function()
|
|
140
|
+
finally:
|
|
141
|
+
flushall()
|
|
142
|
+
os.dup2(saved[0], 1)
|
|
143
|
+
os.dup2(saved[1], 2)
|
|
144
|
+
os.close(saved[0])
|
|
145
|
+
os.close(saved[1])
|
|
146
|
+
capture.seek(0)
|
|
147
|
+
output = capture.read().decode(errors="replace")
|
|
148
|
+
return result, output
|