diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 938927a..88e5a26 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -36,3 +36,26 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + + deploy-docs: + runs-on: ubuntu-latest + needs: build-and-publish + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements_docs.txt + + - name: Deploy MkDocs to GitHub Pages + run: mkdocs gh-deploy --force diff --git a/docs/assets/favicon.png b/docs/assets/favicon.png new file mode 100644 index 0000000..3e90d0e Binary files /dev/null and b/docs/assets/favicon.png differ diff --git a/docs/assets/logo.jpg b/docs/assets/logo.jpg new file mode 100644 index 0000000..12a60d2 Binary files /dev/null and b/docs/assets/logo.jpg differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..3fa1587 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,31 @@ +# RKO - Random-Key Optimizer (Python Framework) + +Welcome to the documentation for the **RKO (Random-Key Optimizer)**. + +The Random-Key Optimizer is a versatile and efficient metaheuristic framework designed for a wide range of combinatorial optimization problems. Its core paradigm is the encoding of solutions as vectors of random keys—real numbers uniformly distributed in the interval [0, 1). This representation maps the discrete, and often complex, search space of a combinatorial problem to a continuous n-dimensional unit hypercube. + +## Installation + +You can download the package using pip: +```bash +pip install rko +``` + +## Getting Started + +To learn how to use RKO, please refer to the Github repository's [README](https://github.com/RKO-solver/RKO_Python) which details how to create your own `Environment` and run the `RKO` solver. + +## API Reference + +Explore the detailed API Reference using the navigation menu: + +- **[RKO](reference/rko.md)**: The core solver class, encapsulating all search operators and metaheuristics. +- **[Environment](reference/environment.md)**: The abstract base class (`RKOEnvAbstract`) enabling the integration of problem-specific logic. +- **[LogStrategy](reference/logstrategy.md)**: Mechanisms for logging search progress. +- **[Plots](reference/plots.md)**: Visualization utilities for convergence and other metrics. + +--- + +### Maintainers +- Felipe Silvestre Cardoso Roberto - [Linkedin](https://www.linkedin.com/in/felipesilvestrecr/) +- João Victor Assaoka Ribeiro - [Linkedin](https://www.linkedin.com/in/assaoka/) diff --git a/docs/reference/environment.md b/docs/reference/environment.md new file mode 100644 index 0000000..688b875 --- /dev/null +++ b/docs/reference/environment.md @@ -0,0 +1,3 @@ +# Environment + +::: rko.Environment diff --git a/docs/reference/logstrategy.md b/docs/reference/logstrategy.md new file mode 100644 index 0000000..b3d0437 --- /dev/null +++ b/docs/reference/logstrategy.md @@ -0,0 +1,3 @@ +# LogStrategy + +::: rko.LogStrategy diff --git a/docs/reference/plots.md b/docs/reference/plots.md new file mode 100644 index 0000000..4720e98 --- /dev/null +++ b/docs/reference/plots.md @@ -0,0 +1,3 @@ +# Plots + +::: rko.Plots diff --git a/docs/reference/rko.md b/docs/reference/rko.md new file mode 100644 index 0000000..8e35962 --- /dev/null +++ b/docs/reference/rko.md @@ -0,0 +1,3 @@ +# RKO + +::: rko.RKO diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..a180940 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,29 @@ +:root { + /* Cores principais baseadas no verde da UNIFESP (#225a37) */ + --md-primary-fg-color: #225a37; + --md-primary-fg-color--light: #347d4e; + --md-primary-fg-color--dark: #13331f; + --md-primary-bg-color: #ffffff; + --md-primary-bg-color--light: #ffffffcc; + + /* Cores de destaque (accent) usando o mesmo verde */ + --md-accent-fg-color: #225a37; + --md-accent-fg-color--transparent: #225a37f2; + --md-accent-bg-color: #ffffff; + --md-accent-bg-color--light: #ffffffcc; +} + +[data-md-color-scheme="slate"] { + /* Header verde no modo esculo com texto branco */ + --md-primary-fg-color: #225a37; + --md-primary-fg-color--light: #347d4e; + --md-primary-fg-color--dark: #184529; + --md-primary-bg-color: #ffffff; + --md-primary-bg-color--light: #ffffffcc; + + /* Cor de destaque mais clara no escuro para melhor contraste */ + --md-accent-fg-color: #4ea86e; + --md-accent-fg-color--transparent: #4ea86e1a; + --md-accent-bg-color: #000000; + --md-accent-bg-color--light: #000000cc; +} diff --git a/docs/tutorials/knapsack.md b/docs/tutorials/knapsack.md new file mode 100644 index 0000000..6cddc17 --- /dev/null +++ b/docs/tutorials/knapsack.md @@ -0,0 +1,126 @@ +# Knapsack Problem (KP) Tutorial + +This tutorial demonstrates how to solve the classic 0/1 Knapsack Problem using the **RKO** framework. The goal of this problem is to pack a set of items, with given weights and profits, into a knapsack with limited capacity to maximize the total profit. + +The environment requires reading a problem instance from a file, creating a binary solution based on threshold decoding, and computing the total profit with a penalty if the capacity is exceeded. + +## 1. Defining the Environment + +Below is the complete implementation of the `KnapsackProblem` class, which extends the `RKOEnvAbstract` base class. + +```python +import numpy as np +import os +import sys + +from rko import RKO, RKOEnvAbstract, FileLogger, HistoryPlotter + +class KnapsackProblem(RKOEnvAbstract): + """ + An implementation of the Knapsack Problem environment for the RKO solver. + """ + def __init__(self, instance_path: str): + super().__init__() # Initialize the abstract base class + print(f"Loading Knapsack Problem instance from: {instance_path}") + + self.instance_name = instance_path.split('/')[-1] + self.LS_type: str = 'Best' # Options: 'Best' or 'First' + self.dict_best: dict = {"Best": [149]} + + # Internal loading method + self._load_data(instance_path) + + # Solution size represents the number of randomly generated keys + self.tam_solution = self.n_items + + self.save_q_learning_report = False + + # Metaheuristics hyperparameters + self.BRKGA_parameters = {'p': [100, 50], 'pe': [0.20, 0.15], 'pm': [0.05], 'rhoe': [0.70]} + self.SA_parameters = {'SAmax': [10, 5], 'alphaSA': [0.5, 0.7], 'betaMin': [0.01, 0.03], 'betaMax': [0.05, 0.1], 'T0': [10]} + self.ILS_parameters = {'betaMin': [0.10, 0.5], 'betaMax': [0.20, 0.15]} + self.VNS_parameters = {'kMax': [5, 3], 'betaMin': [0.05, 0.1]} + self.PSO_parameters = {'PSize': [100, 50], 'c1': [2.05], 'c2': [2.05], 'w': [0.73]} + self.GA_parameters = {'sizePop': [100, 50], 'probCros': [0.98], 'probMut': [0.005, 0.01]} + self.LNS_parameters = {'betaMin': [0.10], 'betaMax': [0.30], 'TO': [100], 'alphaLNS': [0.95, 0.9]} + + def _load_data(self, instance_path: str): + """ + Loads the knapsack problem data from a text file. + The file has format: + + + + ... + """ + with open(instance_path, 'r') as f: + lines = f.readlines() + self.n_items, self.capacity = map(int, lines[0].strip().split()) + + self.profits = [] + self.weights = [] + + for line in lines[1:]: + if line.strip(): + p, w = map(int, line.strip().split()) + self.profits.append(p) + self.weights.append(w) + + def decoder(self, keys: np.ndarray) -> list[int]: + """ + Decodes a random-key vector into a knapsack solution. + An item is included if its corresponding key is > 0.5. + """ + # A solution is a binary list where 1 means the item is in the knapsack + solution = [1 if key > 0.5 else 0 for key in keys] + return solution + + def cost(self, solution: list[int], final_solution: bool = False) -> float: + """ + Calculates the cost of the knapsack solution. + Since this is a maximization problem, the cost is the negative of the total profit. + A penalty is applied for exceeding the knapsack's capacity. + """ + total_profit = 0 + total_weight = 0 + for i, item_included in enumerate(solution): + if item_included: + total_profit += self.profits[i] + total_weight += self.weights[i] + + # Apply a heavy penalty for infeasible solutions (exceeding capacity) + if total_weight > self.capacity: + penalty = 100000 * (total_weight - self.capacity) + total_profit -= penalty + + # The RKO framework assumes a minimization problem by default, + # so we return the negative of the profit. + return -total_profit +``` + +### Problem Constraint Penalty +Often, the search process requires exploring the infeasible domain space to find the optimal global structure. Penalties like `100000 * (total_weight - self.capacity)` severely lower the score, deterring solvers while giving feedback on how close the solution was to feasibility. Notice that the score function returns `-total_profit`, since RKO always minimizes globally. + +## 2. Setting Up and Optimizing + +With the Environment mapped, simply set the logger up, start the search methods, and visualize the output graph when finished. + +```python +if __name__ == "__main__": + current_directory = os.path.dirname(os.path.abspath(__file__)) + + # 1. Instantiate the Instance Environment + env = KnapsackProblem(os.path.join(current_directory, 'kp50.txt')) + + # 2. Add Logger configuration + logger = FileLogger(os.path.join(current_directory, 'results.txt'), reset=True) + + # 3. Solver Initiation + solver = RKO(env, logger=logger) + + # 4. Solves with all heuristics combined over 30s limit overall + solver.solve(time_total=30, brkga=1, lns=1, vns=1, ils=1, sa=1, pso=1, ga=1, runs=2) + + # 5. Output Graphics + HistoryPlotter.plot_convergence(os.path.join(current_directory, 'results.txt'), run_number=1).show() +``` diff --git a/docs/tutorials/tsp.md b/docs/tutorials/tsp.md new file mode 100644 index 0000000..fb82e70 --- /dev/null +++ b/docs/tutorials/tsp.md @@ -0,0 +1,131 @@ +# Travelling Salesperson Problem (TSP) Tutorial + +This tutorial demonstrates how to solve the Travelling Salesperson Problem (TSP) using the **RKO** framework. TSP is a classic combinatorial optimization problem where the goal is to find the shortest possible route that visits every city exactly once and returns to the origin city. + +In our implementation, the environment handles generating a random TSP instance, calculating distances, and decoding random keys into a valid tour. + +## 1. Creating the Environment + +Below is the complete implementation of the `TSPProblem` environment. It extends the `RKOEnvAbstract` class, defining the problem's specifics, such as city generation, random key decoding, and the cost function. + +```python +import numpy as np +import os +import random +import matplotlib.pyplot as plt +from rko import RKO, RKOEnvAbstract, check_env, FileLogger, HistoryPlotter + +class TSPProblem(RKOEnvAbstract): + """ + An implementation of the Traveling Salesperson Problem (TSP) environment for the RKO solver. + This class generates a random instance upon initialization. + """ + def __init__(self, num_cities: int = 20): + super().__init__() + print(f"Generating a random TSP instance with {num_cities} cities.") + + self.num_cities = num_cities + self.instance_name = f"TSP_{num_cities}_cities" + self.LS_type: str = 'Best' + self.dict_best: dict = {} + + self.save_q_learning_report = False + + # Generate city coordinates and the distance matrix + self.cities = self._generate_cities(num_cities) + self.distance_matrix = self._calculate_distance_matrix() + + # Solution size represents the number of randomly generated keys + self.tam_solution = self.num_cities + + # Customizing parameters for metaheuristics (example) + self.BRKGA_parameters = {'p': [100, 50], 'pe': [0.20, 0.15], 'pm': [0.05], 'rhoe': [0.70]} + self.SA_parameters = {'SAmax': [10, 5], 'alphaSA': [0.5, 0.7], 'betaMin': [0.01, 0.03], 'betaMax': [0.05, 0.1], 'T0': [10]} + self.ILS_parameters = {'betaMin': [0.10, 0.5], 'betaMax': [0.20, 0.15]} + self.VNS_parameters = {'kMax': [5, 3], 'betaMin': [0.05, 0.1]} + self.PSO_parameters = {'PSize': [100, 50], 'c1': [2.05], 'c2': [2.05], 'w': [0.73]} + self.GA_parameters = {'sizePop': [100, 50], 'probCros': [0.98], 'probMut': [0.005, 0.01]} + self.LNS_parameters = {'betaMin': [0.10], 'betaMax': [0.30], 'TO': [100], 'alphaLNS': [0.95, 0.9]} + + def _generate_cities(self, num_cities: int) -> np.ndarray: + """Generates random (x, y) coordinates for each city in a 100x100 grid.""" + return np.random.rand(num_cities, 2) * 100 + + def _calculate_distance_matrix(self) -> np.ndarray: + """Computes the Euclidean distance between every pair of cities.""" + num_cities = len(self.cities) + dist_matrix = np.zeros((num_cities, num_cities)) + for i in range(num_cities): + for j in range(i, num_cities): + dist = np.linalg.norm(self.cities[i] - self.cities[j]) + dist_matrix[i, j] = dist_matrix[j, i] = dist + return dist_matrix + + def decoder(self, keys: np.ndarray) -> list[int]: + """ + Decodes a random-key vector into a TSP tour. + The tour is determined by the sorted order of the keys. + """ + tour = np.argsort(keys) + return tour.tolist() + + def cost(self, solution: list[int], final_solution: bool = False) -> float: + """ + Calculates the total distance of a given TSP tour. + The RKO framework will minimize this value. + """ + total_distance = 0 + num_cities_in_tour = len(solution) + for i in range(num_cities_in_tour): + from_city = solution[i] + to_city = solution[(i + 1) % num_cities_in_tour] + total_distance += self.distance_matrix[from_city, to_city] + + return total_distance +``` + +### Decoding Strategy +In the `decoder` method, `np.argsort()` takes the randomly generated variables (in `[0, 1)`) and returns the indices representing their sorted order. This creates a valid permutation of cities automatically. + +## 2. Running the RKO Solver + +Once the environment is set up, you can instantiate the `RKO` solver and initiate the search. This setup uses multiple metaheuristics concurrently. + +```python +if __name__ == "__main__": + current_directory = os.path.dirname(os.path.abspath(__file__)) + + # 1. Instantiate the problem environment (50 cities). + env = TSPProblem(num_cities=50) + check_env(env) # Verify the environment implementation is valid! + + # 2. Setup the logger + logger = FileLogger(os.path.join(current_directory, 'results.txt'), reset=True) + + # 3. Instantiate the RKO solver. + solver = RKO(env=env, logger=logger) + + # 4. Run the solver for 10 seconds with selected metaheuristics + final_cost, final_solution, time_to_best = solver.solve( + time_total=10, + runs=1, + vns=1, + ils=1, + sa=1 + ) + + solution = env.decoder(final_solution) + + # 5. Output and logging + HistoryPlotter.plot_convergence(os.path.join(current_directory, 'results.txt'), run_number=1, title="TSP Convergence").show() + + print("\n" + "="*30) + print(" FINAL RESULTS ") + print("="*30) + print(f"Instance Name: {env.instance_name}") + print(f"Best Tour Cost Found: {final_cost:.4f}") + print(f"Time to Find Best Solution: {time_to_best}s") + print(f"Best Tour (City Sequence): {solution}") +``` + +This will run VNS, ILS and SA in parallel, sharing solutions between all of them. The `HistoryPlotter` can be used to generate beautiful convergence graphs visualizing the search trajectory for the best value. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..98c6f73 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,65 @@ +site_name: RKO - Random-Key Optimizer +site_description: A Python library for Random-Key Optimization (RKO). +site_author: Felipe Silvestre Cardoso Roberto & João Assaoka +repo_url: https://github.com/RKO-solver/RKO_Python +repo_name: RKO-solver/RKO_Python + +theme: + name: material + logo: assets/logo.jpg + favicon: assets/favicon.png + features: + - navigation.tabs + - navigation.sections + - navigation.top + - search.suggest + - search.highlight + - content.code.copy + palette: + - scheme: default + primary: custom + accent: custom + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: custom + accent: custom + toggle: + icon: material/brightness-4 + name: Switch to light mode + +extra_css: + - stylesheets/extra.css + +plugins: + - search + - mkdocstrings: + default_handler: python + handlers: + python: + paths: [src] + options: + docstring_style: google + show_root_heading: true + show_source: true + +nav: + - Home: index.md + - API Reference: + - RKO: reference/rko.md + - Environment: reference/environment.md + - LogStrategy: reference/logstrategy.md + - Plots: reference/plots.md + - Tutorials: + - Knapsack Problem: tutorials/knapsack.md + - Travelling Salesperson Problem: tutorials/tsp.md + +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences diff --git a/pyproject.toml b/pyproject.toml index f3e11bf..4b5af35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rko" -version = "0.1.2" +version = "0.1.3" description = "A Python library for Random-Key Optimization (RKO)." readme = "README.md" requires-python = ">=3.8" diff --git a/requirements_docs.txt b/requirements_docs.txt new file mode 100644 index 0000000..93361b0 --- /dev/null +++ b/requirements_docs.txt @@ -0,0 +1,4 @@ +mkdocs==1.6.1 +mkdocs-material==9.7.3 +pymdown-extensions==10.21 +mkdocstrings[python]==1.0.3 diff --git a/src/rko/LogStrategy.py b/src/rko/LogStrategy.py index ff36761..b4ee4f3 100644 --- a/src/rko/LogStrategy.py +++ b/src/rko/LogStrategy.py @@ -6,48 +6,128 @@ # === Interface Strategy === class LogStrategy(ABC): + """ + Abstract Base Class for logging strategies. + + Defines the standard interface for any logging strategy used in the framework. + """ + @abstractmethod def log(self, *args: list[any], **kwargs: dict[str, any]): + """ + Logs the provided arguments. + + Args: + *args (list[any]): Variable length argument list to be logged. + **kwargs (dict[str, any]): Arbitrary keyword arguments (e.g., end='\\n'). + """ pass # === Estratégias Concretas === class TerminalLogger(LogStrategy): - """Escreve apenas no terminal.""" + """ + Logging strategy that writes output exclusively to the terminal. + + Useful for interactive monitoring of the optimization process. + """ + def log(self, *args: list[any], **kwargs: dict[str, any]): - # flush=True é vital em multiprocessing para não bufferizar a saída + """ + Prints the provided arguments to the standard output. + + Args: + *args (list[any]): Variable length argument list to be printed. + **kwargs (dict[str, any]): Arbitrary keyword arguments. + """ + # flush=True is vital in multiprocessing to prevent output buffering print(*args, **kwargs, flush=True) class FileLogger(LogStrategy): - """Escreve apenas em arquivo.""" + """ + Logging strategy that writes output exclusively to a specified file. + + Useful for keeping persistent records of the optimization runs. + """ + def __init__(self, filepath: str, reset: bool = False): + """ + Initializes the file logger. + + Args: + filepath (str): The path to the file where logs should be written. + reset (bool, optional): If True, overwrites the existing file. + If False, appends to the existing file. Defaults to False. + """ self.filepath = filepath if reset: with open(self.filepath, 'w') as f: f.write(f"--- Log Iniciado em {datetime.now()} ---\n") def log(self, *args: list[any], **kwargs: dict[str, any]): + """ + Appends the provided arguments to the configured log file. + + Args: + *args (list[any]): Variable length argument list to be written. + **kwargs (dict[str, any]): Arbitrary keyword arguments. + """ with open(self.filepath, 'a') as f: print(*args, **kwargs, file=f) class DualLogger(LogStrategy): - """Escreve no Terminal E no Arquivo ao mesmo tempo (Composite Pattern).""" + """ + Composite logging strategy that writes to both the terminal and a file simultaneously. + """ + def __init__(self, filepath: str, reset: bool = False): + """ + Initializes the dual logger. + + Args: + filepath (str): The path to the file where logs should be written. + reset (bool, optional): If True, overwrites the existing log file. + Defaults to False. + """ self.terminal = TerminalLogger() self.file = FileLogger(filepath, reset) def log(self, *args: list[any], **kwargs: dict[str, any]): + """ + Logs the provided arguments to both the terminal and the file. + + Args: + *args (list[any]): Variable length argument list to be logged. + **kwargs (dict[str, any]): Arbitrary keyword arguments. + """ self.terminal.log(*args, **kwargs) self.file.log(*args, **kwargs) # === Gerenciador de Processos === class ParallelLogManager: + """ + Manages logging asynchronously in a multiprocessing environment. + + Creates a dedicated listener process that receives log messages from + different workers via a thread-safe Queue, preventing race conditions. + """ + def __init__(self, strategy: LogStrategy): + """ + Initializes the parallel log manager. + + Args: + strategy (LogStrategy): The concrete logging strategy to be used + (e.g., TerminalLogger, FileLogger, DualLogger). + """ self.strategy = strategy self.queue = multiprocessing.Manager().Queue() self.stop_event = multiprocessing.Event() self.listener_process = None def start(self): + """ + Starts the dedicated listener daemon process for handling logs. + """ self.listener_process = multiprocessing.Process( target=self._listener_worker, args=(self.queue, self.strategy, self.stop_event) @@ -55,16 +135,32 @@ def start(self): self.listener_process.start() def stop(self): + """ + Signals the listener process to terminate and waits for it to finish gracefully. + """ self.stop_event.set() if self.listener_process: self.listener_process.join() def get_logger(self): - """Retorna o objeto que será passado para os workers.""" + """ + Produces a lightweight proxy logger instance to be passed to parallel workers. + + Returns: + WorkerLogger: A logger proxy that sends messages to the central queue. + """ return WorkerLogger(self.queue) @staticmethod def _listener_worker(queue, strategy, stop_event): + """ + Background worker method that consumes log messages from the queue. + + Args: + queue (multiprocessing.Queue): The queue holding incoming log payloads. + strategy (LogStrategy): The strategy responsible for executing the logging action. + stop_event (multiprocessing.Event): Event to signal termination. + """ while not stop_event.is_set() or not queue.empty(): while not queue.empty(): try: @@ -77,8 +173,25 @@ def _listener_worker(queue, strategy, stop_event): # === Adaptador para os Workers === class WorkerLogger: + """ + Proxy logger passed to worker processes to offload log actions to the main queue. + """ + def __init__(self, queue): + """ + Initializes the worker logger. + + Args: + queue (multiprocessing.Queue): The shared queue connected to the LogManager. + """ self.queue = queue def log(self, *args, **kwargs): + """ + Puts the log payload onto the queue for execution by the central listener. + + Args: + *args: Variable length argument list to be logged. + **kwargs: Arbitrary keyword arguments. + """ self.queue.put((args, kwargs))