From 8975c08ea0c92336c6167b280b5e7987269525be Mon Sep 17 00:00:00 2001 From: "Daniel Precioso, PhD" Date: Thu, 12 Jun 2025 12:22:02 +0200 Subject: [PATCH 1/8] Refactor dependency management in pyproject.toml and uv.lock to simplify CUDA support for JAX, and enhance plotting functions in paper_plots.py for better clarity and flexibility in visualizing simulation results. --- .pre-commit-config.yaml | 14 +++---- Makefile | 4 +- pyproject.toml | 5 ++- routetools/plot.py | 10 ++++- scripts/paper_plots.py | 82 +++++++++++++++-------------------------- uv.lock | 13 +++++-- 6 files changed, 62 insertions(+), 66 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 66a5b94..3ac61a4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,13 +36,13 @@ repos: args: [--fix, --show-fixes, --exit-non-zero-on-fix] - id: ruff-format types_or: [python, pyi, jupyter] - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.1 - hooks: - - id: mypy - language: system - args: ["--install-types", "--non-interactive"] - pass_filenames: false + # - repo: https://github.com/pre-commit/mirrors-mypy + # rev: v1.11.1 + # hooks: + # - id: mypy + # language: system + # args: ["--install-types", "--non-interactive"] + # pass_filenames: false - repo: https://github.com/codespell-project/codespell rev: v2.3.0 hooks: diff --git a/Makefile b/Makefile index 600d943..36c40ec 100644 --- a/Makefile +++ b/Makefile @@ -30,8 +30,8 @@ ruff: test: uv run pytest -mypy: - uv run mypy --install-types --non-interactive +#mypy: +# uv run mypy --install-types --non-interactive docker: build run diff --git a/pyproject.toml b/pyproject.toml index 1dbfd44..a034e64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "fastapi", "uvicorn", "typer-slim", - "jax[cuda12]>=0.4.33", + "jax>=0.4.33", "matplotlib>=3.9.2", "cma>=4.0.0", "pytest>=8.3.2", @@ -93,4 +93,7 @@ dev-dependencies = [ "ipykernel", ] +[project.optional-dependencies] +cuda = ["jax[cuda]>=0.4.33"] + diff --git a/routetools/plot.py b/routetools/plot.py index f018600..7afc941 100644 --- a/routetools/plot.py +++ b/routetools/plot.py @@ -17,6 +17,14 @@ "FMS": "green", } +DICT_VF_NAMES = { + "circular": "Circular", + "fourvortices": "Four Vortices", + "doublegyre": "Double Gyre", + "techy": "Techy", + "swirlys": "Swirlys", +} + def plot_curve( vectorfield: Callable[ @@ -219,7 +227,7 @@ def plot_route_from_json(path_json: str) -> tuple[Figure, Axes]: ) # Set the title and tight layout if water_level == 1: - title = vfname + title = DICT_VF_NAMES.get(vfname, vfname) else: title = ( f"Water level: {water_level} | Resolution: {resolution} | " diff --git a/scripts/paper_plots.py b/scripts/paper_plots.py index 01ae456..c8f03f5 100644 --- a/scripts/paper_plots.py +++ b/scripts/paper_plots.py @@ -10,7 +10,7 @@ from routetools.fms import optimize_fms from routetools.plot import plot_curve, plot_route_from_json -COST_LITERATURE = { +DICT_COST_LITERATURE = { "circular": 1.98, "fourvortices": 8.95, "doublegyre": 1.01, @@ -153,66 +153,44 @@ def run_single_simulation( plt.close(fig) -def plot_best_no_land( - path_csv: str = "./output/results_noland.csv", folder: str = "./output/" +def plot_best_values( + path_csv: str = "./output/results_noland.csv", + folder: str = "./output/", + col: str = "gain_fms", + ascending: bool = False, + size: int = 2, ): - """Generate plots for the best examples without land avoidance. - - Parameters - ---------- - folder : str, optional - The directory containing the results CSV file and JSON files, - by default "output". - """ - df = pd.read_csv(path_csv) - - # Filter the rows with highest "gain_fms", grouped by vectorfield - df_filtered = ( - df.groupby("vectorfield")[["vectorfield", "json", "gain_fms"]] - .apply(lambda x: x.nlargest(1, "gain_fms")) - .reset_index(drop=True) - .sort_values("gain_fms", ascending=False) - ) - - # Plot the top examples - for idx in df_filtered.index: - row = df_filtered.iloc[idx] - vf = row["vectorfield"] - json_id = int(row["json"]) - print(f"Best without land avoidance: processing {json_id}...") - fig, ax = plot_route_from_json(f"{folder}/noland/{json_id:06d}.json") - fig.savefig(f"{folder}/best_{vf}.png") - plt.close(fig) - - -def plot_biggest_difference( - path_csv: str = "./output/results_noland.csv", folder: str = "./output/" -): - """Generate plots for the examples with the biggest FMS savings. + """Generate plots for the examples with the highest values. Parameters ---------- + path_csv : str, optional + Path to the CSV file containing the results of the experiments, + by default "./output/results_noland.csv" folder : str, optional The directory containing the results CSV file and JSON files, by default "output". + col : str, optional + The column to sort by, by default "gain_fms". + ascending : bool, optional + Whether to sort the values in ascending order, by default False. + size : int, optional + The number of top examples to plot per vectorfield, by default 2. """ df = pd.read_csv(path_csv) - # Filter the rows with highest "gain_fms", grouped by vectorfield - df_filtered = ( - df.groupby("vectorfield")[["json", "vectorfield", "gain_fms"]] - .apply(lambda x: x.nlargest(2, "gain_fms")) - .reset_index(drop=True) - .sort_values("gain_fms", ascending=False) + # Filter the rows with highest col, grouped by vectorfield + df_filtered = df.groupby("vectorfield")[["vectorfield", "json", col]].apply( + lambda x: x.nsmallest(size, col) if ascending else x.nlargest(size, col) ) # Plot the top examples - for idx in df_filtered.index: - row = df_filtered.iloc[idx] + for multiidx, row in df_filtered.iterrows(): + vf, idx = multiidx json_id = int(row["json"]) - print(f"Biggest FMS savings: processing {json_id}...") + print(f"Best {col}: processing {json_id}...") fig, ax = plot_route_from_json(f"{folder}/noland/{json_id:06d}.json") - fig.savefig(f"{folder}/biggest_fms_{idx}.png") + fig.savefig(f"{folder}/{col}_{vf}_{idx}.png") plt.close(fig) @@ -250,7 +228,7 @@ def plot_land_avoidance( # Generate plots for the worst ten examples idx = 0 - for complexity, df_sub in df_land.groupby("complexity"): + for _, df_sub in df_land.groupby("complexity"): # Sort by gain df_sub = df_sub.sort_values("gain_fms", ascending=True) # Take the worst three examples @@ -286,7 +264,7 @@ def experiment_parameter_sensitivity( df_noland = pd.read_csv(path_csv) # Assign literature cost using "vectorfield" - df_noland["cost_reference"] = df_noland["vectorfield"].map(COST_LITERATURE) + df_noland["cost_reference"] = df_noland["vectorfield"].map(DICT_COST_LITERATURE) # Choose only the following vectorfields ls_vf = ["fourvortices", "swirlys"] @@ -520,10 +498,10 @@ def main(folder: str = "./output/"): """Run the experiments and plot the results.""" print("---\nSINGLE SIMULATION\n---") # run_single_simulation(path_img=folder) - print("\n---\nBEST EXAMPLES WITHOUT LAND AVOIDANCE\n---") - # plot_best_no_land(folder=folder) - print("\n---\nBIGGEST FMS SAVINGS\n---") - plot_biggest_difference(folder=folder) + print("\n---\nBIGGEST FMS GAINS\n---") + plot_best_values(folder=folder, col="gain_fms", ascending=False, size=2) + print("\n---\nBIGGEST BERS SAVINGS\n---") + plot_best_values(folder=folder, col="cost_fms", ascending=True, size=2) print("\n---\nLAND AVOIDANCE ANALYSIS\n---") plot_land_avoidance(folder=folder) print("\n---\nPARAMETER SENSITIVITY EXPERIMENTS\n---") diff --git a/uv.lock b/uv.lock index 66182d0..0f1c695 100644 --- a/uv.lock +++ b/uv.lock @@ -328,7 +328,7 @@ wheels = [ ] [package.optional-dependencies] -cuda12 = [ +cuda = [ { name = "jax-cuda12-plugin", extra = ["with-cuda"] }, { name = "jaxlib" }, ] @@ -1015,7 +1015,7 @@ source = { editable = "." } dependencies = [ { name = "cma" }, { name = "fastapi" }, - { name = "jax", extra = ["cuda12"] }, + { name = "jax" }, { name = "matplotlib" }, { name = "pandas" }, { name = "perlin-numpy" }, @@ -1027,6 +1027,11 @@ dependencies = [ { name = "xarray" }, ] +[package.optional-dependencies] +cuda = [ + { name = "jax", extra = ["cuda"] }, +] + [package.dev-dependencies] dev = [ { name = "ipykernel" }, @@ -1040,7 +1045,8 @@ dev = [ requires-dist = [ { name = "cma", specifier = ">=4.0.0" }, { name = "fastapi" }, - { name = "jax", extras = ["cuda12"], specifier = ">=0.4.33" }, + { name = "jax", specifier = ">=0.4.33" }, + { name = "jax", extras = ["cuda"], marker = "extra == 'cuda'", specifier = ">=0.4.33" }, { name = "matplotlib", specifier = ">=3.9.2" }, { name = "pandas" }, { name = "perlin-numpy", specifier = ">=0.0.1" }, @@ -1051,6 +1057,7 @@ requires-dist = [ { name = "uvicorn" }, { name = "xarray", specifier = ">=2024.11.0" }, ] +provides-extras = ["cuda"] [package.metadata.requires-dev] dev = [ From b58fb38b905c1c6cebd5986ec016230bf50081db Mon Sep 17 00:00:00 2001 From: "Daniel Precioso, PhD" Date: Thu, 12 Jun 2025 13:23:20 +0200 Subject: [PATCH 2/8] Avoid negative cost --- scripts/paper_plots.py | 196 +++++++++++++++------------------------ scripts/paper_results.py | 9 +- 2 files changed, 82 insertions(+), 123 deletions(-) diff --git a/scripts/paper_plots.py b/scripts/paper_plots.py index c8f03f5..e2d3e80 100644 --- a/scripts/paper_plots.py +++ b/scripts/paper_plots.py @@ -8,7 +8,7 @@ from routetools.cmaes import optimize from routetools.fms import optimize_fms -from routetools.plot import plot_curve, plot_route_from_json +from routetools.plot import DICT_VF_NAMES, plot_curve, plot_route_from_json DICT_COST_LITERATURE = { "circular": 1.98, @@ -267,143 +267,95 @@ def experiment_parameter_sensitivity( df_noland["cost_reference"] = df_noland["vectorfield"].map(DICT_COST_LITERATURE) # Choose only the following vectorfields - ls_vf = ["fourvortices", "swirlys"] + ls_vf = ["circular", "fourvortices", "swirlys"] df_noland = df_noland[df_noland["vectorfield"].isin(ls_vf)] - # Compute percentage errors, clip at 0% - df_noland["percterr_cmaes"] = ( - df_noland["cost_cmaes"] / df_noland["cost_reference"] * 100 - 100 - ).clip(lower=0) - df_noland["percterr_fms"] = ( - df_noland["cost_fms"] / df_noland["cost_reference"] * 100 - 100 - ).clip(lower=0) - - df_noland["gain_fms"] = 100 - df_noland["cost_fms"] / df_noland["cost_cmaes"] * 100 - - # If any "percterr_cmaes" is equal or higher than 1e10, we warn the user and drop it - if (df_noland["percterr_cmaes"] >= 1e10).any(): - print( - "Warning: Some percentage errors for CMA-ES are equal or higher than 1e10. " - "These will be dropped from the analysis." - ) - df_noland = df_noland[df_noland["cost_cmaes"] < 1e10] - # Same with "percterr_fms" - df_noland = df_noland[df_noland["cost_fms"] < 1e10] - - # We will group results by "K", "sigma0" and compute their average "percterr_cmaes" - df_noland = ( - df_noland.groupby(["K", "sigma0"]) - .agg( - avg_percterr_cmaes=("percterr_cmaes", "mean"), - avg_comp_time_cmaes=("comp_time_cmaes", "mean"), - avg_gain_fms=("gain_fms", "mean"), - avg_comp_time_fms=("comp_time_fms", "mean"), - avg_percterr_fms=("percterr_fms", "mean"), - avg_comp_time=("comp_time", "mean"), - ) - .reset_index() - ) + # Compute the difference with the literature cost + df_noland["diff_cmaes"] = 1 - df_noland["cost_cmaes"] / df_noland["cost_reference"] + df_noland["diff_fms"] = 1 - df_noland["cost_fms"] / df_noland["cost_reference"] def _helper_experiment_parameter_sensitivity( col1: str, col2: str, - cmap: str, - vmin: float = 0, - vmax: float = 100, title: str = "", ): - # Plot a heatmap where: + # Plot multiple heatmaps in a single figure: + # First row: col1 for each vectorfield + # Second row: col2 for each vectorfield + # In each heatmap: # x-axis: "K" (number of control points for Bézier curve) # y-axis: "sigma0" (standard deviation of the CMA-ES distribution) - # color: col1 - # We place the number on each cell of the heatmap, using white letters - # Below each number, we add the computation time too (col2) - plt.figure(figsize=(8, 6)) - ax = plt.gca() # Get current axes - heatmap = ax.pcolor( - df_noland.pivot(index="sigma0", columns="K", values=col1), - cmap=cmap, - edgecolors="k", - linewidths=0.5, - vmin=vmin, - vmax=vmax, # Set limits for the color scale + # color: col1 or col2 (depending on the heatmap) + num_cols = len(ls_vf) + fig, axs = plt.subplots( + nrows=2, ncols=num_cols, figsize=(num_cols * 4, 8), sharex=True, sharey=True ) - vmean = (vmax + vmin) / 2 - # Add the numbers in each cell - for (i, j), val in np.ndenumerate( - df_noland.pivot(index="sigma0", columns="K", values=col1) - ): - ax.text( - j + 0.5, - i + 0.5, - f"{val:.2f}%", - ha="center", - va="center", - color="black" if val < vmean else "white", - fontsize=10, + fig.suptitle(title, fontsize=16) + for i, vf in enumerate(ls_vf): + vfname = DICT_VF_NAMES.get(vf, vf) + # Filter the DataFrame for the current vectorfield + df_vf = df_noland[df_noland["vectorfield"] == vf] + + # Pivot the DataFrame to create a heatmap + heatmap_data = df_vf.pivot(index="sigma0", columns="K", values=col1) + # Plot the heatmap for col1 + im1 = axs[0, i].imshow( + heatmap_data, + cmap="bwr_r", + aspect="equal", + # Center the map around zero + vmin=-1.5, + vmax=1.5, ) - # Add computation time below the percentage - ct = df_noland.loc[ - (df_noland["K"] == df_noland["K"].unique()[j]) - & (df_noland["sigma0"] == df_noland["sigma0"].unique()[i]), - col2, - ].values[0] - ax.text( - j + 0.5, - i + 0.3, - f"CT: {ct:.2f}s", - ha="center", - va="center", - color="black" if val < vmean else "white", - fontsize=8, + axs[0, i].set_title(vfname) + axs[0, i].set_xlabel("K (Control Points)") + axs[0, i].set_ylabel(r"$\sigma_0$ (Standard Deviation)") + axs[0, i].set_xticks(np.arange(len(heatmap_data.columns))) + axs[0, i].set_xticklabels(heatmap_data.columns) + axs[0, i].set_yticks(np.arange(len(heatmap_data.index))) + axs[0, i].set_yticklabels(heatmap_data.index) + + # Plot the heatmap for col2 + heatmap_data = df_vf.pivot(index="sigma0", columns="K", values=col2) + im2 = axs[1, i].imshow( + heatmap_data, + cmap="Reds", + aspect="equal", + vmin=0, + vmax=10, ) - # Set the ticks and labels for the axes - ax.set_xticks(np.arange(len(df_noland["K"].unique())) + 0.5) - ax.set_xticklabels(df_noland["K"].unique()) - ax.set_yticks(np.arange(len(df_noland["sigma0"].unique())) + 0.5) - ax.set_yticklabels(df_noland["sigma0"].unique()) - ax.set_xlabel("Number of Control Points (K)") - ax.set_ylabel(r"Standard Deviation of CMA-ES ($\sigma_0$)") - ax.set_title(title) - plt.colorbar(heatmap, label="Loss w.r.t. Literature (%)") - plt.tight_layout() + axs[1, i].set_title(vfname) + axs[1, i].set_xlabel("K (Control Points)") + axs[1, i].set_ylabel(r"$\sigma_0$ (Standard Deviation)") + axs[1, i].set_xticks(np.arange(len(heatmap_data.columns))) + axs[1, i].set_xticklabels(heatmap_data.columns) + axs[1, i].set_yticks(np.arange(len(heatmap_data.index))) + axs[1, i].set_yticklabels(heatmap_data.index) + # Add colorbars to im1 and im2 + cbar1 = fig.colorbar( + im1, + ax=axs[0, :], + orientation="vertical", + fraction=0.02, + location="right", + ) + cbar1.set_label("Cost reduction factor") + cbar2 = fig.colorbar( + im2, + ax=axs[1, :], + orientation="vertical", + fraction=0.02, + location="right", + ) + cbar2.set_label("Computation time (s)") # GRAPH 1 - # color: "avg_percterr_cmaes" (average percentage of error) - _helper_experiment_parameter_sensitivity( - col1="avg_percterr_cmaes", - col2="avg_comp_time_cmaes", - cmap="Reds", - vmin=0, - vmax=50, - title="Parameter Sensitivity of CMA-ES", - ) + _helper_experiment_parameter_sensitivity(col1="diff_cmaes", col2="comp_time_cmaes") plt.savefig(folder + "parameter_sensitivity_cmaes.png", dpi=300) plt.close() # GRAPH 2 - # color: "gain_fms" (percentage of gain by FMS compared to CMA-ES) - _helper_experiment_parameter_sensitivity( - col1="avg_gain_fms", - col2="avg_comp_time_fms", - cmap="Reds", - vmin=0, - vmax=20, - title="Average Percentage of Gain by FMS Compared to CMA-ES", - ) - plt.savefig(folder + "parameter_sensitivity_fms.png", dpi=300) - plt.close() - - # GRAPH 3 - # color: "avg_percterr_fms" (average percentage of error for BERS) - _helper_experiment_parameter_sensitivity( - col1="avg_percterr_fms", - col2="avg_comp_time", - cmap="Reds", - vmin=0, - vmax=50, - title="Average Percentage of Error for BERS", - ) + _helper_experiment_parameter_sensitivity(col1="diff_fms", col2="comp_time") plt.savefig(folder + "parameter_sensitivity_bers.png", dpi=300) plt.close() @@ -499,11 +451,11 @@ def main(folder: str = "./output/"): print("---\nSINGLE SIMULATION\n---") # run_single_simulation(path_img=folder) print("\n---\nBIGGEST FMS GAINS\n---") - plot_best_values(folder=folder, col="gain_fms", ascending=False, size=2) + # plot_best_values(folder=folder, col="gain_fms", ascending=False, size=2) print("\n---\nBIGGEST BERS SAVINGS\n---") - plot_best_values(folder=folder, col="cost_fms", ascending=True, size=2) + # plot_best_values(folder=folder, col="cost_fms", ascending=True, size=2) print("\n---\nLAND AVOIDANCE ANALYSIS\n---") - plot_land_avoidance(folder=folder) + # plot_land_avoidance(folder=folder) print("\n---\nPARAMETER SENSITIVITY EXPERIMENTS\n---") experiment_parameter_sensitivity(folder=folder) print("\n---\nLAND COMPLEXITY EXPERIMENTS\n---") diff --git a/scripts/paper_results.py b/scripts/paper_results.py index 8ecf58b..5d4f4cc 100644 --- a/scripts/paper_results.py +++ b/scripts/paper_results.py @@ -94,7 +94,7 @@ def run_param_configuration( ) # Check if the route crosses land - if land(curve).any(): + if land(curve).any() or dict_cmaes["cost"] < 0: print(f"{idx}: CMA-ES solution crosses land.") # Store NaN as cost results["cost_cmaes"] = float("nan") @@ -178,6 +178,13 @@ def build_dataframe( # Build the dataframe df = pd.DataFrame(ls_results) + # Any negative cost is turned into NaN + df["cost_cmaes"] = df["cost_cmaes"].where(df["cost_cmaes"] >= 0, float("nan")) + df["cost_fms"] = df["cost_fms"].where(df["cost_fms"] >= 0, float("nan")) + + # Drop rows with NaN costs + df = df.dropna(subset=["cost_cmaes", "cost_fms"], how="any") + # Land generation check: under the same conditions, the land should be the same # When the land makes source or destination not reachable, the cost is NaN # Thus, when a cost is NaN, it should be NaN for all the rows with the same From 1e25866c1e6da2a5cbe4ac87a805cd409913dbb2 Mon Sep 17 00:00:00 2001 From: "Daniel Precioso, PhD" Date: Thu, 12 Jun 2025 15:14:46 +0200 Subject: [PATCH 3/8] Update plot names --- routetools/plot.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/routetools/plot.py b/routetools/plot.py index 7afc941..bd35e2d 100644 --- a/routetools/plot.py +++ b/routetools/plot.py @@ -194,6 +194,8 @@ def plot_route_from_json(path_json: str) -> tuple[Figure, Axes]: water_level = data["water_level"] resolution = data.get("resolution", 0) random_seed = data.get("random_seed", 0) + k = data.get("K") + sigma0 = data.get("sigma0") # Generate the land if resolution != 0: @@ -227,12 +229,10 @@ def plot_route_from_json(path_json: str) -> tuple[Figure, Axes]: ) # Set the title and tight layout if water_level == 1: - title = DICT_VF_NAMES.get(vfname, vfname) + vf = DICT_VF_NAMES.get(vfname, vfname) + title = f"{vf} | K = {int(k)} | " + r"$\sigma_0$ = " + f"{sigma0:.1f}" else: - title = ( - f"Water level: {water_level} | Resolution: {resolution} | " - + f"Seed: {random_seed}" - ) + title = f"Water level: {water_level:.1f} | Resolution: {int(resolution)}" ax.set_title(title) fig.tight_layout() return fig, ax From d66444488f216b18969e42be58efa71f9a2b3f5c Mon Sep 17 00:00:00 2001 From: "Daniel Precioso, PhD" Date: Thu, 12 Jun 2025 16:27:30 +0200 Subject: [PATCH 4/8] Add viable area plotting function and update simulation parameters - Introduced `plot_viable_area` function to visualize the viable area for specified vector fields. - Updated `run_single_simulation` parameters for improved performance and flexibility, including changes to vector field defaults and optimization settings. - Adjusted plotting limits and labels in the `experiment_parameter_sensitivity` function for better clarity in results. --- scripts/paper_plots.py | 107 ++++++++++++++++++++++++++++++++++++++--- scripts/single_run.py | 38 ++++++--------- 2 files changed, 115 insertions(+), 30 deletions(-) diff --git a/scripts/paper_plots.py b/scripts/paper_plots.py index e2d3e80..fb18bdd 100644 --- a/scripts/paper_plots.py +++ b/scripts/paper_plots.py @@ -19,6 +19,79 @@ } +def plot_viable_area(vectorfield: str, config: str = "config.toml", t: float = 0): + """Plot the viable area for the given vector field. + + Parameters + ---------- + vectorfield : str + The name of the vector field function to use. + """ + # Load the vectorfield function + vectorfield_module = __import__( + "routetools.vectorfield", fromlist=["vectorfield_" + vectorfield] + ) + vectorfield_fun = getattr(vectorfield_module, "vectorfield_" + vectorfield) + + # Load the config file as a dictionary + with open(config, "rb") as f: + config = tomllib.load(f) + # Extract the vectorfield parameters + vfparams = config["vectorfield"][vectorfield] + + xlim = vfparams.get("xlim", (-1, 1)) + ylim = vfparams.get("ylim", (-1, 1)) + + # Create a meshgrid for the vector field + x = jnp.linspace(xlim[0], xlim[1], 1000) + y = jnp.linspace(ylim[0], ylim[1], 1000) + X, Y = jnp.meshgrid(x, y) + T = jnp.zeros_like(X) + t + # Compute the vector field + U, V = vectorfield_fun(X, Y, T) + # Compute the module of the vector field + module = jnp.sqrt(U**2 + V**2) + # Create a figure and axis + fig, ax = plt.subplots(figsize=(8, 8)) + # Plot the mask as a colormap + # We use a colormap that goes from blue (low values) to red (high values) + ax.imshow( + module, + extent=(xlim[0], xlim[1], ylim[0], ylim[1]), + origin="lower", + cmap="coolwarm", + vmin=0, + vmax=2, + ) + # Plot the vector field + # We will see an arrow every 0.25 units in both directions + step = 0.25 + x = jnp.arange(xlim[0] - step, xlim[1] + step, step) + y = jnp.arange(ylim[0] - step, ylim[1] + step, step) + X, Y = jnp.meshgrid(x, y) + T = jnp.zeros_like(X) + t + U, V = vectorfield_fun(X, Y, T) + ax.quiver(X, Y, U, V) + # Plot source and destination + src = jnp.array(vfparams["src"]) + dst = jnp.array(vfparams["dst"]) + ax.plot(src[0], src[1], "ro", markersize=10, label="Source") + ax.plot(dst[0], dst[1], "go", markersize=10, label="Destination") + # Set the axis limits + ax.set_xlim(xlim) + ax.set_ylim(ylim) + # Set the axis labels + ax.set_xlabel("X-axis") + ax.set_ylabel("Y-axis") + # Set the title + ax.set_title(f"Viable Area for {vectorfield} Vector Field") + # Show the plot + plt.tight_layout() + t = str(round(t, 2)).replace(".", "_") # Replace dot with underscore for filename + plt.savefig(f"./output/area_{vectorfield}_{t}.png", dpi=300) + plt.close() + + def run_single_simulation( vectorfield: str = "fourvortices", cmaes_K: int = 6, @@ -267,17 +340,25 @@ def experiment_parameter_sensitivity( df_noland["cost_reference"] = df_noland["vectorfield"].map(DICT_COST_LITERATURE) # Choose only the following vectorfields - ls_vf = ["circular", "fourvortices", "swirlys"] + ls_vf = ["circular", "doublegyre", "fourvortices", "swirlys", "techy"] df_noland = df_noland[df_noland["vectorfield"].isin(ls_vf)] # Compute the difference with the literature cost df_noland["diff_cmaes"] = 1 - df_noland["cost_cmaes"] / df_noland["cost_reference"] df_noland["diff_fms"] = 1 - df_noland["cost_fms"] / df_noland["cost_reference"] + df_noland["niter"] = df_noland["niter_cmaes"] + df_noland["niter_fms"] + + # Compute the mean of all these values, grouped by vectorfield, K, and sigma0 + df_noland = df_noland.groupby( + ["vectorfield", "K", "sigma0"], + as_index=False, + ).mean(numeric_only=True) def _helper_experiment_parameter_sensitivity( col1: str, col2: str, title: str = "", + limits2: tuple = (0, 500), # Limits for col2 heatmap ): # Plot multiple heatmaps in a single figure: # First row: col1 for each vectorfield @@ -304,8 +385,8 @@ def _helper_experiment_parameter_sensitivity( cmap="bwr_r", aspect="equal", # Center the map around zero - vmin=-1.5, - vmax=1.5, + vmin=-0.5, + vmax=0.5, ) axs[0, i].set_title(vfname) axs[0, i].set_xlabel("K (Control Points)") @@ -321,8 +402,8 @@ def _helper_experiment_parameter_sensitivity( heatmap_data, cmap="Reds", aspect="equal", - vmin=0, - vmax=10, + vmin=limits2[0], + vmax=limits2[1], # Set limits for col2 heatmap ) axs[1, i].set_title(vfname) axs[1, i].set_xlabel("K (Control Points)") @@ -347,15 +428,19 @@ def _helper_experiment_parameter_sensitivity( fraction=0.02, location="right", ) - cbar2.set_label("Computation time (s)") + cbar2.set_label("Number of iterations") # GRAPH 1 - _helper_experiment_parameter_sensitivity(col1="diff_cmaes", col2="comp_time_cmaes") + _helper_experiment_parameter_sensitivity( + col1="diff_cmaes", col2="niter_cmaes", limits2=[0, 200] + ) plt.savefig(folder + "parameter_sensitivity_cmaes.png", dpi=300) plt.close() # GRAPH 2 - _helper_experiment_parameter_sensitivity(col1="diff_fms", col2="comp_time") + _helper_experiment_parameter_sensitivity( + col1="diff_fms", col2="niter", limits2=[0, 5000] + ) plt.savefig(folder + "parameter_sensitivity_bers.png", dpi=300) plt.close() @@ -448,6 +533,12 @@ def experiment_land_complexity( def main(folder: str = "./output/"): """Run the experiments and plot the results.""" + for t in [0, 0.2, 0.5, 0.7, 1.0]: + print(f"\n---\nVIABLE AREA FOR TECHY VECTOR FIELD AT t={t}\n---") + plot_viable_area("techy", t=t) + plot_viable_area("fourvortices") + plot_viable_area("circular") + plot_viable_area("doublegyre") print("---\nSINGLE SIMULATION\n---") # run_single_simulation(path_img=folder) print("\n---\nBIGGEST FMS GAINS\n---") diff --git a/scripts/single_run.py b/scripts/single_run.py index e1ddde3..4a73ab7 100644 --- a/scripts/single_run.py +++ b/scripts/single_run.py @@ -1,4 +1,3 @@ -import time import tomllib import jax.numpy as jnp @@ -12,24 +11,24 @@ def run_single_simulation( - vectorfield: str = "zero", - land_waterlevel: float = 0.7, - land_resolution: int = 20, + vectorfield: str = "techy", + land_waterlevel: float = 1.0, + land_resolution: int = 5, land_seed: int = 0, land_penalty: float = 100, outbounds_is_land: bool = False, cmaes_K: int = 6, - cmaes_L: int = 256, + cmaes_L: int = 200, cmaes_numpieces: int = 1, - cmaes_popsize: int = 5000, - cmaes_sigma: float = 1, - cmaes_tolfun: float = 0.1, + cmaes_popsize: int = 500, + cmaes_sigma: float = 2, + cmaes_tolfun: float = 1e-3, cmaes_damping: float = 1.0, - cmaes_maxfevals: int = 200000, + cmaes_maxfevals: int = 500000, cmaes_seed: int = 0, - fms_tolfun: float = 1e-10, - fms_damping: float = 0.9, - fms_maxfevals: int = 100000, + fms_tolfun: float = 1e-6, + fms_damping: float = 0.5, + fms_maxfevals: int = 500000, path_img: str = "./output", path_config: str = "config.toml", ): @@ -109,8 +108,6 @@ def run_single_simulation( return # CMA-ES optimization algorithm - start = time.time() - curve_cmaes, dict_cmaes = optimize( vectorfield_fun, src, @@ -133,12 +130,10 @@ def run_single_simulation( if land(curve_cmaes).any(): print("The curve is on land") cost_cmaes = jnp.inf - - print(f"Computation time of CMA-ES: {time.time() - start}") + else: + cost_cmaes = dict_cmaes["cost"] # FMS variational algorithm (refinement) - start = time.time() - curve_fms, dict_fms = optimize_fms( vectorfield_fun, curve=curve_cmaes, @@ -152,19 +147,18 @@ def run_single_simulation( ) # FMS returns an extra dimensions, we ignore that curve_fms = curve_fms[0] - cost_fms = dict_fms["cost"][0] # FMS returns a list of costs if land(curve_fms).any(): print("The curve is on land") cost_fms = jnp.inf - - print(f"Computation time of FMS: {time.time() - start}") + else: + cost_fms = dict_fms["cost"][0] # FMS returns a list of costs # Plot them fig, ax = plot_curve( vectorfield_fun, [curve_cmaes, curve_fms], - ls_name=["CMA-ES", "FMS"], + ls_name=["CMA-ES", "BERS"], ls_cost=[cost_cmaes, cost_fms], land=land, xlim=land_xlim, From 1d87dc38fccd675ac5fdcb245a89b2a202fd5018 Mon Sep 17 00:00:00 2001 From: daniprec Date: Fri, 13 Jun 2025 11:32:29 +0200 Subject: [PATCH 5/8] Enable plotting functions in main execution flow of paper_plots.py for single simulation, best values, and land avoidance analysis. --- scripts/paper_plots.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/paper_plots.py b/scripts/paper_plots.py index fb18bdd..333494e 100644 --- a/scripts/paper_plots.py +++ b/scripts/paper_plots.py @@ -540,13 +540,13 @@ def main(folder: str = "./output/"): plot_viable_area("circular") plot_viable_area("doublegyre") print("---\nSINGLE SIMULATION\n---") - # run_single_simulation(path_img=folder) + run_single_simulation(path_img=folder) print("\n---\nBIGGEST FMS GAINS\n---") - # plot_best_values(folder=folder, col="gain_fms", ascending=False, size=2) + plot_best_values(folder=folder, col="gain_fms", ascending=False, size=2) print("\n---\nBIGGEST BERS SAVINGS\n---") - # plot_best_values(folder=folder, col="cost_fms", ascending=True, size=2) + plot_best_values(folder=folder, col="cost_fms", ascending=True, size=2) print("\n---\nLAND AVOIDANCE ANALYSIS\n---") - # plot_land_avoidance(folder=folder) + plot_land_avoidance(folder=folder) print("\n---\nPARAMETER SENSITIVITY EXPERIMENTS\n---") experiment_parameter_sensitivity(folder=folder) print("\n---\nLAND COMPLEXITY EXPERIMENTS\n---") From 27c6a7d35208022e34624ca6210a033c322a3d4a Mon Sep 17 00:00:00 2001 From: daniprec Date: Fri, 13 Jun 2025 16:09:44 +0200 Subject: [PATCH 6/8] Add cmaes_seed parameter to config_noland.toml, adjust marker size in plot_curve, and update plot filenames in paper_plots.py for consistency. Enhance cost handling in paper_results.py to prevent errors and improve data integrity. --- config_noland.toml | 1 + routetools/plot.py | 2 +- scripts/paper_plots.py | 33 ++++++++++++++++----------------- scripts/paper_results.py | 17 ++++++++++++++--- 4 files changed, 32 insertions(+), 21 deletions(-) diff --git a/config_noland.toml b/config_noland.toml index 232a9f9..ec9ec5c 100644 --- a/config_noland.toml +++ b/config_noland.toml @@ -53,6 +53,7 @@ sigma0 = [0.5, 1, 1.5, 2, 2.5, 3] tolfun = [1e-3] damping = [1.0] maxfevals = [500000] +cmaes_seed = [0, 1, 2, 3, 4] [refiner.fms] tolfun = [1e-6] diff --git a/routetools/plot.py b/routetools/plot.py index bd35e2d..8b91ea2 100644 --- a/routetools/plot.py +++ b/routetools/plot.py @@ -116,7 +116,7 @@ def plot_curve( curve[:, 0], curve[:, 1], marker="o", - markersize=2, + markersize=1, label=label, zorder=2, color=color, diff --git a/scripts/paper_plots.py b/scripts/paper_plots.py index 333494e..dcf5f5f 100644 --- a/scripts/paper_plots.py +++ b/scripts/paper_plots.py @@ -1,6 +1,7 @@ import tomllib import jax.numpy as jnp +import matplotlib import matplotlib.pyplot as plt import numpy as np import pandas as pd @@ -263,7 +264,7 @@ def plot_best_values( json_id = int(row["json"]) print(f"Best {col}: processing {json_id}...") fig, ax = plot_route_from_json(f"{folder}/noland/{json_id:06d}.json") - fig.savefig(f"{folder}/{col}_{vf}_{idx}.png") + fig.savefig(f"{folder}/{vf}_{json_id}.png") plt.close(fig) @@ -300,24 +301,18 @@ def plot_land_avoidance( df_land = df_land.dropna(subset=["complexity"]) # Generate plots for the worst ten examples - idx = 0 for _, df_sub in df_land.groupby("complexity"): - # Sort by gain - df_sub = df_sub.sort_values("gain_fms", ascending=True) - # Take the worst three examples - df_worst = df_sub.tail(3) - for _, row in df_worst.iterrows(): - # Extract the configuration parameters - + # Take three random values (fixed random seed for reproducibility) + df_random = df_sub.sample(n=3, random_state=1) + for _, row in df_random.iterrows(): # Load the JSON file for the identified example json_id = int(row["json"]) print(f"Land avoidance: processing {json_id}...") # Print what was the CMA-ES configuration fig, ax = plot_route_from_json(f"{folder}/land/{json_id:06d}.json") - fig.savefig(f"{folder}/land_avoidance_{idx}.png") + fig.savefig(f"{folder}/land_{json_id}.png") plt.close(fig) - idx += 1 def experiment_parameter_sensitivity( @@ -380,9 +375,11 @@ def _helper_experiment_parameter_sensitivity( # Pivot the DataFrame to create a heatmap heatmap_data = df_vf.pivot(index="sigma0", columns="K", values=col1) # Plot the heatmap for col1 + cmap = matplotlib.cm.bwr_r + cmap.set_bad("black", 1.0) im1 = axs[0, i].imshow( heatmap_data, - cmap="bwr_r", + cmap=cmap, aspect="equal", # Center the map around zero vmin=-0.5, @@ -398,9 +395,11 @@ def _helper_experiment_parameter_sensitivity( # Plot the heatmap for col2 heatmap_data = df_vf.pivot(index="sigma0", columns="K", values=col2) + cmap = matplotlib.cm.Reds + cmap.set_bad("black", 1.0) im2 = axs[1, i].imshow( heatmap_data, - cmap="Reds", + cmap=cmap, aspect="equal", vmin=limits2[0], vmax=limits2[1], # Set limits for col2 heatmap @@ -533,6 +532,8 @@ def experiment_land_complexity( def main(folder: str = "./output/"): """Run the experiments and plot the results.""" + print("\n---\nPARAMETER SENSITIVITY EXPERIMENTS\n---") + experiment_parameter_sensitivity(folder=folder) for t in [0, 0.2, 0.5, 0.7, 1.0]: print(f"\n---\nVIABLE AREA FOR TECHY VECTOR FIELD AT t={t}\n---") plot_viable_area("techy", t=t) @@ -542,13 +543,11 @@ def main(folder: str = "./output/"): print("---\nSINGLE SIMULATION\n---") run_single_simulation(path_img=folder) print("\n---\nBIGGEST FMS GAINS\n---") - plot_best_values(folder=folder, col="gain_fms", ascending=False, size=2) + plot_best_values(folder=folder, col="gain_fms", ascending=False, size=4) print("\n---\nBIGGEST BERS SAVINGS\n---") - plot_best_values(folder=folder, col="cost_fms", ascending=True, size=2) + plot_best_values(folder=folder, col="cost_fms", ascending=True, size=4) print("\n---\nLAND AVOIDANCE ANALYSIS\n---") plot_land_avoidance(folder=folder) - print("\n---\nPARAMETER SENSITIVITY EXPERIMENTS\n---") - experiment_parameter_sensitivity(folder=folder) print("\n---\nLAND COMPLEXITY EXPERIMENTS\n---") experiment_land_complexity(folder=folder) diff --git a/scripts/paper_results.py b/scripts/paper_results.py index 5d4f4cc..37ecf66 100644 --- a/scripts/paper_results.py +++ b/scripts/paper_results.py @@ -80,13 +80,15 @@ def run_param_configuration( tolfun=params["tolfun"], damping=params["damping"], maxfevals=params["maxfevals"], + seed=params.get("cmaes_seed", 0), verbose=verbose, ) + cost_cmaes = dict_cmaes["cost"] # Update the results dictionary with the optimization results results.update( { - "cost_cmaes": dict_cmaes["cost"], + "cost_cmaes": cost_cmaes, "comp_time_cmaes": dict_cmaes["comp_time"], "niter_cmaes": dict_cmaes["niter"], "curve_cmaes": curve.tolist(), @@ -94,7 +96,7 @@ def run_param_configuration( ) # Check if the route crosses land - if land(curve).any() or dict_cmaes["cost"] < 0: + if land(curve).any() or cost_cmaes < 0: print(f"{idx}: CMA-ES solution crosses land.") # Store NaN as cost results["cost_cmaes"] = float("nan") @@ -118,11 +120,16 @@ def run_param_configuration( ) # FMS returns an extra dimension, we ignore that curve_fms = curve_fms[0] + cost_fms = dict_fms["cost"][0] + + if round(cost_fms, 3) > round(cost_cmaes, 3): + # The FMS went wrong + cost_fms = float("nan") # Update the results dictionary with the optimization results results.update( { - "cost_fms": dict_fms["cost"][0], # FMS returns a list of costs + "cost_fms": cost_fms, # FMS returns a list of costs "comp_time_fms": dict_fms["comp_time"], "niter_fms": dict_fms["niter"], "curve_fms": curve_fms.tolist(), @@ -182,6 +189,10 @@ def build_dataframe( df["cost_cmaes"] = df["cost_cmaes"].where(df["cost_cmaes"] >= 0, float("nan")) df["cost_fms"] = df["cost_fms"].where(df["cost_fms"] >= 0, float("nan")) + # Any cost higher than 1e4 is considered an error and turned into NaN + df["cost_cmaes"] = df["cost_cmaes"].where(df["cost_cmaes"] < 1e4, float("nan")) + df["cost_fms"] = df["cost_fms"].where(df["cost_fms"] < 1e4, float("nan")) + # Drop rows with NaN costs df = df.dropna(subset=["cost_cmaes", "cost_fms"], how="any") From 89fa549a9af6560938894430fef567802aba9fe1 Mon Sep 17 00:00:00 2001 From: Daniel Precioso Date: Wed, 18 Jun 2025 12:04:59 +0200 Subject: [PATCH 7/8] Update configuration and enhance plotting functions in paper_plots.py - Changed the `K` parameter in `config_land.toml` from 12 to 9 for improved simulation settings. - Added a `size` parameter to the `plot_land_avoidance` function to allow dynamic control over the number of samples plotted. - Adjusted subplot sizes and labels in `experiment_parameter_sensitivity` for better visualization. - Updated figure size in `experiment_land_complexity` for improved clarity in results presentation. --- config_land.toml | 2 +- scripts/paper_plots.py | 43 ++++++++++++++++++++++-------------------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/config_land.toml b/config_land.toml index 1ad694a..f524a2f 100644 --- a/config_land.toml +++ b/config_land.toml @@ -23,7 +23,7 @@ outbounds_is_land = [false] popsize = [500] L = [200] num_pieces = [1] -K = [12] +K = [9] sigma0 = [2] tolfun = [1e-3] damping = [1.0] diff --git a/scripts/paper_plots.py b/scripts/paper_plots.py index dcf5f5f..ec9af5b 100644 --- a/scripts/paper_plots.py +++ b/scripts/paper_plots.py @@ -269,7 +269,9 @@ def plot_best_values( def plot_land_avoidance( - path_csv: str = "./output/results_land.csv", folder: str = "./output/" + path_csv: str = "./output/results_land.csv", + folder: str = "./output/", + size: int = 3, ): """ Generate and save plots for land avoidance analysis based on simulation results. @@ -303,7 +305,7 @@ def plot_land_avoidance( # Generate plots for the worst ten examples for _, df_sub in df_land.groupby("complexity"): # Take three random values (fixed random seed for reproducibility) - df_random = df_sub.sample(n=3, random_state=1) + df_random = df_sub.sample(n=size, random_state=1) for _, row in df_random.iterrows(): # Load the JSON file for the identified example json_id = int(row["json"]) @@ -364,7 +366,7 @@ def _helper_experiment_parameter_sensitivity( # color: col1 or col2 (depending on the heatmap) num_cols = len(ls_vf) fig, axs = plt.subplots( - nrows=2, ncols=num_cols, figsize=(num_cols * 4, 8), sharex=True, sharey=True + nrows=2, ncols=num_cols, figsize=(num_cols * 2, 4), sharex=True, sharey=True ) fig.suptitle(title, fontsize=16) for i, vf in enumerate(ls_vf): @@ -386,8 +388,8 @@ def _helper_experiment_parameter_sensitivity( vmax=0.5, ) axs[0, i].set_title(vfname) - axs[0, i].set_xlabel("K (Control Points)") - axs[0, i].set_ylabel(r"$\sigma_0$ (Standard Deviation)") + if i == 0: + axs[0, i].set_ylabel(r"$\sigma_0$") axs[0, i].set_xticks(np.arange(len(heatmap_data.columns))) axs[0, i].set_xticklabels(heatmap_data.columns) axs[0, i].set_yticks(np.arange(len(heatmap_data.index))) @@ -404,9 +406,9 @@ def _helper_experiment_parameter_sensitivity( vmin=limits2[0], vmax=limits2[1], # Set limits for col2 heatmap ) - axs[1, i].set_title(vfname) - axs[1, i].set_xlabel("K (Control Points)") - axs[1, i].set_ylabel(r"$\sigma_0$ (Standard Deviation)") + axs[1, i].set_xlabel("K") + if i == 0: + axs[1, i].set_ylabel(r"$\sigma_0$") axs[1, i].set_xticks(np.arange(len(heatmap_data.columns))) axs[1, i].set_xticklabels(heatmap_data.columns) axs[1, i].set_yticks(np.arange(len(heatmap_data.index))) @@ -489,13 +491,13 @@ def experiment_land_complexity( # Vertical axis: "cost_fms" # We also overlay a line showing the average "comp_time" for each complexity level # Use a second y-axis for the average "comp_time" line - plt.figure(figsize=(8, 4)) + plt.figure(figsize=(6, 3)) ax = plt.gca() # Get current axes ax = df_land.boxplot( column="cost_fms", by="complexity", grid=False, showfliers=False, ax=ax ) - ax.set_ylim(8.5, 11.5) + ax.set_ylim(8.5, 12) ax.set_xlabel("Land Complexity Level (affects resolution and water level)") ax.set_ylabel("Travel time") ax.set_title("Results of BERS by Land Complexity Level") @@ -532,24 +534,25 @@ def experiment_land_complexity( def main(folder: str = "./output/"): """Run the experiments and plot the results.""" + print("---\nSINGLE SIMULATION\n---") + run_single_simulation("fourvortices", path_img=folder) + run_single_simulation("swirlys", path_img=folder) print("\n---\nPARAMETER SENSITIVITY EXPERIMENTS\n---") experiment_parameter_sensitivity(folder=folder) + print("\n---\nLAND COMPLEXITY EXPERIMENTS\n---") + experiment_land_complexity(folder=folder) + print("\n---\nBIGGEST FMS GAINS\n---") + plot_best_values(folder=folder, col="gain_fms", ascending=False, size=4) + print("\n---\nBIGGEST BERS SAVINGS\n---") + plot_best_values(folder=folder, col="cost_fms", ascending=True, size=4) + print("\n---\nLAND AVOIDANCE ANALYSIS\n---") + plot_land_avoidance(folder=folder, size=10) for t in [0, 0.2, 0.5, 0.7, 1.0]: print(f"\n---\nVIABLE AREA FOR TECHY VECTOR FIELD AT t={t}\n---") plot_viable_area("techy", t=t) plot_viable_area("fourvortices") plot_viable_area("circular") plot_viable_area("doublegyre") - print("---\nSINGLE SIMULATION\n---") - run_single_simulation(path_img=folder) - print("\n---\nBIGGEST FMS GAINS\n---") - plot_best_values(folder=folder, col="gain_fms", ascending=False, size=4) - print("\n---\nBIGGEST BERS SAVINGS\n---") - plot_best_values(folder=folder, col="cost_fms", ascending=True, size=4) - print("\n---\nLAND AVOIDANCE ANALYSIS\n---") - plot_land_avoidance(folder=folder) - print("\n---\nLAND COMPLEXITY EXPERIMENTS\n---") - experiment_land_complexity(folder=folder) if __name__ == "__main__": From b2ce002bb3c6fab1ca1a3e4de7f276300cfd897a Mon Sep 17 00:00:00 2001 From: "Daniel Precioso, PhD" Date: Thu, 19 Jun 2025 13:11:51 +0200 Subject: [PATCH 8/8] Comment out grid --- scripts/paper_plots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/paper_plots.py b/scripts/paper_plots.py index ec9af5b..7de4a21 100644 --- a/scripts/paper_plots.py +++ b/scripts/paper_plots.py @@ -523,7 +523,7 @@ def experiment_land_complexity( ax2.set_ylim(0, 30) # Add a grid - ax.grid(True, which="both", linestyle="--", linewidth=0.5) + # ax.grid(True, which="both", linestyle="--", linewidth=0.5) plt.suptitle("") plt.xticks([1, 2, 3], ["Easy", "Medium", "Hard"])