mdpsolver 0.12.0__tar.gz → 0.12.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.
Files changed (44) hide show
  1. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/ModifiedPolicyIteration.cpp +15 -0
  2. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/ModifiedPolicyIteration.h +2 -0
  3. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/ModuleInterface.cpp +113 -6
  4. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/ModuleInterface.h +9 -1
  5. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/PythonModule.cpp +2 -1
  6. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/PKG-INFO +2 -2
  7. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/Python/src/mdpsolver/model.py +86 -4
  8. mdpsolver-0.12.1/Python/tests/test_average.py +313 -0
  9. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/Python/tests/test_order.py +19 -9
  10. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/Python/tests/test_output.py +93 -21
  11. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/README.md +1 -1
  12. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/pyproject.toml +1 -1
  13. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/.gitignore +0 -0
  14. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/CBMmodel.cpp +0 -0
  15. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/CBMmodel.h +0 -0
  16. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/CMakeLists.txt +0 -0
  17. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/GeneralMDPmodel.cpp +0 -0
  18. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/GeneralMDPmodel.h +0 -0
  19. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/ModelType.cpp +0 -0
  20. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/ModelType.h +0 -0
  21. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/Policy.cpp +0 -0
  22. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/Policy.h +0 -0
  23. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/Rewards.cpp +0 -0
  24. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/Rewards.h +0 -0
  25. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/TBMmodel.cpp +0 -0
  26. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/TBMmodel.h +0 -0
  27. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/TransitionMatrix.cpp +0 -0
  28. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/TransitionMatrix.h +0 -0
  29. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/ValueVector.cpp +0 -0
  30. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/CPP_Source_Code/ValueVector.h +0 -0
  31. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/LICENSE +0 -0
  32. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/Python/src/mdpsolver/__init__.py +0 -0
  33. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/Python/tests/knownsolutions.py +0 -0
  34. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/Python/tests/loadmdpsolver.py +0 -0
  35. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/Python/tests/test_arrays.py +0 -0
  36. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/Python/tests/test_checkmodel.py +0 -0
  37. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/Python/tests/test_imports.py +0 -0
  38. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/Python/tests/test_large.py +0 -0
  39. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/Python/tests/test_mpi.py +0 -0
  40. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/Python/tests/test_pi.py +0 -0
  41. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/Python/tests/test_random.py +0 -0
  42. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/Python/tests/test_reuse.py +0 -0
  43. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/Python/tests/test_threads.py +0 -0
  44. {mdpsolver-0.12.0 → mdpsolver-0.12.1}/Python/tests/test_vi.py +0 -0
@@ -73,6 +73,8 @@ ModifiedPolicyIteration::ModifiedPolicyIteration(double epsilon, string algorith
73
73
  diverged(false),
74
74
  outOfRangeState(-1),
75
75
  outOfRangeValue(0.0),
76
+ gainLower(numeric_limits<double>::quiet_NaN()),
77
+ gainUpper(numeric_limits<double>::quiet_NaN()),
76
78
  parIter(0)
77
79
  {
78
80
  //check valid string input
@@ -194,6 +196,19 @@ void ModifiedPolicyIteration::solve(ModelType * mdl, Policy * ply, ValueVector *
194
196
  auto t2 = chrono::high_resolution_clock::now(); //stop time
195
197
  duration = (double) chrono::duration_cast<chrono::nanoseconds>( t2 - t1 ).count() / 1e6;
196
198
 
199
+ //BOUNDS ON THE GAIN
200
+
201
+ //every loop ends with an improvement step, v' = Lv, whose smallest and largest change
202
+ //(diffMin and diffMax) bound the gain under the average reward criterion. The policy
203
+ //found chose its actions in that step, so its gain is an average of v'-v, weighted by
204
+ //its limiting distribution, and lies between them; the optimal gain is at least the
205
+ //policy's, and at most the same average for an optimal policy, which is at most diffMax.
206
+ //They hold for any v, so also when a limit stopped the loops.
207
+ if (useAvg) {
208
+ gainLower = diffMin;
209
+ gainUpper = diffMax;
210
+ }
211
+
197
212
  //POST PROCESSING
198
213
 
199
214
  //make sure v is the last updated vector if we use standard updates
@@ -57,6 +57,8 @@ public:
57
57
  bool diverged; //the values became infinite or NaN, which ends the loops (a NaN norm passes their test)
58
58
  int outOfRangeState; //the first state with such a value (-1 if none)
59
59
  double outOfRangeValue; //and its value
60
+ double gainLower; //under the average reward criterion, a lower bound on the gain (NaN otherwise)
61
+ double gainUpper; //and an upper bound
60
62
  int getIterationLimit() { return iterLim; }
61
63
 
62
64
  //methods
@@ -342,7 +342,10 @@ void ModuleInterface::solve(string algorithm,
342
342
  results.outOfRangeState=solver.outOfRangeState;
343
343
  results.outOfRangeValue=solver.outOfRangeValue;
344
344
  results.iterationLimit=solver.getIterationLimit();
345
-
345
+ //save the bounds on the gain (NaN under the discounted criterion)
346
+ results.gainLower=solver.gainLower;
347
+ results.gainUpper=solver.gainUpper;
348
+
346
349
  }
347
350
 
348
351
  void ModuleInterface::setInitPolicy(py::list initPolicy){
@@ -418,14 +421,114 @@ void ModuleInterface::printValueVector(){
418
421
  }
419
422
  }
420
423
 
421
- void ModuleInterface::saveToFile(string fileName, string type){
424
+ //---------------------------------------
425
+ // SAVING: the policy or the value vector
426
+ //---------------------------------------
427
+ //saveToFile() writes the policy or the value vector to a NumPy .npy file (filetype "npy", the
428
+ //default) or to a comma-separated file (filetype "csv"). The .npy file holds a 1D array with an
429
+ //entry per state, as np.save writes it, so that np.load gives what getPolicy() (int64) and
430
+ //getValueVector() (float64) return. The comma-separated file has a header line and a row per
431
+ //state: the state index, then the action index or the value.
432
+
433
+ namespace {
434
+
435
+ //the start of an .npy file (version 1.0) of a 1D array of n numbers of the NumPy type (e.g.
436
+ //"i8"), in the computer's byte order: the magic string, the version, the length of the
437
+ //header, and the header, padded with spaces so that the numbers start at a multiple of 64
438
+ //bytes, as np.save aligns them (see "LOADING: NumPy .npy files" for the format)
439
+ string npyStart(const string& type, size_t n){
440
+ const uint16_t one = 1;
441
+ const char order = (*(const unsigned char*)&one == 1) ? '<' : '>';
442
+ string header = "{'descr': '" + string(1, order) + type + "', 'fortran_order': False, 'shape': (" + to_string(n) + ",), }";
443
+ header.append((64 - (10 + header.size() + 1) % 64) % 64, ' ');
444
+ header += '\n';
445
+ string start = "\x93NUMPY";
446
+ start += (char)1;
447
+ start += (char)0;
448
+ start += (char)(header.size() & 0xff);
449
+ start += (char)(header.size() >> 8);
450
+ return start + header;
451
+ }
452
+
453
+ //closes a file that has been written, and throws if any of it could not be written (e.g. on a
454
+ //full disk)
455
+ void closeWritten(ofstream& outFile, const string& fileName){
456
+ outFile.close();
457
+ if (outFile.fail()){
458
+ throw runtime_error("saveToFile(): failed to write the file " + fileName + ".");
459
+ }
460
+ }
461
+
462
+ }
463
+
464
+ void ModuleInterface::saveToFile(string fileName, string type, string filetype){
465
+ bool policy;
422
466
  if (type.compare("policy")==0 || type.compare("p")==0){
423
- savePolicyToFile(fileName,',');
467
+ policy = true;
424
468
  }else if(type.compare("value")==0 || type.compare("values")==0 || type.compare("v")==0){
425
- saveValueVectorToFile(fileName,',');
469
+ policy = false;
426
470
  }else{
427
471
  throw invalid_argument("saveToFile(): the type '" + type + "' is not recognized. Select either 'policy' or 'value'.");
428
472
  }
473
+ //the filetype in any case, e.g. "NPY"
474
+ const string given = filetype;
475
+ transform(filetype.begin(), filetype.end(), filetype.begin(), [](unsigned char c){ return (char)tolower(c); });
476
+ if (filetype.compare("npy")!=0 && filetype.compare("csv")!=0){
477
+ throw invalid_argument("saveToFile(): the filetype '" + given + "' is not recognized. Select either 'npy' or 'csv'.");
478
+ }
479
+ const bool npy = (filetype.compare("npy")==0);
480
+
481
+ //the file name, by default result.npy or result.csv. A name ending in .csv or .npy must
482
+ //agree with the filetype, as mdp() reads a file by its extension.
483
+ if (fileName.empty()){
484
+ fileName = "result." + filetype;
485
+ }
486
+ const string other = npy ? ".csv" : ".npy";
487
+ if (hasExtension(fileName, other)){
488
+ throw invalid_argument("saveToFile(): the file name " + fileName + " ends in " + other + ", but the filetype is '" + filetype + "'. Use filetype='" + other.substr(1) + "' to save a " + (npy ? "comma-separated" : "NumPy .npy") + " file.");
489
+ }
490
+
491
+ if (npy && policy){
492
+ savePolicyToNpy(fileName);
493
+ }else if (npy){
494
+ saveValueVectorToNpy(fileName);
495
+ }else if (policy){
496
+ savePolicyToFile(fileName,',');
497
+ }else{
498
+ saveValueVectorToFile(fileName,',');
499
+ }
500
+ }
501
+
502
+ void ModuleInterface::savePolicyToNpy(string fileName){
503
+ ofstream outFile(fileName, ios::binary);
504
+ if (!outFile.is_open()) {
505
+ throw runtime_error("saveToFile(): failed to open the file " + fileName + " for writing.");
506
+ }
507
+ //as int64, as getPolicy() returns it, converted a block of states at a time
508
+ const vector<int>& policy = problem.policy.policy;
509
+ const string start = npyStart("i8", policy.size());
510
+ outFile.write(start.data(), (streamsize)start.size());
511
+ vector<int64_t> block(min(policy.size(), (size_t)65536));
512
+ for (size_t first = 0; first < policy.size(); first += block.size()) {
513
+ size_t n = min(block.size(), policy.size() - first);
514
+ for (size_t k = 0; k < n; k++) {
515
+ block[k] = policy[first + k];
516
+ }
517
+ outFile.write((const char*)block.data(), (streamsize)(n * sizeof(int64_t)));
518
+ }
519
+ closeWritten(outFile, fileName);
520
+ }
521
+
522
+ void ModuleInterface::saveValueVectorToNpy(string fileName){
523
+ ofstream outFile(fileName, ios::binary);
524
+ if (!outFile.is_open()) {
525
+ throw runtime_error("saveToFile(): failed to open the file " + fileName + " for writing.");
526
+ }
527
+ const vector<double>& values = problem.valueVector.valueVector;
528
+ const string start = npyStart("f8", values.size());
529
+ outFile.write(start.data(), (streamsize)start.size());
530
+ outFile.write((const char*)values.data(), (streamsize)(values.size() * sizeof(double)));
531
+ closeWritten(outFile, fileName);
429
532
  }
430
533
 
431
534
  void ModuleInterface::savePolicyToFile(string fileName, char sep){
@@ -437,7 +540,7 @@ void ModuleInterface::savePolicyToFile(string fileName, char sep){
437
540
  for (int sidx = 0; sidx < problem.policy.policy.size(); sidx++) {
438
541
  outFile << sidx << sep << problem.policy.policy[sidx] << endl;
439
542
  }
440
- outFile.close();
543
+ closeWritten(outFile, fileName);
441
544
  }
442
545
 
443
546
  void ModuleInterface::saveValueVectorToFile(string fileName, char sep){
@@ -449,7 +552,7 @@ void ModuleInterface::saveValueVectorToFile(string fileName, char sep){
449
552
  for (int sidx = 0; sidx < problem.valueVector.valueVector.size(); sidx++) {
450
553
  outFile << sidx << sep << problem.valueVector.valueVector[sidx] << endl;
451
554
  }
452
- outFile.close();
555
+ closeWritten(outFile, fileName);
453
556
  }
454
557
 
455
558
  double ModuleInterface::getRuntime(){
@@ -473,6 +576,10 @@ py::dict ModuleInterface::getConvergence(){
473
576
  return(convergence);
474
577
  }
475
578
 
579
+ py::tuple ModuleInterface::getGainBounds(){
580
+ return(py::make_tuple(results.gainLower, results.gainUpper));
581
+ }
582
+
476
583
  int ModuleInterface::getNumberOfStates(){
477
584
  //the number of states of the general MDP model
478
585
  if (problem.problemType.compare("mdp")==0){
@@ -29,6 +29,7 @@
29
29
  #include <cstdint>
30
30
  #include <string>
31
31
  #include <iostream>
32
+ #include <limits>
32
33
 
33
34
  #include "ModelType.h" //Generic model class
34
35
  #include "ModifiedPolicyIteration.h" //The solver
@@ -121,6 +122,9 @@ public:
121
122
  int outOfRangeState=-1;
122
123
  double outOfRangeValue=0.0;
123
124
  int iterationLimit=0;
125
+ //bounds on the gain under the average reward criterion (NaN after a solve under the discounted criterion)
126
+ double gainLower=numeric_limits<double>::quiet_NaN();
127
+ double gainUpper=numeric_limits<double>::quiet_NaN();
124
128
  } results;
125
129
 
126
130
 
@@ -192,8 +196,9 @@ public:
192
196
  int getNumberOfActions(int sidx); //returns the number of actions of a state of the general MDP model (-1 for the built-in models or a state outside the model)
193
197
  py::array_t<int64_t> getPolicy(); //returns the entire policy, as a NumPy array (a copy)
194
198
  py::array_t<double> getValueVector(); //returns the entire value vector, as a NumPy array (a copy)
195
- void saveToFile(string fileName, string type); //save the policy or value vector to a file
199
+ void saveToFile(string fileName, string type, string filetype); //save the policy or value vector to a NumPy .npy file or a comma-separated file (by default result.npy or result.csv when fileName is empty)
196
200
  py::dict getConvergence(); //whether the last solve converged, and if not, why
201
+ py::tuple getGainBounds(); //the lower and upper bound on the gain from the last solve under the average reward criterion
197
202
 
198
203
  //------ model check ------
199
204
 
@@ -224,6 +229,9 @@ private:
224
229
  //the elementwise formats from NumPy .npy files, read in blocks straight into the tables
225
230
  void loadRewardsFromNpy(string rewardsFromFile);
226
231
  void loadTranMatFromNpy(string tranMatFromFile);
232
+ //the policy or the value vector to a NumPy .npy file (a 1D array) or a comma-separated file
233
+ void savePolicyToNpy(string fileName);
234
+ void saveValueVectorToNpy(string fileName);
227
235
  void savePolicyToFile(string fileName, char sep);
228
236
  void saveValueVectorToFile(string fileName, char sep);
229
237
 
@@ -68,8 +68,9 @@ PYBIND11_MODULE(solvermodule, m) {
68
68
  .def("getValue", &ModuleInterface::getValue,"Returns a value from the optimized policy.",py::arg("stateIndex")=0)
69
69
  .def("getPolicy", &ModuleInterface::getPolicy,"Returns the optimized policy as a NumPy array.")
70
70
  .def("getValueVector", &ModuleInterface::getValueVector,"Returns the optimized value vector as a NumPy array.")
71
- .def("saveToFile", &ModuleInterface::saveToFile,"Saves the optimized policy or value vector to a file.",py::arg("fileName")="result.csv",py::arg("type")="policy")
71
+ .def("saveToFile", &ModuleInterface::saveToFile,"Saves the optimized policy or value vector to a NumPy .npy file (filetype='npy') or a comma-separated file (filetype='csv'), in any case; an empty fileName saves to result.npy or result.csv.",py::arg("fileName")="",py::arg("type")="policy",py::arg("filetype")="npy")
72
72
  .def("getConvergence", &ModuleInterface::getConvergence,"Returns whether the last solve converged, and if not, why.")
73
+ .def("getGainBounds", &ModuleInterface::getGainBounds,"Returns the lower and upper bound on the gain from the last solve under the average reward criterion (NaN after a solve under the discounted criterion).")
73
74
  .def("checkModel", &ModuleInterface::checkModel,"Checks the general MDP model for malformed input; returns a description of each kind of problem found. Never called by mdp() or solve().",py::arg("tolerance")=1e-9);
74
75
 
75
76
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: mdpsolver
3
- Version: 0.12.0
3
+ Version: 0.12.1
4
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
@@ -49,7 +49,7 @@ MDPSolver is a Python package for large Markov Decision Processes (MDPs) with in
49
49
  | 5,000,000 | 500,000,000 | 6.3 GB | 5.03 s |
50
50
  | 10,000,000 | 1,000,000,000 | 12.7 GB | 13.0 s |
51
51
 
52
- Mean of 10 random models with 5 actions per state and 20 possible next states per state and action, loaded from NPY-files and solved by modified policy iteration under the discounted reward criterion with discount 0.9. Measured on an Apple M5 with 10 cores.
52
+ Mean of 10 random models with 5 actions per state and 20 possible next states per state and action, loaded from NPY-files and solved by modified policy iteration under the discounted reward criterion with discount $\lambda=0.9$ and tolerance $\epsilon = 0.001$. Measured on an Apple M5 with 10 cores.
53
53
 
54
54
  # Installation
55
55
 
@@ -121,6 +121,8 @@ class model:
121
121
  # A model object contains only one model
122
122
  self._modelDefined = False
123
123
  self._solved = False
124
+ # The optimality criterion of the last run of solve()
125
+ self._criterion = None
124
126
 
125
127
  def _requireSolution(self, method):
126
128
  """
@@ -235,6 +237,7 @@ class model:
235
237
  # Set after the run, so that a run that did not finish leaves the
236
238
  # object without a solution
237
239
  self._solved = True
240
+ self._criterion = criterion
238
241
 
239
242
  convergence = self.mdl.getConvergence()
240
243
  hint = " Under the average reward criterion, this happens with periodic models." if criterion == "average" else ""
@@ -373,27 +376,106 @@ class model:
373
376
  return self.mdl.getValueVector()
374
377
 
375
378
  @_oneAtATime
376
- def saveToFile(self, fileName: str = "result.csv", type: str = "policy") -> None:
379
+ def getGain(self, bound: str | None = None) -> float:
380
+ """
381
+ Get the gain, the long-run average reward per period, from the last
382
+ run of solve() under the average reward criterion.
383
+
384
+ The smallest and largest change of the values in the last iteration
385
+ of solve() are a lower and an upper bound on the gain: the gain of the
386
+ optimized policy and the optimal gain both lie between them. When
387
+ solve() has converged, the bounds are less than the tolerance apart,
388
+ so their midpoint is within half the tolerance of the gain.
389
+
390
+ Args:
391
+ bound (str): None for the midpoint of the bounds, 'lower' for the lower bound, or 'upper' for the upper bound (in any case).
392
+
393
+ Returns:
394
+ float: The gain, or the bound selected.
395
+
396
+ Raises RuntimeError if the last run of solve() used the discounted
397
+ reward criterion.
398
+ """
399
+ self._requireAverage("getGain", "gain")
400
+ if not (bound is None or (isinstance(bound, str) and bound.lower() in ("lower", "upper"))):
401
+ raise ValueError("Bound not recognized. Select either: None, 'lower', or 'upper'.")
402
+ lower, upper = self.mdl.getGainBounds()
403
+ if bound is None:
404
+ return (lower + upper) / 2.0
405
+ return lower if bound.lower() == "lower" else upper
406
+
407
+ @_oneAtATime
408
+ def getBias(self, stateIndex: int, referenceIndex: int = 0) -> float:
409
+ """
410
+ Get the bias (the relative value) of a state from the last run of
411
+ solve() under the average reward criterion, with the bias of a
412
+ reference state set to 0. Thus, the bias of a state is its value
413
+ minus the value of the reference state.
414
+
415
+ Args:
416
+ stateIndex (int): Index of the state.
417
+ referenceIndex (int): Index of the reference state, whose bias is 0.
418
+
419
+ Returns:
420
+ float: Bias of the given state.
421
+
422
+ Raises RuntimeError if the last run of solve() used the discounted
423
+ reward criterion, and IndexError for a state outside the model.
424
+ """
425
+ self._requireAverage("getBias", "bias")
426
+ numberOfStates = self.mdl.getNumberOfStates()
427
+ for name, index in (("state", stateIndex), ("reference state", referenceIndex)):
428
+ if not isinstance(index, numbers.Integral) or isinstance(index, bool):
429
+ raise TypeError("The %s needs to be an integer state index (got %r)." % (name, index))
430
+ if not 0 <= index < numberOfStates:
431
+ raise IndexError("The %s %d is outside the model, which has %d states (indices 0 to %d)." % (name, index, numberOfStates, numberOfStates - 1))
432
+ return self.mdl.getValue(stateIndex=int(stateIndex)) - self.mdl.getValue(stateIndex=int(referenceIndex))
433
+
434
+ def _requireAverage(self, method, quantity):
435
+ """
436
+ Raise RuntimeError unless the last run of solve() used the average
437
+ reward criterion.
438
+ """
439
+ self._requireSolution(method)
440
+ if self._criterion != "average":
441
+ raise RuntimeError("The %s is defined under the average reward criterion, but the last run of solve() used criterion='%s'. Call solve(criterion='average') first." % (quantity, self._criterion))
442
+
443
+ @_oneAtATime
444
+ def saveToFile(self, fileName: str | None = None, type: str = "policy", filetype: str = "npy") -> None:
377
445
  """
378
446
  Save the optimized policy or value vector to a file.
379
447
 
380
448
  Args:
381
- fileName (str): Name of the output file.
449
+ fileName (str): Name of the output file. By default result.npy, or result.csv when filetype is 'csv'.
382
450
  type (str): Type of result to save ('policy' or 'value').
451
+ filetype (str): Type of file to save ('npy' or 'csv', in any case). 'npy' saves a NumPy .npy file of a 1D array with an entry per state, which np.load reads as the array getPolicy() (int64) or getValueVector() (float64) returns. 'csv' saves a comma-separated file with a header line and a row per state: the state index, then the action index or the value.
452
+
453
+ A file name ending in .csv with filetype 'npy', or in .npy with
454
+ filetype 'csv', raises a ValueError.
383
455
 
384
456
  Returns:
385
457
  None
386
458
  """
387
-
459
+
388
460
  self._requireSolution("saveToFile")
389
461
  if not (type == "policy" or type == "value"):
390
462
  raise ValueError("Type not recognized. Select either: 'policy' or 'value'.")
463
+ # (in any case, e.g. "NPY")
464
+ if not (isinstance(filetype, str) and filetype.lower() in ("npy", "csv")):
465
+ raise ValueError("Filetype not recognized. Select either: 'npy' or 'csv'.")
466
+ filetype = filetype.lower()
467
+ if fileName is None:
468
+ fileName = "result." + filetype
469
+ # (mdp() reads a file by its extension)
470
+ other = ".csv" if filetype == "npy" else ".npy"
471
+ if str(fileName).lower().endswith(other):
472
+ raise ValueError("The file name %s ends in %s, but filetype is '%s'. Use filetype='%s' to save a %s file." % (fileName, other, filetype, other[1:], "comma-separated" if filetype == "npy" else "NumPy .npy"))
391
473
  error = _openError(fileName, "w")
392
474
  if error is not None:
393
475
  # (the argument type hides the built-in type())
394
476
  raise error.__class__("The file %s could not be opened for writing (%s)." % (fileName, error.strerror or error))
395
477
 
396
- return self.mdl.saveToFile(fileName=fileName, type=type)
478
+ return self.mdl.saveToFile(fileName=fileName, type=type, filetype=filetype)
397
479
 
398
480
  @_oneAtATime
399
481
  def mdp(
@@ -0,0 +1,313 @@
1
+ '''
2
+ UNIT TEST FOR THE GAIN AND THE BIAS UNDER THE AVERAGE REWARD CRITERION
3
+ (model.getGain() and model.getBias()).
4
+
5
+ Under the average reward criterion, solve() ends every algorithm with an
6
+ improvement step v' = Lv. The smallest and largest change in that step, the
7
+ smallest and largest entry of v' - v, bound the gain: the policy found chose
8
+ its actions in that step, so its gain is an average of v' - v (weighted by its
9
+ limiting distribution), and the optimal gain is at least the policy's and at
10
+ most the largest entry (Puterman, 1994, Section 8.5). The bounds hold for any
11
+ v, so also when solve() stops at a limit. getGain() returns their midpoint, and
12
+ getGain(bound="lower") and getGain(bound="upper") the bounds themselves.
13
+
14
+ The values grow by about the gain in every iteration, so only their
15
+ differences are meaningful: they approach the differences between the biases
16
+ of the states. getBias(s, referenceIndex=k) returns the value of state s
17
+ minus the value of state k, the bias of state s with the bias of state k set
18
+ to 0.
19
+
20
+ Sub-tests:
21
+ Case 1: The hub-and-spoke problem of knownsolutions.py, whose gain is known
22
+ in closed form, under value iteration, policy iteration and
23
+ modified policy iteration, with serial and parallel computation, and
24
+ at a loose and a tight tolerance: the bounds contain the gain and
25
+ are less than the tolerance apart, getGain() is their midpoint and
26
+ lies within half the tolerance of the gain, and the run converged.
27
+ Case 2: Random unichain models, at a loose tolerance: the bounds contain
28
+ both the gain of the policy found and the optimal gain, each
29
+ computed exactly by solving linear equations.
30
+ Case 3: A periodic model, a cycle of three states with one action each,
31
+ where value iteration and the evaluation of policy iteration never
32
+ converge: solve() stops at its limit with a ConvergenceWarning, and
33
+ the bounds still contain the gain, while their midpoint does not
34
+ equal it.
35
+ Case 4: The hub-and-spoke problem and the random models of case 2, whose
36
+ biases are known (in closed form, and by solving linear
37
+ equations), at a tight tolerance, under every algorithm: for
38
+ every state and every reference state, the bias is a float, the
39
+ value of the state minus that of the reference state (so exactly
40
+ 0 for the reference state itself), and lies within BIASTOL of the
41
+ known bias with that of the reference state subtracted. The
42
+ default reference state is state 0, and NumPy integers work as
43
+ well.
44
+ Case 5: getGain() and getBias() before solve() raise a RuntimeError, and
45
+ so do they after a run under the discounted criterion, also when
46
+ an earlier run on the same object was under the average criterion.
47
+ A run under the average criterion after a discounted one gives the
48
+ gain and the bias. The bound is selected in any case ("Lower",
49
+ "UPPER"), and any other bound raises a ValueError. A state or a
50
+ reference state outside the model raises an IndexError, and one
51
+ that is not an integer a TypeError.
52
+
53
+ References:
54
+ Puterman, M. L. (1994). Markov Decision Processes: Discrete Stochastic
55
+ Dynamic Programming. Wiley. https://doi.org/10.1002/9780470316887
56
+
57
+ mdpsolver is loaded by loadmdpsolver.py, which explains where the compiled
58
+ solvermodule is taken from.
59
+ '''
60
+
61
+ import sys
62
+ import warnings
63
+
64
+ import numpy as np
65
+
66
+ from loadmdpsolver import mdpsolver, finalline, quietly
67
+ from knownsolutions import arrayinput, exactaverage, hubaverage, hubproblem, matrices
68
+
69
+
70
+ # ------------------------------------------------------------------
71
+ # INITIALIZATION
72
+ # ------------------------------------------------------------------
73
+
74
+ DISCOUNT = 0.9 # mdp() needs one; the average reward criterion ignores it
75
+ TOLERANCES = (1e-3, 1e-9) # a loose and a tight epsilon handed to solve()
76
+ LOOSE = TOLERANCES[0]
77
+ TIGHT = TOLERANCES[1]
78
+ ROUNDING = 1e-12 # the margin for rounding when the bounds are compared with an exact gain
79
+ BIASTOL = 1e-8 # how far a bias may lie from the known one at the tight tolerance (the errors are about 1e-10)
80
+
81
+ ALGORITHMS = (("value iteration", "vi"), ("policy iteration", "pi"), ("modified policy iteration", "mpi"))
82
+
83
+ HUBREWARDS, HUBTRANSITIONS = hubproblem()
84
+ HUBGAIN, HUBBIAS = hubaverage()[1:]
85
+
86
+ # Case 2: random models in which every action leads to state 0 with a
87
+ # probability of at least MINPROB, so every policy is unichain (state 0 is in
88
+ # its only recurrent class) and aperiodic (state 0 can stay in state 0)
89
+ NSTATES, NACTIONS, NNEXT = 40, 3, 4
90
+ MINPROB = 0.05
91
+ SEEDS = range(5)
92
+
93
+ # Case 3: a cycle 0 -> 1 -> 2 -> 0 with the reward CYCLEREWARD in state 2 only,
94
+ # whose gain is CYCLEREWARD / 3, while the changes of a step are 0 and CYCLEREWARD
95
+ CYCLEREWARD = 3.0
96
+ CYCLEREWARDS = [[0.0], [0.0], [CYCLEREWARD]]
97
+ CYCLETRANSITIONS = [[{1: 1.0}], [{2: 1.0}], [{0: 1.0}]]
98
+ CYCLEGAIN = CYCLEREWARD / 3.0
99
+
100
+ if not HUBGAIN > 0.0:
101
+ sys.exit("Validation test failed at initialization: the gain of the hub-and-spoke problem is %r, where it should be positive." % HUBGAIN)
102
+ # the biases of the hub-and-spoke problem differ between the states by far
103
+ # more than BIASTOL, so a wrong reference state cannot pass case 4
104
+ if HUBBIAS[0] != 0.0 or np.min(np.abs(np.subtract.outer(HUBBIAS, HUBBIAS))[~np.eye(len(HUBBIAS), dtype=bool)]) < 100 * BIASTOL:
105
+ sys.exit("Validation test failed at initialization: the biases of the hub-and-spoke problem, %s, are not 0 in the hub and far apart." % HUBBIAS)
106
+ if np.isclose(CYCLEGAIN, CYCLEREWARD / 2.0):
107
+ sys.exit("Validation test failed at initialization: the gain of the cycle equals the midpoint of its bounds, so case 3 could not tell them apart.")
108
+
109
+
110
+ # ------------------------------------------------------------------
111
+ # HELPER FUNCTIONS
112
+ # ------------------------------------------------------------------
113
+
114
+ def solve(rewards, transitions, **settings):
115
+ '''A new model object, solved under the average reward criterion; anything solve() prints ends the test.'''
116
+ def run():
117
+ mdl = mdpsolver.model()
118
+ mdl.mdp(discount=DISCOUNT, **arrayinput(rewards, transitions, bycolumn=True))
119
+ mdl.solve(criterion="average", **settings)
120
+ return mdl
121
+
122
+ mdl, output = quietly(run)
123
+ if output:
124
+ sys.exit("Validation test failed: solve() with %s printed %r." % (settings, output.strip()))
125
+ return mdl
126
+
127
+
128
+ def bounds(mdl):
129
+ '''The lower bound, the midpoint and the upper bound that getGain() returns.'''
130
+ return mdl.getGain(bound="lower"), mdl.getGain(), mdl.getGain(bound="upper")
131
+
132
+
133
+ def checkbounds(case, label, lower, upper, gain, what="the gain"):
134
+ '''Ends the test unless the bounds contain the gain.'''
135
+ if not (np.isfinite(lower) and np.isfinite(upper)):
136
+ sys.exit("Validation test failed at case %d: %s gives the bounds [%r, %r] on the gain." % (case, label, lower, upper))
137
+ if not (lower - ROUNDING <= gain <= upper + ROUNDING):
138
+ sys.exit("Validation test failed at case %d: %s gives the bounds [%.15g, %.15g], which do not contain %s, %.15g." % (case, label, lower, upper, what, gain))
139
+
140
+
141
+ def randommodel(seed):
142
+ '''A random unichain and aperiodic model, as rewards[s][a] and transitions[s][a] = {next state: probability}.'''
143
+ rng = np.random.default_rng(seed)
144
+ rewards, transitions = [], []
145
+ for s in range(NSTATES):
146
+ rewards.append(list(rng.uniform(0.0, 10.0, NACTIONS)))
147
+ state = []
148
+ for a in range(NACTIONS):
149
+ others = rng.choice(np.arange(1, NSTATES), size=NNEXT - 1, replace=False)
150
+ probs = MINPROB + (1.0 - NNEXT * MINPROB) * rng.dirichlet(np.ones(NNEXT))
151
+ state.append({0: float(probs[0]), **{int(j): float(p) for j, p in zip(others, probs[1:])}})
152
+ transitions.append(state)
153
+ return rewards, transitions
154
+
155
+
156
+ def policygain(rewards, transitions, policy):
157
+ '''The gain of a unichain policy, from h + g = r + P h with h(0) = 0.'''
158
+ R, P = matrices(rewards, transitions)
159
+ n = len(R)
160
+ A = np.zeros((n + 1, n + 1))
161
+ A[:n, :n] = np.eye(n) - np.array([P[s][policy[s]] for s in range(n)])
162
+ A[:n, n] = 1.0
163
+ A[n, 0] = 1.0
164
+ return np.linalg.solve(A, np.r_[[R[s][policy[s]] for s in range(n)], 0.0])[n]
165
+
166
+
167
+ def raises(error, function):
168
+ '''Whether the function raises the error; returns the message, or None.'''
169
+ try:
170
+ function()
171
+ except error as raised:
172
+ return str(raised)
173
+ return None
174
+
175
+
176
+ # ------------------------------------------------------------------
177
+ # CASE 1: The hub-and-spoke problem, whose gain is known
178
+ # ------------------------------------------------------------------
179
+
180
+ for algorithmlabel, algorithm in ALGORITHMS:
181
+ for parallel in (False, True):
182
+ for tolerance in TOLERANCES:
183
+ label = "%s (parallel=%s, tolerance %g) on the hub-and-spoke problem" % (algorithmlabel, parallel, tolerance)
184
+ mdl = solve(HUBREWARDS, HUBTRANSITIONS, algorithm=algorithm, tolerance=tolerance, parallel=parallel)
185
+ if not mdl.hasConverged():
186
+ sys.exit("Validation test failed at case 1: %s did not converge." % label)
187
+ lower, middle, upper = bounds(mdl)
188
+ checkbounds(1, label, lower, upper, HUBGAIN)
189
+ if not upper - lower < tolerance:
190
+ sys.exit("Validation test failed at case 1: %s gives bounds %.3e apart, which is not less than the tolerance." % (label, upper - lower))
191
+ if middle != (lower + upper) / 2.0:
192
+ sys.exit("Validation test failed at case 1: %s gives getGain() = %.15g, where the midpoint of the bounds is %.15g." % (label, middle, (lower + upper) / 2.0))
193
+ if abs(middle - HUBGAIN) > tolerance / 2.0:
194
+ sys.exit("Validation test failed at case 1: %s gives getGain() = %.15g, which is %.3e from the gain %.15g, more than half the tolerance." % (label, middle, abs(middle - HUBGAIN), HUBGAIN))
195
+
196
+
197
+ # ------------------------------------------------------------------
198
+ # CASE 2: Random unichain models, against exact gains
199
+ # ------------------------------------------------------------------
200
+
201
+ for seed in SEEDS:
202
+ rewards, transitions = randommodel(seed)
203
+ optimalgain = exactaverage(rewards, transitions)[1]
204
+ for algorithmlabel, algorithm in ALGORITHMS:
205
+ label = "%s (tolerance %g) on random model %d" % (algorithmlabel, LOOSE, seed)
206
+ mdl = solve(rewards, transitions, algorithm=algorithm, tolerance=LOOSE, parallel=False)
207
+ if not mdl.hasConverged():
208
+ sys.exit("Validation test failed at case 2: %s did not converge." % label)
209
+ lower, middle, upper = bounds(mdl)
210
+ checkbounds(2, label, lower, upper, policygain(rewards, transitions, [int(a) for a in mdl.getPolicy()]), "the gain of the policy found")
211
+ checkbounds(2, label, lower, upper, optimalgain, "the optimal gain")
212
+ if not upper - lower < LOOSE:
213
+ sys.exit("Validation test failed at case 2: %s gives bounds %.3e apart, which is not less than the tolerance." % (label, upper - lower))
214
+
215
+
216
+ # ------------------------------------------------------------------
217
+ # CASE 3: A periodic model, where solve() stops at a limit
218
+ # ------------------------------------------------------------------
219
+
220
+ for algorithmlabel, algorithm in ALGORITHMS[:2]: # (modified policy iteration would take 100 sweeps per iteration to its limit)
221
+ label = "%s on the three-state cycle" % algorithmlabel
222
+ with warnings.catch_warnings(record=True) as caught:
223
+ warnings.simplefilter("always")
224
+ mdl = solve(CYCLEREWARDS, CYCLETRANSITIONS, algorithm=algorithm, tolerance=LOOSE, parallel=False)
225
+ if mdl.hasConverged() or not any(issubclass(w.category, mdpsolver.ConvergenceWarning) for w in caught):
226
+ sys.exit("Validation test failed at case 3: %s converged or gave no ConvergenceWarning, where the cycle should stop it at its limit." % label)
227
+ lower, middle, upper = bounds(mdl)
228
+ checkbounds(3, label, lower, upper, CYCLEGAIN)
229
+ if abs(middle - CYCLEGAIN) < LOOSE:
230
+ sys.exit("Validation test failed at case 3: %s gives the midpoint %.15g, which equals the gain, where the bounds [%.15g, %.15g] should be too far apart for that." % (label, middle, lower, upper))
231
+
232
+
233
+ # ------------------------------------------------------------------
234
+ # CASE 4: The bias, against known biases
235
+ # ------------------------------------------------------------------
236
+
237
+ problems = [("the hub-and-spoke problem", HUBREWARDS, HUBTRANSITIONS, HUBBIAS)]
238
+ for seed in SEEDS:
239
+ rewards, transitions = randommodel(seed)
240
+ problems.append(("random model %d" % seed, rewards, transitions, exactaverage(rewards, transitions)[2]))
241
+
242
+ for problemlabel, rewards, transitions, knownbias in problems:
243
+ for algorithmlabel, algorithm in ALGORITHMS:
244
+ label = "%s (tolerance %g) on %s" % (algorithmlabel, TIGHT, problemlabel)
245
+ mdl = solve(rewards, transitions, algorithm=algorithm, tolerance=TIGHT, parallel=False)
246
+ if not mdl.hasConverged():
247
+ sys.exit("Validation test failed at case 4: %s did not converge." % label)
248
+ values = mdl.getValueVector()
249
+ for reference in range(len(rewards)):
250
+ for state in range(len(rewards)):
251
+ bias = mdl.getBias(state, referenceIndex=reference)
252
+ if not isinstance(bias, float) or bias != values[state] - values[reference]:
253
+ sys.exit("Validation test failed at case 4: getBias(%d, referenceIndex=%d) after %s gives %r, where it should be the float value of state %d minus that of state %d, %r." % (state, reference, label, bias, state, reference, values[state] - values[reference]))
254
+ difference = abs(bias - (knownbias[state] - knownbias[reference]))
255
+ if difference > BIASTOL:
256
+ sys.exit("Validation test failed at case 4: getBias(%d, referenceIndex=%d) after %s differs by %.3e from the known bias, which is more than %.1e." % (state, reference, label, difference, BIASTOL))
257
+ for state in range(len(rewards)):
258
+ if mdl.getBias(state) != mdl.getBias(state, referenceIndex=0):
259
+ sys.exit("Validation test failed at case 4: getBias(%d) after %s differs from getBias(%d, referenceIndex=0)." % (state, label, state))
260
+ if mdl.getBias(np.int64(2), referenceIndex=np.int64(1)) != mdl.getBias(2, referenceIndex=1):
261
+ sys.exit("Validation test failed at case 4: getBias() after %s gives another result with the NumPy integers 2 and 1 than with the integers." % label)
262
+
263
+
264
+ # ------------------------------------------------------------------
265
+ # CASE 5: Errors, the bound and the reference state
266
+ # ------------------------------------------------------------------
267
+
268
+ mdl = mdpsolver.model()
269
+ mdl.mdp(discount=DISCOUNT, **arrayinput(HUBREWARDS, HUBTRANSITIONS, bycolumn=True))
270
+ CALLS = (("getGain()", lambda: mdl.getGain()), ("getBias(1)", lambda: mdl.getBias(1)))
271
+ for call, function in CALLS:
272
+ message = raises(RuntimeError, function)
273
+ if message is None or "solve()" not in message:
274
+ sys.exit("Validation test failed at case 5: %s before solve() gives %r, where it should raise a RuntimeError naming solve()." % (call, message))
275
+
276
+ quietly(lambda: mdl.solve(tolerance=LOOSE))
277
+ for call, function in CALLS:
278
+ message = raises(RuntimeError, function)
279
+ if message is None or "discounted" not in message:
280
+ sys.exit("Validation test failed at case 5: %s after a run under the discounted criterion gives %r, where it should raise a RuntimeError naming the criterion." % (call, message))
281
+
282
+ # a run under the average criterion after the discounted one (the note that
283
+ # the model has been solved before is printed)
284
+ quietly(lambda: mdl.solve(tolerance=TIGHT, criterion="average"))
285
+ lower, middle, upper = bounds(mdl)
286
+ checkbounds(5, "a run under the average criterion after a discounted one", lower, upper, HUBGAIN)
287
+ biases = np.array([mdl.getBias(s) for s in range(len(HUBREWARDS))])
288
+ if float(np.max(np.abs(biases - HUBBIAS))) > BIASTOL:
289
+ sys.exit("Validation test failed at case 5: getBias() after a run under the average criterion that followed a discounted one gives %s, where the known bias is %s." % (biases, HUBBIAS))
290
+ for name, expected in (("Lower", lower), ("LOWER", lower), ("upper", upper), ("Upper", upper), ("UPPER", upper), (None, middle)):
291
+ if mdl.getGain(bound=name) != expected:
292
+ sys.exit("Validation test failed at case 5: getGain(bound=%r) gives %r, where it should give %r." % (name, mdl.getGain(bound=name), expected))
293
+ for name in ("middle", "", "low", 1, True):
294
+ if raises(ValueError, lambda: mdl.getGain(bound=name)) is None:
295
+ sys.exit("Validation test failed at case 5: getGain(bound=%r) does not raise a ValueError." % (name,))
296
+ for index, error in ((-1, IndexError), (len(HUBREWARDS), IndexError), (1.0, TypeError), ("0", TypeError), (True, TypeError), (None, TypeError)):
297
+ for call, function in (("getBias(%r)" % (index,), lambda: mdl.getBias(index)),
298
+ ("getBias(0, referenceIndex=%r)" % (index,), lambda: mdl.getBias(0, referenceIndex=index))):
299
+ if raises(error, function) is None:
300
+ sys.exit("Validation test failed at case 5: %s does not raise %s." % (call, error.__name__))
301
+
302
+ # and a run under the discounted criterion again
303
+ quietly(lambda: mdl.solve(tolerance=LOOSE))
304
+ for call, function in CALLS:
305
+ if raises(RuntimeError, function) is None:
306
+ sys.exit("Validation test failed at case 5: %s after a discounted run that followed an average one does not raise a RuntimeError." % call)
307
+
308
+
309
+ # ------------------------------------------------------------------
310
+ # FINAL VALIDATION
311
+ # ------------------------------------------------------------------
312
+
313
+ print(finalline(__file__))
@@ -3,8 +3,9 @@ UNIT TEST FOR CALLING THE METHODS OF A MODEL OBJECT IN THE WRONG ORDER.
3
3
 
4
4
  A model object is used in three steps: mdp() defines the model, solve() solves
5
5
  it, and the output methods (getPolicy(), getValueVector(), getAction(),
6
- getValue(), getRuntime(), getIterations(), hasConverged(), printPolicy(),
7
- printValueVector() and saveToFile()) report the solution. checkModel() needs
6
+ getValue(), getGain(), getBias(), getRuntime(), getIterations(),
7
+ hasConverged(), printPolicy(), printValueVector() and saveToFile()) report
8
+ the solution. checkModel() needs
8
9
  only mdp(). Before a solution exists, the solver
9
10
  holds a placeholder policy and value vector, a single -1 each, which the output
10
11
  methods used to return as if it were a solution: getPolicy() gave [-1] and
@@ -42,7 +43,10 @@ Sub-tests:
42
43
  getValueVector(), which are the known solution; printPolicy(),
43
44
  printValueVector() and the file of saveToFile() with them; and
44
45
  getIterations() and getRuntime() are a positive count and a time,
45
- and hasConverged() is True.
46
+ and hasConverged() is True. The run is under the discounted
47
+ criterion, where getGain() and getBias() raise a RuntimeError
48
+ naming the average reward criterion (test_average.py tests them
49
+ under that criterion).
46
50
  A later run of solve() rejected by its own checks leaves the
47
51
  solution in place.
48
52
 
@@ -81,7 +85,7 @@ if NSTATES < 2 or -1 in HUBPOLICY or np.any(HUBVALUES == -1.0):
81
85
  tmpdir = tempfile.TemporaryDirectory()
82
86
 
83
87
  # The output methods, each with the arguments of a call
84
- OUTPUTFILE = os.path.join(tmpdir.name, "policy.csv")
88
+ OUTPUTFILE = os.path.join(tmpdir.name, "policy.npy")
85
89
  OUTPUTMETHODS = {
86
90
  "getRuntime": dict(),
87
91
  "getIterations": dict(),
@@ -90,6 +94,8 @@ OUTPUTMETHODS = {
90
94
  "printValueVector": dict(),
91
95
  "getAction": dict(stateIndex=1),
92
96
  "getValue": dict(stateIndex=1),
97
+ "getGain": dict(),
98
+ "getBias": dict(stateIndex=1),
93
99
  "getPolicy": dict(),
94
100
  "getValueVector": dict(),
95
101
  "saveToFile": dict(fileName=OUTPUTFILE, type="policy"),
@@ -231,6 +237,12 @@ for rejected in (False, True):
231
237
  results, printed = {}, {}
232
238
  for method in OUTPUTMETHODS:
233
239
  removeoutputfile()
240
+ if method in ("getGain", "getBias"):
241
+ # (the gain and the bias exist under the average reward criterion only)
242
+ error, output = attempt(lambda: call(mdl, method))
243
+ if type(error) is not RuntimeError or "average" not in str(error) or output:
244
+ sys.exit("Validation test failed at case 3: %s() %s under the discounted criterion gave %r and printed %r, where it should raise a RuntimeError naming the average reward criterion and print nothing else." % (method, after, error, output.strip()))
245
+ continue
234
246
  try:
235
247
  results[method], printed[method] = quietly(lambda: call(mdl, method))
236
248
  except Exception as error:
@@ -254,11 +266,9 @@ for rejected in (False, True):
254
266
  or not np.allclose([float(line.split(": ")[1]) for line in lines], values, rtol=1e-5):
255
267
  sys.exit("Validation test failed at case 3: printValueVector() %s printed %r, where it should print the %d values of getValueVector()." % (after, printed["printValueVector"].strip(), NSTATES))
256
268
 
257
- with open(OUTPUTFILE) as file:
258
- rows = file.read().strip().splitlines()
259
- expected = ["State_Index,Action_Index"] + ["%d,%d" % (s, a) for s, a in enumerate(policy)]
260
- if rows != expected:
261
- sys.exit("Validation test failed at case 3: saveToFile() %s wrote %r, where it should write %r." % (after, rows, expected))
269
+ saved = np.load(OUTPUTFILE, allow_pickle=False)
270
+ if list(saved) != policy:
271
+ sys.exit("Validation test failed at case 3: saveToFile() %s wrote %r, where it should write %r." % (after, list(saved), policy))
262
272
 
263
273
  iterations, runtime = results["getIterations"], results["getRuntime"]
264
274
  if not isinstance(iterations, int) or iterations < 1:
@@ -2,8 +2,9 @@
2
2
  UNIT TEST FOR THE OUTPUT OF A SOLVED MODEL: saveToFile(), getAction(),
3
3
  getValue() AND THE REPORT ON CONVERGENCE.
4
4
 
5
- saveToFile() writes the policy or the value vector to a comma-separated file.
6
- type="value" used to write nothing, since the compiled module knew only
5
+ saveToFile() writes the policy or the value vector to a NumPy .npy file
6
+ (filetype="npy", the default) or to a comma-separated file (filetype="csv");
7
+ until 0.12.0 it wrote only comma-separated files. type="value" used to write nothing, since the compiled module knew only
7
8
  "values", and a file that could not be opened was reported on stderr only.
8
9
  getAction() and getValue() used to return -1 and 0.0 for a state outside the
9
10
  model, and 0.0 cannot be told from a real value; they now raise IndexError.
@@ -18,12 +19,20 @@ limit, when the values become infinite or NaN, or when the final check
18
19
  discount allow.
19
20
 
20
21
  Sub-tests:
21
- Case 1: saveToFile() with type="policy" and type="value" writes a header
22
- and one row per state, equal to getPolicy() and getValueVector()
23
- (to the six significant digits written), replacing a file that is
24
- there. An unknown type raises a ValueError, and a file in a folder
25
- that does not exist raises a FileNotFoundError; neither writes a
26
- file.
22
+ Case 1: saveToFile() with type="policy" and type="value" writes by
23
+ default an .npy file that np.load reads as the array of
24
+ getPolicy() (int64) or getValueVector() (float64), and with
25
+ filetype="csv" a header and one row per state, equal to them (to
26
+ the six significant digits written); both replace a file that is
27
+ there. Without a file name, it writes result.npy, or result.csv
28
+ with filetype="csv", in the working directory. The filetype may be
29
+ in any case, e.g. "NPY" or "Csv", also in the compiled module,
30
+ and a default name is then in lower case. An unknown type or
31
+ filetype, and a name ending in .csv with filetype="npy" or in .npy
32
+ with filetype="csv" (in any case), raise a ValueError, also in the
33
+ compiled module, whose message for a name names the filetype to
34
+ use; a file in a folder that does not exist raises a
35
+ FileNotFoundError. None of them writes a file.
27
36
  Case 2: getAction() and getValue() agree with getPolicy() and
28
37
  getValueVector() in every state, and raise an IndexError naming the
29
38
  index for a state outside the model.
@@ -137,6 +146,14 @@ def readrows(path):
137
146
  return file.read().strip().splitlines()
138
147
 
139
148
 
149
+ def loadednpy(path):
150
+ '''The array np.load reads from an .npy file, or the exception it raises.'''
151
+ try:
152
+ return np.load(path, allow_pickle=False)
153
+ except Exception as error:
154
+ return error
155
+
156
+
140
157
  mdl, caught, output = solved(HUBMODEL)
141
158
  if output or caught:
142
159
  sys.exit("Validation test failed at initialization: solving the hub-and-spoke problem printed %r and warned %r." % (output.strip(), [str(w.message) for w in caught]))
@@ -149,29 +166,84 @@ if POLICY != HUBPOLICY or float(np.max(np.abs(np.array(VALUES) - HUBVALUES))) >
149
166
  # CASE 1: saveToFile()
150
167
  # ------------------------------------------------------------------
151
168
 
152
- for kind, header, column in (("policy", "State_Index,Action_Index", POLICY), ("value", "State_Index,Value", VALUES)):
153
- path = os.path.join(tmpdir.name, "%s.csv" % kind)
169
+ for kind, header, column, dtype in (("policy", "State_Index,Action_Index", POLICY, np.int64), ("value", "State_Index,Value", VALUES, np.float64)):
170
+ # The default filetype: the array of getPolicy() or getValueVector()
171
+ path = os.path.join(tmpdir.name, "%s.npy" % kind)
154
172
  with open(path, "w") as file:
155
173
  file.write("this file is replaced\n")
156
174
  error, output = raises(lambda: mdl.saveToFile(fileName=path, type=kind))
157
175
  if error is not None or output:
158
176
  sys.exit("Validation test failed at case 1: saveToFile(type=%r) raised %r and printed %r." % (kind, error, output.strip()))
177
+ array = loadednpy(path)
178
+ if not isinstance(array, np.ndarray) or array.dtype != dtype or array.shape != (NSTATES,) or not np.array_equal(array, column):
179
+ sys.exit("Validation test failed at case 1: saveToFile(type=%r) wrote a file that np.load reads as %r, where it should be the %s array %r." % (kind, array, np.dtype(dtype).name, column))
180
+
181
+ # filetype="csv": a header and a row per state
182
+ path = os.path.join(tmpdir.name, "%s.csv" % kind)
183
+ with open(path, "w") as file:
184
+ file.write("this file is replaced\n")
185
+ error, output = raises(lambda: mdl.saveToFile(fileName=path, type=kind, filetype="csv"))
186
+ if error is not None or output:
187
+ sys.exit("Validation test failed at case 1: saveToFile(type=%r, filetype='csv') raised %r and printed %r." % (kind, error, output.strip()))
159
188
  rows = readrows(path)
160
189
  if not rows or rows[0] != header or len(rows) != NSTATES + 1:
161
- sys.exit("Validation test failed at case 1: saveToFile(type=%r) wrote %r, where it should write the header %r and %d rows." % (kind, rows, header, NSTATES))
190
+ sys.exit("Validation test failed at case 1: saveToFile(type=%r, filetype='csv') wrote %r, where it should write the header %r and %d rows." % (kind, rows, header, NSTATES))
162
191
  for s, row in enumerate(rows[1:]):
163
192
  index, entry = row.split(",")
164
193
  if int(index) != s or not np.isclose(float(entry), column[s], rtol=1e-5, atol=0.0):
165
- sys.exit("Validation test failed at case 1: saveToFile(type=%r) wrote the row %r for state %d, where its %s is %r." % (kind, row, s, kind, column[s]))
166
-
167
- for label, given, expected in (("an unknown type", dict(type="values2"), ValueError),
168
- ("a file in a folder that does not exist", dict(type="policy"), FileNotFoundError)):
169
- path = os.path.join(tmpdir.name, "missing" if expected is FileNotFoundError else "", "result.csv")
170
- error, output = raises(lambda: mdl.saveToFile(fileName=path, **given))
171
- if not isinstance(error, expected):
172
- sys.exit("Validation test failed at case 1: saveToFile() with %s raised %r, where it should raise a %s." % (label, error, expected.__name__))
173
- if output or os.path.exists(path):
174
- sys.exit("Validation test failed at case 1: saveToFile() with %s printed %r or wrote a file." % (label, output.strip()))
194
+ sys.exit("Validation test failed at case 1: saveToFile(type=%r, filetype='csv') wrote the row %r for state %d, where its %s is %r." % (kind, row, s, kind, column[s]))
195
+
196
+ # Without a file name: result.npy or result.csv in the working directory (in
197
+ # lower case also for a filetype in upper case)
198
+ folder = os.path.join(tmpdir.name, "defaults")
199
+ os.mkdir(folder)
200
+ here = os.getcwd()
201
+ os.chdir(folder)
202
+ try:
203
+ results = [raises(lambda: mdl.saveToFile(type="value")), raises(lambda: mdl.saveToFile(type="value", filetype="CSV"))]
204
+ finally:
205
+ os.chdir(here)
206
+ for (error, output), given in zip(results, ("", ", filetype='CSV'")):
207
+ if error is not None or output:
208
+ sys.exit("Validation test failed at case 1: saveToFile(type='value'%s) without a file name raised %r and printed %r." % (given, error, output.strip()))
209
+ if sorted(os.listdir(folder)) != ["result.csv", "result.npy"]:
210
+ sys.exit("Validation test failed at case 1: saveToFile() without a file name wrote %s, where it should write result.npy, and result.csv with filetype='csv'." % sorted(os.listdir(folder)))
211
+ array, rows = loadednpy(os.path.join(folder, "result.npy")), readrows(os.path.join(folder, "result.csv"))
212
+ if not isinstance(array, np.ndarray) or not np.array_equal(array, VALUES) or rows[0] != "State_Index,Value" or len(rows) != NSTATES + 1:
213
+ sys.exit("Validation test failed at case 1: saveToFile(type='value') without a file name wrote %r to result.npy and %r to result.csv, where they should hold the value vector." % (array, rows))
214
+
215
+ # The filetype in any case, also in the compiled module
216
+ for filetype in ("NPY", "Csv"):
217
+ for name, method in (("saveToFile()", mdl.saveToFile), ("The compiled module's saveToFile()", mdl.mdl.saveToFile)):
218
+ path = os.path.join(tmpdir.name, "anycase.%s" % filetype.lower())
219
+ if os.path.exists(path):
220
+ os.remove(path)
221
+ error, output = raises(lambda: method(fileName=path, type="value", filetype=filetype))
222
+ if error is not None or output:
223
+ sys.exit("Validation test failed at case 1: %s with filetype=%r raised %r and printed %r." % (name, filetype, error, output.strip()))
224
+ saved = loadednpy(path) if filetype.lower() == "npy" else readrows(path)
225
+ if (filetype.lower() == "npy" and not (isinstance(saved, np.ndarray) and np.array_equal(saved, VALUES))) \
226
+ or (filetype.lower() == "csv" and (saved[0] != "State_Index,Value" or len(saved) != NSTATES + 1)):
227
+ sys.exit("Validation test failed at case 1: %s with filetype=%r wrote %r, where it should write the value vector as a %s file." % (name, filetype, saved, filetype.lower()))
228
+
229
+ # Arguments that are not used, and a file that cannot be opened (the compiled
230
+ # module makes the same checks of the arguments)
231
+ for label, given, expected, words in (
232
+ ("an unknown type", dict(fileName="result.npy", type="values2"), ValueError, "'policy' or 'value'"),
233
+ ("an unknown filetype", dict(fileName="result.npy", filetype="npz"), ValueError, "'npy' or 'csv'"),
234
+ ("a name ending in .csv and the default filetype", dict(fileName="result.csv"), ValueError, "filetype='csv'"),
235
+ ("a name ending in .CSV and filetype='npy'", dict(fileName="result.CSV", filetype="npy"), ValueError, "filetype='csv'"),
236
+ ("a name ending in .npy and filetype='csv'", dict(fileName="result.npy", filetype="csv"), ValueError, "filetype='npy'"),
237
+ ("a name ending in .npy and filetype='CSV'", dict(fileName="result.npy", filetype="CSV"), ValueError, "filetype='npy'"),
238
+ ("a file in a folder that does not exist", dict(fileName=os.path.join("missing", "result.npy")), FileNotFoundError, "missing")):
239
+ path = os.path.join(tmpdir.name, given["fileName"])
240
+ methods = [("saveToFile()", mdl.saveToFile)] + ([("The compiled module's saveToFile()", mdl.mdl.saveToFile)] if expected is ValueError else [])
241
+ for name, method in methods:
242
+ error, output = raises(lambda: method(**{**given, "fileName": path}))
243
+ if not isinstance(error, expected) or words not in str(error):
244
+ sys.exit("Validation test failed at case 1: %s with %s raised %r, where it should raise a %s that says %r." % (name, label, error, expected.__name__, words))
245
+ if output or os.path.exists(path):
246
+ sys.exit("Validation test failed at case 1: %s with %s printed %r or wrote a file." % (name, label, output.strip()))
175
247
 
176
248
 
177
249
  # ------------------------------------------------------------------
@@ -30,7 +30,7 @@ MDPSolver is a Python package for large Markov Decision Processes (MDPs) with in
30
30
  | 5,000,000 | 500,000,000 | 6.3 GB | 5.03 s |
31
31
  | 10,000,000 | 1,000,000,000 | 12.7 GB | 13.0 s |
32
32
 
33
- Mean of 10 random models with 5 actions per state and 20 possible next states per state and action, loaded from NPY-files and solved by modified policy iteration under the discounted reward criterion with discount 0.9. Measured on an Apple M5 with 10 cores.
33
+ Mean of 10 random models with 5 actions per state and 20 possible next states per state and action, loaded from NPY-files and solved by modified policy iteration under the discounted reward criterion with discount $\lambda=0.9$ and tolerance $\epsilon = 0.001$. Measured on an Apple M5 with 10 cores.
34
34
 
35
35
  # Installation
36
36
 
@@ -30,7 +30,7 @@ CMAKE_BUILD_TYPE = "Release"
30
30
 
31
31
  [project]
32
32
  name = "mdpsolver"
33
- version = "0.12.0"
33
+ version = "0.12.1"
34
34
  authors = [
35
35
  { name="Anders Reenberg Andersen", email="andersra@live.dk" },
36
36
  { name="Jesper Fink Andersen", email="jesperfinkandersen@gmail.com" },
File without changes
File without changes