From 36541ecb145cffc83c87ad5d0ef24a8b3cff03e6 Mon Sep 17 00:00:00 2001 From: David Hadka Date: Fri, 20 Sep 2024 07:55:25 -0600 Subject: [PATCH] Add logging to CLI --- docs/cli.rst | 25 ++++++------ platypus/__main__.py | 92 +++++++++++++++++++++++++++++-------------- platypus/_tools.py | 2 +- platypus/core.py | 20 +++++----- platypus/evaluator.py | 45 ++++++++++----------- 5 files changed, 105 insertions(+), 79 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 342554e1..f743099a 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -47,21 +47,10 @@ which outputs: 0.23519328623761482 -Plotting --------- - -For 2 and 3 objectives, we can also generate a plot, either interactive or saving as an image: - -.. code:: bash - - python -m platypus plot NSGAII_DTLZ2.set - python -m platypus plot --output NSGAII_DTLZ2.png NSGAII_DTLZ2.set - Filtering --------- -The results can be filtered to remove any dominated solutions, with optional support to remove -infeasible, duplicate, or using epsilon-dominance: +The results can be filtered to remove any dominated, infeasible, or duplicate solutions: .. code:: bash @@ -81,6 +70,16 @@ the normalized solutions: python -m platypus normalize --output NSGAII_DTLZ2_normalized.set --reference_set ./examples/DTLZ2.2D.pf NSGAII_DTLZ2.set python -m platypus normalize --output NSGAII_DTLZ2_normalized.set --minimum 0.0,0.0 --maximum 1.0,1.0 NSGAII_DTLZ2.set +Plotting +-------- + +For 2 and 3 objectives, we can also generate a plot, either interactive or saving as an image: + +.. code:: bash + + python -m platypus plot NSGAII_DTLZ2.set + python -m platypus plot --output NSGAII_DTLZ2.png NSGAII_DTLZ2.set + Combining or Chaining Commands ------------------------------ @@ -91,5 +90,5 @@ as demonstrated below: .. code:: bash python -m platypus solve --algorithm NSGAII --problem DTLZ2 --nfe 10000 | \ - python -m platypus filter -e 0.01,0.01 | \ + python -m platypus filter --epsilons 0.01,0.01 | \ python -m platypus plot diff --git a/platypus/__main__.py b/platypus/__main__.py index 5e11f623..459a9b39 100644 --- a/platypus/__main__.py +++ b/platypus/__main__.py @@ -16,9 +16,11 @@ # # You should have received a copy of the GNU General Public License # along with Platypus. If not, see . - +import os +import re import sys import json +import locale import random import logging import platypus @@ -27,40 +29,80 @@ coalesce def main(input): - logging.basicConfig(level=logging.INFO) + """The main entry point for the Platypus CLI.""" + LOGGER = logging.getLogger("Platypus") + + def load_set(file): + """Loads input file from stdin or file.""" + if file is None: + return platypus.load(sys.stdin) + + try: + return platypus.load_json(file) + except json.decoder.JSONDecodeError: + return platypus.load_objectives(file) + + def save_set(result, file, indent=4): + """Output result to stdout or file.""" + if file is None: + platypus.dump(result, sys.stdout, indent=indent) + sys.stdout.write(os.linesep) + else: + platypus.save_json(file, result, indent=indent) + + def split_list(type, separator=None): + """Argparse type for comma-separated list of values. + + Accepts arguments of the form:: + + --arg val1,val2,val3 + + This is, in my opinion, a bit easier to use than argparse's default + which will capture any subsequent positional arguments unless + separated by ``--``. + + By default, supports either ``,`` or ``;`` as the separator, except + in locales using that character as a decimal point. + """ + separator = coalesce(separator, "".join({",", ";"}.difference(locale.localeconv()["decimal_point"]))) + pattern = re.compile(f"[{re.escape(separator)}]") + return lambda input: [type(s.strip()) for s in pattern.split(input)] + + def debug_inputs(args): + """Log CLI arguments.""" + for attr in dir(args): + if not attr.startswith("_"): + LOGGER.debug("Argument: %s=%s", attr, getattr(args, attr)) parser = ArgumentParser(prog="platypus", description="Platypus (platypus-opt) - Multobjective optimization in Python") parser.add_argument("-v", "--version", action="version", version=platypus.__version__) + parser.add_argument('--log', help='set the logging level', type=str.upper, default='WARNING', + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]) subparsers = parser.add_subparsers(title="commands", required=True, dest="command") - # Use comma-separated lists instead of nargs, since nargs="+" or nargs="*" will capture positional - # arguments unless separated by --. - def split_list(type): - return lambda input: [type(s.strip()) for s in input.split(',')] - hypervolume_parser = subparsers.add_parser("hypervolume", help="compute hypervolume") hypervolume_parser.add_argument("-r", "--reference_set", help="reference set") hypervolume_parser.add_argument("--minimum", help="minimum bounds, optional", type=split_list(float)) hypervolume_parser.add_argument("--maximum", help="maximum bounds, optional", type=split_list(float)) - hypervolume_parser.add_argument("filename", nargs="?") + hypervolume_parser.add_argument("filename", help="input filename", nargs="?") gd_parser = subparsers.add_parser("gd", help="compute generaional distance") gd_parser.add_argument("-r", "--reference_set", help="reference set", required=True) - gd_parser.add_argument("filename", nargs="?") + gd_parser.add_argument("filename", help="input filename", nargs="?") igd_parser = subparsers.add_parser("igd", help="compute inverted generaional distance") igd_parser.add_argument("-r", "--reference_set", help="reference set", required=True) - igd_parser.add_argument("filename", nargs="?") + igd_parser.add_argument("filename", help="input filename", nargs="?") epsilon_parser = subparsers.add_parser("epsilon", help="compute additive epsilon indicator") epsilon_parser.add_argument("-r", "--reference_set", help="reference set", required=True) - epsilon_parser.add_argument("filename", nargs="?") + epsilon_parser.add_argument("filename", help="input filename", nargs="?") spacing_parser = subparsers.add_parser("spacing", help="compute spacing") - spacing_parser.add_argument("filename", nargs="?") + spacing_parser.add_argument("filename", help="input filename", nargs="?") solve_parser = subparsers.add_parser("solve", help="solve a built-in problem") solve_parser.add_argument("-p", "--problem", help="name of the problem", required=True) @@ -78,36 +120,26 @@ def split_list(type): filter_parser.add_argument("-u", "--unique", help="remove any duplicate solutions", action='store_true') filter_parser.add_argument("-n", "--nondominated", help="remove any dominated solutions", action='store_true') filter_parser.add_argument("-o", "--output", help="output filename") - filter_parser.add_argument("filename", nargs="?") + filter_parser.add_argument("filename", help="input filename", nargs="?") normalize_parser = subparsers.add_parser("normalize", help="normalize results") normalize_parser.add_argument("-r", "--reference_set", help="reference set") normalize_parser.add_argument("--minimum", help="minimum values for each objective", type=split_list(float)) normalize_parser.add_argument("--maximum", help="maximum values for each objective", type=split_list(float)) normalize_parser.add_argument("-o", "--output", help="output filename") - normalize_parser.add_argument("filename", nargs="?") + normalize_parser.add_argument("filename", help="input filename", nargs="?") plot_parser = subparsers.add_parser("plot", help="generate simple 2D or 3D plot") plot_parser.add_argument("-t", "--title", help="plot title") plot_parser.add_argument("-o", "--output", help="output filename") - plot_parser.add_argument("filename", nargs="?") + plot_parser.add_argument("filename", help="input filename", nargs="?") args = parser.parse_args(input) - def load_set(file): - if file is None: - return platypus.load(sys.stdin) - - try: - return platypus.load_json(file) - except json.decoder.JSONDecodeError: - return platypus.load_objectives(file) + if args.log: + logging.basicConfig(level=args.log) - def save_set(result, file, indent=4): - if file is None: - platypus.dump(result, sys.stdout, indent=indent) - else: - platypus.save_json(file, result, indent=indent) + debug_inputs(args) if args.command == "hypervolume": ref_set = load_set(args.reference_set) @@ -185,12 +217,12 @@ def save_set(result, file, indent=4): if args.reference_set: if minimum is not None or maximum is not None: - print("ignoring --minimum and --maximum options since a reference set is provided", file=sys.stderr) + LOGGER.warn("ignoring --minimum and --maximum options since a reference set is provided", file=sys.stderr) ref_set = load_set(args.reference_set) minimum, maximum = platypus.normalize(ref_set) norm_min, norm_max = platypus.normalize(input_set, minimum, maximum) - print(f"Using bounds minimum={norm_min}; maximum={norm_max}") + LOGGER.info(f"Using bounds minimum={norm_min}; maximum={norm_max}") for s in input_set: s.objectives = s.normalized_objectives diff --git a/platypus/_tools.py b/platypus/_tools.py index 55dc8e2a..a160a3a2 100644 --- a/platypus/_tools.py +++ b/platypus/_tools.py @@ -74,7 +74,7 @@ def only_keys_for(d, func): def log_args(args, target): """Logs the arguments.""" for k, v in args.items(): - LOGGER.log(logging.INFO, "Setting %s=%s on %s", k, v, target) + LOGGER.info("Setting %s=%s on %s", k, v, target) def parse_cli_keyvalue(args): """Parses CLI key=value pairs into a dictionary. diff --git a/platypus/core.py b/platypus/core.py index e9ef4a50..7d32df47 100644 --- a/platypus/core.py +++ b/platypus/core.py @@ -516,26 +516,24 @@ def run(self, condition, callback=None): last_log = self.nfe start_time = time.time() - LOGGER.log(logging.INFO, "%s starting", type(self).__name__) + LOGGER.info("%s starting", type(self).__name__) while not condition(self): self.step() if self.log_frequency is not None and self.nfe >= last_log + self.log_frequency: - LOGGER.log(logging.INFO, - "%s running; NFE Complete: %d, Elapsed Time: %s", - type(self).__name__, - self.nfe, - datetime.timedelta(seconds=time.time()-start_time)) + LOGGER.info("%s running; NFE Complete: %d, Elapsed Time: %s", + type(self).__name__, + self.nfe, + datetime.timedelta(seconds=time.time()-start_time)) if callback is not None: callback(self) - LOGGER.log(logging.INFO, - "%s finished; Total NFE: %d, Elapsed Time: %s", - type(self).__name__, - self.nfe, - datetime.timedelta(seconds=time.time()-start_time)) + LOGGER.info("%s finished; Total NFE: %d, Elapsed Time: %s", + type(self).__name__, + self.nfe, + datetime.timedelta(seconds=time.time()-start_time)) def _constraint_eq(x, y): return abs(x - y) diff --git a/platypus/evaluator.py b/platypus/evaluator.py index 1e4eb8b0..9dee85e8 100644 --- a/platypus/evaluator.py +++ b/platypus/evaluator.py @@ -142,11 +142,10 @@ def evaluate_all(self, jobs, **kwargs): for chunk in _chunks(jobs, log_frequency): result.extend(self.map_func(run_job, chunk)) - LOGGER.log(logging.INFO, - "%s running; Jobs Complete: %d, Elapsed Time: %s", - job_name, - len(result), - datetime.timedelta(seconds=time.time()-start_time)) + LOGGER.info("%s running; Jobs Complete: %d, Elapsed Time: %s", + job_name, + len(result), + datetime.timedelta(seconds=time.time()-start_time)) return result @@ -192,11 +191,10 @@ def evaluate_all(self, jobs, **kwargs): for chunk in _chunks(futures, log_frequency): result.extend([f.result() for f in chunk]) - LOGGER.log(logging.INFO, - "%s running; Jobs Complete: %d, Elapsed Time: %s", - job_name, - len(result), - datetime.timedelta(seconds=time.time()-start_time)) + LOGGER.info("%s running; Jobs Complete: %d, Elapsed Time: %s", + job_name, + len(result), + datetime.timedelta(seconds=time.time()-start_time)) return result @@ -246,11 +244,10 @@ def evaluate_all(self, jobs, **kwargs): for chunk in _chunks(futures, log_frequency): result.extend([f.get() for f in chunk]) - LOGGER.log(logging.INFO, - "%s running; Jobs Complete: %d, Elapsed Time: %s", - job_name, - len(result), - datetime.timedelta(seconds=time.time()-start_time)) + LOGGER.info("%s running; Jobs Complete: %d, Elapsed Time: %s", + job_name, + len(result), + datetime.timedelta(seconds=time.time()-start_time)) return result @@ -279,19 +276,19 @@ def __init__(self, pool): self.pool = pool if hasattr(pool, "_processes"): - LOGGER.log(logging.INFO, "Started pool evaluator with %d processes", pool._processes) + LOGGER.info("Started pool evaluator with %d processes", pool._processes) else: - LOGGER.log(logging.INFO, "Started pool evaluator") + LOGGER.info("Started pool evaluator") def close(self): - LOGGER.log(logging.DEBUG, "Closing pool evaluator") + LOGGER.debug("Closing pool evaluator") self.pool.close() if hasattr(self.pool, "join"): - LOGGER.log(logging.DEBUG, "Waiting for all processes to complete") + LOGGER.debug("Waiting for all processes to complete") self.pool.join() - LOGGER.log(logging.INFO, "Closed pool evaluator") + LOGGER.info("Closed pool evaluator") class MultiprocessingEvaluator(PoolEvaluator): """Evaluator using Python's multiprocessing library. @@ -333,15 +330,15 @@ def __init__(self, processes=None): from concurrent.futures import ProcessPoolExecutor self.executor = ProcessPoolExecutor(processes) super().__init__(self.executor.submit) - LOGGER.log(logging.INFO, "Started process pool evaluator") + LOGGER.info("Started process pool evaluator") if processes: - LOGGER.log(logging.INFO, "Using user-defined number of processes: %d", processes) + LOGGER.info("Using user-defined number of processes: %d", processes) except ImportError: # prevent error from showing in Eclipse if concurrent.futures not available raise def close(self): - LOGGER.log(logging.DEBUG, "Closing process pool evaluator") + LOGGER.debug("Closing process pool evaluator") self.executor.shutdown() - LOGGER.log(logging.INFO, "Closed process pool evaluator") + LOGGER.info("Closed process pool evaluator")