如何在代码更改最少的情况下循环使用完整的Python脚本



免责声明:我是科学家,而不是开发人员。我更喜欢可读和可维护的代码,但我编写代码是为了产生结果,而不是代码。

我经常发现自己需要多次运行一个简短的脚本来测试一个或多个参数的影响。很多时候,事先知道我将改变哪些参数并不容易。

假设我有一个伪代码:

INPUT_FILE = "data.csv"
N_COMP = 7
MIN_SIZE = 35
MAX_SIZE = 70
OUTPUT_FILE = f"plot_{N_COMP}.pdf"
data = read(INPUT_FILE)
results = process(data, N_COMP)
figure = plot(results, MIN_SIZE, MAX_SIZE)
store(figure, OUTPUT_FILE)

现在,我想尝试N_COMP的各种值的影响。我可以在大部分脚本上添加一个循环:

INPUT_FILE = "data.csv"
# N_COMP = 7
MIN_SIZE = 35
MAX_SIZE = 70
for N_COMP in (3, 5, 7, 9, 11):
OUTPUT_FILE = f"plot_{N_COMP}.pdf"

data = read(INPUT_FILE)
results = process(data, N_COMP)
figure = plot(results, MIN_SIZE, MAX_SIZE)
store(figure, OUTPUT_FILE)

但是,一旦我想遍历几个变量(甚至可能同时遍历(,甚至没有提到每次缩进一个额外级别时black将对我的接近88个字符的行做什么,这就变得很混乱。

我也可以将循环体包裹在一个函数中:

INPUT_FILE = "data.csv"
MIN_SIZE = 35
MAX_SIZE = 70
def pipeline(N_COMP):
OUTPUT_FILE = f"plot_{N_COMP}.pdf"
data = read(INPUT_FILE)
results = process(data, N_COMP)
figure = plot(results, MIN_SIZE, MAX_SIZE)
store(figure, OUTPUT_FILE)
for N_COMP in (3, 5, 7, 9, 11):
pipeline(N_COMP)

然而,缩进问题仍然存在,现在每次想要添加一个可能想要循环的额外参数时,我都必须在三个位置添加它,而不是只添加一个。最后,参数的定义在代码的不同位置。(我可以通过在顶部定义元组并在循环中重用它,使它变成四个而不是三个。(

所以我正在寻找这样一个解决方案:

from autoloop import looptuple
INPUT_FILE = "data.csv"
N_COMP = looptuple(3, 5, 7, 9, 11)
MIN_SIZE = 35
MAX_SIZE = 70
OUTPUT_FILE = f"plot_{N_COMP}.pdf"
data = read(INPUT_FILE)
results = process(data, N_COMP)
figure = plot(results, MIN_SIZE, MAX_SIZE)
store(figure, OUTPUT_FILE)

这应该和上面的代码示例一样,每个参数只更改一行,没有额外的缩进。我可以使用不同的命令行来调用这个脚本,但这应该是一种与要循环的参数或参数值无关的通用方式。

这怎么可能?

没有内部方法"自动循环";就像这样,尤其是当您的代码没有封装到函数中并使用全局变量时。

使用环境变量

影响最小的更改可能是为要更改的参数使用环境变量,例如

import os
INPUT_FILE = "data.csv"
N_COMP = int(os.environ.get("N_COMP", 7))
MIN_SIZE = 35
MAX_SIZE = 70
OUTPUT_FILE = f"plot_{N_COMP}.pdf"
# ...

然后您可以使用运行脚本(假设是UNIX-y shell(

env N_COMP=16 python my_script.py

并可能实现自动化,例如

for n_comp in 3 5 7 9 11 42; do 
env N_COMP=$n_comp python my_script.py
done

使用runner函数

如果脚本的主体与上面类似,那么为了在内部实现自动化,入口点必须封装在一个函数中,例如

def run_experiment():
data = read(INPUT_FILE)
results = process(data, N_COMP)
figure = plot(results, MIN_SIZE, MAX_SIZE)
store(figure, OUTPUT_FILE)

之后,您可以添加runner函数;通过对globals()的一些轻微滥用,我们可以使其变得非常动态:

import itertools
# Defaults (will be overwritten)
N_COMP = 3
N_KITTENS = 8

def run_experiment():
print("N_COMP * N_KITTENS:", N_COMP * N_KITTENS)

def run_experiments():
experiments = {
"N_COMP": (1, 3, 5, 7),
"N_KITTENS": (3, 9, 42, 64),
}
keys, values = zip(*experiments.items())
for value_combo in itertools.product(*values):
experiment_values = dict(zip(keys, value_combo))
# You should add dependent values such as OUTPUT_FILE here
print("Running:", experiment_values)
globals().update(experiment_values)
run_experiment()

if __name__ == "__main__":
run_experiments()

换句话说,任何想要重用的东西都应该模块化。第一个层次是创建一个函数。(在更复杂的情况下,您可能希望将其放在一个单独的文件中,和/或使用几个方法创建一个类。(

您希望封装内部(函数内部的任何内容(,但暴露调用方应该控制的参数。具体来说,函数应该接受任何应该由外部变量或常量控制的内容作为参数。

(如果你想创建额外的包装器函数来硬编码一组参数,这很好,但只是方便。(

INPUT_FILE = "data.csv"
MIN_SIZE = 35
MAX_SIZE = 70
def pipeline(n_comp, min_size, max_size, input_file):
output_file = f"plot_{n_comp}.pdf"
data = read(input_file)
results = process(data, n_comp)
figure = plot(results, min_size, max_size)
store(figure, output_file)
for N_COMP in (3, 5, 7, 9, 11):
pipeline(N_COMP, MIN_SIZE, MAX_SIZE, INPUT_FILE)

您可以选择为某些参数声明默认值。

def pipeline(n_comp, input_file, min_size=MIN_SIZE, max_size=MAX_SIZE):

您可以使用pipeline(3, "data.csv")调用此函数,并且该函数将恢复到未提供的参数的默认值。可选参数必须是最后一个,所以我不得不在这里重新排序。

这个autoloop.py很好地满足了我的目的:

"""
Upon import, read and execute initial script for autoloop parameter combinations.
Example code:
```
import autoloop
X = autoloop.looptuple(1, 2)
Y = autoloop.looprange(10, 12)
print(X, Y)
```
"""
import itertools
import os
import re
import sys

def noloop():
"""Make pylint happy."""

def loopdummy(*args):
"""Return first element in case we are not looping."""
return args[0] if args else None

looptuple = loopdummy
looprange = loopdummy

def find_vars(script, looptype):
"""Parse var = autoloop.looptype(value1, value2, ...) lines."""
pattern = fr"(?P<var>[^W0-9]w*)s*=s*autoloop.loop{looptype}((?P<values>.+))"
matches = [match for line in script if (match := re.fullmatch(pattern, line))]
variables = [match.group("var") for match in matches]
value_lists = [re.split(r"s*,s*", match.group("values")) for match in matches]
if looptype == "range":
value_lists = [range(*map(int, values)) for values in value_lists]
return (variables, value_lists)

def loop():
"""See module docstring."""
if sys.argv[0] == __file__:
return
with open(sys.argv[0]) as script_file:
script = script_file.read().splitlines()
if "autoloop.noloop()" in script:
return
(tuple_vars, tuple_value_lists) = find_vars(script, "tuple")
(range_vars, range_value_lists) = find_vars(script, "range")
variables = tuple_vars + range_vars
value_lists = tuple_value_lists + range_value_lists
# if not variables:
#     return
script = [line for line in script if "autoloop" not in line]
for values in itertools.product(*value_lists):
preamble = [f"{var} = {value}" for (var, value) in zip(variables, values)]
print("Exec'ing with", ", ".join(preamble), "...")
new_script = "n".join(itertools.chain(preamble, script))
exec(new_script, locals(), locals())  # pylint: disable=exec-used
print("Done.")
os._exit(0)  # pylint: disable=protected-access

loop()

要使用它,

  • 添加import autoloop(它还没有做任何事情(
  • 转换至少一个常数,例如X = 1X = autoloop.looptuple(1, 2, 3)

要暂停,

  • 在代码中的任何位置添加autoloop.noloop()(它将使用looptuple/looprange中的第一个值(

示例代码打印:

Exec'ing with X = 1, Y = 10 ...
1 10
Exec'ing with X = 1, Y = 11 ...
1 11
Exec'ing with X = 2, Y = 10 ...
2 10
Exec'ing with X = 2, Y = 11 ...
2 11
Done.

这少了一大堆支票。它目前将变量添加到顶部,而不是应该定义的位置,因此依赖关系无法正常工作。当您在本身已导入的文件中导入autoloop时,它将不起作用。解析充其量是最小的。但就我的小例子而言,它运行良好。

最新更新