-
Notifications
You must be signed in to change notification settings - Fork 2
Conversation
The commit 8958291, that moves the solver into a function that returns the result, raised some issues. Possibly related to #95 Euro-NeurIPS-2022/benchmark.py Lines 42 to 45 in 386f07e
Somehow it seems that we cannot always access this value anymore after the function containing the solver gets out of scope. |
I will continue on this refactoring later today or tomorrow. import tools
hgspy = tools.get_hgspy_module()
def solve_static(
instance,
*,
seed=1,
max_runtime=None,
max_iterations=None,
initial_solutions=(),
excl_ops=(),
solver_config=None,
**kwargs
):
config = hgspy.Config(
seed=seed, **solver_config if solver_config is not None else {}
)
params = hgspy.Params(config, **tools.inst_to_vars(instance))
rng = hgspy.XorShift128(seed=seed)
pop = hgspy.Population(params, rng)
for sol in initial_solutions:
pop.add_individual(hgspy.Individual(params, sol))
ls = hgspy.LocalSearch(params, rng)
node_ops = [
hgspy.operators.Exchange10(params),
hgspy.operators.Exchange11(params),
hgspy.operators.Exchange20(params),
hgspy.operators.MoveTwoClientsReversed(params),
hgspy.operators.Exchange21(params),
hgspy.operators.Exchange22(params),
hgspy.operators.TwoOpt(params),
]
for op in node_ops:
if not any(isinstance(op, excl) for excl in excl_ops):
ls.add_node_operator(op)
route_ops = [
hgspy.operators.RelocateStar(params),
hgspy.operators.SwapStar(params),
]
for op in route_ops:
if not any(isinstance(op, excl) for excl in excl_ops):
ls.add_route_operator(op)
algo = hgspy.GeneticAlgorithm(params, rng, pop, ls)
crossover_ops = [
hgspy.crossover.broken_pairs_exchange,
hgspy.crossover.selective_route_exchange,
]
for op in crossover_ops:
if op not in excl_ops:
algo.add_crossover_operator(op)
if max_runtime is not None:
stop = hgspy.stop.MaxRuntime(max_runtime)
else:
stop = hgspy.stop.MaxIterations(max_iterations)
return algo.run(stop)
instance = tools.read_vrplib("instances/ORTEC-VRPTW-ASYM-5fa16580-d1-n329-k25.txt")
res = solve_static(instance, max_iterations=25)
best = res.get_best_found()
routes = [route for route in best.get_routes() if route]
cost = best.cost()
print(cost, routes) And raises a memory allocation error for routes (see prev comment) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a much needed re-organization of the code base. Overall I think the proposed changes are in the right direction. I think one particular point that we need to discuss in more detail is how to manage all the different solver-configs and operators, so that it can also be tuned more easily.
def solve_static( | ||
instance, | ||
*, | ||
seed=1, | ||
max_runtime=None, | ||
max_iterations=None, | ||
initial_solutions=(), | ||
excl_ops=(), | ||
solver_config=None, | ||
**kwargs | ||
): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's discuss what we need for solving static instances. In the bigger picture, there are 3 static problems that need to be solved:
- The static instance (3-15 minutes)
- The dynamic epoch instance (1-2 minutes)
- The rollout simulation instance (0-1 seconds, or max iterations)
I suspect that all three will have different configs, different operators. Maybe the first two can be using the same solver, but for the last one we really need a different one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See comment below
from .baselines import greedy, random, lazy | ||
from .rollout import rollout_count |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: I think it works easier to have dynamic/baselines
and dynamic/rollout
. The strategies
layer is redundant because we only have two strategies.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The main reason to introduce it, is as a namespace and a generic way to lookup strategies.
See the part getattr(dynamic.strategies, "name")
We could also do that without the namespace, but then we should add a list of strings and check if the strategy exist. To prevent that you could accidently call any other methods as strategy. That's why I proposed this as a cleaner way
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could also do that without the namespace, but then we should add a list of strings and check if the strategy exist. To prevent that you could accidently call any other methods as strategy. That's why I proposed this as a cleaner way
There are other ways. In particular, I am a big fan of explicitly grouping things as follows in module-level init files:
import s1
import s2
...
STRATEGIES = {
"name1": s1,
"name2": s2,
...
}
Then all that's needed outside this module is
from module import STRATEGIES
The added benefit of this over getattr
on an imported module is that it explicitly decouples external strategy names from their implementation name, and does not expose all sorts of unrelated fields on the module object which getattr
could access. With a dict, we can also use STRATEGIES.keys()
as the only allowed choices for the argument parser option. That would completely remove the need for a try/except around the strategy.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good, will do it like that!
from . import strategies | ||
from . import utils | ||
|
||
from .solver import solve_dynamic, solve_epoch, run_oracle |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: having solve_dynamic
and run_oracle
in separate files like before makes it easier to manage changes and is more clear in intent.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure what's best here. I would not prefer having a single file per function in general, if things are logical to group.
But we could indeed put it in separate files and just group them in the init file, like the strategies.
@N-Wouda what would you suggest?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I normally do one public (=to be used by code outside this file) definition per file. So one class, or one function. If the function needs helpers that are not exported, then they can exist in the same file.
If I have multiple public functions that are logically grouped, I'd put them in a package and organise the imports nicely in the package's __init__.py
, so that the rest of the code can import from the package as if it were a single module.
One definition per file has some precedent in other languages:
- Java requires one class per file. Java might be the only language that's this strict about it.
- C/C++ people often put one function or class in a single file because then it's in its own translation unit, and the compiler can compile it faster in parallel. The reasoning here's more about pragmatism than style, I think, although I know some people that take this so far that every class method is in its own file, too (so if a class has 10 methods, there'll be ten separate source files for it). But that's the exception, not the rule.
- I think the folks over at Node.js also do it this way, but my experience with backend JS is very limited.
But in Python there does not seem to be consensus: some people do it this way, others bundle everything up in one file.
I personally care more about files and functions not being too large and logically split, and modules and packages exposing sane APIs, than that I do about there being more than one function in a single file. But that's a bit inconsistent of me, because I would for example never accept more than one class in a single file :-).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See #113 and let's see if we can get to some structure everyone agrees on
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
run_oracle
and solve_dynamic
belong to the module dynamic/
but not to the same file in my opinion. A single solver
file with multiple dynamic solvers is not clear enough: what solvers are available? My general logic for this is that a single file provides 1 function(ality), with some exceptions such as utils.py
.
In that case, we don't even have to group them in the init
file but can just expose directly:
from .solve_dynamic import solve_dynamic
from .run_oracle import run_oracle
etc.
with open("dynamic/strategies/rollout/config.yml") as file: | ||
config = SimpleNamespace(**yaml.safe_load(file)) | ||
|
||
from .algorithms import rollout_count |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: for each rollout variant, it's easier to manage if we make a separate files instead of packing it into algorithms
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure what's best here. I would not prefer having a single file per function in general, if things are logical to group.
But we could indeed put it in separate files and just group them in the init file, like the strategies.
@N-Wouda what would you suggest?
I guess the same holds here
FYI: |
Agreed. I have some suggestion for that. I will elaborate on that later. To summarize, I would suggest a config file for each of the three cases you mention. + the possibility to pass a different config file name as argument for testing / tuning |
The good news is that this is unrelated to #95. This is just how cpp object lifetimes work. We cannot transfer ownership, so the only way around this is to either copy the individual into |
My initial take on this is that I'm happy we're removing most of the other baseline strategies. I'm unhappy about the lack of cohesion in this PR - it's doing too many things at once. From what I can tell, those things include (at least): removing baselines, changing config management, and removing various duplications. All those should have been their own PRs.
This PR is big as-is. Can we work towards getting this thing merged in (possibly in reduced form), rather than adding additional changes here? A sequence of more focused PRs is much more manageable for me (and, I think, Leon) to think about and review. |
I agree with @N-Wouda. I suggest to split this PR into the parts that Niels suggested:
In general, it would also be very helpful if you make issues for each of the proposed changes before you open a PR. That way we can all discuss about what needs to be done and have more effective reviews. Ideally, the issues should be the place for discussions about what and how to change, and then in the PR we can focus on specific implementation details. |
if args.strategy.startswith("rollout"): | ||
from dynamic.strategies.rollout import constants |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: are we going to have more than one rollout? I suspect we'll just be changing the existing version, rather than write multiple different ones. Similar to how we're altering the static solver (of which we also do not have multiple versions).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We still need to experiment a lot, so having the option to include multiple rollout versions is something I would prefer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed with Leon. At least I have one ready on the shelf I want to test
from .baselines import greedy, random, lazy | ||
from .rollout import rollout_count |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could also do that without the namespace, but then we should add a list of strings and check if the strategy exist. To prevent that you could accidently call any other methods as strategy. That's why I proposed this as a cleaner way
There are other ways. In particular, I am a big fan of explicitly grouping things as follows in module-level init files:
import s1
import s2
...
STRATEGIES = {
"name1": s1,
"name2": s2,
...
}
Then all that's needed outside this module is
from module import STRATEGIES
The added benefit of this over getattr
on an imported module is that it explicitly decouples external strategy names from their implementation name, and does not expose all sorts of unrelated fields on the module object which getattr
could access. With a dict, we can also use STRATEGIES.keys()
as the only allowed choices for the argument parser option. That would completely remove the need for a try/except around the strategy.
import yaml | ||
|
||
with open("dynamic/strategies/rollout/config.yml") as file: | ||
config = SimpleNamespace(**yaml.safe_load(file)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: can we keep config management in Python, e.g. via dicts? It's much easier to tune without a dependency on a config file in another format.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We certainly could. Main reason to not use python files as config files in general is that any code that's put in there will be executed and thus it's very unsafe. While yaml files are basically (nested) dictionaries in text format. But for this project, I am fine with python files as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Funnily enough, that's exactly how I feel about yaml files and its insane standard that's much, much more than just dictionaries. I like toml files a lot more!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Totally fine with toml files as well!
|
||
|
||
def rollout(info, obs, rng): | ||
def rollout_count(info, obs, rng): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Refactor: here, and in other places, can we inject the config into this function? That would make tuning much easier.
def solve_static( | ||
instance, | ||
*, | ||
seed=1, | ||
max_runtime=None, | ||
max_iterations=None, | ||
initial_solutions=(), | ||
excl_ops=(), | ||
solver_config=None, | ||
**kwargs | ||
): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
About this signature:
- Can we write out
exclude_operators
in full? It's currently the only abbreviated argument in the whole parameter list. kwargs
andsolver_config
seems to me to be the same thing (and this function does not currently appear to usekwargs
). Can we removesolver_config
, and add a docstring stating something like "any additional keyword arguments are passed to the static solver configuration"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Sure
- The reason for that is the following: We currently have some places where we parse the input arguments and pass them as kwargs to all other functions, meaning functions also get kwargs that they do not use. However, putting unknown arguments to the solver raises issues. That's why there is a seperate variable for the solver configuration, and **kwargs just "catches" all other arguments that are passed but not used. Agreed that this is all not very clear, but at that moment the only way to get things to work properly
if op not in excl_ops: | ||
algo.add_crossover_operator(op) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Issue?: should this also use the isinstance stuff from above?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think so. The crossover operators are functions, that you could just check like this.
isinstance is needed, because the other operators are objects of a certain class
node_ops = [ | ||
hgspy.operators.Exchange10(params), | ||
hgspy.operators.Exchange11(params), | ||
hgspy.operators.Exchange20(params), | ||
hgspy.operators.MoveTwoClientsReversed(params), | ||
hgspy.operators.Exchange21(params), | ||
hgspy.operators.Exchange22(params), | ||
hgspy.operators.TwoOpt(params), | ||
] | ||
|
||
for op in node_ops: | ||
if not any(isinstance(op, excl) for excl in excl_ops): | ||
ls.add_node_operator(op) | ||
|
||
route_ops = [ | ||
hgspy.operators.RelocateStar(params), | ||
hgspy.operators.SwapStar(params), | ||
] | ||
|
||
for op in route_ops: | ||
if not any(isinstance(op, excl) for excl in excl_ops): | ||
ls.add_route_operator(op) | ||
|
||
algo = hgspy.GeneticAlgorithm(params, rng, pop, ls) | ||
|
||
crossover_ops = [ | ||
hgspy.crossover.broken_pairs_exchange, | ||
hgspy.crossover.selective_route_exchange, | ||
] | ||
|
||
for op in crossover_ops: | ||
if op not in excl_ops: | ||
algo.add_crossover_operator(op) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This particular implementation allows selective filtering away of operators, but not changing their ordering. If we allow passing these in as node_ops = ()
, route_ops = ()
, and crossover_ops = ()
arguments, we can always set our defaults when these lists are empty. Something like:
if not node_ops:
node_ops = [
hgspy.operators.Exchange10(params),
hgspy.operators.Exchange11(params),
hgspy.operators.Exchange20(params),
hgspy.operators.MoveTwoClientsReversed(params),
hgspy.operators.Exchange21(params),
hgspy.operators.Exchange22(params),
hgspy.operators.TwoOpt(params),
]
etc. That leaves full control to the call site in case something else is needed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See #114 for further discussion
Agreed, sorry for that. Has something todo with this being my first ever project using the full scope of possibilities in github 😅 I would propose the following for now:
Do you agree that is the right way forward now? |
Sounds good! :-) |
I want to propose a somewhat bigger refactoring to the python files regarding:
Since it might be quite a change, I am very curious what you think about it.