WUT_Computer_Science/Programming/EARIN/lab3/main.py
Krzysztof kuhy Rudnicki 42007d56e4 PORR runs in 28s, and three checks so the next pass cannot repeat my mistakes
PORR was the last course whose `run` did not work. Four measurements, not
guesses, explain why -- and none of them is the matrix size everyone assumes:

  ProcessingType.PROCESSES   20.9s at n=2, 32.8s at n=5, 78.2s at n=10. That
                             is process-pool setup PER TEST, and it grows.
  matrix_type poli3          densifies a sparse .mat into a 2.3 GB npz cache
                             (nemeth12: 723 MB) -- over the 2G ceiling before
                             any solving happens.
  n=10000                    800 MB matrix, and np.linalg.solve wants a copy.
  n=5000                     spd_5000.npz was a Git LFS pointer whose object
                             404s on GitHub, so it regenerates from scratch.

Remove those and what is left still exercises the whole Richardson
implementation at every size that fits: 18 tests, 28 seconds through the
harness, exit 0. The submitted parameter set is one documented environment
variable away. `main.py` now propagates pytest's status (verified: exit 5 when
nothing is collected) -- it discarded it before, so `run` could not tell a
passing suite from a failing one.

The rest of this commit is scripts/check_refactor_safety.py, which exists
because three regressions got past both ruff and a smoke run during this
campaign, and I would rather they could not get past the next one:

  --signatures      diffs two trees function by function, parameter by
                    parameter, default by default. This is what found the 22
                    EARIN functions whose defaults an annotation pass silently
                    dropped.
  --logging         a module with a __main__ guard that logs at INFO must call
                    basicConfig, or it prints nothing at all. Found two more
                    in PORR just now.
  --print-keywords  refuses to let an automated rewrite near print(x, end="")
                    or print(x, file=sys.stderr); losing those turns a
                    rendered board into one glyph per line, or silently moves
                    error output onto stdout.

All three pass on the whole archive. Also in here: 12 latent TypeErrors mypy
found in EARIN that ruff could not -- call sites still passing positionally to
parameters this campaign had made keyword-only, plus one caller of a function
whose argument I had removed. Every one would have raised at runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172dj34FDpao6wsix8ZuCzE
2026-08-28 20:50:46 +02:00

370 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""Program that optimizes Rastrigin function: file_ (x_point_value, y_point_value) =.
20 + (x_point_value^2 - 10cos(2πx)) + (y_point_value^2 - 10 cos(2πy)).
Using Evolutionary Strategy (μ, λ).
"""
import logging
import sys
import tempfile
import time
from collections.abc import Callable
from pathlib import Path
import cv2
import matplotlib.pyplot as plt
import numpy as np
# cv2.waitKey returns the ASCII code of the key pressed; 'q' quits.
KEY_Q = ord("q")
# NumPy's legacy np.random.* functions share one global state; Generator is
# the modern API and is what NPY002 asks for.
RNG = np.random.default_rng()
logger = logging.getLogger(__name__)
def rastrigin(x_argument: float, y_argument: float) -> float:
"""Define the Rastrigin function."""
return (
20
+ x_argument**2
- 10 * np.cos(2 * np.pi * x_argument)
+ y_argument**2
- 10 * np.cos(2 * np.pi * y_argument)
)
def generate(population: object, arguments: dict[str, float]) -> tuple[object, object]:
"""Run single generation."""
# Evaluate the fitness of each individual
fitness = np.array(
[
rastrigin(x_point_value, y_point_value)
for x_point_value, y_point_value in population
]
)
# Select the top arguments["number_of_parents"] individuals
parents = population[np.argsort(fitness)[: arguments["number_of_parents"]]]
# Generate the next generation of lambda individuals by recombination
children = np.concatenate(
[
RNG.permutation(parents)
for i in range(
(arguments["size_of_population"] // arguments["number_of_parents"]) + 1
)
]
)
children = children[: arguments["size_of_population"]]
# Add mutation to the children
mutation = RNG.normal(
loc=0,
scale=arguments["mutation_strength"],
size=(arguments["size_of_population"], 2),
)
population = children + mutation
return fitness, population
def evolution_strategy(
arguments: dict[str, float], *, no_display: bool = False
) -> tuple[object, object]:
"""Define the Evolutionary Strategy (μ, λ) algorithm."""
# Initialize the population
print_info = []
population = RNG.uniform(
low=arguments["min"],
high=arguments["max"],
size=(arguments["size_of_population"], 2),
)
summary = []
if not no_display:
print_info.append(
(
population,
0,
f"""0:nop-{arguments["number_of_parents"]}:sop-{arguments["size_of_population"]}:
ms-{arguments["mutation_strength"]}:nog-{arguments["number_of_generations"]}:
min-max-{arguments["min"], arguments["max"]}:
noo-{arguments["number_of_outputs"]}""",
)
)
arguments["number_of_outputs"] = min(
[arguments["number_of_outputs"] - 1, arguments["number_of_generations"]]
)
# Iterate until we reach max number of generate and terminate
for generation_number in range(1, arguments["number_of_generations"] + 1):
fitness, population = generate(population, arguments)
step = (
arguments["number_of_generations"] // arguments["number_of_outputs"]
if arguments["number_of_generations"] % arguments["number_of_outputs"] == 0
else arguments["number_of_generations"]
// (arguments["number_of_outputs"] - 1)
)
offset = arguments["number_of_generations"] % step
if (generation_number - offset) % step == 0 and not no_display:
print_info.append(
(
population,
generation_number,
f"""{generation_number}:nop_{arguments["number_of_parents"]}:
sop_{arguments["size_of_population"]}:ms_{arguments["mutation_strength"]}:
nog_{arguments["number_of_generations"]}:
min_max_{arguments["min"], arguments["max"]}:
noo_{arguments["number_of_outputs"]}""",
)
)
summary.append(population)
# Evaluate the fitness of the final population
fitness = np.array(
[
rastrigin(x_point_value, y_point_value)
for x_point_value, y_point_value in population
]
)
# Return the best individual found
best_idx = np.argmin(fitness)
return (
population[best_idx],
fitness[best_idx],
population,
print_info,
summary,
)
def print_help() -> None:
"""Print program functionality and how to access it."""
logger.info(
"\n python main.py - Default functionality optimizing\n Rastrigin "
"function file_ (x_point_value, y_point_value) =\n 20 + "
"(x_point_value^2 - 10cos(2πx)) + (y_point_value^2 - 10 cos(2πy))\n "
"using Evolutionary Strategy (μ, λ), using only default values\n "
'Default values:\n arguments["number_of_parents"]=5,\n '
'arguments["size_of_population"]=20,\n '
'arguments["mutation_strength"]=0.1,\n '
'arguments["number_of_generations"]=100,\n min_value=-5.12,\n '
'max_value=5.12\n arguments["number_of_outputs"] = 100\n\n python '
"main.py -h --help print this prompt\n Any of the default values an be"
' changed using arguments:\n -nop --arguments["number_of_parents"] '
'[number]\n -sop --arguments["size_of_population"] [number]\n -ms '
'--arguments["mutation_strength"] [number]\n -nog '
'--arguments["number_of_generations"] [number]\n -min --min_value '
"[number]\n -max --max_value [number]\n -noo, "
'--arguments["number_of_outputs"] [number]\n Those arguments can be '
"given in any order and any argument\n which was not entered will be "
"replaced with default value,\n Additional flags:\n -nd, "
"--no-display (does not show the plots)\n -s, --save (if issued WILL "
"save the files)\n exemplary use:\n python main.py -nop 5 -sop 20 "
"-ms 0.1 -nog 100 -min -5.12 -max 5.12 -noo 100\n "
)
def get_output_bounds(
x_data: object, y_data: object
) -> tuple[tuple[float, float], tuple[float, float]]:
"""Get x and y output limits for pyplot."""
min_output_size = ARGUMENTS["mutation_strength"] * 10
xmin = min(x_data)
xmax = max(x_data)
ymin = min(y_data)
ymax = max(y_data)
x_diff = xmax - xmin
y_diff = ymax - ymin
if min_output_size is None:
min_output_size = max(x_diff, y_diff)
margin = max(x_diff, y_diff) / 5
if x_diff < min_output_size:
xmax += (min_output_size - x_diff) / 2
xmin -= (min_output_size - x_diff) / 2
if y_diff < min_output_size:
ymax += (min_output_size - y_diff) / 2
ymin -= (min_output_size - y_diff) / 2
x_bounds = [xmin - margin, xmax + margin]
y_bounds = [ymin - margin, ymax + margin]
return x_bounds, y_bounds
def output(
population_output: object,
generation_number: int,
file_name: str = "temp",
*,
save_results: bool = False,
) -> None:
"""Draw result of our function."""
# define the visualization params
colors = RNG.random(len(population_output))
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as file_:
# iterate over the optimization steps
x_data = []
y_data = []
for x_point_value, y_point_value in population_output:
x_data.append(x_point_value)
y_data.append(y_point_value)
x_lim, y_lim = get_output_bounds(x_data, y_data)
# plot the data
plt.cla()
plt.figure()
plt.scatter(x_data, y_data, c=colors, alpha=0.5)
plt.xlim(x_lim)
plt.ylim(y_lim)
plt.savefig(file_.name)
# read image
image = cv2.imread(file_.name)
# show the image, provide window name first
cv2.imshow(f"Generation {generation_number}", image)
if save_results:
cv2.imwrite(file_name + ".jpg", image)
# add wait key. window waits until user presses a key and quits if
# the key is 'q'
if cv2.waitKey(0) == KEY_Q:
# and finally destroy/close all open windows
sys.exit()
cv2.destroyAllWindows()
file_.close()
Path(file_.name).unlink()
def print_summary(
populations: object, file_name: str = "temp_summary", *, save_results: bool = False
) -> None:
"""Draw result of our function for chosen generations."""
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as file_:
# iterate over the optimization steps
# generate random 2D data - replace it with the results from your
# algorithm
plt.cla()
plt.figure()
bounds = None
for ind, pop in enumerate(populations):
x_data = []
y_data = []
for x_point_value, y_point_value in pop:
x_data.append(x_point_value)
y_data.append(y_point_value)
if ind == 0:
bounds = get_output_bounds(x_data, y_data)
# plot the data
transparency = ind / (len(populations) - 1)
color = [[transparency, 0, 0]] * len(pop)
plt.scatter(x_data, y_data, c=color, alpha=transparency, label=f"{ind}")
plt.xlim(bounds[0])
plt.ylim(bounds[1])
plt.savefig(file_.name)
# read image
image = cv2.imread(file_.name)
if save_results:
cv2.imwrite("SUMMARY:" + file_name + ".jpg", image)
# show the image, provide window name first
cv2.imshow("Summary", image)
# add wait key. window waits until user presses a key and quits if
# the key is 'q'
if cv2.waitKey(0) == KEY_Q:
# and finally destroy/close all open windows
sys.exit()
cv2.destroyAllWindows()
file_.close()
Path(file_.name).unlink()
# Long option, short option, and how to read the value that follows. A table
# rather than an if-ladder: the ladder was C901 12, and one row per flag makes
# adding one a data change.
_OPTIONS: dict[tuple[str, str], tuple[str, Callable[[str], float] | None]] = {
("-nop", "--number_of_parents"): ("number_of_parents", int),
("-sop", "--size_of_population"): ("size_of_population", int),
("-ms", "--mutation_strength"): ("mutation_strength", float),
("-nog", "--number_of_generations"): ("number_of_generations", int),
("-min", "--min_value"): ("min", float),
("-max", "--max_value"): ("max", float),
("-noo", "--number_of_outputs"): ("number_of_outputs", int),
("-nd", "--no_display"): ("no_display", None),
("-s", "--save"): ("save", None),
}
def user_input() -> dict[str, float]:
"""Handle user terminal arguments."""
arguments: dict[str, float] = {
"number_of_parents": 5,
"size_of_population": 20,
"mutation_strength": 0.1,
"number_of_generations": 100,
"min": -5.12,
"max": 5.12,
"number_of_outputs": 10,
"no_display": False,
"save": False,
}
for index, argument in enumerate(sys.argv):
if argument in ("-h", "--help"):
print_help()
sys.exit()
for flags, (key, parse) in _OPTIONS.items():
if argument in flags:
arguments[key] = True if parse is None else parse(sys.argv[index + 1])
return arguments
def print_output(print_info: object, save_results: object, summary: object) -> None:
"""Print out population and summary plots."""
for population, generation_number, file_name in print_info:
output(population, generation_number, file_name, save_results=save_results)
summary_file_name = file_name
print_summary(summary, summary_file_name, save_results=save_results)
# Ran first in the code
if __name__ == "__main__":
# Run the Evolutionary Strategy algorithm
logging.basicConfig(format="%(message)s", level=logging.INFO)
ARGUMENTS = user_input()
TOTAL_TIME = 0
start_time = time.perf_counter()
(
best_individual,
best_fitness,
output_population,
PRINT_INFO,
SUMMARY,
) = evolution_strategy(
ARGUMENTS,
no_display=bool(ARGUMENTS["no_display"]),
)
end_time = time.perf_counter()
if not ARGUMENTS["no_display"]:
print_output(PRINT_INFO, ARGUMENTS["save"], SUMMARY)
TOTAL_TIME = end_time - start_time
time_per_generation = TOTAL_TIME / ARGUMENTS["number_of_generations"]
logger.info("Best individual found: %s", best_individual)
logger.info("Best fitness found: %s", best_fitness)
logger.info("total_generation_time: %s", TOTAL_TIME)
logger.info("time_per_generation: %s", time_per_generation)