diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index dff2ebe..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index cbc26af..3264286 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,15 @@ ENV/ env/ mobo-env/ +# Private campaign inputs (never commit experimental workbooks/data) +local_inputs/ +local_outputs/ +# The launcher writes round reports BESIDE the workbook, so they normally +# land under local_inputs/ -- but only if the workbook is kept there. This +# catches them wherever a user actually puts it. +*_reports/ +*_PRIVATE_EVIDENCE_DO_NOT_SHARE.zip + # Distribution / packaging .Python build/ @@ -55,4 +64,5 @@ Desktop.ini .tox/ .nox/ .pytest_cache/ +.mypy_cache/ htmlcov/ diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 9b38853..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "python.testing.pytestArgs": [ - "tests" - ], - "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true -} \ No newline at end of file diff --git a/README.md b/README.md index f01fd3d..8f66dec 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ -# README: MOBO-Kit -by Ethan Schwartz, Daniel Abdoue, Nicky Evans, and Tonio Buonassisi +# MOBO-Kit + +initially designed by Ethan Schwartz, Daniel Abdoue, Nicky Evans, and Tonio Buonassisi
+updated and reconstructed by Ziyang (Colin) Qi and Annie Xu +

Slot-die optimization logo @@ -8,280 +11,297 @@ by Ethan Schwartz, Daniel Abdoue, Nicky Evans, and Tonio Buonassisi

-[![DOI](https://img.shields.io/badge/DOI-TBD-blue)](https://doi.org/TBD) -[![arXiv](https://img.shields.io/badge/arXiv-TBD-blue.svg?logo=arxiv&logoColor=white.svg)](https://arxiv.org/abs/TBD) -[![Requires Python 3.10+](https://img.shields.io/badge/Python-3.10+-blue.svg?logo=python&logoColor=white)](https://python.org/downloads) +[![Requires Python 3.11-3.12](https://img.shields.io/badge/Python-3.11--3.12-blue.svg?logo=python&logoColor=white)](https://python.org/downloads)

-**MOBO-Kit** is an open-source toolkit for accelerating design of experiments via **multi-objective Bayesian optimization**. Developed collaboratively across University of Washington, UC San Diego, and MIT, this toolkit enables rapid optimization of complex systems by balancing multiple objectives across any number of inputs and outputs (>2). While demonstrated for slot-die coating experiments (e.g., optimizing efficiency, repeatability, and stability), MOBO-Kit is generalizable to any multi-objective optimization problem. +**MOBO-Kit** accelerates design of experiments with **multi-objective Bayesian +optimization**. It proposes small batches of experimental conditions that trade +off several objectives at once, for problems with more than two inputs and more +than two outputs. Developed across the University of Washington, UC San Diego and +MIT, and demonstrated on slot-die coated perovskite films. + +## Three contracts, one live campaign + +| | v2 — test data | v3 — test data | v4 — **the real campaign** | +|---|---|---|---| +| config | `campaign_d2d_perovskite.yaml` (archived) | `campaign_d2d_perovskite_test.yaml` (archived) | `campaign_d2d_perovskite_final.yaml` | +| contract | `d2d-objectives-v2-nm-thickness` | `d2d-objectives-v3-test` | `d2d-objectives-v4-final` | +| workbook | `Summary Table.xlsx` | `Summary Table Test.xlsx` | `Final Summary Table.xlsx` | +| sheet | `Sheet1` | `Sheet1` | `R0` | +| purpose | early toolkit testing | rehearsing this contract's shape | **the experiment being run** | + +Uniformity and optoelectronic have been renormalised twice, so **none of v2's or +v3's fitted numbers carry over** — they are about quantities that were redefined. +Each earlier contract is kept as a record, with a banner on every document that +describes it. The launcher and every script default to v4. + +**In v4 the uniformity and optoelectronic scores are FROZEN**: they are read from +the workbook as stored, with no recomputation in Python, because the group is +still revising how they are defined. Thickness is still computed, because its +definition has been stable and the recomputation is what lets an operator-flagged +reading be excluded and reported. See `docs/CAMPAIGN_STATUS.md` for what freezing +costs and what replaces the missing cross-check. + +## The campaign loop + +A campaign runs in three rounds. Each proposed condition is run in triplicate so +reproducibility can be measured. + +| Round | Method | Conditions | Films | +|---|---|---:|---:| +| R0 | Latin hypercube sampling | 15 | 45 | +| R1 | UCB-HVI + local penalization | 5 | 15 | +| R2 | qLogNEHVI | 3 | 9 | -## Key Features +```python +from mobo_kit.campaign import load_campaign_config, run_r0_lhs, run_r1_ucb, run_r2_qlognehvi +from mobo_kit.workbook_io import read_campaign_workbook -MOBO-Kit provides a complete package for multi-objective Bayesian optimization with: -- **Latin Hypercube Sampling** for initial experiment design -- **Gaussian Process models** with BoTorch -- **Multi-objective acquisition functions** (qNEHVI) -- **Batch candidate proposal** for efficient parallel experimentation -- **Comprehensive plotting and analysis tools** -- **Constraint handling** for complex design spaces -- **Command-line interface** and Python API +config = load_campaign_config("configs/campaign_d2d_perovskite_final.yaml") ---- +r0 = run_r0_lhs(config, n=15) # space-filling, no model -## Table of Contents +# uniformity and optoelectronic are read from the workbook as stored (frozen); +# thickness is computed from the raw readings and cross-checked +contents = read_campaign_workbook("local_inputs/Final Summary Table.xlsx", config) +X_phys = contents.inputs.to_numpy(float) +Y_model = contents.model_values.to_numpy(float) # in objective order +assert contents.errors == () # fail closed before fitting -- [Key Features](#key-features) -- [Installation](#installation) -- [Quick Start](#quick-start) -- [Configuration](#configuration) -- [Package Structure](#package-structure) -- [Troubleshooting](#troubleshooting) -- [Next Steps](#next-steps) -- [Citation](#citation) -- [License](#license) -- [Get in Touch](#get-in-touch) +r1 = run_r1_ucb(config, X_phys, Y_model, n=5) # after R0 is measured +r2 = run_r2_qlognehvi(config, X_phys, Y_model, n=3) # after R1 is measured +``` ---- +Each call returns a `RoundResult` with `conditions` (distinct recipes, physical +units), `replicates` (one row per film, grouped), and `diagnostics` (seed, pool +size, fit warnings, and a validity report). + +**New to this repo?** Read `docs/HANDOFF.md` first — reading order, where the +project stands, what is genuinely open, and the questions already settled. +`docs/CAMPAIGN_STATUS.md` is the working guide: what to pass, what comes back, and +the evidence behind each decision. + +## Running a round without writing code + +Double-click **`launch_mobo_kit.bat`** (Windows) or **`launch_mobo_kit.command`** +(macOS — `chmod +x` it once first). A small window opens: + +1. **Browse** to the campaign workbook. It is remembered next time. +2. **Check workbook** — reports which round is due, and anything the read + noticed: a stored score that no longer matches its measurements, a reading the + operator flagged, a film whose thickness readings disagree with each other. +3. **Propose R1** (or R2) — fits the model, scores the candidate pool, and writes + the batch to a **new file beside the workbook**, never into it. That file gets + two sheets: the worklist to fill in, and a **`Review`** sheet giving each + proposed condition's predicted objectives with uncertainties, its predicted + thickness in nanometres, its distance from anything already measured, and which + settings sit at the edge of their range. The same text appears in the window, so + it can be forwarded to the group as-is. + +4. **Figures.** The same press renders six figures beside the workbook, under + `_reports/_/`: where the batch sits in recipe space, + how well the model predicts a film it has not seen, which inputs move each + objective, what the batch is expected to produce, hypervolume so far, and the + trade-off itself. Each one writes the CSV behind it. A second button, + **Figures from current data**, renders the four that need no batch — useful the + moment measurements are entered. + +Then run the films, fill in the highlighted columns of that new sheet, and press +the button again. R2 reads the R1 measurements back and aggregates each condition's +three films into one observation. + +The window approves nothing. It shows the proposed conditions in physical units +with the batch's spacing diagnostics; a human decides whether to fabricate. +Everything it does is available as plain functions in `mobo_kit.launcher` +(`inspect_campaign`, `gather_observations`, `generate_next_round`) for anyone who +would rather script it. ## Installation -### Option 1: Install from Source (Recommended) - -We recommend creating a clean Python environment using `conda` or `venv`: - ```bash -# Create and activate environment -conda create -n mobo-fom python=3.10 -conda activate mobo-fom - -# Or using venv -python -m venv mobo-env -source mobo-env/bin/activate # On Windows: mobo-env\Scripts\activate - -# Install MOBO-Kit -git clone https://github.com/PV-Lab/MOBO-FOM.git -cd MOBO-FOM -pip install -e . +conda create -n mobo-kit python=3.12 +conda activate mobo-kit +git clone https://github.com/PV-Lab/MOBO-Kit.git +cd MOBO-Kit +python -m pip install -r requirements/dev.txt ``` -This will automatically install all required dependencies including: -- Core scientific computing: numpy, pandas, scipy, matplotlib, seaborn, scikit-learn -- Machine learning: torch, gpytorch, botorch, emukit -- Additional tools: shap, pyDOE, pyyaml +Tested on CPU with Python 3.12, PyTorch 2.8.0, BoTorch 0.15.1, GPyTorch 1.14. +The exact stack is pinned in `requirements/constraints.txt`. -### GPU Support (CUDA [Windows]) +## Does the optimizer actually work? -MOBO-Kit uses PyTorch for machine learning models. By default, the installation includes the CPU-only version of PyTorch. For GPU acceleration, you'll need to install the CUDA version of PyTorch. +`tests/test_dtlz2_acceptance.py` runs the whole loop on **DTLZ2** — a synthetic +3-objective, 10-input problem with a known Pareto front — so the algorithm can be +checked independently of any experimental data. -**Check your CUDA version:** ```bash -nvidia-smi +pytest tests/test_dtlz2_acceptance.py -m "not slow" +pytest tests/test_dtlz2_acceptance.py -m slow ``` -**Install PyTorch with CUDA support:** -```bash -# For CUDA 12.1 (recommended for most systems) -pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 +Cumulative hypervolume rises monotonically **by construction**, so that alone +proves nothing — random sampling passes it too. The informative comparison is +against a random baseline at equal budget: mean hypervolume gain **+0.075 (BO) +against +0.056 (random)**, winning on 5 of 8 seeds. BO wins on the mean, not on +every seed, which is the honest expectation for 8 added points in 10 dimensions. -# For CUDA 11.8 (more compatible with older systems) -pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 +`python scripts/plot_dtlz2_report.py` renders the round-by-round GP fit, +uncertainty, acquisition surface and selected batch. -# For CUDA 12.4 -pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu124 +## How beta and radius were chosen -# For CUDA 12.8 (latest) -pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128 -``` +The live campaign runs **beta = 4.0** and **radius = 0.25**. They were determined +by a sweep over two instruments on the campaign's own data: per-round utility +**box plots** across a grid of **beta from 9 to 49** and **radius from 0.05 to +0.45**, and **heat maps** -- 2-D slices through the higher-dimensional +Gaussian-process model -- at the same cells. `scripts/plot_boxplot_sweep.py` and +`scripts/plot_round_simulation.py` produce them; the outputs stay local, because +they are how the group picks a setting rather than a result about the chemistry. -**Verify GPU support:** -```python -import torch -print("CUDA available:", torch.cuda.is_available()) -print("Device count:", torch.cuda.device_count()) -``` +Two things to know before quoting that choice. -### Option 2: Install with pip (once software license received) +**The sweep could not rank the cells.** The whole spread across betas was 0.0065 +against a trial-to-trial standard deviation of 0.010--0.027, and the best cell was +a different (beta, radius) in every trial. So this is a declared policy about how +much to explore, not a measured optimum. -```bash -pip install mobo-kit -``` +**The campaign ran at beta = 36 from 2026-08 to 2026-09-03**, on the argument that +two of three objectives carried no learnable signal and heavy exploration was +therefore the right posture. That was retired when 45 rows of repeated recipes +showed *why* those two axes are unlearnable -- one is dominated by +between-campaign measurement drift, the other is reproducible but too sparsely +sampled -- neither of which more exploration reaches. At beta = 36 the radius knob +was also provably inert: radii 0.15, 0.25 and 0.35 returned bit-identical batches, +and 18 of 50 proposed coordinates sat on a grid bound. At beta = 4 / radius 0.25 +that falls to 11. See `docs/CAMPAIGN_STATUS.md` for the table and its caveats. -### Option 3: Google Colab - -**Option 3a: Direct Notebook Link** -- [Open MOBO-Kit notebook in Google Colab](https://colab.research.google.com/drive/1VzlCSTDw42kWxlI2xNUAOfmCZpLeKpN0?usp=sharing) - -**Option 3b: Install in your own Colab notebook** -```python -# Install in Google Colab -!pip install git+https://github.com/PV-Lab/MOBO-FOM.git +`docs/CAMPAIGN_STATUS.md` carries the full record, including the two triggers for +revisiting the choice. -# Import and use -import mobo_kit -``` - -### Dependencies - -MOBO-Kit requires: -- Python 3.10+ -- PyTorch 1.12+ -- BoTorch 0.8+ -- GPyTorch 1.8+ -- NumPy, Pandas, Scikit-learn -- Matplotlib, Seaborn - -See `requirements.txt` for the complete list of dependencies. - -## Quick Start - -### 1. Command Line Interface - -```bash -# Run with default configuration -mobo-kit --csv data/processed/configCSV_example.csv - -# Run with custom output directory -mobo-kit --csv data/my_data.csv --out results/my_experiment - -# Run with verbose output -mobo-kit --csv data/my_data.csv --verbose -``` - -### 2. Python API - -```python -import mobo_kit -from mobo_kit.main import main +## Repository layout -# Run the main workflow -main() ``` - -### 3. Advanced Usage Examples - -```python -# Generate initial experiments -from mobo_kit.main import generate_initial_experiments - -results = generate_initial_experiments( - config_path="configs/demo_config.yaml", - n_samples=20, - save_path="initial_experiments.csv" -) - -# Run MOBO optimization with custom parameters -from mobo_kit.main import run_mobo_experiment - -results = run_mobo_experiment( - csv_path="data/processed/configCSV_example.csv", - save_dir="results/experiment", - verbose=True -) +src/mobo_kit/ + campaign.py the three rounds; start here + design.py input grid and bounds + lhs.py Latin hypercube sampling (R0) + candidate_pool.py discrete candidate sampling + sobol_pool.py nested Sobol pools (alternative sampler) + models.py GP construction + model_validation.py strict fitting, exact LOOCV, fit guards + structured_mean.py physics-informed GP mean functions + scores.py measurement columns -> objective values, cross-checked + objectives.py objective value -> utility contract + replicate_variance.py replicate films -> observation variance (train_Yvar) + batch_review.py what a proposed batch says, before anyone fabricates it + round_report.py the six figures a round produces, and their data + loocv.py the one leave-one-out fold loop, shared by all callers + attribution.py exact Shapley values over the campaign's own models + launcher.py the one-button loop, and the tkinter window over it + ucb_hvi.py UCB hypervolume-improvement scoring (R1) + qlognehvi_batch.py qLogNEHVI batch selection (R2) + batch_selection.py local penalization, shared by both + discrete_refinement.py exact-grid local search + workbook_io.py Excel read / candidate-sheet write / read results back + metrics.py Pareto front and hypervolume + plotting.py diagnostic plots + candidate_diagnostics.py, acquisition.py, cli.py, main.py, + data.py, constraints.py, utils.py + + research_qnehvi.py qNEHVI as a research-only R2 variant, NOT the campaign + +configs/ campaign_d2d_perovskite_test.yaml (the live campaign), + campaign_d2d_perovskite.yaml (archived, first campaign) + two examples +docs/ HANDOFF.md, CAMPAIGN_STATUS.md, GP_MODEL_DECISION.md, + R1_BATCH_WITHDRAWAL.md, ROUND_SIM_DELTA.md, ROUND_SIM_MANIFEST.md, + SHAP_SUMMARY.md +scripts/ diagnostics, report figures, intake_new_data.py, + dtlz2_parameter_sweep.py, plot_round_simulation.py, + plot_shap_attribution.py, permutation_rank_test.py, + generate_round_report.py +tests/ 601 tests +launch_mobo_kit.bat, launch_mobo_kit.command double-click entry points ``` -### 4. Jupyter Notebooks - -See the `notebooks/` directory for interactive examples: -- `MOBO_demo_annotated.ipynb` - Complete workflow demonstration - - *Note*: The LOOCV function may have trouble converging on small noisy datasets and is still in development. - ## Configuration -MOBO-Kit uses YAML configuration files. See `configs/` directory for examples: - -- `demo_config.yaml` - Basic configuration -- `configCSV_example_config.yaml` - Configuration from CSV metadata - -### Configuration Structure +Objectives declare **what the model trains on** separately from **how that +becomes a utility**, because the two are not always the same column: ```yaml -inputs: - - name: "parameter1" - unit: "unit" - start: 0.0 - stop: 1.0 - step: 0.01 - objectives: - names: - - objective 1 - - objective 2 - - objective 3 - -constraints: - - clausius_clapeyron: true - ah_col: "absolute_humidity" - temp_c_col: "temperature_c" - -## Package Structure - -``` -src/mobo_kit/ -├── main.py # Main API functions -├── cli.py # Command-line interface -├── design.py # Design space construction -├── data.py # Data loading and preprocessing -├── models.py # Gaussian Process models -├── acquisition.py # Acquisition functions and batch proposal -├── lhs.py # Latin Hypercube Sampling -├── plotting.py # Visualization tools -├── metrics.py # Performance metrics -├── constraints.py # Constraint handling -└── utils.py # Utility functions + contract_version: d2d-objectives-v2-nm-thickness + scaling_mode: fixed_affine + specs: + - name: thickness + model_source_column: "Thickness (avg)" # stored cell: cross-check only + transform: gaussian_target # utility peaks at the target + target: 650.0 + sigma: 176.7766952966369 + measurement: # what the GP actually trains on + recipe: mean_of_present # mean of whichever were measured + inputs: [{column: T1}, {column: T2}, {column: T3}, {column: T4}] + excluded: [{column: "T anom"}] # operator-flagged, never averaged + cross_check: [{column: "Thickness (avg)", atol: 0.5}] + mean_function: # physics-informed trend + response: log + features: + - {column: speed_1, transform: log} + - {column: precur_conc, transform: log} ``` -## Troubleshooting - -### Common Installation Issues - -1. **Import errors**: Ensure all dependencies are installed: - ```bash - pip install -r requirements.txt - ``` - -2. **CUDA/GPU support**: Install PyTorch with CUDA (example, please use matching nvidia-smi): - ```bash - pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 - ``` - -3. **Python version compatibility**: Use Python 3.10 or 3.11: - ```bash - conda create -n mobo-kit python=3.10 - conda activate mobo-kit - pip install -e . - ``` - -4. **Jupyter notebook support**: - ```bash - pip install jupyter ipykernel - python -m ipykernel install --user --name=mobo-kit --display-name "Python (mobo-kit)" - ``` - -### Runtime Issues +The `measurement` block exists because several of the workbook's derived score +cells are pasted literals rather than formulas, so they do not update when the +measurements behind them are edited. `scores.py` recomputes each objective from +the raw columns and demotes the stored cell to a cross-check that warns on +disagreement — see `docs/CAMPAIGN_STATUS.md` issue 2 for the audit. + +Objective scales are **fixed for the whole campaign** and must never be +re-derived from observed data — otherwise utility space moves between rounds and +hypervolume stops being comparable across them. +`assert_scaling_is_campaign_fixed` enforces this and runs inside +`build_objective_transform`, so no transform can bypass it. + +## Current parameters + +| Setting | Value | Config key | +|---|---|---| +| UCB beta (R1) | 36.0 | `rounds.r1.beta` | +| Local penalization radius | 0.35 | `local_penalization.radius` | +| Minimum batch spacing | 0.15 | `local_penalization.min_batch_distance` | +| Candidate pool | 32768 | `rounds.*.candidate_pool_size` | +| Posterior samples (R1) | 256 | `rounds.r1.posterior_samples` | +| MC samples (R2) | 128 | `rounds.r2.mc_samples` | +| GP variant | `dim_scaled_prior` | `model.variant` | +| Seed | 73 | `reproducibility.seed` | + +All of these are campaign configuration, not code. Tuning them does not require +touching the algorithm. + +The ten input grids hold 11/10/11/13/21/17/18/11/21/17 values, so the full +Cartesian product is 396,945,008,460 recipes. It must never be materialised — +that is what the sampled candidate pool and the discrete local search are for. + +**Constraints are config too, and the live campaign declares three.** They are +enforced by filtering the candidate pool before any acquisition scores it, and +re-checked independently when the batch is validated: -- **Memory issues**: For large datasets, consider using CPU instead of GPU or reducing batch sizes -- **Convergence issues**: The LOOCV function may have trouble converging on small noisy datasets -- **CUDA out of memory**: Reduce batch size or use CPU mode - -## Next Steps - -1. **Try the demo**: `mobo-kit --csv data/processed/configCSV_example.csv --verbose` -2. **Generate initial experiments**: `mobo-kit generate --config configs/demo_config.yaml --n-samples 20 --out my_experiments.csv` -3. **Explore Jupyter notebooks** in the `notebooks/` directory -4. **Check configuration examples** in the `configs/` directory - -## Citation - -*Citation information will be added upon publication.* +```yaml +constraints: + # a second spin stage either happens or it does not + - zero_coupled: [speed_2, time_2] + # the antisolvent has to land while the substrate is still spinning + - sum_upper_strict: {lhs: anti_time, rhs: [time_1, time_2]} + # and if it happens, it runs for at least 10 s + - nonzero_minimum: {column: time_2, minimum: 10} +``` ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +MIT — see [LICENSE](LICENSE). -## Get in Touch +## Get in touch -For questions, issues, or contributions, please: -- Open an issue on GitHub -- Contact the development team -- Check the documentation in the `notebooks/` directory +Open an issue on GitHub, or contact the development team. diff --git a/configs/campaign_d2d_perovskite.yaml b/configs/campaign_d2d_perovskite.yaml new file mode 100644 index 0000000..1a0f9ff --- /dev/null +++ b/configs/campaign_d2d_perovskite.yaml @@ -0,0 +1,369 @@ +# D2D campaign configuration - FA0.9Cs0.1PbI3 slot-die/spin campaign. +# +# Rounds: R0 LHS (15, complete) -> R1 UCB-HVI (5 conditions) -> R2 qLogNEHVI (3). +# Each proposed condition is run in triplicate; the three films share a +# replicate_group and are aggregated to one condition-level observation before +# the next round trains on them. +# +# Objective rationale and the measurements behind it are in +# docs/GP_MODEL_DECISION.md. The short version: thickness trains on raw +# nanometres and the 650 nm Gaussian is applied to the posterior, because the +# score itself is a 2-to-1 folded transform that destroys learnable signal. +campaign: + name: D2D_FA0.9Cs0.1PbI3 + status: archived # superseded by campaign_d2d_perovskite_test.yaml, 2026-08-17 + schema_version: d2d-campaign-v2 + workbook_profile: d2d_summary_v3_scores + +inputs: + - name: speed_1 + unit: rpm + start: 1000 + stop: 6000 + step: 500 + - name: time_1 + unit: s + start: 5 + stop: 50 + step: 5 + - name: speed_2 + unit: rpm + start: 0 + stop: 5000 + step: 500 + - name: time_2 + unit: s + start: 10 + stop: 60 + step: 5 + - name: precur_conc + unit: M + start: 1.00 + stop: 2.00 + step: 0.05 + - name: precur_vol + unit: uL + start: 40 + stop: 200 + step: 10 + - name: anneal_temp + unit: C + start: 100 + stop: 185 + step: 5 + - name: anneal_time + unit: min + start: 10 + stop: 60 + step: 5 + - name: anti_vol + unit: uL + start: 100 + stop: 200 + step: 5 + - name: anti_time + unit: s + start: 9 + stop: 25 + step: 2 + +# All three utilities are maximised after transformation. `transform` maps a +# model output to utility; the two differ only for thickness. +# +# `measurement` is what the GP trains on: a recipe plus the raw measurement +# columns it consumes, computed in Python. `model_source_column` names the +# workbook cell that USED to be read directly and is now only a label and a +# cross-check target. Three of those cells are pasted literals rather than +# formulas, so they do not update when the measurements behind them change -- +# see docs/CAMPAIGN_STATUS.md issue 2 for the audit. +objectives: + contract_version: d2d-objectives-v2-nm-thickness + # Scales are FIXED for the whole campaign and must never be re-derived from + # observed data. If a scale tracks the data, utility space moves between + # rounds and hypervolume stops being comparable - the progress plot silently + # becomes meaningless. Widen a range deliberately and bump contract_version; + # never let it follow the measurements. + scaling_mode: fixed_affine + specs: + - name: uniformity + model_source_column: "Uniformity score" # workbook Z = L*N*O + transform: affine + goal: maximize + # Computed from Coverage (L), Uniformity (M) and Phase purity (O). The + # workbook multiplies by its "1 - Uniformity" column (N) instead, which is + # itself a pasted literal; taking the complement of M here means N is never + # trusted, and because Z is built from N the cross-check below will fire if + # the two ever stop agreeing. + measurement: + recipe: product + inputs: + - {column: "Coverage"} + - {column: "Uniformity", transform: complement} + - {column: "Phase purity"} + # Z is a live formula, so it should agree to floating-point noise. It does: + # 0.0 difference on all 15 R0 rows. + cross_check: + - {column: "Uniformity score", atol: 1.0e-9} + # fixed campaign scale, NOT recomputed per round; hypervolume would + # otherwise be incomparable between rounds + lower_anchor: 0.0 + upper_anchor: 1.0 + # No learnable signal from the 15 R0 points (permutation p = 0.82). + # R1 is exploration-dominated for this objective by design. + signal_status: exploration_only + + - name: optoelectronic + model_source_column: "Optoelectronic score" # workbook AA = LOG10(P*Q) + transform: affine + goal: maximize + # log10(photoconductance x implied Voc); observed R0 span -9.14 to -7.27 + lower_anchor: -10.0 + upper_anchor: -6.0 + signal_status: learnable + # A single linear term on anneal_temp and nothing else. Six forms were + # tested; every addition made it worse, and an Arrhenius 1/T form bought + # nothing over plain linear temperature (+0.226 vs +0.244). anneal_temp is + # also the least search-contaminated choice: it came from a marginal + # correlation already on record (rho = -0.651, p = 0.009), not from the + # search over mean shapes -- and it happens to win anyway. + # Plain GP -0.342 (worse than the -0.148 null) -> structured +0.355. + # + # Computed as log10(P) + log10(Q), which is log10(P*Q) without an + # intermediate product that could overflow. Checked against both the live + # formula in R and the pasted literal in AA: a full-precision paste agrees to + # 1.8e-15 today, so a tight tolerance is what makes a stale paste audible. + # P and Q are confirmed never zero or blank for a measured film; if one ever + # is, the recipe errors rather than producing a silent -inf. + measurement: + recipe: log10_product + inputs: + - {column: "PL - Implied Voc (Max)"} + - {column: "Photoconductance (Max)"} + cross_check: + - {column: "Optoelectronic score", atol: 1.0e-9} + - column: "Log10 (Photoconductance (Max) x PL - Implied Voc (Max))" + atol: 1.0e-9 + mean_function: + response: identity + features: + - column: anneal_temp + transform: identity + + - name: thickness + # trains on nanometres, NOT on the workbook thickness score + model_source_column: "Thickness (avg)" # workbook X, nm + transform: gaussian_target + goal: target + target: 650.0 + # the workbook writes EXP(-(((T-650)/250)^2)), which has no factor of 1/2; + # in the exp(-0.5*((T-c)/sigma)^2) convention used here that is 250/sqrt(2) + sigma: 176.7766952966369 + equivalent_workbook_formula: "EXP(-(((X-650)/250)^2))" + signal_status: learnable + # Spin-coating theory gives T ~ omega^-0.5; the measured exponent is -0.38. + # NOTE the opposite pattern to optoelectronic: neither term alone is worth + # much here (+0.159, +0.187), the PAIR carries the signal. Do not assume + # either shape generalises to a new objective. + # Plain GP +0.183 -> structured +0.384. + # + # Computed as the mean of whichever of T1..T4 were measured -- nine R0 rows + # have two readings, three have three, three have four, so requiring all four + # would reject the campaign. Blank means not measured, never zero. + # + # "T anom" holds readings the operator judged anomalous (sample 4: 1618 + # against its own 650/655/670/680; sample 14: 630). They never enter the mean; + # their presence is reported so the exclusion is visible rather than silent. + # CONFIRMED by the group 2026-07-31: the operator's judgement is the intended + # filter, so these stay excluded and stay reported. + # + # X is ROUND(mean(T1..T4)), so it can legitimately differ by half a + # nanometre -- hence atol 0.5 rather than a tight tolerance. Against + # sigma = 176.8 nm that rounding moves the utility by under 1e-5. + # + # spread_warning_ratio fires on samples 8, 12 and 15, whose readings split + # into two clusters rather than scattering: sample 12 is 1600 and 709, and its + # recorded 1155 nm is the midpoint of the two. Sample 12 also carries the + # highest leverage in the design (0.462), so this is worth hearing about. + # CONFIRMED by the group 2026-07-31: that variation is real and the MEAN is + # the intended summary, so no re-derivation, exclusion or re-weighting. The + # warning stays on anyway -- what was settled is the action, not the fact, and + # a row whose readings split 2.3-fold is still a different kind of observation + # from one whose readings agree to 3%. + measurement: + recipe: mean_of_present + inputs: + - {column: "T1"} + - {column: "T2"} + - {column: "T3"} + - {column: "T4"} + excluded: + - {column: "T anom"} + cross_check: + - {column: "Thickness (avg)", atol: 0.5} + spread_warning_ratio: 0.25 + # response: log makes the model output lognormal, so the utility + # expectation must use Gauss-Hermite quadrature, not the Gaussian closed + # form -- moment-matching there is 506x less accurate and its bias changes + # sign across the range, which reorders candidates. + # + # The three replicate films of one condition are averaged in LOG SPACE, for + # the same reason train_Yvar is pooled there: `response: log` means the GP + # trains on log(T), so the geometric mean is the arithmetic mean in the space + # the model actually works in. The difference from a plain mean is second + # order in the replicate spread -- under 0.1% at the ~3% spread most R0 rows + # show, about 14% on a film set as inconsistent as sample 12's. Change this + # one key if the group prefers the arithmetic mean of nanometres. + replicate_aggregate: mean_of_log + mean_function: + response: log + features: + - column: speed_1 + transform: log + - column: precur_conc + transform: log + +# Reference point in UTILITY space, after the transforms above. Every objective +# is on a comparable [0, 1]-ish scale there, so no axis silently dominates the +# hypervolume. On the previous raw-scale reference [-0.01, -10.0, -0.01] the +# optoelectronic axis was 4.01x the uniformity axis. +reference_point_utility: [-0.01, -0.01, -0.01] + +rounds: + r1: + method: ucb_hvi + batch_size: 5 + replicates_per_condition: 3 + beta: 4.0 + candidate_pool_size: 32768 + posterior_samples: 256 + # thickness utility is a nonlinear function of the model output, so utility + # moments must come from posterior samples, not from moments of the mean + moment_method: monte_carlo + r2: + method: qlognehvi + batch_size: 3 + replicates_per_condition: 3 + candidate_pool_size: 32768 + mc_samples: 128 + sequential_pending: true + +# What the batch-review artifact interrogates and states. Campaign knowledge, so +# it lives here rather than in batch_review.py. +review: + # A probe holds every other coordinate of each proposed candidate and forces one + # input to a value worth asking about. The sd comparison is the informative part: + # UCB rewards uncertainty, so a region the batch skips while the model still + # calls it uncertain is losing a trade-off, whereas a region the batch skips + # while the model calls it certain has been resolved -- possibly into an average. + probes: + - name: low-speed corner + column: speed_1 + value: 1000 + note: > + Samples 1 and 12 are the only two observations at speed_1 = 1000 and they + contradict each other: sample 1 has the higher concentration (1.4 against + 1.1) but the thinner film (687 against 1155 nm), inverting the expected + relationship. Sample 12 also carries the highest leverage in the design + (0.462), and its 1155 nm is ROUND(mean(1600, 709)) -- two readings a factor + of 2.26 apart. So before this corner is read as a model conclusion, it is a + measurement question. `log(speed_2 + 1)` does not explain the inversion + (LOO R2 +0.449 -> -0.827); do not add it. See docs/GP_MODEL_DECISION.md. + + notes: + - > + anneal_temp at or near 100 C in every proposed condition is the mean function + speaking, not a discovery. The optoelectronic objective carries a monotone + linear mean on anneal_temp with a negative slope (marginal rho = -0.651, + p = 0.009), and a monotone trend puts its optimum at a range edge by + construction. The open question is chemical: if the group would never anneal + below some temperature, that is a bound to declare in `constraints:` (which + is currently empty) rather than something to discover from a shipped batch. + - > + Uniformity is exploration-only. Nothing beat the leave-one-out null across + ~240 model configurations at N=15 (permutation p = 0.82), so its predicted + utility below carries no signal and should not be read as one. Whether that + is physics or measurement noise is answerable from the R1 replicates. + - > + WITHDRAWN AND REISSUED. Any R1 batch described before 2026-07-31 is void. + run_r1_ucb was scoring candidates against an observed baseline whose + thickness axis had collapsed to zero -- it handed the objective transform + nanometres where the transform expects log(nm) and exponentiates -- so the + baseline hypervolume was 0.004659 against a true 0.436442. Four of the five + conditions are unchanged; the fifth moves from speed_1 4000 / precur_conc + 1.70 to speed_1 2500 / precur_conc 1.45, and the batch's minimum spacing + from 0.9209 to 0.6337. No films were fabricated from the withdrawn batch. + Full diff in docs/R1_BATCH_WITHDRAWAL.md; the defect is fixed in commit + 4b76670. Delete this note once R1 is measured. + +local_penalization: + distance_metric: normalized_euclidean + dimension_weights: null + radius: 0.25 + min_batch_distance: 0.15 + min_observed_distance: 0.0 + +model: + variant: dim_scaled_prior + # R0 has no film replicates. Once R1 triplicates land, pool their + # within-condition variance (5 conditions x 2 dof = 10 dof) and pass it as + # train_Yvar. + # + # Pool thickness variance in LOG SPACE, not in nanometres: `response: log` + # above means the GP trains on log(T), so train_Yvar must be the variance of + # log(T). Passing a variance in nm^2 would be wrong by a factor of T^2 -- + # roughly 1.3e5 at 360 nm and 1.7e6 at 1303 nm, so it is not even a constant + # rescaling across the observed range. + # + # The R0 rows are not entirely without a noise handle: each has 2-4 thickness + # points (T1..T4). Their pooled within-row variance of log(T) is 0.0593 + # (sd 0.244, 24 dof), against a total observed log(T) span of 1.86. That is + # within-FILM spatial spread, not film-to-film reproducibility, so it is a + # floor on the noise rather than an estimate of it -- and it is dominated by + # three rows (samples 8, 12, 15 at sd_log 0.137-0.584; the other twelve are + # all under 0.048). Decide deliberately whether to use it for the R0 rows. + # + # Applying an R1-derived estimate to the R0 rows assumes the measurement + # process is unchanged between rounds; that assumption is recorded here + # deliberately rather than left implicit. + # + # Stays `fit_from_marginal_likelihood` until the R1 triplicates land. The + # machinery is wired and tested (replicate_variance.py); it needs data, not code. + observation_noise: fit_from_marginal_likelihood + + replicate_variance: + # TWO DIFFERENT VARIANCES. Do not substitute one for the other. + # + # BETWEEN-FILM is what train_Yvar needs: two films from the same recipe differ + # by everything that varies run to run. Only measurable once R1 ships + # triplicates (5 conditions x 2 dof = 10 dof). + # + # WITHIN-FILM is the scatter of the 2-4 thickness points across one film -- + # measurement plus spatial nonuniformity. Available today: pooled over the R0 + # rows it is 0.0593 on log(T), 24 dof. It contains NO run-to-run variation, so + # it is a FLOOR, not an estimate. If pooled between-film variance ever comes + # out below it, films would be more reproducible than points on a single film, + # which is not a thing -- so it indicates a measurement or pooling mistake. + sanity_floor: + thickness: 0.0593 + # Film count assumed for rows that have no replicates -- the R0 rows, which + # predate the triplicate policy. 1 means their observation carries the full + # between-film variance rather than a third of it. + rows_without_replicates: 1 + +reproducibility: + seed: 73 + record_git_commit: true + record_environment_versions: true + record_resolved_config_hash: true + +# EMPTY DELIBERATELY, confirmed by the group 2026-07-31. No process constraint +# applies to this campaign; the legacy humidity/temperature Clausius-Clapeyron +# constraint is for a different design. +# +# This is a decision, not an omission, and it is the reason `anneal_temp` is free +# to sit at its lower bound in every proposed condition. The optimum of a monotone +# mean function is at a range edge by construction, so if the group would never +# anneal below some temperature, the fix is a bound DECLARED here rather than a +# batch quietly discarded later. Adding one is a one-key change; see +# docs/CAMPAIGN_STATUS.md issue 4. +constraints: [] diff --git a/configs/campaign_d2d_perovskite_extended_c1c2.yaml b/configs/campaign_d2d_perovskite_extended_c1c2.yaml new file mode 100644 index 0000000..26fd525 --- /dev/null +++ b/configs/campaign_d2d_perovskite_extended_c1c2.yaml @@ -0,0 +1,167 @@ +# DIAGNOSTIC ONLY. Not a campaign contract, and nothing here proposes films. +# +# The workbook is local_inputs/Extended Summary Table C1C2.xlsx (gitignored), +# supplied 2026-09-03 as "summary table with extended dataset from Campaign 1 & 2". +# It holds 45 rows, and the 45 rows are 15 RECIPES MEASURED THREE TIMES: samples +# 1-15, 16-30 and 31-45 carry identical inputs, recipe for recipe. That makes this +# the first dataset in the project that can separate "the recipe did it" from "the +# measurement did it", which is the whole reason for reading it. +# +# WHY IT IS NOT A CONTRACT. +# * The optoelectronic definition MOVED. AK is now `=R2*X2*AA2`, a raw product +# of clamped Voc (V) x floor-corrected photoconductance (S) x capped +# photosensitivity -- not the normalised mean the v4 contract fingerprints. +# Its values span 6.1e-11 to 6.2e-6, five orders of magnitude, and are not in +# [0, 1]. The v4 anchors would map every film to utility 0. +# * Samples 17 and 32 are `speed_2 = 0, time_2 = 60`, which breaks +# second_stage_all_or_nothing. Sample 2 is the same recipe with the group's +# correction (time_2 = 0) applied. The correction reached one of the three +# replicates, not all three. +# * The scores are used AS STORED, all three of them, per the request. That +# includes thickness, which every real contract trains on in nanometres. +# +# Read the intake report before quoting any number out of this file. +campaign: + name: D2D_FA0.9Cs0.1PbI3_extended_c1c2_diagnostic + status: diagnostic + schema_version: d2d-campaign-v4 + workbook_profile: d2d_summary_final_v4 + source_sheet: R0 + +# UNCHANGED from v4. All 45 rows land on these grids. +inputs: + - {name: speed_1, unit: rpm, start: 1000, stop: 6000, step: 500} + - {name: time_1, unit: s, start: 5, stop: 50, step: 5} + - {name: speed_2, unit: rpm, start: 0, stop: 5000, step: 500} + - {name: time_2, unit: s, start: 0, stop: 60, step: 5} + - {name: precur_conc, unit: M, start: 1.00, stop: 2.00, step: 0.05} + - {name: precur_vol, unit: uL, start: 40, stop: 200, step: 10} + - {name: anneal_temp, unit: C, start: 100, stop: 185, step: 5} + - {name: anneal_time, unit: min, start: 10, stop: 60, step: 5} + - {name: anti_vol, unit: uL, start: 100, stop: 200, step: 5} + - {name: anti_time, unit: s, start: 9, stop: 25, step: 1} + +objectives: + contract_version: d2d-objectives-v5-extended-diagnostic + scaling_mode: fixed_affine + specs: + - name: uniformity + model_source_column: "Uniformity score (Avg (Coverage + (1-Uniformity) + Phase purity))" + transform: affine + goal: maximize + measurement: + recipe: stored + inputs: + - {column: "Uniformity score (Avg (Coverage + (1-Uniformity) + Phase purity))"} + formula_fingerprint: + column: "Uniformity score (Avg (Coverage + (1-Uniformity) + Phase purity))" + formula: "=(L2+O2+P2)/3" + lower_anchor: 0.0 + upper_anchor: 1.0 + signal_status: exploration_only + + - name: optoelectronic + model_source_column: "Optoelectronic score (Normalized (Voc + (0.75*Photoconductance + 0.25*Photosensitivity))/2" + transform: affine + goal: maximize + measurement: + recipe: stored + inputs: + - {column: "Optoelectronic score (Normalized (Voc + (0.75*Photoconductance + 0.25*Photosensitivity))/2"} + # DELIBERATELY the NEW formula. Fingerprinting the v4 one here would make + # every run of this diagnostic shout about a change the group already made. + # The v4 config still fingerprints `=(S2+((0.75*Y2)+(0.25*AB2)))/2`, so the + # change stays audible where it matters -- on the campaign contract. + formula_fingerprint: + column: "Optoelectronic score (Normalized (Voc + (0.75*Photoconductance + 0.25*Photosensitivity))/2" + formula: "=R2*X2*AA2" + agreement_check: + raw: "Photoconductance (Max - based on raw slopes)" + normalized: "Normalized photoconductance (test)" + min_spearman: 0.0 + # NOT [0, 1]. A raw triple product is not a normalised score, and anchoring it + # at [0, 1] would put all 45 films on top of each other at utility ~1e-6. + # These anchors bracket the observed span (6.06e-11 .. 6.22e-06) and are + # declared, not derived per round -- but they are declared FROM THIS DATA, + # which is exactly what a campaign contract must never do. Diagnostic only. + lower_anchor: 0.0 + upper_anchor: 7.0e-06 + signal_status: exploration_only + + - name: thickness + # AS STORED, per the request: this is AL = AI = EXP(-(((AH-650)/250)^2)), the + # workbook's normalised thickness, NOT the nanometres every contract trains + # on. The Gaussian target is therefore already applied before the GP sees it, + # which folds a hard non-monotone transform into the response. + model_source_column: "Thickness score (normalized of avg)" + transform: affine + goal: maximize + measurement: + recipe: stored + inputs: + - {column: "Thickness score (normalized of avg)"} + formula_fingerprint: + column: "Thickness score (normalized of avg)" + formula: "=AI2" + lower_anchor: 0.0 + upper_anchor: 1.0 + signal_status: learnable + +reference_point_utility: [-0.01, -0.01, -0.01] + +rounds: + r1: + method: ucb_hvi + batch_size: 5 + replicates_per_condition: 3 + beta: 36.0 + candidate_pool_size: 32768 + posterior_samples: 256 + moment_method: monte_carlo + r2: + method: qlognehvi + batch_size: 3 + replicates_per_condition: 3 + candidate_pool_size: 32768 + mc_samples: 128 + sequential_pending: true + +# Kept so the samples 17 / 32 violation is REPORTED rather than absorbed. +constraints: + - zero_coupled: [speed_2, time_2] + name: second_stage_all_or_nothing + - sum_upper_strict: {lhs: anti_time, rhs: [time_1, time_2]} + name: antisolvent_lands_while_spinning + - nonzero_minimum: {column: time_2, minimum: 10} + name: second_stage_runs_at_least_10s + +review: + probes: [] + notes: + - > + 45 rows, 15 recipes, three replicates each. Ordinary leave-one-out on this + sheet LEAKS: hold out row 4 and rows 19 and 34 carry the same inputs, so the + GP interpolates its own replicate and the R2 measures reproducibility rather + than prediction. Leave-one-RECIPE-out (all three rows together) is the + honest test. Both are reported; do not quote the row-wise one alone. + +local_penalization: + distance_metric: normalized_euclidean + dimension_weights: null + radius: 0.35 + min_batch_distance: 0.15 + min_observed_distance: 0.0 + +model: + variant: dim_scaled_prior + observation_noise: fit_from_marginal_likelihood + replicate_variance: + sanity_floor: + thickness: 0.003007 + rows_without_replicates: 1 + +reproducibility: + seed: 73 + record_git_commit: true + record_environment_versions: true + record_resolved_config_hash: true diff --git a/configs/campaign_d2d_perovskite_final.yaml b/configs/campaign_d2d_perovskite_final.yaml new file mode 100644 index 0000000..0bf876a --- /dev/null +++ b/configs/campaign_d2d_perovskite_final.yaml @@ -0,0 +1,396 @@ +# D2D FINAL campaign - FA0.9Cs0.1PbI3 slot-die/spin. THE REAL RUN. +# +# Rounds: R0 (15 measured, complete) -> R1 UCB-HVI (5 conditions) -> R2 qLogNEHVI (3). +# Each proposed condition is run in triplicate. +# +# THREE CONTRACTS HAVE EXISTED. Naming them is not bookkeeping -- utility space is +# what hypervolume is measured in, and an objective that keeps its name while +# changing its construction makes every cross-contract number incomparable while +# every plot still renders. +# +# v2 d2d-objectives-v2-nm-thickness ALGORITHM TESTING. Proved the loop worked. +# v3 d2d-objectives-v3-test DRY RUN on test data. Rehearsed this +# contract's shape; the workbook was +# literally called "Test". +# v4 d2d-objectives-v4-final THIS ONE. The campaign that produces films. +# +# Nothing from v2 or v3 transfers. Their fitted numbers are about quantities that +# have been redefined twice. +# +# The workbook is local_inputs/Final Summary Table.xlsx (gitignored). Its columns +# moved again: the three scores are now AJ / AK / AL, and the thickness readings +# are AC..AF with AG holding the operator's anomalies. +campaign: + name: D2D_FA0.9Cs0.1PbI3_final + status: active + schema_version: d2d-campaign-v4 + workbook_profile: d2d_summary_final_v4 + # NEW KEY. The workbook names its sheets by round now, so the sheet holding the + # measured rows is campaign configuration rather than the hard-coded "Sheet1" + # every earlier contract assumed. + # + # The workbook also carries an `R1` sheet. IT IS DELIBERATELY NOT READ. The + # round contract is unchanged: each round's worklist is written to a NEW file + # beside the workbook and filled in there, and the source workbook is never + # opened for writing. That sheet is the group's own template and the tool + # ignores it. + source_sheet: R0 + +# Ten inputs, columns B..K of the R0 sheet, in this order. GRIDS UNCHANGED from +# v3 -- verified, all 15 measured rows land on them. +inputs: + - name: speed_1 + unit: rpm + start: 1000 + stop: 6000 + step: 500 + - name: time_1 + unit: s + start: 5 + stop: 50 + step: 5 + - name: speed_2 + unit: rpm + start: 0 + stop: 5000 + step: 500 + - name: time_2 + unit: s + start: 0 + stop: 60 + step: 5 + - name: precur_conc + unit: M + start: 1.00 + stop: 2.00 + step: 0.05 + - name: precur_vol + unit: uL + start: 40 + stop: 200 + step: 10 + - name: anneal_temp + unit: C + start: 100 + stop: 185 + step: 5 + - name: anneal_time + unit: min + start: 10 + stop: 60 + step: 5 + - name: anti_vol + unit: uL + start: 100 + stop: 200 + step: 5 + - name: anti_time + unit: s + start: 9 + stop: 25 + step: 1 + +# UNIFORMITY AND OPTOELECTRONIC ARE READ AS STORED. The workbook's score column +# IS the objective value. +# +# HOW THOSE SCORES ARE CONSTRUCTED IS NOT RECORDED HERE, BY DECISION. +# +# The SCORE VALUE is the interface. How a composite is defined -- which terms, +# what weighting, which normalisation -- is a choice this group makes for itself, +# and another group running MOBO-Kit on the same chemistry may define uniformity +# completely differently and be equally right. What is shared is the contract's +# SHAPE: one number per objective per film, on a declared scale. Encoding this +# group's particular arithmetic in the tool would make the tool quietly specific +# to us. It also gave the definition a second place to live and go stale, which +# happened three times. +# +# WHAT THIS COSTS: no independent recomputation, so a stale pasted literal in +# either column cannot be caught by comparing it against anything. That is the +# price of the freeze and it is paid deliberately. The `formula_fingerprint` below +# is the partial replacement and is NOT documentation -- it is a runtime alarm +# that fires when the column's formula TEXT changes, which is the event that makes +# hypervolume incomparable across rounds. It records what the formula was so that +# a change is audible; it does not claim to explain it. +# +# Thickness is the exception and is COMPUTED, because the recomputation is what +# lets an anomalous `T anom` reading be excluded AND NAMED, and because the model +# trains on nanometres rather than on the stored score. +objectives: + contract_version: d2d-objectives-v4-final-nomean + scaling_mode: fixed_affine + specs: + - name: uniformity + model_source_column: "Uniformity score (Avg (Coverage + (1-Uniformity) + Phase purity))" + transform: affine + goal: maximize + # Read, never recomputed. + measurement: + recipe: stored + inputs: + - {column: "Uniformity score (Avg (Coverage + (1-Uniformity) + Phase purity))"} + # A runtime alarm, not documentation: it fires if this column's formula + # text changes, because that is the event that silently redefines the + # objective and makes hypervolume incomparable across rounds. + formula_fingerprint: + column: "Uniformity score (Avg (Coverage + (1-Uniformity) + Phase purity))" + formula: "=(L2+O2+P2)/3" + # The score is a fraction, so [0, 1] is its range rather than a guess. + # Observed span on the 15 rows: 0.599 to 0.882. + lower_anchor: 0.0 + upper_anchor: 1.0 + # MEASURED ON THESE ROWS. Plain GP LOO R2 -0.4688 against a null of -0.1480: + # no learnable signal, for the third contract running. R1 is + # exploration-dominated for this objective by design. + # + # The workbook was revised on 2026-09-03: `Phase purity` moved on samples 4 + # and 12 (0.7951 -> 0.7997 and 0.7800 -> 0.7882), which moves this column + # through `=(L+O+P)/3`. The number was -0.4778 on the previous copy. Nothing + # else on the sheet changed, and no verdict moved: the shift is 0.0090 + # against a resolution sd of 0.236. + signal_status: exploration_only + + - name: optoelectronic + model_source_column: "Optoelectronic score (Normalized (Voc + (0.75*Photoconductance + 0.25*Photosensitivity))/2" + transform: affine + goal: maximize + # Read, never recomputed. + # + # The agreement check below stays because it caught a real defect once: on the + # v3 workbook the normalised photoconductance column ranked BACKWARDS against + # its own raw measurement (Spearman -0.5484, p = 0.0343). It is +1.0000 here. + # The check is cheap and the failure it watches for is silent when it recurs. + measurement: + recipe: stored + inputs: + - {column: "Optoelectronic score (Normalized (Voc + (0.75*Photoconductance + 0.25*Photosensitivity))/2"} + formula_fingerprint: + column: "Optoelectronic score (Normalized (Voc + (0.75*Photoconductance + 0.25*Photosensitivity))/2" + formula: "=(S2+((0.75*Y2)+(0.25*AB2)))/2" + agreement_check: + raw: "Photoconductance (Max - based on raw slopes)" + normalized: "Normalized photoconductance (test)" + min_spearman: 0.0 + # The score is a fraction, so [0, 1] is its range rather than a guess. + # Observed span on the 15 rows: 0.477 to 0.762. + lower_anchor: 0.0 + upper_anchor: 1.0 + # MEASURED ON THESE ROWS, 2026-09-02. Plain GP LOO R2 -0.7038 against a null + # of -0.1480 -- further below it than on v3 (-0.5842), even though the + # photoconductance inversion that made v3's version suspect is fixed. The + # renormalisation did not make this axis learnable, and no mean function is + # declared: the first campaign's anneal_temp trend was deleted on v3's intake + # verdict and nothing since has argued for reinstating it. + signal_status: exploration_only + + - name: thickness + # trains on nanometres, NOT on the workbook thickness score + model_source_column: "Thickness (avg)" + transform: gaussian_target + goal: target + target: 650.0 + # the workbook writes EXP(-(((AH-650)/250)^2)), which has no factor of 1/2; + # in the exp(-0.5*((T-c)/sigma)^2) convention used here that is 250/sqrt(2) + sigma: 176.7766952966369 + equivalent_workbook_formula: "EXP(-(((AH-650)/250)^2))" + # The only objective with signal, for the third contract running: plain GP + # +0.5814. + # + # NOTE THAT -0.1480 IS NOT THE BAR. See "The leave-one-out null was never a + # significance threshold" in docs/CAMPAIGN_STATUS.md -- 28.7% of pure-noise + # shuffles beat it. This axis earns `learnable` on its RANK PERMUTATION TEST, + # which was always the right instrument and is now the only one. + # + # RE-MEASURED 2026-09-06 on the PLAIN GP, after the mean function was + # withdrawn, because the earlier verdict was measured with it: + # + # observed rank rho +0.8036 + # null mean -0.1762 (sd 0.4070) + # null 95th pct +0.5607 <- the honest single-candidate bar + # exceedances 9 of 1800 + # p 0.0056 95% CI [0.0021, 0.0090] + # + # Identical p to the structured version's, and the observed rank clears the + # null's own 95th percentile by 0.24. Removing the prior cost R2, not the + # verdict. + signal_status: learnable + # NOT frozen, and the only objective still computed in Python. Its definition + # has been stable across all three contracts and the recomputation earns its + # place: it is what lets `T anom` be excluded from the mean and reported + # rather than silently dropped. + # + # The readings moved to AC..AF and the anomaly column to AG. Rows carry three + # or four readings, so `mean_of_present` is what the data needs. + # + # `Thickness (avg)` is a live unrounded AVERAGE, so anything above + # floating-point noise is a real disagreement -- hence the tight tolerance. + measurement: + recipe: mean_of_present + inputs: + - {column: "T1"} + - {column: "T2"} + - {column: "T3"} + - {column: "T4"} + excluded: + - {column: "T anom"} + cross_check: + - {column: "Thickness (avg)", atol: 1.0e-9} + spread_warning_ratio: 0.25 + replicate_aggregate: mean_of_log + # NO MEAN FUNCTION. Withdrawn 2026-09-06 by the group's decision, after the + # physics justification failed its one clean test. + # + # What was here: `log T ~ log(speed_1) + log(precur_conc)`, declared from + # spin-coating theory and carried unchanged through all three contracts. It + # measured +0.7423 against the plain GP's +0.5823. + # + # WHY IT WENT. The supporting claim was that the fitted speed exponent agreed + # with Meyerhofer's `T ~ omega^-0.5`. It does not. OLS on the 15 films gives + # the exponent as -0.2554 with a standard error of 0.0593, a 95% interval of + # [-0.385, -0.126] -- the textbook -0.5 sits OUTSIDE it, 4.1 standard errors + # away. Fixing the exponents at their theoretical values (-0.5 and +1.0) and + # fitting only an intercept scores +0.5600, WORSE than having no trend at all. + # So the theory does not predict these films; the concentration exponent + # (+1.313, CI [0.932, 1.694]) does contain the mass-balance +1.0, which is + # consistent with the antisolvent quench freezing the film before viscous + # thinning completes. + # + # WHAT WAS TRUE, and is worth recording because it is the argument for ever + # bringing this back: the VARIABLE choice was real even though the magnitudes + # were not physics. Four matched-flexibility control pairs -- three fitted + # parameters each, physically unmotivated -- scored +0.3706, +0.2960, +0.3174 + # and +0.4033, all BELOW the plain GP. It is not the case that any fitted + # two-term trend helps; most actively hurt. + # + # THE COST, stated plainly: thickness LOO R2 falls from +0.7423 to +0.5823 and + # rank from +0.864 to +0.804. It remains the only objective with signal and it + # still clears its rank permutation test. The gain never had a permutation test + # of its own, only an R2 comparison, which this project now knows is the weaker + # instrument. + # + # `structured_mean.py` stays in the package, wired and tested, for a future + # prior that earns its place. Reproduce the numbers above with: + # python scripts/raw_component_screen.py --candidates thickness_nm + +# Reference point in UTILITY space, carried over verbatim. All three objectives +# live natively in [0, 1], so no axis dominates. +reference_point_utility: [-0.01, -0.01, -0.01] + +rounds: + r1: + method: ucb_hvi + batch_size: 5 + replicates_per_condition: 3 + # LOWERED from 36 to 4 on 2026-09-03, by the group's decision. + # + # 36 was chosen for the v3 dry run on the argument that two of three + # objectives carried no learnable signal, so heavy exploration was the right + # posture. That argument has been overtaken. On the extended C1&C2 sheet the + # two dead axes are dead for reasons beta cannot address: the optoelectronic + # score is 84.5% between-campaign drift (recipe ICC 0.000, and the GP refuses + # to fit it at all), and uniformity is reproducible (ICC 0.730) but not + # predictable from ten inputs at fifteen distinct recipes. Exploring harder + # buys nothing against either, and it costs real batch quality. + # + # What it costs, measured on this workbook at seed 73 (scripts in + # docs/, reproduced by the beta scan): + # + # beta radius HV gain edge coords/50 spacing sd ratio + # 4 0.25 +0.0009 11 0.781 5.8x + # 9 0.25 +0.0000 15 0.882 6.2x + # 36 0.25 +0.0000 18 1.213 6.5x + # + # `edge coords` counts how many of the 5 x 10 proposed coordinates are pinned + # to a grid bound. At 36 the acquisition spends the batch on the corners of + # the box; at 4 it proposes films that sit inside it. beta = 4 is also the + # value the original 108-cell box-plot/heat-map sweep settled on before v3's + # no-signal verdict overrode it. + # + # STILL A DECLARED POLICY, NOT A MEASURED OPTIMUM. The +0.0009 HV gain is far + # below the trial sd of 0.010-0.027 that sweep measured; it is a tiebreak, not + # evidence. What IS evidence is the edge count and the spacing, which are + # facts about the batch at a fixed seed rather than single-seed hypervolume. + beta: 4.0 + candidate_pool_size: 32768 + posterior_samples: 256 + moment_method: monte_carlo + r2: + method: qlognehvi + batch_size: 3 + replicates_per_condition: 3 + candidate_pool_size: 32768 + mc_samples: 128 + sequential_pending: true + +# Carried over verbatim from v3. All 15 measured rows satisfy all three -- sample +# 2 was `speed_2 = 0, time_2 = 60` in an earlier draft of this workbook, which +# breaks the first rule; the group corrected it to `time_2 = 0`. +constraints: + - zero_coupled: [speed_2, time_2] + name: second_stage_all_or_nothing + - sum_upper_strict: {lhs: anti_time, rhs: [time_1, time_2]} + name: antisolvent_lands_while_spinning + - nonzero_minimum: {column: time_2, minimum: 10} + name: second_stage_runs_at_least_10s + +review: + probes: [] + + notes: + - > + Uniformity and optoelectronic are read from the workbook as stored. A stale + pasted value in either column would not be caught by anything here; the + formula fingerprints catch a changed definition, not a value that has + stopped tracking its inputs. + +local_penalization: + distance_metric: normalized_euclidean + dimension_weights: null + # 0.35 -> 0.25 on 2026-09-03, together with beta. + # + # THIS KNOB WAS INERT AND NOW IS NOT, which is the whole reason to set it + # deliberately. At beta = 36 the scan returns bit-identical batches at radius + # 0.15, 0.25 and 0.35 -- same spacing 1.213, same 18 edge coordinates, same + # utilities -- because the acquisition was already spreading the batch further + # than any of those radii asked for. At beta = 4 it bites: + # + # radius spacing edge coords/50 + # 0.15 0.682 13 + # 0.25 0.781 11 + # 0.35 0.941 13 + # + # 0.25 is the cell with the fewest coordinates pinned to a grid bound. That is a + # weak reason on its own -- 11 against 13 at one seed -- but it is the only + # discriminating measurement available, and the alternative is inheriting a + # number chosen while the knob did nothing. + radius: 0.25 + min_batch_distance: 0.15 + min_observed_distance: 0.0 + +model: + variant: dim_scaled_prior + # Once the R1 triplicates land, switch to `replicate_pooled` -- one key. + observation_noise: fit_from_marginal_likelihood + + replicate_variance: + # TWO DIFFERENT VARIANCES. Do not substitute one for the other. + # + # BETWEEN-FILM is what train_Yvar needs and is only measurable once R1 ships + # triplicates. WITHIN-FILM is the scatter of the 3-4 thickness points across + # one film, available today, and contains NO run-to-run variation -- so it is + # a FLOOR, not an estimate. If pooled between-film variance ever lands below + # it, films would be more reproducible than points on a single film. + # + # RECOMPUTED ON THIS WORKBOOK, 2026-09-02: 0.003007 on log(T), 36 dof + # (sd 0.0548), against v3's 0.006374. Roughly half, and the reason is visible: + # sample 1's 418.5 reading moved into `T anom` on this sheet, which took that + # film's sd_log from 0.218 to 0.085. Inheriting v3's constant would have set + # the floor twice too high and called an ordinary pooled variance a mistake. + sanity_floor: + thickness: 0.003007 + rows_without_replicates: 1 + +reproducibility: + seed: 73 + record_git_commit: true + record_environment_versions: true + record_resolved_config_hash: true diff --git a/configs/campaign_d2d_perovskite_test.yaml b/configs/campaign_d2d_perovskite_test.yaml new file mode 100644 index 0000000..8493f02 --- /dev/null +++ b/configs/campaign_d2d_perovskite_test.yaml @@ -0,0 +1,479 @@ +# D2D test campaign - FA0.9Cs0.1PbI3 slot-die/spin, SECOND dataset. +# +# Rounds: R0 (15 measured, complete) -> R1 UCB-HVI (5 conditions) -> R2 qLogNEHVI (3). +# Each proposed condition is run in triplicate; the three films share a +# replicate_group and are aggregated to one condition-level observation before +# the next round trains on them. +# +# WHY THIS IS A NEW FILE rather than an edit to campaign_d2d_perovskite.yaml. +# Two of the three objectives are computed differently here -- uniformity is now +# the MEAN of its three terms rather than their product, and optoelectronic is the +# mean of two normalised terms rather than log10 of a product. Utility space is +# what hypervolume is measured in, so an objective that keeps its name while +# changing its construction makes every cross-round and cross-campaign number +# incomparable while every plot still renders. The old config stays as the +# historical record with status: archived, and this one carries a new +# contract_version. Nothing about the first campaign's numbers transfers. +# +# The workbook is local_inputs/Summary Table Test.xlsx (gitignored), whose column +# layout differs from the first campaign's throughout. +campaign: + name: D2D_FA0.9Cs0.1PbI3_test + status: archived # superseded by campaign_d2d_perovskite_final.yaml, 2026-09-02 + schema_version: d2d-campaign-v3 + workbook_profile: d2d_summary_test_v3 + +# Ten inputs, columns B..K of Sheet1, in this order. +# +# TWO GRIDS CHANGED from the first campaign, both forced by the measured rows. +# Everything else is carried over unchanged and every observed value lands on it. +inputs: + - name: speed_1 + unit: rpm + start: 1000 + stop: 6000 + step: 500 + - name: time_1 + unit: s + start: 5 + stop: 50 + step: 5 + - name: speed_2 + unit: rpm + start: 0 + stop: 5000 + step: 500 + # CHANGED: was 10..60. Sample 2 is a one-step film -- speed_2 = 0 and + # time_2 = 0 -- so 0 has to be on the grid or a real recipe is off-grid. + # + # Reaching 0 with step 5 also reaches 5, which the first campaign's grid + # excluded and which no film has ever run. Rather than widen the design space in + # silence, the hole is declared as a `nonzero_minimum` constraint below. An + # explicit value list would express {0} U {10..60} directly, but `lhs` asserts + # that every design grid is uniformly spaced, so an irregular grid would need + # changes to two modules this campaign deliberately leaves alone. + - name: time_2 + unit: s + start: 0 + stop: 60 + step: 5 + - name: precur_conc + unit: M + start: 1.00 + stop: 2.00 + step: 0.05 + - name: precur_vol + unit: uL + start: 40 + stop: 200 + step: 10 + - name: anneal_temp + unit: C + start: 100 + stop: 185 + step: 5 + - name: anneal_time + unit: min + start: 10 + stop: 60 + step: 5 + - name: anti_vol + unit: uL + start: 100 + stop: 200 + step: 5 + # CHANGED: was step 2, giving 9/11/13/.../25. Sample 1 runs anti_time = 12, + # which that grid cannot hold; the first campaign carried it as a declared + # off-grid exception, excluded from pool bookkeeping. Step 1 makes it an + # ordinary observation. The axis goes from 9 values to 17. + - name: anti_time + unit: s + start: 9 + stop: 25 + step: 1 + +# All three utilities are maximised after transformation. `transform` maps a +# model output to utility; the two differ only for thickness. +# +# `measurement` is what the GP trains on: a recipe plus the raw measurement +# columns it consumes, computed in Python. `model_source_column` names the stored +# workbook cell, which is a label and a cross-check target, never an input. +# +# POLICY FOR THIS WORKBOOK, per the group: the stored score columns are +# authoritative and the recompute is the cross-check. Every recipe below +# reproduces its stored column exactly on all 15 rows -- worst disagreement +# 2.3e-13, on thickness, which is floating-point noise -- so the two are the same +# number today. A disagreement is therefore a WARNING finding and not a block: +# the computed value is what the model uses, and a human decides what the +# divergence means. +objectives: + contract_version: d2d-objectives-v3-test + # Scales are FIXED for the whole campaign and must never be re-derived from + # observed data. If a scale tracks the data, utility space moves between rounds + # and hypervolume stops being comparable - the progress plot silently becomes + # meaningless. Widen a range deliberately and bump contract_version; never let + # it follow the measurements. + scaling_mode: fixed_affine + specs: + - name: uniformity + model_source_column: "Uniformity score (Avg (Coverage + (1-Uniformity) + Phase purity))" + transform: affine + goal: maximize + # AB = (L + O + P) / 3, the mean of Coverage, 1 - clamped Uniformity and + # Phase purity. The first campaign multiplied these instead. + # + # Computed from `Uniformity` (M), never from the workbook's clamped copy (N) + # or its complement (O). N is a pasted literal on every row and O is a formula + # on fourteen rows and a literal on the fifteenth, so both are exactly the kind + # of cell that stops updating when the measurement behind it is edited. Taking + # the reading and clamping it here means the cross-check against AB -- which is + # built from O -- fires if that ever happens. + # + # The clamp is strictly above 1.0, matching the sheet: readings of 1.659 + # (sample 4) and 1.277 (sample 8) become 0.99, and an exact 1.0 would keep its + # own value. Uniformity above 1 is out of range for a fraction; the clamp + # records that the film was bad without letting one reading drive the mean + # negative. + measurement: + recipe: mean + inputs: + - {column: "Coverage"} + - {column: "Uniformity", transform: clamped_complement, clamp_above: 1.0, clamp_to: 0.99} + - {column: "Phase purity"} + # AB is a live formula, so it agrees to floating point. Measured: 0.0 on all + # 15 rows. + cross_check: + - {column: "Uniformity score (Avg (Coverage + (1-Uniformity) + Phase purity))", atol: 1.0e-9} + # The mean of three terms each in [0, 1] is itself in [0, 1] by construction, + # so these anchors are the objective's natural range and not a guess about the + # data. Observed span on the 15 rows is 0.365 to 0.878. + lower_anchor: 0.0 + upper_anchor: 1.0 + # MEASURED ON THESE ROWS, 2026-08-17, not inherited. The first campaign's + # "no learnable signal" verdict was about a different construction (the + # product) on a different set of films, so it could not transfer -- but the + # answer came out the same: plain GP LOO R2 -0.6447 against a null of -0.1480. + # Well below. R1 is exploration-dominated for this objective by design. + # + # Reproduce with: + # python scripts/intake_new_data.py --workbook "local_inputs/Summary Table Test.xlsx" + signal_status: exploration_only + + - name: optoelectronic + model_source_column: "Optoelectronic score (Avg normalized (Voc + Photocondiuctivity)" + transform: affine + goal: maximize + # AC = (R + T) / 2, the mean of normalised Voc and normalised photoconductance. + # The first campaign used log10(Voc * photoconductance), a different quantity + # on a different scale. + # + # R = Q / 1.4 in the sheet, with NO ceiling. `capped_ratio` adds one, so this + # computes min(Q, 1.4) / 1.4. THAT IS A DELIBERATE DIVERGENCE from the + # workbook and it is dormant today: the largest observed reading is 1.135, so + # the cap never binds and the cross-check agrees exactly. The day a film + # exceeds 1.4 V the two will disagree and the cross-check will say so, which is + # the intended behaviour -- the group asked for Voc to be clamped at 1.4, and a + # clamp that only exists in Python has to announce itself. + # + # T is taken as provided. Nothing in the workbook derives it, so there is no + # recipe to reproduce and no cross-check to write; see agreement_check. + measurement: + recipe: mean + inputs: + - {column: "PL - Implied Voc (Max) Raw ", transform: capped_ratio, cap: 1.4} + - {column: "Normalized photoconductance "} + cross_check: + - {column: "Optoelectronic score (Avg normalized (Voc + Photocondiuctivity)", atol: 1.0e-9} + # THE NORMALISATION IS KNOWN TO BE WRONG AND THIS IS HOW IT STAYS VISIBLE. + # `Normalized photoconductance` does not rank like the raw photoconductance + # it claims to summarise: Spearman is -0.5484 (p = 0.0343) on the 15 rows. + # The strongest film (8.81e-07) normalises to 0.010, the lowest value in the + # column, and three films at low raw photoconductance sit at exactly 1.000. + # So half of this objective currently rewards the opposite of what it names. + # + # It is a finding, never a gate. The group knows and the formula is coming; + # until it does, every R1 review carries the notice below and this axis is + # provisional. Closing it is one recipe edit plus one intake run. + agreement_check: + raw: "Photoconductance (Max)" + normalized: "Normalized photoconductance " + min_spearman: 0.0 + # Both terms are normalised to [0, 1], so their mean is too. Observed span on + # the 15 rows is 0.373 to 0.905. + lower_anchor: 0.0 + upper_anchor: 1.0 + # MEASURED ON THESE ROWS, 2026-08-17. Plain GP LOO R2 -0.5842 against a null + # of -0.1480: this objective carries no learnable signal either, and like the + # first campaign's optoelectronic it sits BELOW the null, which means the ten + # inputs are doing damage rather than merely diluting. + # + # THE MEAN FUNCTION WAS DELETED, and that was the designed outcome rather than + # a setback. The first campaign carried `mean_function: linear anneal_temp` + # here, worth -0.342 -> +0.355 on ITS score. On this one it makes the fit + # WORSE: -0.5842 -> -0.6977, a swing of -0.1135. The target changed underneath + # it -- this objective is now (min(Voc, 1.4)/1.4 + T)/2 rather than + # log10(Voc * photoconductance) -- so the old evidence was never about this + # quantity. Do not reinstate it without a fresh intake verdict. + # + # The prime suspect is the agreement_check above. Half of this objective is a + # normalisation that ranks BACKWARDS against its own raw measurement, and no + # model can learn a column that does not track what it claims to summarise. + # Re-run the intake once the group supplies the real formula; a mean function + # may well earn its place then, and this verdict is about the column as it + # stands rather than about anneal_temp. + signal_status: exploration_only + + - name: thickness + # trains on nanometres, NOT on the workbook thickness score + model_source_column: "Thickness (avg)" + transform: gaussian_target + goal: target + target: 650.0 + # the workbook writes EXP(-(((Z-650)/250)^2)), which has no factor of 1/2; + # in the exp(-0.5*((T-c)/sigma)^2) convention used here that is 250/sqrt(2) + sigma: 176.7766952966369 + equivalent_workbook_formula: "EXP(-(((Z-650)/250)^2))" + # MEASURED ON THESE ROWS, 2026-08-17. The one objective with real signal, and + # it is much stronger than in the first campaign: plain GP LOO R2 +0.5227 + # against a null of -0.1480, where the first campaign's plain thickness GP + # managed +0.116. These films are far more internally consistent, which is the + # likely reason. + signal_status: learnable + # Unchanged from the first campaign in every respect except the tolerance. + # The score is still a peaked Gaussian on a 650 nm target and still folds the + # range 2-to-1 -- films at 372 nm and 953 nm score alike from opposite sides of + # the peak -- so the model still trains on nanometres and the Gaussian is + # applied to the posterior. Observed range is 371.9 to 1310.9 nm, straddling + # the target as before. + # + # Eleven rows carry three readings and four carry four, so `mean_of_present` + # is what the data needs; requiring all four would reject two thirds of the + # campaign. Blank means not measured, never zero. + # + # "T anom" holds readings the operator judged anomalous (samples 4, 8, 11 and + # 12). They never enter the mean -- the sheet's own AVERAGE(U:X) excludes the + # column too -- and their presence is reported so the exclusion is visible. + # + # TOLERANCE TIGHTENED, 0.5 -> 1e-9. The first campaign's `Thickness (avg)` was + # ROUND(mean(T1..T4)), a rounded literal that could legitimately differ by half + # a nanometre. This sheet's Z is a live unrounded AVERAGE, so anything above + # floating-point noise is a real disagreement. Measured worst case on the 15 + # rows: 2.3e-13. + # + # spread_warning_ratio fires on sample 1 alone (readings 584.4, 418.5, 692.0, + # 624.6 -- a 47% spread around their mean). These films are far more internally + # consistent than the first campaign's: pooled within-film sd of log(T) is + # 0.0798 here against 0.244 there. + measurement: + recipe: mean_of_present + inputs: + - {column: "T1"} + - {column: "T2"} + - {column: "T3"} + - {column: "T4"} + excluded: + - {column: "T anom"} + cross_check: + - {column: "Thickness (avg)", atol: 1.0e-9} + spread_warning_ratio: 0.25 + # response: log makes the model output lognormal, so the utility expectation + # must use Gauss-Hermite quadrature, not the Gaussian closed form. + # + # The three replicate films of one condition are averaged in LOG SPACE, for the + # same reason train_Yvar is pooled there: `response: log` means the GP trains + # on log(T), so the geometric mean is the arithmetic mean in the space the + # model actually works in. + replicate_aggregate: mean_of_log + # KEPT, and the case is the RANK PERMUTATION rather than the R2 swing -- + # the same standing the first campaign's thickness mean function had. + # + # Intake 2026-08-17 left this INCONCLUSIVE on R2: plain +0.5227, structured + # +0.6630, a swing of +0.1403 against a resolution floor of 0.236. That is not + # a verdict, it is a statement that R2 cannot resolve the difference at N=15. + # + # The permutation adjudicated it on 2026-08-18, and decisively: + # + # observed rank rho +0.7250 + # null mean -0.1917 (sd 0.2937) + # exceedances 4 of 1800 + # p 0.0028 95% CI [0.0003, 0.0052] + # + # Reproduce with: + # python scripts/permutation_rank_test.py --objective thickness --permutations 1800 + # + # Stronger than the first campaign's p = 0.0350 on its own films. Rank is the + # right statistic because rank is what the acquisition consumes; it never sees + # R2. Do not quote the +0.1403 swing as evidence -- it is inside the floor, and + # the permutation is what carries the weight. + mean_function: + response: log + features: + - column: speed_1 + transform: log + - column: precur_conc + transform: log + +# Reference point in UTILITY space, after the transforms above. All three +# objectives now live natively in [0, 1], so the axes are more comparable than in +# the first campaign, not less; the reference is unchanged. +reference_point_utility: [-0.01, -0.01, -0.01] + +rounds: + r1: + method: ucb_hvi + batch_size: 5 + replicates_per_condition: 3 + # CHANGED 2026-08-18, from 4.0. Chosen by the group with Aleks from the + # 108-cell beta x radius sweep (3 trials x 4 betas x 9 radii, full production + # settings, local_outputs/boxplot_sweep). beta = 36 means kappa = sqrt(36) = 6: + # heavy exploration, which is the right posture when two of the three axes + # carry no learnable signal and the third is the only one worth exploiting. + # + # READ THE SWEEP'S OWN CAVEAT BEFORE QUOTING IT AS EVIDENCE. That sweep scored + # candidates against a noiseless GP oracle of the same model class the + # optimiser fits, so the landscape held no surprises and exploration had + # unusually little to earn; it systematically UNDERVALUES large beta. It also + # could not rank cells - the spread across betas was 0.0065 against a + # trial-to-trial sd of 0.010-0.027, and the best cell was a different (beta, r) + # in every trial. So this is a declared policy choice about how much to + # explore, not a measured optimum, and it should be recorded as such. + # + # MEASURED CONSEQUENCES, live R1: batch spacing 1.091 (three times the + # radius, so penalization is inert here) and 21 of 50 coordinates at a + # range edge. Revisit when the photoconductance normalisation is fixed or + # when R1 noise replaces the oracle -- see CAMPAIGN_STATUS. + beta: 36.0 + candidate_pool_size: 32768 + posterior_samples: 256 + # thickness utility is a nonlinear function of the model output, so utility + # moments must come from posterior samples, not from moments of the mean + moment_method: monte_carlo + r2: + method: qlognehvi + batch_size: 3 + replicates_per_condition: 3 + candidate_pool_size: 32768 + mc_samples: 128 + sequential_pending: true + +# FIRST REAL CONSTRAINTS IN THIS PROJECT. The first campaign's list was empty by +# decision; these are process facts about the recipe, supplied by the group. +# +# Enforcement lives in the campaign layer, never in acquisition. The candidate +# pool is filtered immediately after generation, before anything is scored, and +# `validate_batch` re-checks the proposed batch by an independent route. The +# acquisition modules are untouched. `discrete_refinement` is NOT constraint-aware +# and is not wired into a round; its docstring says so. +# +# Watch `constraint_pool_survival_rate` in the round diagnostics. The sampler +# draws until it has the requested pool size, so a mis-specified constraint +# produces a normal-looking pool drawn from a sliver of the space, and the +# survival rate is the only place that shows. +constraints: + # A second spin stage either happens or it does not. 0 rpm for 30 s and 3500 rpm + # for 0 s are both contradictions; both zero is a one-step film, which sample 2 + # actually is. Stated as an iff because that is what keeps the one-step recipe + # reachable - a plain lower bound on either column would delete it. + - zero_coupled: [speed_2, time_2] + name: second_stage_all_or_nothing + + # The antisolvent has to land while the substrate is still spinning, so equality + # is already too late. Strict, deliberately. + - sum_upper_strict: {lhs: anti_time, rhs: [time_1, time_2]} + name: antisolvent_lands_while_spinning + + # See the time_2 grid note above: step 5 from 0 reaches 5, which no film has run + # and which the first campaign's grid excluded. This keeps the hole declared + # rather than silently filling it. + - nonzero_minimum: {column: time_2, minimum: 10} + name: second_stage_runs_at_least_10s + +# What the batch-review artifact interrogates and states. Campaign knowledge, so +# it lives here rather than in batch_review.py. +# +# Starts clean: the first campaign's probes and its R1 withdrawal note were facts +# about that dataset and do not travel. +review: + probes: [] + + notes: + - > + THE OPTOELECTRONIC AXIS IS PROVISIONAL. Half of it is + `Normalized photoconductance`, a column supplied ready-normalised with no + derivation in the workbook, and it does not rank like the raw + `Photoconductance (Max)` it summarises: Spearman -0.5484, p = 0.0343 over + the 15 R0 rows. The strongest film measured (8.81e-07) carries the lowest + normalised value in the column (0.010), and three films at low raw + photoconductance sit at exactly 1.000. So this objective currently rewards + weaker photoconductance, and any condition proposed partly on optoelectronic + grounds inherits that. The group is supplying the intended formula; until it + lands, read this axis as unresolved rather than as a result. Closing it is + one recipe edit plus one intake run. + - > + TWO OF THE THREE OBJECTIVES ARE EXPLORATION-ONLY. Measured on these 15 rows + by scripts/intake_new_data.py on 2026-08-17, against a leave-one-out null of + -0.1480: uniformity -0.6447 and optoelectronic -0.5842, both well below it, + so neither model has learned anything and neither predicted utility should be + read as one. Only thickness carries signal (+0.5227 plain, +0.6630 with its + mean function). A batch is therefore being chosen on one informative axis and + two uninformative ones, which is a legitimate exploration round but is not + the same thing as a three-objective optimisation, and the review should say + so rather than let the predicted numbers imply otherwise. + - > + The optoelectronic mean function was DELETED on the intake verdict. The first + campaign carried a linear anneal_temp trend there, worth -0.342 to +0.355 on + its own score; on this one it makes the fit worse, -0.5842 to -0.6977. The + objective was redefined underneath it, so the old evidence was never about + this quantity. It is not to be reinstated without a fresh verdict. + +local_penalization: + distance_metric: normalized_euclidean + dimension_weights: null + # CHANGED 2026-08-18, from 0.25. Same sweep, same standing: a policy choice. + # Radius buys batch diversity and pays for it in range-edge pinning - measured on + # the first campaign at 11 -> 15 edge coordinates across the arm. The sweep also + # found that radius binds LESS as beta rises (at beta 49 the nine radii produced + # only 3-4 distinct batches, with achieved spacing already above every radius + # tested), so at beta = 36 this knob has less to do than it did at beta = 4. + radius: 0.35 + min_batch_distance: 0.15 + min_observed_distance: 0.0 + +model: + variant: dim_scaled_prior + # R0 has no film replicates. Once R1 triplicates land, pool their + # within-condition variance (5 conditions x 2 dof = 10 dof) and pass it as + # train_Yvar by setting this to `replicate_pooled`. + # + # Pool thickness variance in LOG SPACE, not in nanometres: `response: log` + # above means the GP trains on log(T), so train_Yvar must be the variance of + # log(T). A variance in nm^2 would be wrong by a factor of T^2 - roughly 1.4e5 + # at 372 nm and 1.7e6 at 1311 nm, so not even a constant rescaling. + observation_noise: fit_from_marginal_likelihood + + replicate_variance: + # TWO DIFFERENT VARIANCES. Do not substitute one for the other. + # + # BETWEEN-FILM is what train_Yvar needs and is only measurable once R1 ships + # triplicates. WITHIN-FILM is the scatter of the 3-4 thickness points across + # one film, available today, and contains NO run-to-run variation - so it is a + # FLOOR, not an estimate. If pooled between-film variance ever lands below it, + # films would be more reproducible than points on a single film. + # + # RECOMPUTED ON THIS WORKBOOK: 0.006374 on log(T), 37 dof, against 0.0593 on + # the first campaign's films. These films are about ten times more internally + # consistent, and inheriting the old constant would have set the floor an order + # of magnitude too high - it would have called a perfectly ordinary pooled + # variance a pooling mistake. + sanity_floor: + thickness: 0.006374 + # Film count assumed for rows that have no replicates - the R0 rows. 1 means + # their observation carries the full between-film variance rather than a third + # of it. + rows_without_replicates: 1 + +reproducibility: + seed: 73 + record_git_commit: true + record_environment_versions: true + record_resolved_config_hash: true diff --git a/configs/campaign_d2d_raw_components.yaml b/configs/campaign_d2d_raw_components.yaml new file mode 100644 index 0000000..40b2669 --- /dev/null +++ b/configs/campaign_d2d_raw_components.yaml @@ -0,0 +1,186 @@ +# DIAGNOSTIC ONLY. The same campaign with the two failing COMPOSITE SCORES +# replaced by RAW MEASUREMENTS, so the two can be compared figure for figure. +# +# THE QUESTION THIS ANSWERS. R1 and R2 were not improving on uniformity or +# optoelectronic, and the leave-one-out parity plot showed the model learning +# neither. Both are composites: uniformity is (Coverage + (1-Uniformity) + +# Phase purity)/3, optoelectronic is a weighted blend of normalised Voc, +# photoconductance and photosensitivity. So: is the COMBINATION the problem? Feed +# the GP one raw measurement per axis instead and look at the same two figures. +# +# Everything else is held identical to `campaign_d2d_perovskite_final.yaml` -- the +# same ten inputs and grids, the same constraints, the same beta = 4 / radius = +# 0.25, the same seed, the same reference point, thickness untouched. ONLY the +# first two objectives change. That is what makes the comparison a comparison. +# +# NOT A CAMPAIGN CONTRACT. It proposes nothing and nothing is written from it. +# Its contract_version is distinct so no hypervolume from it can ever be compared +# against a v4 number. +campaign: + name: D2D_FA0.9Cs0.1PbI3_raw_components_diagnostic + status: diagnostic + schema_version: d2d-campaign-v4 + workbook_profile: d2d_summary_final_v4 + source_sheet: R0 + +inputs: + - {name: speed_1, unit: rpm, start: 1000, stop: 6000, step: 500} + - {name: time_1, unit: s, start: 5, stop: 50, step: 5} + - {name: speed_2, unit: rpm, start: 0, stop: 5000, step: 500} + - {name: time_2, unit: s, start: 0, stop: 60, step: 5} + - {name: precur_conc, unit: M, start: 1.00, stop: 2.00, step: 0.05} + - {name: precur_vol, unit: uL, start: 40, stop: 200, step: 10} + - {name: anneal_temp, unit: C, start: 100, stop: 185, step: 5} + - {name: anneal_time, unit: min, start: 10, stop: 60, step: 5} + - {name: anti_vol, unit: uL, start: 100, stop: 200, step: 5} + - {name: anti_time, unit: s, start: 9, stop: 25, step: 1} + +objectives: + contract_version: d2d-objectives-raw-components-diagnostic + scaling_mode: fixed_affine + specs: + # ---------------------------------------------------------------- axis 1 ---- + # REPLACES the uniformity score. Phase purity is the only one of that score's + # three components with any claim to signal on its own: coverage is -0.6638, + # 1-Uniformity is -0.6119, phase purity is +0.0571, against the composite's + # -0.4688. It is also the component the composite gives the LEAST weight to in + # practice -- 1-Uniformity carries 81% of the composite's variance and phase + # purity 18%. + - name: phase_purity + model_source_column: "Phase purity" + transform: affine + goal: maximize + measurement: + recipe: stored + inputs: + - {column: "Phase purity"} + # A fraction. Observed span on the 15 rows: 0.6305 to 0.9827. + lower_anchor: 0.0 + upper_anchor: 1.0 + # +0.0571 plain. Above the -0.1480 that this project used to call the null, + # but that number is NOT a significance threshold (28.7% of pure-noise + # shuffles beat it) and phase purity's own empirical bar is +0.2309. With a + # precur_conc mean function it reaches +0.3244, which was refuted 3-0 on + # verification: the effect is three films below 1.25 M, and among the ten + # high-purity films the model ranks them BACKWARDS (rho -0.754). + signal_status: exploration_only + + # ---------------------------------------------------------------- axis 2 ---- + # REPLACES the optoelectronic score, with the group's own example: raw + # photoconductance, one measurement, no blending. + - name: photoconductance + # The workbook's own `=MIN(1, X2/(0.000001))` version of the SAME measurement. + # + # WHY NOT THE RAW SIEMENS COLUMN. Tried first, and the oracle fit COLLAPSED on + # it: at ~1e-7 the latent sd falls to 1.2e-4 of the fitted noise sd and the + # round simulation refuses to render rather than build every surface on a + # collapsed fit. That is numerical, not scientific -- dividing by the sheet's + # own 1e-6 is a strictly monotone rescale and the two carry identical + # information (LOO R2 -0.6308 raw against -0.6302 normalised, rank -0.243 in + # both). The lesson is worth keeping: `Standardize` does not make this + # pipeline scale-invariant in practice, so an objective living at 1e-7 needs + # rescaling before it can be modelled at all. + model_source_column: "Normalized photoconductance (test)" + transform: affine + goal: maximize + measurement: + recipe: stored + inputs: + - {column: "Normalized photoconductance (test)"} + # Capped at 1 by the sheet's own MIN. Observed span: 0.0339 to 0.6789. + lower_anchor: 0.0 + upper_anchor: 1.0 + # -0.6308 raw, -0.3254 in logs. Both far below even the old null, and below + # the MEDIAN of this axis's own permutation null. Worse than the composite it + # replaces (-0.7038 vs -0.6308 is inside the +-0.236 resolution, so call them + # equal). Splitting the optoelectronic score into its parts does not help, + # which is the finding. + signal_status: exploration_only + + # ---------------------------------------------------------------- axis 3 ---- + # UNCHANGED from v4, deliberately: it is the control. If the raw-component + # figures look different from the score figures on this axis, something other + # than the objective definition moved and the comparison is void. + - name: thickness + model_source_column: "Thickness (avg)" + transform: gaussian_target + goal: target + target: 650.0 + sigma: 176.7766952966369 + equivalent_workbook_formula: "EXP(-(((AH-650)/250)^2))" + signal_status: learnable + measurement: + recipe: mean_of_present + inputs: + - {column: "T1"} + - {column: "T2"} + - {column: "T3"} + - {column: "T4"} + excluded: + - {column: "T anom"} + cross_check: + - {column: "Thickness (avg)", atol: 1.0e-9} + spread_warning_ratio: 0.25 + replicate_aggregate: mean_of_log + mean_function: + response: log + features: + - {column: speed_1, transform: log} + - {column: precur_conc, transform: log} + +reference_point_utility: [-0.01, -0.01, -0.01] + +rounds: + r1: + method: ucb_hvi + batch_size: 5 + replicates_per_condition: 3 + beta: 4.0 + candidate_pool_size: 32768 + posterior_samples: 256 + moment_method: monte_carlo + r2: + method: qlognehvi + batch_size: 3 + replicates_per_condition: 3 + candidate_pool_size: 32768 + mc_samples: 128 + sequential_pending: true + +constraints: + - zero_coupled: [speed_2, time_2] + name: second_stage_all_or_nothing + - sum_upper_strict: {lhs: anti_time, rhs: [time_1, time_2]} + name: antisolvent_lands_while_spinning + - nonzero_minimum: {column: time_2, minimum: 10} + name: second_stage_runs_at_least_10s + +review: + probes: [] + notes: + - > + Diagnostic only. Two of three objectives are raw measurements substituted + for the composite scores the campaign runs, so that the leave-one-out + parity plot and the round-simulation boxplots can be compared side by side + against the score version. Nothing here proposes films. + +local_penalization: + distance_metric: normalized_euclidean + dimension_weights: null + radius: 0.25 + min_batch_distance: 0.15 + min_observed_distance: 0.0 + +model: + variant: dim_scaled_prior + observation_noise: fit_from_marginal_likelihood + replicate_variance: + sanity_floor: + thickness: 0.003007 + rows_without_replicates: 1 + +reproducibility: + seed: 73 + record_git_commit: true + record_environment_versions: true + record_resolved_config_hash: true diff --git a/configs/demo_config.yaml b/configs/demo_config.yaml deleted file mode 100644 index 96e8ecb..0000000 --- a/configs/demo_config.yaml +++ /dev/null @@ -1,50 +0,0 @@ -inputs: -- name: speed_inorg - unit: m/min - start: 0.25 - stop: 1.0 - step: 0.01 -- name: speed_org - unit: m/min - start: 0.25 - stop: 1.0 - step: 0.01 -- name: inkfl_inorg - unit: uL/min - start: 80.0 - stop: 240.0 - step: 1.0 -- name: inkfl_org - unit: uL/min - start: 100.0 - stop: 280.0 - step: 1.0 -- name: conc_inorg - unit: M - start: 0.8 - stop: 1.4 - step: 0.05 -- name: conc_org - unit: M - start: 0.4 - stop: 1.2 - step: 0.05 -- name: temperature_c - unit: F - start: 20.0 - stop: 50.0 - step: 1.0 -- name: absolute_humidity - unit: g/m^3 - start: 2.0 - stop: 37.0 - step: 1.0 -objectives: - names: - - PCE - - Stability - - Repeatability -constraints: -- clausius_clapeyron: true - ah_col: absolute_humidity - temp_c_col: temperature_c diff --git a/configs/auto_config.yaml b/configs/example_demo.yaml similarity index 100% rename from configs/auto_config.yaml rename to configs/example_demo.yaml diff --git a/configs/configCSV_example_config.yaml b/configs/example_from_csv.yaml similarity index 100% rename from configs/configCSV_example_config.yaml rename to configs/example_from_csv.yaml diff --git a/data/.DS_Store b/data/.DS_Store deleted file mode 100644 index e733d79..0000000 Binary files a/data/.DS_Store and /dev/null differ diff --git a/docs/CAMPAIGN_STATUS.md b/docs/CAMPAIGN_STATUS.md new file mode 100644 index 0000000..312b227 --- /dev/null +++ b/docs/CAMPAIGN_STATUS.md @@ -0,0 +1,1349 @@ +# Campaign status and how to use it + +Snapshot for collaborators. The full loop runs: R0 LHS -> R1 UCB-HVI (5) -> +R2 qLogNEHVI (3), three replicate films per condition, 23 distinct conditions. + +## The live campaign: contract v4, from 2026-09-02 — READ THIS FIRST + +**The final workbook arrived and the score contract moved again.** Uniformity and +optoelectronic were renormalised a second time, and the group's decision this time +is to **freeze them**: read the workbook's own score columns and compute nothing. + +| | v2 — test data | v3 — test data | v4 — **the real campaign** | +|---|---|---|---| +| config | `campaign_d2d_perovskite.yaml` (archived) | `campaign_d2d_perovskite_test.yaml` (archived) | `campaign_d2d_perovskite_final.yaml` | +| contract | `d2d-objectives-v2-nm-thickness` | `d2d-objectives-v3-test` | `d2d-objectives-v4-final` | +| workbook | `Summary Table.xlsx` | `Summary Table Test.xlsx` | `Final Summary Table.xlsx` | +| sheet | `Sheet1` | `Sheet1` | **`R0`** | +| uniformity | `Coverage * (1-Uniformity) * Phase purity` | `mean(...)` computed | **read from AJ** | +| optoelectronic | `log10(Voc * Photoconductance)` | `mean(...)` computed | **read from AK** | +| thickness | computed from `T1..T4`, nm | unchanged | unchanged, readings now AC–AG | + +### The freeze, and what it costs + +Uniformity and optoelectronic use a new `stored` recipe: the workbook's score +column *is* the objective value, with no recomputation. **This reverses this +project's usual polarity**, which is "Python computes and the stored cell is +demoted to a cross-check". + +**Why.** Both objectives have now been renormalised twice and the group is still +revising them. Reimplementing a formula that is about to change means the code and +the sheet disagree at exactly the moment someone edits the sheet, and the +disagreement looks like a bug in whichever was checked second. Reading the value +makes the workbook the single source of truth while the definition moves. + +**What it costs, stated plainly because it is the cross-check this project +otherwise insists on:** there is **no independent recomputation** of these two +objectives, so a stale pasted literal in AJ or AK cannot be caught by comparing it +against anything. Intake and every round report print that in one line. + +**What partly replaces it: `formula_fingerprint`.** The config records the formula +text of each frozen column, and the read compares it — reading the formula, never +evaluating it. Recorded on 2026-09-02: + +| column | recorded formula | +|---|---| +| AJ uniformity | `=(L2+O2+P2)/3` | +| AK optoelectronic | `=(S2+((0.75*Y2)+(0.25*AB2)))/2` | +| AH thickness (avg) | `=AVERAGE(AC2:AF2)` (cross-checked, not frozen) | + +Comparison is row- and whitespace-independent, so one fingerprint covers all +fifteen rows. **It notices a changed definition, not a stale value** — that gap is +inherent to freezing and is asserted by a test so nobody later mistakes the +fingerprint for a value check. A score column holding literals rather than +formulas is flagged too, since that is the one failure this contract cannot see. + +**Unfreezing is a config edit, not a rebuild.** The v3 recipes (`mean`, +`clamped_complement`, `capped_ratio`) stay in `scores.py`, unwired and tested. When +the group settles the formulas, swap the recipe back and bump `contract_version`. + +### The sheet is `R0` now + +The workbook names its sheets by round, so the source sheet became a config key, +`campaign.source_sheet`. Older contracts declare nothing and default to `Sheet1`. + +**The workbook's own `R1` sheet is deliberately not read.** The round contract is +unchanged: each round's worklist is written to a NEW file beside the workbook and +filled in there, and the source workbook is never opened for writing. + +### What the final data supports + +`scripts/intake_new_data.py`, exact leave-one-out, null −0.1480 at N=15, +resolution floor ±0.236: + +| objective | plain GP | mean function | verdict | +|---|---:|---:|---| +| uniformity | **−0.4688** | none | below the null, **exploration only** | +| optoelectronic | **−0.7038** | none | below the null, **exploration only** | +| thickness | **+0.5814** | **none — withdrawn 2026-09-06** | **learnable**, on its rank permutation | + +**The thickness mean function was withdrawn on 2026-09-06.** It measured +0.7422 +against the plain GP's +0.5814, but its stated justification — that the fitted +speed exponent agreed with spin-coating theory's −0.5 — is false. See "The +thickness prior was half-earned" below. Nothing else in the campaign declares one, +so **no objective now carries a physics prior.** + +All 15 rows are on-grid, all satisfy all three constraints, and both anchors hold +(uniformity 0.599–0.882, optoelectronic 0.477–0.762). Sample 2 was +`speed_2 = 0, time_2 = 60` in an earlier draft — which breaks the first constraint +— and the group corrected it to `time_2 = 0`. + +**Thickness keeps its mean function, on the rank permutation.** Intake leaves it +*inconclusive on R²* — the +0.1608 swing sits inside the ±0.236 floor, which is a +statement that R² cannot resolve it at N=15 rather than a verdict. +`scripts/permutation_rank_test.py` adjudicated on 2026-09-02: + +| | value | +|---|---:| +| observed rank ρ | **+0.6500** | +| null mean (sd) | −0.1892 (0.2944) | +| exceedances | **9 of 1800** | +| p | **0.0056**, 95% CI [0.0021, 0.0090] | + +v3's p was 0.0028 on its own rows; that verdict did not transfer and this one was +measured fresh. **Rank is the right statistic because rank is what the acquisition +consumes** — it never sees R². **Do not quote the +0.1608 swing as evidence.** + +**Issue 10 is CLOSED.** The v3 photoconductance normalisation ranked backwards +against its own raw measurement (Spearman −0.5484, p = 0.0343). On v4 the same +comparison gives **+1.0000**. The diagnostic stays on because the failure is +silent when it recurs. + +### The knob decision: beta = 36 → 4, radius = 0.35 → 0.25 (2026-09-03) + +**Superseded.** The reasoning below is kept because it is what the decision was +reversed *from*. + +> The revisit trigger recorded under v3 was "when the photoconductance +> normalisation is fixed and optoelectronic may become learnable". It fired, and +> the answer was to keep the knob. `beta = 36` was chosen because two of three +> objectives carried no learnable signal, which makes heavy exploration the right +> posture; on v4, still only thickness beat the null. Had two or more axes become +> learnable, the recommendation would have been to return toward the +> sweep-settled `beta = 4`. + +That argument had a hidden premise: **that heavy exploration was how the two dead +axes would come alive.** The extended C1&C2 sheet (see "The same 15 recipes, made +three times") shows it is not, because it shows *why* they are dead: + +* **optoelectronic** is 84.5% between-campaign drift. Recipe ICC **0.000**, F 0.18, + p 0.9992; the GP refuses to fit it in 15 of 15 folds. No β reaches this. +* **uniformity** is reproducible (ICC **0.730**, p < 0.00001) but not predictable + from ten inputs at fifteen distinct recipes (leave-one-recipe-out R² −0.243). + It needs more distinct recipes, not wider ones. + +So exploration buys nothing against either axis, and the batch quality it costs is +measurable. **beta = 4.0 and radius = 0.25**, which is where the original +108-cell sweep sat before v3's no-signal verdict overrode it. + +The second revisit trigger — R1 measurements replacing the oracle — has still not +fired. + +### The thickness prior was half-earned, and it has been withdrawn (2026-09-06) + +`log T ~ log(speed_1) + log(precur_conc)` was declared from spin-coating theory in +commit `600ef60` and carried unchanged through all three contracts. It has been +removed from the live config by the group's decision. Two measurements decided it. + +**The physics claim is false for these films.** OLS on the 15 films, no replicates +needed for a standard error: + +| coefficient | estimate | std err | 95% CI | theory | +|---|---:|---:|---|---| +| log(speed_1) | **−0.2554** | 0.0593 | **[−0.385, −0.126]** | −0.5 — **outside the interval, 4.1 SE away** | +| log(precur_conc) | +1.3130 | 0.1747 | [+0.932, +1.694] | +1.0 — inside | + +Mass balance holds; Meyerhofer's viscous-thinning scaling does not. That is what +you would expect if the antisolvent quench freezes the film before the thinning +stage completes. **Fixing the exponents at their theoretical values and fitting +only an intercept scores +0.5600 — worse than having no trend at all (+0.5823).** +So the docs' long-standing citation of "−0.38 against theory's −0.5" as supporting +evidence was never evidence; it has been removed from `GP_MODEL_DECISION.md`. + +**What was true, and is the argument for ever bringing a prior back.** The +*variable choice* was real even though the magnitudes were not physics. Four +matched-flexibility controls — three fitted parameters each, physically +unmotivated pairs — all scored below the plain GP: + +| trend | free params | LOO R² | +|---|---:|---:| +| plain GP, no trend | 0 | +0.5823 | +| fitted log(speed_1) + log(precur_conc) | 3 | **+0.7680** | +| **theory, exponents FIXED at −0.5 / +1.0** | 1 | **+0.5600** | +| control: fitted log(anti_vol) + log(time_1) | 3 | +0.3706 | +| control: fitted log(anneal_time) + log(anti_time) | 3 | +0.2960 | +| control: fitted log(anneal_temp) + log(anti_vol) | 3 | +0.3174 | +| control: fitted log(time_1) + log(anneal_temp) | 3 | +0.4033 | + +It is **not** the case that any fitted two-term trend helps; most actively hurt. + +**The cost, stated plainly:** thickness LOO R² falls +0.7423 → +0.5814 and rank ++0.864 → +0.804. It remains the only objective with signal. The withdrawn gain +never had a permutation test of its own, only an R² comparison, which this project +now knows is the weaker instrument. + +`structured_mean.py` stays in the package, wired and tested, for a prior that earns +its place. The bar: established physics, declared before fitting, beating +matched-flexibility controls, and surviving a permutation test. + +### The leave-one-out null was never a significance threshold (2026-09-04) + +**This corrects a reading this project has used since the first campaign.** + +`1 − (N/(N−1))² = −0.1480` is the score of ONE predictor: predict every held-out +film with the mean of the other fourteen. It has been read as the bar a model must +clear. **A fitted GP does not behave like that predictor**, so it is not that bar. + +Measured two ways that agree — an adversarial verifier at 500 permutations and an +independent reimplementation at 300, different RNG streams: + +| | median | 95th percentile | % of pure-noise draws above −0.1480 | +|---|---:|---:|---:| +| fitted GP, no mean function | −0.4075 / −0.4210 | +0.2309 / +0.2890 | 27.4% / **28.7%** | +| with a 1-variable mean function | −0.4368 | +0.1267 … +0.1944 | 20.6 – 23.6% | +| with a 2-variable mean function | −0.5384 / −0.5443 | +0.0752 … +0.1337 | 16.6 – 18.2% | + +**More than one shuffle in four beats −0.1480 with no signal present at all.** The +GP's predictions under permuted y have roughly six times the spread of the +constant predictor's; they are noise, and they land further from y — which is why +the empirical null sits far below −0.1480 while its upper tail sits far above it. + +**What is still true.** Below −0.1480 a model has certainly learned nothing, so +every "exploration only" verdict in this document stands: uniformity −0.4688, +optoelectronic −0.7038 and the stored thickness score −0.2020 are all below the +*median* of their own nulls. **What is not true** is the converse. A candidate +above −0.1480 has shown nothing by that fact alone, and any argument of the form +"it beat the null" carries no evidential weight. + +**A mean function LOWERS the null rather than raising it** — an OLS trend fitted on +14 rows of shuffled y is a noise fit, and extrapolating it to the held-out row adds +error. So mean-function results were not flattered by an inflated null; they were +scored against a bar roughly five times too low, like everything else. In a +20-variant sweep on phase purity, 20 of 20 "beat" −0.1480 including two +deliberately nonsensical controls (`time_1`, `speed_1`). + +**Thickness is unaffected**, and the reason is on the record above: its verdict has +always rested on the **rank permutation test** (p = 0.0056), never on R² against +this number. That instrument was always the right one and is now the only one. + +**What to use instead.** The 95th percentile of the candidate's own permutation +null, which `scripts/raw_component_screen.py --calibrate` measures, or the rank +permutation test for a verdict. Two hazards found alongside this and now fixed: + +* when every fold fails to fit, a fold-mean fallback produces **exactly −0.1480 + and ρ −1.0000** — a totally broken run reported the project's own null. The + screen now raises instead; any historical result at exactly −0.1480 should be + re-checked for collapsed folds. +* `--seed` is inert on this code path (`fit_model_variant` runs a deterministic + L-BFGS from a deterministic init), so "the number does not move with the seed" + has never been evidence for anything. The real numerical floor, probed by row + ordering, is ~3e-4 rather than the 0.07 previously assumed. + +### The same 15 recipes, made three times (2026-09-03) + +A sheet arrived holding **45 rows that are 15 recipes made three times** — +`local_inputs/Extended Summary Table C1C2.xlsx`, gitignored. Samples 1–15, 16–30 +and 31–45 carry identical inputs recipe for recipe, and block 1 is bit-identical +to `Final Summary Table` on thickness. It is the first dataset in this project +that can separate *the recipe moved the score* from *making and measuring the film +again moved the score*. Reproduce with: + +``` +python scripts/plot_extended_replicates.py \ + --workbook "local_inputs/Extended Summary Table C1C2.xlsx" \ + --config configs/campaign_d2d_perovskite_extended_c1c2.yaml \ + --outdir local_inputs/extended_c1c2_reports --align-blocks-to-first +``` + +That config is `status: diagnostic` and **is not a campaign contract**: it reads +all three scores as stored, including thickness, and its optoelectronic anchors +are derived from this data, which a real contract must never do. + +**Leave-one-out on this sheet leaks and the leak is large.** Hold out one row and +the recipe's other two repeats remain in training at identical inputs, so the GP +interpolates its own repeat. The row-wise LOO prediction correlates **+0.9989** +with "just average the other two repeats", and that naive baseline alone scores ++0.5711 against the GP's +0.5849. Leave-one-**recipe**-out drops all three. + +| objective (score as stored) | row-wise LOO | leave-one-recipe-out | recipe ICC | block share | +|---|---:|---:|---:|---:| +| uniformity | +0.5849 | **−0.2431** | **0.730** | 1.3% | +| optoelectronic | collapsed 45/45 | collapsed 15/15 | **0.000** | **84.5%** | +| thickness score | +0.7556 | **−0.2151** | 0.845 | 0.5% | + +Nulls: −0.0460 row-wise, −0.1480 recipe-wise. They differ because dropping 3 rows +of 45 moves the training mean further than dropping 1. + +**Three findings, and only the third is a modelling matter.** + +1. **Optoelectronic is a drift artefact.** All 15 recipes fall monotonically + block 1 → 2 → 3 (chance: 2.5 of 15), block 1 sitting ~120× above block 2. The + signal-collapse guard fires in every fold: the GP explains the column as pure + noise and its posterior mean is constant. This is metrology, not modelling. + The formula has also moved again — `AK` is now `=R2*X2*AA2`, a raw triple + product spanning 6.1e-11 to 6.2e-6, unnormalised. The v4 contract still + fingerprints the older `=(S2+((0.75*Y2)+(0.25*AB2)))/2`, so intake reports it. +2. **Uniformity is reproducible.** ICC 0.730, F 9.10, p < 0.00001; recipe spread + 0.080 against repeat spread 0.049. Earlier contracts recorded it as possibly + measurement-noise-limited; **that reading is now contradicted.** It is a real, + repeatable property of the recipe that ten inputs at fifteen distinct recipes + are too sparse to pin down. It responds to more distinct recipes and to + structure, not to a different acquisition. +3. **Squashing a measurement before the GP destroys the signal.** Same films, same + folds, leave-one-recipe-out: + + | thickness as… | R² | ρ | + |---|---:|---:| + | the stored score (Gaussian-squashed) | −0.2151 | −0.106 | + | raw nanometres | **+0.4082** | +0.627 | + | log(nm) | **+0.4266** | +0.624 | + + `EXP(-((T-650)/250)²)` is non-monotone, so 500 nm and 800 nm map to the same + score and the GP is asked to learn a fold. **This is why v4 trains thickness on + nanometres and applies the target afterwards** — and it is the strongest + available argument for eventually unfreezing uniformity and optoelectronic and + modelling their components rather than their composites. + +**Caveats on this sheet.** Only samples 1–15 carry raw component data; 16–45 hold +`AH`/`AI`/`AJ`/`AK`/`AL` as pasted literals with nothing underneath, so no +component-level analysis is possible on blocks 2 and 3 and nothing can cross-check +those values against measurements. Samples 17 and 32 still read +`speed_2 = 0, time_2 = 60`; the group's correction to sample 2 reached block 1 +only. The script reports that mismatch and, with `--align-blocks-to-first`, +applies the same correction to the later blocks. + +## The v3 DRY RUN, from 2026-08-17 (superseded) + +> **v3 rehearsed this contract's shape on test data** — its workbook was +> literally called "Test". It is superseded by v4 above and its config is +> archived. The sections below are its record: the mechanisms still apply +> (constraints, the round report, the simulation), and its fitted numbers +> are about objectives that have since been redefined. + + +A second dataset arrived and ran a different objective contract: two of the three +objectives were computed differently, the workbook's columns moved, two grids +changed, and this project's first real constraints went live. Those constraints +and mechanisms carry forward to v4 unchanged; the fitted numbers do not. + +| | v2 — algorithm testing | v3 — this section (now superseded by v4) | +|---|---|---| +| config | `configs/campaign_d2d_perovskite.yaml` (**archived**) | `configs/campaign_d2d_perovskite_test.yaml` | +| contract | `d2d-objectives-v2-nm-thickness` | `d2d-objectives-v3-test` | +| workbook | `local_inputs/Summary Table.xlsx` | `local_inputs/Summary Table Test.xlsx` | +| uniformity | `Coverage * (1-Uniformity) * Phase purity` | `mean(Coverage, 1-clamp(Uniformity), Phase purity)` | +| optoelectronic | `log10(Voc * Photoconductance)` | `mean(min(Voc,1.4)/1.4, Normalized photoconductance)` | +| thickness | mean of `T1..T4`, nm | unchanged | +| constraints | none, deliberately | three, active | + +The old config is archived rather than deleted, and stays complete and loadable: +every number in `GP_MODEL_DECISION.md` is about that contract. Archived means "do +not run new rounds against it". + +**Why a new file and not an edit.** Utility space is what hypervolume is measured +in. An objective that keeps its name while changing its construction makes every +cross-campaign number incomparable while every plot still renders — which is the +failure mode the `contract_version` key exists to prevent. + +**All three recipes reproduce the stored score columns**, worst disagreement +2.3e-13 across all 15 rows. Per the group, for this workbook the stored scores are +authoritative and the recompute is the cross-check, so a disagreement is a warning +finding rather than a block. + +### What the second dataset supports + +`scripts/intake_new_data.py`, 2026-08-17, exact leave-one-out, null −0.1480 at +N=15, resolution floor ±0.236: + +| objective | plain GP | with mean function | verdict | +|---|---:|---:|---| +| uniformity | **−0.6447** | — | below the null, **exploration only** | +| optoelectronic | **−0.5842** | −0.6977 | below the null, **exploration only**, mean function **deleted** | +| thickness | **+0.5227** | **+0.6630** | **learnable**; the swing is inside the floor | + +**Two of the three axes carry no signal.** A batch is therefore chosen on one +informative axis and two uninformative ones. That is a legitimate exploration +round, but it is not a three-objective optimisation, and the review must say so +rather than let the predicted numbers imply otherwise. + +**The optoelectronic mean function was deleted, and that is the designed +outcome.** The first campaign's linear `anneal_temp` trend was worth −0.342 → ++0.355 on its own score; here it makes the fit *worse*, −0.5842 → −0.6977. The +target was redefined underneath it, so the old evidence was never about this +quantity. Do not reinstate it from the archived config without a fresh verdict. +Issue 10 is the prime suspect for why the objective is unlearnable at all. + +**Thickness keeps its mean function, decided by the rank permutation.** The plain +GP now reaches +0.5227 where the first campaign's managed +0.116, so the trend has +much less left to explain and the +0.1403 swing is inside the ±0.236 floor — +*inconclusive on R²*, which is a statement that R² cannot resolve it at N=15 +rather than a verdict. `scripts/permutation_rank_test.py` adjudicated it on +2026-08-18: + +| | value | +|---|---:| +| observed rank ρ | **+0.7250** | +| null mean (sd) | −0.1917 (0.2937) | +| exceedances | **4 of 1800** | +| p | **0.0028**, 95% CI [0.0003, 0.0052] | + +Stronger than the first campaign's p = 0.0350 on its own films. **Rank is the +right statistic because rank is what the acquisition consumes** — it never sees +R². **Do not quote +0.1403 as evidence**; the permutation is what carries the +weight, and the swing is merely consistent with it. + +That two-part rule is now what the intake prints: (i) the structured fit must beat +the null by more than the floor; (ii) when structured-versus-plain lands inside the +floor, the permutation decides. + +### Are beta = 4.0 and radius = 0.25 defensible? + +**The live campaign runs beta = 4.0 and radius = 0.25** as of 2026-09-03. What +follows describes the sweep that produced the earlier 36 / 0.35 cell and then the +measurement that moved it; the sweep's central caveat — that it cannot *rank* +cells — applies to both settings equally. + +**What moved it.** At β = 36 the radius knob is provably inert: on the final +workbook at seed 73 the scan returns bit-identical batches at radius 0.15, 0.25 +and 0.35 (spacing 1.213, 18 of 50 coordinates pinned to a grid bound, identical +mean utilities). At β = 4 it binds, and the batch stops living on the corners: + +| beta | radius | HV gain | edge coords / 50 | spacing | sd ratio | +|---:|---:|---:|---:|---:|---:| +| 4 | 0.15 | +0.0000 | 13 | 0.682 | 5.9× | +| **4** | **0.25** | **+0.0009** | **11** | **0.781** | **5.8×** | +| 4 | 0.35 | +0.0000 | 13 | 0.941 | 6.1× | +| 9 | 0.25 | +0.0000 | 15 | 0.882 | 6.2× | +| 36 | 0.25 | +0.0000 | 18 | 1.213 | 6.5× | + +`sd ratio` is the mean posterior sd at the proposed points over that at the +measured ones. **Read the edge count and the spacing, not the HV gain**: +0.0009 +is far below the 0.010–0.027 trial sd this sweep measured, so it is a tiebreak. +The edge count and spacing are facts about the batch at a fixed seed. + +#### The sweep that produced the earlier cell + +They were determined by a +sweep over two instruments on the campaign's own data — per-round utility **box +plots**, and **heat maps**, which are 2-D slices through the higher-dimensional +Gaussian-process model — across **beta from 9 to 49** (9, 25, 36, 49) and **radius +from 0.05 to 0.45** (nine values, step 0.05), three starting designs per cell at +production settings. **Note that local penalization is inert at the current beta**; +the measured consequences are below. Outputs stay local (`local_outputs/`): they +are how the group picks a setting, not a result about the chemistry, and they are +not part of what this repository publishes. + +**They are a declared policy choice, not a measured optimum, and the distinction +matters.** That sweep **could not rank cells**: the whole spread across betas was +0.0065 against a trial-to-trial sd of 0.010–0.027, and the best cell was a +different (β, r) in every trial. It also scored candidates against a *noiseless +GP oracle of the same model class the optimiser fits*, so the landscape held no +surprises and exploration had unusually little to earn — it **systematically +undervalues large β**, which is the very thing this cell buys. + +The rationale for β = 36 was a posture, not a score: κ = √36 = 6, heavy +exploration, which reads as the right stance when **two of three objectives carry +no learnable signal** and the third is the only one worth exploiting. **Both +consequences it was known to carry are what eventually retired it:** + +* **Local penalization was inert at that β.** Achieved minimum batch spacing was + **1.091**, three times the 0.35 radius, so the knob had nothing to act on. The + sweep predicted this: radius binds *less* as β rises, and at β = 49 the nine + radii produced only 3–4 distinct batches. +* **The batch ran to the edges.** Range-edge coordinates per condition were + **[4, 7, 4, 3, 3]** — 21 of 50 — against 11–15 of 80 on the first campaign's + arm at β = 4. High exploration plus a monotone thickness trend puts candidates + at bounds. + +**The first revisit trigger fired twice.** Once when the photoconductance +normalisation was fixed (issue 10) — that time the posture survived, because +optoelectronic still did not beat the null. Again on 2026-09-03, when the extended +C1&C2 sheet showed the two dead axes are dead for reasons no β addresses; that +time it did not survive. **The second trigger — R1 measurements replacing the +oracle — has still not fired**, and until it does the sweep's central caveat +stands: no cell here has been *ranked*, only argued for. + +### The simulation at the ratified cell + +`scripts/plot_round_simulation.py --cell 0.25,4` runs one campaign against the +frozen oracle at exactly the decided knobs; `scripts/plot_boxplot_sweep.py +--betas 4 --radii 0.25` runs the same cell across the three starting designs so +the per-round boxes have a distribution behind them. Both default to the v3 +config. (The numbers reported immediately below were measured at the earlier +0.35 / 36 cell and have not been re-run.) Outputs: `local_outputs/round_sim_v3_cell` and +`local_outputs/boxplot_v3_cell`. + +Measured on the new data, seed 73: + +| | R0 | +R1 | +R2 | +|---|---:|---:|---:| +| hypervolume | 0.7929 | 0.7929 | 0.8053 | + +**R1 adds no hypervolume at all on this oracle, and R2 adds +0.0124.** That is +what β = 36 looks like against a landscape with no surprises in it: the batch +spends its budget on exploration that a noiseless same-class oracle cannot repay. +It is the caveat above made numerical — the instrument understates the case for +the policy it is testing — and not evidence that the cell is wrong. + +**The cross-instrument identity holds.** The simulated R1 batch hashes to +`60d1682aa055ca97`, the same as the live `run_r1_ucb` proposal from the measured +rows. The simulation is describing the batch the campaign would actually ship, not +a similar one. + +Pre-registered expectations, checked after: the two no-signal axes climb far less +than thickness (+0.1047 and +0.0842 against +0.3967) — **HELD**; the sweep-arm +rules report **NOT APPLICABLE** rather than FAILED, because a single ratified cell +has no arm to vary and calling that a failure would put red lines under a run that +did exactly what was asked. + +The dead axes' surfaces are rendered and captioned as fitted noise, never dropped. +Thickness's surface shows the declared `log T ~ log(speed_1) + log(precur_conc)` +trend, which is **consistency with what the config told the model, not a +discovery**. + +### The round report — figures at propose time + +Pressing **Propose next round** now also renders six figures beside the workbook, +in `_reports/_/`. A second button, **Figures +from current data**, renders the four that need no batch — use it the moment a +round's measurements are entered, before deciding whether to propose at all. Same +thing headless: + +```bash +python scripts/generate_round_report.py --workbook "local_inputs/Final Summary Table.xlsx" --data-only +``` + +**Every figure writes the CSV behind it**, plus a `manifest.json` recording the +contract version, seed, git describe, reference point, runtime and the active +notices. A PNG whose numbers cannot be re-derived is the next +plausible-finite-number bug; this project has had three. Two equalities are +asserted by tests rather than by convention: the parity numbers *are* +`intake_new_data.py`'s numbers (one shared fold loop in `mobo_kit.loocv`, not two +implementations that agree today), and figure 03's numbers *are* the Review +sheet's. + +| figure | what it shows | what it cannot claim | +|---|---|---| +| `00_batch_placement` | proposed recipes over the measured cloud, normalised to the declared grid, plus batch spacing | nothing about quality — only where in recipe space the batch goes | +| `01_loo_parity` | leave-one-out prediction against measurement, per objective, with LOO R² and the null | an axis marked NO LEARNABLE SIGNAL has a model that does not beat the null; its scatter is nothing, not a weak trend | +| `02_attribution` | mean \|SHAP\| per input per objective, in utility units | explains the **model**, not the world; features in a `mean_function` were *told* to it; on a no-signal axis the bars are fitted noise | +| `03_batch_predictions` | predicted measurement and utility per condition, plus the batch's ΔHV distribution and per-candidate P(non-dominated) | predictions, not measurements | +| `04_hv_trajectory` | cumulative observed hypervolume per measured round | monotone **by construction** — random sampling rises too, so this is progress, not proof of optimisation | +| `05_objective_space` | pairwise utility panels with per-pair fronts, 3-objective front ringed, plus one fixed 3D view | the Pareto set is non-dominated among what has been **measured**, not across the design space | + +**Runtime is about 15 s at N=15** on an idle machine, dominated by the 45 +leave-one-out refits (9.8 s) and the attribution (a few seconds at 15 instances). +The fold loop runs single-threaded on purpose: at 14×10 the matrices are small +enough that intra-op threading costs more than it buys — 9.8 s at one thread +against 15.1 s at this box's default of 12, bit-identical either way. + +*The first measurement of that recorded 51 s against 117 s and was wrong: it was +taken while sixteen permutation workers were saturating the CPU. The effect was +real but was of the load, not the thread count. A timing under contention is an +unreproduced number like any other, and this project's rule is that those get +re-measured rather than written down. Add the first render of a session to any of +these: matplotlib builds its font cache once, which cost about a minute here.* + +**A report failure never costs a batch.** The worklist and the Review sheet are +written before the figures are drawn; if rendering fails, `Generated.report_error` +says so and the batch stands. Inside the report, one failed figure is recorded in +the manifest and the rest still render. + +**Three notebook conventions were deliberately not ported.** + +* **In-sample parity.** Asking a model about points it was fitted on measures + memorisation; at N=15 in 10 dimensions it is close to a straight line whatever + the model knows. Parity here is leave-one-out. +* **Ad-hoc sign flips at plot time.** Objective polarity is a config contract + (`goal:`). Flipping a sign in a figure makes the figure disagree with the + optimiser, and only one of them is right. +* **Auto-referenced hypervolume.** The reference point is required and + campaign-fixed. A reference re-derived per call gave 6e-8 against 1.448 on the + same data once already — see issue 5. + +### The two grid edits + +Both forced by the measured rows; everything else carries over unchanged, and all +15 rows land on the declared grid. + +* **`time_2` now starts at 0** (was 10). Sample 2 is a one-step film — `speed_2` + and `time_2` both zero — so 0 has to be on the grid or a real recipe is + off-grid. Reaching 0 with step 5 also reaches 5, which the first campaign's grid + excluded and no film has run, so the hole is declared as a `nonzero_minimum` + constraint rather than filled in silence. +* **`anti_time` now steps by 1** (was 2), 9..25. Sample 1 runs `anti_time = 12`, + which the old grid could not hold; the first campaign carried it as a declared + off-grid exception excluded from pool bookkeeping. The axis goes from 9 values + to 17. + +An explicit value list would express `{0} ∪ {10..60}` directly and avoid the +`nonzero_minimum` workaround, but `lhs` asserts that every design grid is +uniformly spaced, so it would need changes to `design.py` and `lhs.py`. **Worth +raising with the group:** whether a 5 s second spin should ever be allowed, and +whether `anti_time` wants step 1 or an explicit list. + +### The constraints + +Declared in the new config, enforced by filtering the candidate pool before any +acquisition sees it, and re-checked independently by `validate_batch`. The +acquisition modules are byte-identical. `discrete_refinement` is **not** +constraint-aware and is not wired into a round; its docstring says so. + +| name | rule | why | +|---|---|---| +| `second_stage_all_or_nothing` | `speed_2` and `time_2` both zero or both nonzero | a stage at 0 rpm for 30 s is a contradiction; both zero is a one-step film, which sample 2 is | +| `antisolvent_lands_while_spinning` | `anti_time < time_1 + time_2`, strictly | dropping at exactly the end is already too late | +| `second_stage_runs_at_least_10s` | `time_2` is 0 or ≥ 10 | the declared hole in the arithmetic grid, above | + +All 15 measured rows satisfy all three. Observed rows are soft-checked only — +history is history, and a constraint that rejects a film the group actually ran is +far more likely to be wrong than the film is. + +**Watch `constraint_pool_survival_rate`** in the round diagnostics. The sampler +draws until it has the requested pool size, so a mis-specified constraint produces +a normal-looking pool drawn from a sliver of the space, and the survival rate is +the only place that shows. + +## Everything below this line is about the FIRST campaign + +> **The first campaign was algorithm testing.** Its 15 rows and its +> `d2d-objectives-v2-nm-thickness` contract existed to prove the loop worked, not +> to run an experiment. The sections below are its record and its numbers are +> about *its* objectives — uniformity as a product, optoelectronic as a log10 +> product — which the live campaign redefined. **Nothing here transfers unless it +> is method rather than measurement.** Where a mechanism still applies (how a +> round runs, what `Y_model` must contain, the acceptance test) it applies to +> both; where a fitted number appears, it is the first campaign's. + +## Running a round + +```python +from mobo_kit.campaign import load_campaign_config, run_r0_lhs, run_r1_ucb, run_r2_qlognehvi + +config = load_campaign_config("configs/campaign_d2d_perovskite.yaml") + +r0 = run_r0_lhs(config, n=15) # space-filling, no model +r1 = run_r1_ucb(config, X_phys, Y_model, n=5) # UCB-HVI + local penalisation +r2 = run_r2_qlognehvi(config, X_phys, Y_model, n=3) # qLogNEHVI +``` + +Each returns a `RoundResult` with: + +| field | contents | +|---|---| +| `conditions` | distinct proposed conditions, physical units, columns = input names | +| `replicates` | one row per film, with `candidate_id` / `replicate_group` / `replicate_index` | +| `diagnostics` | method, seed, pool size, objective contract version, validity report, fit warnings | + +Two warning keys, deliberately separate. `diagnostics["model_fit_warnings"]` holds +only the fit guard's own findings — the ones a human reviewing a batch must read, +and the ones the launcher and the `Review` sheet surface. +`diagnostics["fit_warnings_raw"]` holds everything the fits raised, including the +~18 numpy-2.0 deprecation notices per fit that this stack emits. Nothing surfaces +the raw list; it is there for debugging a strange fit later, because a BoTorch or +scipy convergence warning that the filter dropped is exactly what would be wanted +then. + +`diagnostics["validity"]` carries `min_pairwise_distance` and +`boundary_coords_per_condition`, which are the numbers to plot per round. + +## What `Y_model` must contain + +**Not the three stored score columns.** Since 2026-07-30 the objectives are +computed in Python from the raw measurement columns, and the stored cells are a +cross-check. Column order comes from `objective_names(config)`: + +``` +("uniformity", "optoelectronic", "thickness") +``` + +`read_campaign_workbook` returns exactly that as `contents.model_values`, so the +normal path is: + +```python +from mobo_kit.workbook_io import read_campaign_workbook + +contents = read_campaign_workbook("local_inputs/Summary Table.xlsx", config) +X_phys = contents.inputs.to_numpy(float) +Y_model = contents.model_values.to_numpy(float) # objective order +assert contents.errors == () # fail closed before fitting +``` + +Each value comes from a recipe declared in config (`objectives.specs[].measurement`): + +| objective | recipe | from | +|---|---|---| +| uniformity | `product` | `Coverage`, `1 - Uniformity` (computed), `Phase purity` | +| optoelectronic | `log10_product` | `PL - Implied Voc (Max)`, `Photoconductance (Max)` | +| thickness | `mean_of_present` | whichever of `T1..T4` were measured | + +Thickness is in **nanometres**, unrounded, because the GP trains on the raw +measurement and the 650 nm Gaussian is applied to the posterior. See +`GP_MODEL_DECISION.md` for why. Anything that collects data for the next round +must collect nm. + +`contents.findings` carries what the read noticed: cross-check mismatches, +readings the operator excluded, and films whose thickness readings disagree. +`contents.errors` is empty on the R0 rows; if it ever is not, do not fit. +`contents.inputs_used` records how many readings each value came from, which is +what Phase 4 needs to turn a spread into an observation variance. + +## For the plotting work + +**This is now implemented.** `scripts/plot_round_simulation.py` runs the whole +loop against a frozen GP oracle and renders contour slices, per-round boxplots and +a hypervolume line, with a batch-identity manifest +(`docs/ROUND_SIM_MANIFEST.md`). Read `docs/ROUND_SIM_DELTA.md` before extending +it. The recipe below is kept because it is what any new plotting code has to get +right, and both conventions still fail silently. + +**Contour slice through the GP.** Fit with the same path a round uses, then +evaluate on a 2-D grid with the other eight inputs held fixed: + +```python +from mobo_kit.campaign import ( + build_objective_transform, + fit_campaign_models, + normalise_inputs, +) + +# same normalisation, structured means, variant and seeding as the round itself, +# so this reproduces the round's model rather than a similar one +model, fit_warnings = fit_campaign_models(config, X_phys, Y_model, seed=73) +assert not fit_warnings # a fit can succeed and still deserve distrust + +model.eval() +with torch.no_grad(): + post = model.posterior(torch.tensor(grid_norm)) # grid_norm in [0,1]^10 + mean, var = post.mean, post.variance +``` + +Two things to respect when turning that into a utility surface: + +* the GP output for thickness is **log(nm)**, not nm. `ObjectiveSpec.model_link` + records this. Use `transform.expected_transform(mean, var)` rather than + transforming the mean yourself; it dispatches per objective and integrates the + lognormal by quadrature where needed. +* inputs are normalised to `[0,1]` against the config grid bounds, not the + observed range. `normalise_inputs(config, X_phys)` is the conversion. A model + fitted on config bounds and evaluated on observed-range coordinates is being + asked about different points than it was told about, and nothing errors. + +**Round-comparison plot.** Keep each `RoundResult` and plot `conditions` per +round on shared axes (R0 blue `#2a78d6` / R1 orange `#eb6834` / R2 green +`#1baf7a` — the palette `plot_dtlz2_report.py` and `plot_round_simulation.py` +both use, so project figures read as one set), plus per-round +`min_pairwise_distance` and boundary counts from `diagnostics`. Contour slices +should show **23 distinct conditions**, not 39 films -- replicates share inputs +and would otherwise overplot. + +Hypervolume is comparable across rounds only because objective scales are fixed +in config; `assert_scaling_is_campaign_fixed` enforces that. Do not re-derive +scales from observed data between rounds. + +## Model state + +Validated on the 15 R0 observations, exact leave-one-out, null R2 = -0.148. These +are the canonical numbers, as `scripts/intake_new_data.py` reports them — same +pipeline and same inputs the model uses: + +| objective | plain GP | with structured mean | swing | +|---|---:|---:|---:| +| thickness (nm) | +0.116 | **+0.381** | +0.265 | +| optoelectronic | -0.342 | **+0.267** | +0.609 | +| uniformity | no learnable signal (permutation p = 0.82) | n/a | — | + +Both swings clear the ±0.236 sampling floor. `GP_MODEL_DECISION.md` records +slightly different figures (+0.183 → +0.384 for thickness, and +0.355 for +optoelectronic); those came from an older instrument reading the workbook's rounded +`Thickness (avg)`, and both differences are accounted for — see issue 1 and the +intake section below. No conclusion depends on which set you read. + +Thickness rests on its rank permutation (p = 0.0350), not on the R2 swing. +Uniformity is exploration-only by measurement, not by choice; the interface must +not imply the model knows more than it does about it. + +## Reading a round's results back + +`read_candidate_results(source_workbook, config, "R1")` reads the filled-in +candidate sheet and returns design points, not films: + +| field | contents | +|---|---| +| `conditions` | one row per condition, input columns | +| `model_values` | one row per condition, objective columns, **aggregated** | +| `replicates` | one row per film, with its own objective values | +| `replicate_spread` | per-condition sd, in each objective's aggregation space | +| `films_used` | how many films each observation was aggregated from | +| `findings` | the same note / warning / error list as the source read | + +Objective values are computed per film with the same recipes Sheet1 uses, so R0 +and R1 observations are commensurable, and only then aggregated per +`replicate_group`. + +**Thickness aggregates in log space** (`replicate_aggregate: mean_of_log`), because +`response: log` means the GP trains on `log T` — the geometric mean is the +arithmetic mean in the space the model works in, and it is the choice consistent +with pooling `train_Yvar` in log space. The difference from a plain mean is second +order in the replicate spread: under 0.1% at the ~3% spread most R0 rows show, +about 14% on a film set as inconsistent as sample 12's. It is one config key per +objective if the group prefers otherwise. + +`replicate_spread` is what Phase 4 (issue 7) pools, and it is already in the right +space: a sd of `log T` for thickness, a sd of the value itself for the other two. +It is NaN for a single film, which is honest — one film measures no +reproducibility at all. + +## Synthetic acceptance test + +`tests/test_dtlz2_acceptance.py` runs DTLZ2 (3 objectives, 10 inputs, known +Pareto front) end to end through `campaign.py`. It exercises the algorithm with +no dependence on whether the experimental measurements are right. + +```bash +pytest tests/test_dtlz2_acceptance.py -m "not slow" # 10 tests, ~12 s +pytest tests/test_dtlz2_acceptance.py -m slow # BO vs random, ~33 s +``` + +Measured on the negated DTLZ2 (max_hv = 0.807): + +| | R0 (15) | +R1 (5) | +R2 (3) | +|---|---:|---:|---:| +| hypervolume | 0.507 | 0.555 | 0.612 | + +Batch spacing: R1 min pairwise 0.735, R2 0.859, against a configured floor of +0.15 -- local penalization is separating candidates, not merely not failing. + +**Cumulative hypervolume rises monotonically by construction**, so that alone is +not evidence of optimisation -- it would hold for random sampling too. The +informative result is the baseline comparison at equal budget (8 extra points +from the same 15-point start): + +| | mean HV gain | +|---|---:| +| Bayesian optimisation | **+0.075** | +| random on-grid search | +0.056 | + +A ratio of **1.35x**, and BO wins on **5 of 8 seeds** -- on the mean, not every +seed. With 8 added points in 10 dimensions that is the honest expectation, so the +test asserts the mean and not a per-seed win. + +Two conventions that fail *silently* if got wrong, both now covered: + +* DTLZ2 minimises by default; `negate=True` is mandatory or the test measures the + opposite of optimisation. +* BoTorch's `Hypervolume` assumes maximisation and **silently drops points that + do not dominate the reference** -- no warning, no exception, just a smaller + number or 0.0. The helper asserts at least one point dominates before + trusting the result. + +## The numbered issues -- read before trusting a batch + +**Issues 1-9 are the first campaign's**, kept because each one's evidence is the +reason a decision holds and because several are the sort of thing that gets +rediscovered and re-argued. **Issue 10 is the live campaign's and is open.** + +Kept numbered and in place even once closed, because each one's *evidence* is the +reason a decision holds, and because several are the sort of thing that gets +rediscovered and re-argued. Status is stated at the top of each. + +1. **CLOSED 2026-07-30. The 0.089 on optoelectronic is a numerical artifact, not a + modelling difference.** The two pipelines specify *the same model*: fitting a + zero-mean GP to `y - trend` and fitting a fixed-mean GP to `y` with mean + `trend` have identical marginal likelihoods, because a fixed mean only shifts + the data. So there was never a modelling question to answer — only a question + about why two routes to one model disagreed. + + Two contributions, measured: + + | | two-stage | mean module | gap | + |---|---:|---:|---:| + | with `Standardize` (production) | +0.3551 | +0.2670 | **+0.0881** | + | without `Standardize` | +0.3385 | +0.2670 | +0.0715 | + + *Standardization scale accounts for about 19%.* With the transform in place the + two pipelines standardize different quantities — the residual in one, the target + in the other — so the outputscale and noise priors, which are defined on + standardized units, act on differently-scaled residuals. Removing it moves the + gap from 0.0881 to 0.0715. + + *The remaining 81% is the MLL optimiser.* With the transform gone the likelihood + surfaces are identical, yet the fits land in slightly different places: across + folds the outputscale differs by up to 2.7%, the noise by 2.7%, and the median + lengthscale by **9.6%**. At N=15 that is enough to move LOO R² by 0.07. The + optimiser is deterministic — the earlier seed sweep found bit-identical results + across four seeds — so this is a different starting point on one surface, not + stochastic variation. + + **The mechanism, stated first because that is the rule.** The gap is roughly + one-fifth a definitional difference between two legitimate conventions and + four-fifths the optimiser landing in a different place on one identical + objective. Both parts are named, measured and reproducible. This project's own + rule is that a deterministic difference on the same rows must be *explained*, + not absorbed into a floor — so the explanation comes first and the floor comes + after it. + + **The floor, as a corollary.** Given the mechanism, 0.0881 is also inside the + ±0.236 sampling floor, and its optimiser component is exactly the measurement + that established the ≈0.07 numerical-reproducibility floor — see + `GP_MODEL_DECISION.md`, "Three floors". So it was never evidence of anything. + That is a consequence of the explanation, not a substitute for it. + + The campaign uses the mean-module convention, the one wired into `campaign.py`. + No action. Kept below for the reasoning, because "two implementations disagree" + is the sort of thing that gets rediscovered. + + --- + + *Original entry, narrowed 2026-07-30 before the closure above.* + Two implementations of the same pipeline on the same 15 rows give LOO R2 +0.355 + (two-stage) and +0.267 (mean module). Reproduced exactly: **+0.0881**. + + **MLL optimiser seeding is ruled out.** Both pipelines give bit-identical LOO R2 + across seeds 7, 73, 137 and 2024 — 0.3551 and 0.2670 every time, zero variation. + That suspect is closed. + + **The standardization-scale suspect is back, and quantitatively consistent.** It + was previously recorded as ruled out "because the direction contradicts the + observed asymmetry"; the measured direction does not contradict it. The two + pipelines hand `Standardize` different things — two-stage standardizes the + *residual*, the mean module standardizes the *target* and then subtracts a + standardized trend — so the deviation the covariance must explain has sd 1.0 in + one and `sd(residual)/sd(target) = 0.762` in the other. The fitted outputscales + match that prediction to 4%: + + | | median outputscale | median noise (standardized) | + |---|---:|---:| + | two-stage | 0.8365 | 0.006516 | + | mean module | 0.4681 | 0.006443 | + | predicted for the mean module, `0.8365 × 0.762²` | 0.4859 | — | + + **The attempt to confirm it failed, and the test was the problem, not the + hypothesis.** Inflating the residual to the target's sd before fitting moved LOO + R2 by +0.0002 — because `Standardize` divides by whatever sd it is given, so + scaling its input is a no-op. That experiment was vacuous by construction and + proves nothing either way. Recorded so nobody re-runs it. + + **The specific next test**, for whoever picks this up: the two pipelines cannot + be separated while both re-standardize, so disable `Standardize` in both (or + standardize both by the same fixed constant) and see whether the gap survives. + If it vanishes, the cause is that the outputscale and noise priors are defined + on standardized units and the two pipelines standardize different quantities. + That is a ~20-line experiment against `_build_single_task_gp`. + + Both numbers remain far better than plain (-0.342), so the direction is not in + doubt and the mean module stays either way. The gap should be closed before + optoelectronic candidates are acted on. + +2. **Done, 2026-07-30 — kept here because the audit is the evidence for how the + objectives are now computed.** Three of the workbook's derived columns are + pasted literals, not formulas. Audited on all 15 rows, 2026-07-29: + + | col | quantity | kind | agrees with recomputation | + |---|---|---|---| + | `Z` | `Uniformity score` | formula `=L2*N2*O2` | exactly | + | `R` | `log10(P*Q)` | formula `=LOG(P2*Q2)` | 1.8e-15 | + | `AA` | `Optoelectronic score` | **literal**, copy of R | 1.8e-15 | + | `Y` | `Normalized thickness` | formula on **X** | — | + | `AB` | `Thickness score` | **literal**, from the **unrounded** T mean | 4.8e-10 | + | `X` | `Thickness (avg)` | **literal**, `ROUND(mean(T1..T4))` | 0.5 nm | + + Two things this changes. First, **`AB` is not a copy of `Y`**: `Y` evaluates + the Gaussian on the rounded `X`, while `AB` was pasted from the same Gaussian + on the unrounded T1..T4 mean. They disagree by up to **1.7e-3** already + (sample 8: 0.651997 against 0.653702). The campaign path reads neither -- it + trains on `X` -- so this is harmless there. `scripts/gp_diagnostic.py` does read + `AB` (its `OBJECTIVE_COLS` are Z/AA/AB), where 1.7e-3 is immaterial to a + variant comparison. Harmless either way today, but it is the same silent + divergence that produced the original uniformity discrepancy, sitting in the + file right now. + + Second, **the column the GP trains on is itself derived and rounded.** `X` is + `mean(T1..T4)` rounded to whole nanometres (sample 4: 663.75 -> 664; sample + 12: 1154.5 -> 1155). Against `sigma = 176.8` nm a 0.5 nm error moves the + utility by under 1e-5, so this is immaterial numerically. It is worth knowing + that no raw measurement column feeds the model directly. + + **What was done.** `src/mobo_kit/scores.py` computes all three objectives from + the measurement columns; `Z`, `R` and `X` became cross-checks that warn on + disagreement, with a per-column tolerance because a live formula and a + deliberately rounded literal do not deserve the same one. On the R0 rows the + recomputation reproduces `Z` to 1.1e-16, `AA`/`R` to 1.8e-15, and `X` to the + 0.5 nm its rounding allows, so nothing about the campaign's numbers changed + except that thickness is now unrounded. The formulas came from + `git show pre-cleanup-2026-07-29:src/mobo_kit/d2d_scores.py` with the polarity + inverted. + + **`Y` and `AB` are deliberately not cross-checked.** They live in utility + space, and a check would have to duplicate the Gaussian that `objectives.py` + owns. Nothing reads them now, so there is no dependency to protect — the + 1.7e-3 divergence above is recorded rather than monitored. If a future reader + ever needs them, check them through `ObjectiveTransform.transform` rather than + re-implementing the transform in `scores.py`. + +3. **openpyxl discards cached formula values on save.** Verified: Z2:Z4 read + `[0.657, 0.587, 0.561]` before a save that only added an empty sheet, and + `[None, None, None]` after. This is why `workbook_io` writes candidates to a + sibling file and never opens the source for writing. Do not "simplify" that + by adding sheets to `Summary Table.xlsx`. + +4. **Done 2026-07-30 — the review artifact exists; the human review itself is + still owed.** `batch_review.py` writes a `Review` sheet into the candidate + workbook and echoes it into the launcher pane: proposed conditions in physical + units, predicted utility and sd per objective through the acquisition's own + posterior-sample path, the prediction decoded into the measurement's units + (median plus a 68% interval, multiplicative for the log-link thickness), + normalised distance to the nearest observed point, and which coordinates sit at + a range edge rather than only how many. Findings from Sheet1 travel with it, so + the sheet can be forwarded on its own. + + **The batch this described was withdrawn and reissued on 2026-07-31** — see + `docs/R1_BATCH_WITHDRAWAL.md`. Four of its five conditions survived unchanged, + including `R1_C01`, which is the condition the numbers below are quoted from, so + **these figures are unchanged and were re-read from the reissued artifact rather + than assumed.** + + **What the artifact says about the R0-trained batch**, on the two flags raised + earlier: + + * `speed_1 = 1000` — the declared probe moves each candidate to the corner and + compares. For `R1_C01`, thickness utility falls from 0.786 to 0.223 while the + sd ratio is **1.02**. Across all five conditions the mean falls 0.791 → 0.299 + with a mean sd ratio of **1.10**. Either way the region is not being skipped + as unexplored, it is being skipped as known and bad. `speed_1` is a feature of + the thickness mean function, so that confidence is a fitted global trend + extrapolating to its range edge, not a local average of samples 1 and 12 — and + the two points anchoring that edge disagree, one of them (sample 12) holding + `ROUND(mean(1600, 709))`. So the corner is a measurement question, as + suspected, but by a different route than "the contradiction was averaged into + confidence". The reissued batch's minimum `speed_1` is still 1500. + * `anneal_temp` at 100–105 in all five conditions is a declared standing note: + a monotone linear mean puts the optimum at a range edge by construction. The + open question is chemical, and if a floor exists it belongs in `constraints:`. + Unchanged in the reissued batch. + + Probes and notes are declared in `configs/…yaml` under `review:`, not hardcoded. + +5. **Done 2026-07-30 — `metrics.compute_ref_pareto_hv` required an explicit + reference.** The `ref_point_np=None` path used `mins - 1e-8`, essentially the + nadir itself: measured HV 6e-8 against 1.448 from `infer_reference_point` on + the same data, and re-derived per call so hypervolumes were not comparable + across iterations. Passing no reference now raises and names + `reference_point_utility`; a reference that nothing dominates also raises, + instead of returning the 0.0 that BoTorch's silent point-dropping produces. + + The precise condition, pinned in `tests/test_metrics.py`: `mins - 1e-8` is + harmless while some *dominated* point sets the per-objective minima, and + collapses once the Pareto set itself sets them — each point best in one + objective and worst in another, which is what a genuine trade-off front is. + Plotting code may now simply pass `config["reference_point_utility"]`. + +6. **Done 2026-07-30 — the signal-collapse guard now distinguishes a collapsed GP + from a mean function that works.** It used to compare + `gp.posterior(X).variance` against the fitted noise and stop there. A mean + module does not enter the variance, so when a structured mean explains most of + the data the residual GP's latent sd goes to ~0 and the guard raised + `ModelFitError` — asserting "its posterior mean is effectively constant", which + is verifiably false in that case, because `posterior().mean` carries the trend. + + Two situations share one numeric signature and now get different answers: + + * **True collapse**: zero-mean GP, outputscale → 0, posterior mean genuinely + flat, nothing can be ranked. Still `ModelFitError`. + * **The mean function did its job**: residual variance ~0, posterior mean + tracks the trend, ranking still works. Now a loud warning and the round + proceeds. Refusing would dead-end the campaign at the moment the physics model + started working, with no remedy — better data cannot be collected without + first proposing conditions. The review artifact is the designed gate. + + The warning is not a formality, and says so: UCB's exploration term reads the + latent posterior that just collapsed, and the mean module's coefficients are + frozen buffers with no uncertainty of their own, so the narrow intervals such a + model reports are **understated rather than earned**. It appears above the + numbers in both the launcher pane and the `Review` sheet, and in + `RoundResult.diagnostics["model_fit_warnings"]`. + + Two calibration notes worth keeping: + + * "Near-constant" is measured against the **observed spread of that + objective**, not against the fitted noise sd. Noise-relative was the first + attempt and is wrong: the noise is inflated precisely in the degenerate case, + so the test co-varies with what it is trying to detect. Measured instance — a + linear mean on `anneal_temp` against a forced noise of 0.9 scored 0.38 on the + noise yardstick and would have been called constant while it was tracking the + data. Floor is 5% of the observed spread. + * Only the guard's own warnings reach a human. `record.warnings` also collects + every Python warning raised during fitting — about 18 numpy-2.0 deprecation + notices per fit on this stack — and putting those in front of someone + reviewing a batch is how people learn to ignore warnings. + + Whether a given dataset trips the collapse is knife-edge: measured across + residual magnitudes from 0 to 0.3 it fires at 0, 1e-4, 0.01 and 0.03 but not at + 0.001 or 0.1, because it depends where the MLL optimiser lands. The guard's + decision is therefore tested directly, and the propagation tests force the + condition rather than hoping data produces it. No fit on the current R0 data + warns, so nothing about the live campaign changed. + +7. **Phase 4 is wired and waiting for data (2026-07-30).** `replicate_variance.py` + pools between-film variance from the replicate scatter and hands it to the model + as `train_Yvar`; `run_r1_ucb` / `run_r2_qlognehvi` / `fit_campaign_models` take + `observed_Yvar`, and the launcher builds it automatically once the config asks. + Enabling it when the triplicates land is one key — + `model.observation_noise: replicate_pooled` — which is the point of wiring it + before the data exists. Tested against synthetic replicates. + + Four things worth knowing before touching it: + + * **The variance handed over is of the MEAN**, `pooled / n_films`, because the + observation is an average of n films. Passing the single-film variance would + be three times too large on a triplicate — *overstating* uncertainty, so the + model would trust the most carefully replicated conditions least — and nothing + errors. + * **Between-film and within-film are different quantities.** Between-film is + what `train_Yvar` needs. The within-film 0.0593 on `log T` (24 dof) contains + no run-to-run variation at all, so it is a **floor**: if the pooled + between-film variance ever lands below it, films would be more reproducible + than points on one film, and `sanity_floor_findings` says so. + * **BoTorch silently ignores `train_Yvar` if a `likelihood` is also passed.** + Verified on 0.15.1: the likelihood wins, stays single-element, and the + replicate information is dropped with no error. `_build_single_task_gp` passes + one or the other, never both. + * **`Standardize` rescales `train_Yvar` along with the targets**, so it must + arrive in the target's own units — and in the model's space, which for + thickness is `log T`, not nanometres. That is why aggregation and variance + pooling are required to share one space. + + Zero pooled variance is refused rather than passed on: replicate films that + agree to the last digit are a transcription, not a measurement, and a zero + `train_Yvar` tells the model the observation is exact. + +8. **Done — the legacy leftovers are gone.** The tkinter launcher landed + 2026-07-30 (`launcher.py`, plus the two double-click scripts; see the README). + The legacy debug ceremony went earlier: `production_gate.py` and 22 other Step + 1/2A/2B/2C modules were removed in `33f101f`, and + `test_validity_report_carries_no_approval_flags` holds the approval tiers out. + +9. **Fixed 2026-07-31 — `run_r1_ucb` scored every candidate against a baseline + whose thickness axis had collapsed to zero.** This is the most consequential + defect found in this project, and the R1 batch it produced was withdrawn: + `docs/R1_BATCH_WITHDRAWAL.md`. + + `ObjectiveTransform.transform` is a MODEL-OUTPUT decoder — it applies `exp()` + to a log-link objective before computing utility. `run_r1_ucb` handed it + `observed_Y_raw`, thickness in **nanometres**, so the value was exponentiated a + second time. `exp(360…1303)` saturates the 650 nm Gaussian to exactly `0.0`. + + | | as called | correctly encoded | + |---|---:|---:| + | observed baseline hypervolume | **0.004659** | **0.436442** | + | baseline Pareto set | 2 points | 5 points | + + A factor of 94, and every candidate's improvement was measured against a front + with no thickness axis at all. On the live batch this moved one of five + conditions and the minimum spacing from 0.9209 to 0.6337 — so it also inflated + the spacing figure that this document used to argue `radius` was inert. + + **The fix** is `ObjectiveTransform.encode_measurements`, with + `transform_measurements` as the one-call safe route, and `run_r1_ucb` encoding + before it proposes (commit `4b76670`). `ucb_hvi.py` is untouched — it is one of + the frozen acquisition modules and the defect was in `campaign.py` + orchestration. **Annie Xu found and fixed this independently on + `ax_plots_simulation` before we knew it existed**; the fix promotes her + `_physical_to_model_output` to the public contract. + + **Every `ObjectiveTransform.transform` call site was audited.** `objectives.py` + 403/481/516 and `ucb_hvi.py:342` all operate on posterior samples, already in + model space; `batch_review.py` never routes measurements through the transform + at all. Exactly one call site was defective, `ucb_hvi.py:698`, reached only + from `run_r1_ucb`. R2 was never affected — qLogNEHVI takes `train_X_norm` and + derives its baseline through the model. + + **This is the third plausible-finite-number failure in this project**, after + the hypervolume auto-reference (issue 5) and the silently swallowed + `train_Yvar` (issue 7). All three share one shape: **a wrong answer that is + finite, ordinary-looking, and compared against nothing.** No guard fires + because nothing is out of range; the number is simply not the number anyone + meant. The lesson is not "add more guards" — each of these passed every guard + it met — it is that **a quantity no test reproduces independently is a + quantity nobody is checking.** `run_r1_ucb` now reports + `observed_baseline_hypervolume` and `observed_baseline_pareto_size` so the + value is observable from outside, and the tests recompute both by a separate + route. + + **Why 446 tests missed it.** Every objective in the synthetic acceptance test + was affine, and for an affine objective measurement space and model space are + the same numbers — a link-encoding mistake is invisible *by construction*. + `test_dtlz2_acceptance.py` now also runs with a log-link objective, so every + end-to-end pass exercises both link types the campaign uses. + +10. **OPEN, second campaign. `Normalized photoconductance` does not rank like the + photoconductance it summarises, and it is half of the optoelectronic + objective.** Nothing in the workbook derives that column — it arrives already + normalised, from outside — so a recipe can only take it on trust, and a + normalisation that has come loose from its measurement is invisible: every + value is in range, every row computes, and the objective is simply about + something other than it says. + + Rank agreement is the check that needs no formula. Whatever the intended + mapping is, it must preserve order. Measured over the 15 R0 rows: + + | | value | + |---|---:| + | Spearman(`Photoconductance (Max)`, `Normalized photoconductance`) | **−0.5484** | + | p | **0.0343** | + | strongest film, 8.81e-07 (sample 15) | normalises to **0.010**, the column minimum | + | films at exactly 1.000 | samples 4, 7 and 10, all at low raw photoconductance | + + So the axis currently rewards *weaker* photoconductance, and it is significant + rather than noisy. **The group has flagged this and is supplying the intended + formula.** + + **This is very likely why optoelectronic is unlearnable.** Its plain GP sits + at −0.5842, below the null, and the first campaign's mean function makes it + worse rather than better. No model can learn a column that ranks backwards + against its own measurement, and half of this objective is that column. + + **Reported as a graded finding, never as a gate**, by + `scores.AgreementCheck`: which column the model trains on is the group's + decision, and a diagnostic that blocked a round would make that decision by + refusing to run. It appears in the workbook read, in + `scripts/intake_new_data.py` output, and as a standing notice on the `Review` + sheet, so nobody reviews a batch without knowing the axis is provisional. + + **Closure path: one recipe edit plus one intake run.** Replace the + pass-through input with the real derivation in + `objectives.specs[1].measurement`, bump `contract_version`, re-run the intake, + and re-decide the mean function — it may well earn its place once the column + tracks its measurement. `test_second_campaign.py` pins the −0.5484, so that + test failing is the signal to update the record rather than to loosen the + check. + +**Nothing on this list is now blocked on code.** What remains is a human reading a +proposed batch (issue 4), the R1 triplicates arriving (issue 7), the +photoconductance formula (issue 10), and a decision about whether an +`anneal_temp` floor belongs in `constraints:` — which is now a live list rather +than an empty one, so adding it is a two-line change. + +## Are beta = 4.0 and radius = 0.25 defensible? (FIRST campaign) + +> Superseded for the live campaign by "Are beta = 4.0 and radius = 0.25 +> defensible?" near the top of this document. Kept as the record of how the first +> campaign's defaults were checked. + +`scripts/dtlz2_parameter_sweep.py`, 8 seeds per cell, `min_batch_distance` fixed at +0.15. Metric is mean hypervolume gain over the R0 start for the 8 points R1 and R2 +add, against a random on-grid baseline at the same budget (+0.0453 in every cell, +since it does not depend on either knob). + +| beta | radius | mean gain | per-seed sd | min spacing | edge coords / 80 | +|---:|---:|---:|---:|---:|---:| +| 2 | 0.15 | +0.0816 | 0.0508 | 0.719 | 16.4 | +| 2 | 0.25 | +0.0781 | 0.0493 | 0.810 | 16.5 | +| 2 | 0.35 | +0.0801 | 0.0481 | 0.955 | 16.9 | +| 4 | 0.15 | +0.0776 | 0.0440 | 0.719 | 16.0 | +| **4** | **0.25** | **+0.0780** | **0.0428** | **0.891** | **16.8** | +| 4 | 0.35 | +0.0961 | 0.0735 | 0.982 | 17.0 | +| 8 | 0.15 | +0.0868 | 0.0523 | 0.871 | 16.9 | +| 8 | 0.25 | +0.0868 | 0.0523 | 0.871 | 16.9 | +| 8 | 0.35 | +0.0839 | 0.0458 | 0.953 | 17.2 | + +**No change.** The pre-committed rule required a challenger to beat +0.0780 by more +than the per-seed sd of 0.0428 — that is, to exceed +0.1208 — without reducing +spacing; seven cells have a higher mean and none comes close, the whole grid +spanning +0.0776 to +0.0961 against sds of 0.043 to 0.074. BO beats the random +baseline on the mean in 9 of 9 cells, so the sweep is measuring optimisation rather +than noise, and the edge-coordinate count is flat at 16–17 of 80 across every cell, +which says neither knob is what drives batches onto range edges (on the live +campaign that was the monotone `anneal_temp` mean function). + +**One limit worth stating**: `radius` is not binding **on DTLZ2**. Achieved batch +spacings there are 0.72–0.98, far above every radius tested, so local penalization +rarely has two candidates close enough to penalise — visible in `beta=8` giving +identical results at radius 0.15 and 0.25. This sweep therefore validates `beta` +properly and says little about `radius` on that problem. + +**It does bind on the live campaign, and the earlier claim here that it probably +did not was itself an artifact.** That claim rested on the R1 batch's minimum +spacing of 0.921 — a number produced by the mis-encoded UCB-HVI baseline described +in `docs/R1_BATCH_WITHDRAWAL.md`. With the baseline corrected the live R1 batch +spaces at 0.6337, and the round simulation measures a clean monotone staircase +(`scripts/plot_round_simulation.py`, 13 cells, seed 73, oracle-scored R0): + +| radius | 0.05 | 0.10 | 0.15 | 0.20 | 0.25 | 0.30 | 0.35 | 0.40 | 0.45 | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| achieved R1 spacing | 0.455 | 0.455 | 0.455 | 0.543 | 0.720 | 0.921 | 0.921 | 0.921 | 0.921 | +| range-edge coords | 11 | 11 | 11 | 12 | 13 | 13 | 15 | 15 | 15 | + +Nine cells produce **six distinct R1 batches**. `radius` binds below about 0.30 +and saturates above it, and it buys spacing at a measurable cost: **11 → 15 +range-edge coordinates across the arm.** That trade-off — diversity against +edge-pinning — had not been measured before, and it is a policy choice for the +group rather than a tuning question. + +**`radius = 0.25` stays the default for now**, mid-staircase, but as a declared +choice rather than an inherited one. Note the numbers above are single-seed: +*which* batch a cell proposes is a fact, because the pipeline is deterministic at +a fixed seed, but the hypervolumes cannot rank cells at n = 1. Re-run the radius +arm at ~5 seeds before changing the default on performance grounds. + +Inert is acceptable for a safety knob, but then it has to be shown to work +deliberately rather than inferred from a campaign that never exercised it. +`test_radius_pushes_the_second_pick_out_of_the_penalised_neighbourhood` does that +by construction: three candidates crowded 0.02 apart scoring better than an +isolated fourth, where greedy selection takes the two best and penalization pushes +the second pick beyond the radius. Its companion pins the inert case — a radius +smaller than the gaps must change nothing. + +## When new data arrives + +One command: + +```bash +python scripts/intake_new_data.py --workbook "local_inputs/Summary Table.xlsx" +``` + +The group has always called the current numbers test data, so a replacement was +expected. When it lands, the question is not whether the code runs — the tests +answer that — but whether the model commitments this campaign made still earn +their place on the new rows. Several were justified by measurements on 15 specific +rows and do not transfer. + +It prints, per objective: the read audit and its findings; whether the declared +`mean_function` still beats the leave-one-out null by more than the resolution +floor, naming the exact config block to delete if not; the fit guard's status, +including the case where the mean function explains so much that the residual GP +collapses; whether the fixed anchors still span the data; and whether the +campaign-fixed scaling guard passes. + +**Both floors are recomputed at the new N rather than reused.** The null is +`1 - (N/(N-1))²` — −0.148 at 15, −0.105 at 21, −0.069 at 31. The ±0.236 resolution +figure was a bootstrap at N=15 and is rescaled by `sqrt(15/N)`, labelled in the +output as an estimate: re-run the bootstrap if a decision turns on the third +decimal. + +On the current 15 rows it reports: uniformity does not beat the null (−0.681), +optoelectronic keeps its mean function (−0.342 → +0.267, swing +0.609), thickness +keeps its mean function (+0.116 → +0.381, swing +0.265). The guard is clean for +both. + +**This is the canonical instrument for LOO numbers from now on.** It reports plain +thickness at +0.116 where `GP_MODEL_DECISION.md` records +0.183; that was +reconciled on 2026-07-30 and the whole difference is the data, not the method. The +older instrument read the workbook's stored `ROUND(mean(T1..T4))`; the model now +trains on the unrounded mean. Seven of fifteen rows change, by at most 0.50 nm, and +that alone moves LOO R² by 0.067 — the pipeline contributes exactly nothing, since +with no mean function the two routes are the same code. Same fragility as the 0.089 +above, and comfortably inside the ±0.236 floor. Neither conclusion changes: the +structured swing is +0.201 on the old values and +0.265 on the new. + +## Reproducing the analysis + +```bash +python scripts/gp_diagnostic.py --variants legacy_matern_no_prior dim_scaled_prior +python scripts/validate_structured_means.py +python scripts/thickness_objective_check.py +python scripts/dtlz2_parameter_sweep.py # beta x radius, needs no data +``` + +All but the sweep need the ignored private workbook at +`local_inputs/Summary Table.xlsx`. diff --git a/docs/GP_MODEL_DECISION.md b/docs/GP_MODEL_DECISION.md new file mode 100644 index 0000000..2769c0f --- /dev/null +++ b/docs/GP_MODEL_DECISION.md @@ -0,0 +1,465 @@ +# GP model decision record + +Measured 2026-07-28 on the corrected `Summary Table.xlsx` (15 R0 observations, +10 inputs). Reproduce with `python scripts/gp_diagnostic.py`. + +> **This document is about TEST DATA.** Its contract was +> `d2d-objectives-v2-nm-thickness` and its workbook existed to develop and +> check the toolkit rather than to run an experiment; it is kept as that +> record. **The real campaign is v4**, on `Final Summary Table.xlsx`. A second +> test contract (`d2d-objectives-v3-test`) came in between, on another +> workbook: uniformity became a mean rather than a product and optoelectronic a +> mean of normalised terms rather than a log10 product, so **no LOO R², signal +> verdict or mean function below transfers to it** — they are about quantities +> that were redefined. Its numbers come from +> `python scripts/intake_new_data.py --workbook "local_inputs/Final Summary Table.xlsx"` +> and are recorded in `CAMPAIGN_STATUS.md`. What does carry over is the *method*: +> the three floors, the null, the refit-inside-every-fold rule, and the two +> degenerate fitting modes. + +## Which instrument produced these numbers + +**Every LOO R² in this document was measured by `scripts/validate_structured_means.py` +and `scripts/gp_diagnostic.py`, reading the workbook's stored `Thickness (avg)` +column, which is `ROUND(mean(T1..T4))`.** The model no longer trains on that column +— since 2026-07-30 it trains on the unrounded mean — so the numbers below describe a +model input that has been superseded. + +**`scripts/intake_new_data.py` is the canonical instrument from now on.** It uses the +same pipeline the campaign uses and the same values the model is given. Where it +disagrees with a table here, it is right and the table is historical. + +The two were reconciled on 2026-07-30 and the difference is fully accounted for: + +| thickness, LOO R² | two-stage | mean module | +|---|---:|---:| +| rounded `X` (this document) | +0.1830 | +0.1830 | +| unrounded mean (the intake, and the model) | +0.1160 | +0.1160 | + +**The pipeline makes no difference at all here** — with no mean function the two +routes are the same code — and the entire 0.067 gap is the rounding: 7 of the 15 +rows change, by at most **0.50 nm**. Half a nanometre on seven rows moves LOO R² by +0.067, which is the same fragility that produced the 0.089 optoelectronic gap, and +both sit far inside the ±0.236 resolution floor. + +The structured numbers barely move: +0.3842 (this document's convention) against ++0.3806 (the intake), with decode and rounding choices spanning 0.006 in total. The +swing that justifies the mean function is +0.201 on the old data and +0.265 on the +new — it clears the floor either way, so no conclusion in this document changes. + +## What was wrong + +`ScaleKernel(MaternKernel(nu=2.5, ard_num_dims=10))` was built with no lengthscale +prior. GPyTorch's default constraint is `Positive()` — lower bound 0, upper bound +infinity — so nothing bounded the fit. At N=15 in 10 dimensions the marginal +likelihood interpolates every observation by making a few directions extremely +wiggly and switching the rest off: + +| Objective | Fitted ARD lengthscales | Directions ≥ 10 | Noise | +|---|---|---|---| +| Uniformity | 0.22 … 2229 | 6/10 | pinned at 1e-3 floor | +| Optoelectronic | 3.0 … 38105 | 9/10 | pinned at 1e-3 floor | +| Thickness | 0.13 … 2150 | 7/10 | pinned at 1e-3 floor | + +This is overfitting, not the prior-mean collapse originally hypothesised: the +posterior mean varied over 104–131% of each observed range across the design +space. Noise at its floor means the model believed the data were noiseless. + +## The fix + +BoTorch 0.15.1's `SingleTaskGP` already applies a dimension-scaled LogNormal +lengthscale prior — `LogNormal(loc = √2 + ln(d)/2, scale = √3)` — whenever +`covar_module` is not supplied. MOBO-Kit was discarding it by passing an explicit +module. `dim_scaled_prior` restores the prior while keeping the `ScaleKernel` +wrapper that the hyperparameter readout and plots depend on. + +| Variant | Median lengthscale | Flat directions | Uniformity LOO R² | +|---|---|---|---| +| `legacy_matern_no_prior` | 1121 | 6/10 | −1.557 | +| `conservative` | 2735 | 6/10 | −1.453 | +| **`dim_scaled_prior`** | **0.88** | **0/10** | **−0.537** | + +## Naming + +`default_current` was retired to `legacy_matern_no_prior` rather than redefined, +so archived Step 2B/2C artifacts stay interpretable. `dim_scaled_prior` is +`PRIMARY_VARIANT`; the retired contract must be requested by name and is kept +only for reproducing old runs. + +## Three floors. Check all three before comparing any two numbers. + +**1. Null: −0.148.** Predicting the leave-one-out mean of the other N−1 gives +LOO R² = 1 − (N/(N−1))² and Spearman exactly −1, independent of the data. A model +below this learned nothing. It moves with N: −0.148 at 15, −0.105 at 21, −0.069 at +31, so recompute it rather than reusing this number on a bigger dataset. + +**2. Sampling: ±0.236** (called the *resolution floor* in older text here and in +`CAMPAIGN_STATUS.md` — same number, same thing). Parametric bootstrap at N=15, +4000 resamples from the same underlying truth, gives an LOO R² standard deviation +of **0.236** and a central 95% range of **[−0.231, +0.666]**. The identical +relationship produces anything in that range purely by resampling. Shrinks roughly +as 1/√N. + +**3. Numerical reproducibility: ≈0.07** (added 2026-07-30). Two *independent* +perturbations, neither of which changes the model or the data in any meaningful +sense, each move LOO R² by about this much at N=15: + +| perturbation | size | LOO R² moves | +|---|---|---:| +| MLL optimiser landing elsewhere on an **identical** likelihood surface | outputscale and noise ≤2.7%, median lengthscale 9.6% | 0.0715 | +| rounding the thickness input to whole nanometres | ≤0.5 nm on 7 of 15 rows | 0.0670 | + +Neither is sampling noise — both are deterministic and reproducible — and neither +reflects a real difference in what the model knows. **So a second-decimal +difference in LOO R² at this N is below what the metric can reproduce even on +identical data with identical models.** Where floor 2 says a difference may be +luck, floor 3 says it may not be a difference at all. + +So **two LOO R² values less than about half a point apart are not a comparison at +this N, and anything in the second decimal place is not even a measurement.** This +project walked into floor 2 twice — once arguing −0.017 against −0.145, once +arguing +0.355 against +0.244 — both times because the number moved in the pleasing +direction, and the second of those turned out to be floor 3 all along. + +Differences that survive: the plain-vs-structured swings below (0.20 and 0.70). +Differences that do not: anything in the second decimal place. + +**A corollary worth its own line.** Under the same 0.5 nm rounding the plain GP +moved 0.067 while the structured model moved 0.006 — a tenfold difference in +sensitivity to a perturbation below measurement precision. A model whose answer +turns on half a nanometre is reporting arithmetic; one that ignores it is reporting +a trend. That is independent evidence for the mean function carrying the signal, +arrived at without looking at either model's score. + +## The bar for "the model learned something" + +For N observations, predicting the leave-one-out mean of the other N−1 gives + + LOO R² = 1 − (N/(N−1))² (−0.1480 at N=15) + Spearman = −1 exactly + +both independent of the data, because that prediction is a strictly decreasing +function of the held-out value. So a **negative LOOCV Spearman is the signature +of a model that learned nothing**, and the bar to clear is −0.148, not 0. + +## What the data supports + +- **Uniformity** — no learnable signal. Nothing beat the null across ~240 model + configurations, 7 target transforms, or modelling Coverage / (1−Uniformity) / + Phase purity separately. Permutation p = 0.82. Treat as exploration-only. +- **Optoelectronic** — weak but real, via `anneal_temp` (single-input LOO + R² +0.244). `=LOG10(P*Q)` is monotone, so model the score directly. +- **Thickness** — see below. + +## Thickness: model nanometres, not the score + +`Normalized thickness = EXP(-(((T-650)/250)^2))` is a peaked Gaussian on a 650 nm +target. The map T → score is **2-to-1**: films at 400 nm and 900 nm receive +near-identical scores from opposite sides of the peak, and the observed films +straddle the target (4 below, 11 above, 360–1303 nm). A GP trained on the score +must represent a folded bimodal ridge in process space; a GP trained on +nanometres sees a smooth trend. + +Raw thickness is the most predictable quantity in the campaign: +`log T ~ log(speed_1) + log(precur_conc)` gives LOO R² **+0.449**, Spearman +**+0.714**, permutation p **0.0067**. + +> **CORRECTION, 2026-09-06.** This paragraph used to end "with fitted speed +> exponent −0.38 against spin-coating theory's −0.5", offered as evidence that the +> trend was physically grounded. **It is not evidence.** On the v4 workbook the +> exponent's 95% interval is [−0.385, −0.126], which EXCLUDES −0.5 by 4.1 standard +> errors, and fixing the exponents at their theoretical values scores +0.5600 +> against +0.5823 for no trend at all. The mean function has been withdrawn from +> the live config; see CAMPAIGN_STATUS.md, "The thickness prior was half-earned". + +Predicting the score, exact leave-one-out (`dim_scaled_prior`): + +| Approach | LOO R² | Spearman | +|---|---|---| +| train on the score directly | −0.444 | −0.764 | +| train on nm → E[score], analytic | **−0.147** | **+0.279** | +| train on nm → score(mean) only | −0.492 | +0.161 | +| null | −0.148 | −1.000 | + +Rank correlation flips from actively misleading to usable, which is what drives +candidate selection. R² only reaching the null is the honest outcome: the raw-nm +posterior is wide (median 157 nm against a Gaussian width of 250/√2 ≈ 177 nm), so +expected scores are correctly pulled toward the middle. + +Note the third row. Transforming only the posterior *mean* is worse than the null +— it is biased by Jensen's inequality and blind to variance. Use +`ObjectiveTransform.expected_transform`, which has a closed form for +`Y ~ N(μ, v)`: + + E[exp(-½((Y-c)/s)²)] = √(s²/(s²+v)) · exp(-½(μ-c)²/(s²+v)) + +verified against Monte Carlo to <1e-3. It reduces to the plain transform at v = 0 +and penalises uncertainty at the target: at μ = 650 exactly, expected score is +0.994 / 0.870 / 0.508 for posterior σ of 20 / 100 / 300 nm. + +The workbook's `exp(-((T-650)/250)²)` has no ½, so in this parameterisation +`sigma = 250/√2 ≈ 176.78`. + +## Both priors are required, not just the lengthscale one + +With the lengthscale prior but a bare noise floor, the fit has a *second* +degenerate mode: the outputscale collapses to ~0 and the model declares the data +pure noise. Measured on the thickness score, 10 of 15 leave-one-out folds landed +there — fitted noise 0.93 against a latent predictive sd of 1e-4, giving +z-scores in the thousands. Adding BoTorch's `LogNormal(-4, 1)` noise prior +removes it entirely. + +**Since 2026-07-30 the guard asks a second question.** A collapsed latent sd means +one of two things, and they need different answers. If the posterior *mean* is also +near-constant — measured against the objective's observed spread, floor 5% — the +model has genuinely explained the data as noise and the fit is refused. If the mean +still varies, a structured mean is carrying the signal: the residual GP having +nothing left to model is success, not degeneracy, and refusing would dead-end the +campaign exactly when the physics model started working. That case warns instead, +naming the two things to distrust — the exploration term is dead, and the frozen +mean coefficients carry no uncertainty, so reported intervals are understated +rather than earned. Details in `CAMPAIGN_STATUS.md` issue 6. + +The collapse is specific to the thickness *score*, the folded 2-to-1 objective — +uniformity, optoelectronic and raw nm are stable either way, and the noise prior +costs them nothing (latent sd 0.1226 vs 0.1236). It matters because acquisition +consumes the *latent* posterior: a predictive interval can look well calibrated +while the latent variance has collapsed, because the large fitted noise hides it. + +Calibration, exact leave-one-out, predictive (noise-inclusive) intervals against +nominal 0.68 / 0.95: + +| Variant | Uniformity | Optoelectronic | Thickness | Mean NLPD | +|---|---|---|---|---| +| `legacy_matern_no_prior` | 0.067 / 0.533 | 0.467 / 0.667 | 0.467 / 0.600 | 1.76 / 2.82 / 1.35 | +| `dim_scaled_prior` | 0.400 / 0.800 | 0.533 / 0.800 | 0.533 / 0.867 | 0.58 / 1.44 / 0.82 | + +Interval coverage must use the predictive sd, not the latent sd. Using the latent +sd understates every interval and makes a calibrated model look overconfident. + +## Structured means: two objectives, opposite shapes + +Physics fixes the features in advance, so this is not selection on the outcome. +Linear coefficients are refit inside every fold. + +| Objective | Mean function | LOO R² | Spearman | +|---|---|---:|---:| +| thickness nm | none | +0.183 | +0.586 | +| thickness nm | `log T ~ log(speed_1) + log(precur_conc)` | **+0.384** | +0.682 | +| optoelectronic | none | **−0.342** | −0.100 | +| optoelectronic | linear `anneal_temp` | **+0.355** | +0.618 | + +Null −0.148. Both swings (0.20 and 0.70) clear the ±0.236 resolution floor. + +**The optoelectronic result is the larger finding.** Its plain GP sat *below the +null* — actively worse than predicting the mean — so the seven irrelevant inputs +were not merely diluting the fit, they were doing damage. Removing the +temperature trend first fixes it. + +The two shapes are **opposite** and neither generalises. Thickness needs a pair +of log terms and neither alone is worth much (+0.159, +0.187). Optoelectronic +needs exactly one linear term: every addition tested made it worse, and an +Arrhenius `1/T` form bought nothing over plain linear temperature (+0.226 vs ++0.244). `anneal_temp` is also the least search-contaminated choice available — +it came from a marginal correlation already on record (ρ = −0.651, p = 0.009), +not from the six-form search that was run afterwards. + +**Not claimed:** that linear-mean-plus-GP beats linear-mean-alone. The observed +gap (+0.355 vs +0.244) is 0.47 sd of the ±0.236 resolution floor — indistinguishable +from sampling noise. It is an open hypothesis with a specific test attached: does +linear-mean-plus-GP beat linear-mean-alone under the permutation null? That +question rides along with the optoelectronic permutation run. + +## Structured mean for thickness + +Physics fixes the two predictors in advance, so this is not selection on the +outcome. Refitting the linear coefficients inside every fold: + +| Raw-nm model | LOO R² | Spearman | +|---|---|---| +| plain GP, 10 inputs | +0.183 | +0.586 | +| **GP + linear mean on log(speed_1), log(precur_conc)** | **+0.384** | **+0.682** | +| 2-input log-log reference | +0.449 | +0.714 | + +Carried through to the score: R² −0.145 → **−0.019**, Spearman +0.243 → **+0.461**. +The structured mean legitimately reaches what the legacy model reached by +accident of overconfidence. + +**Wired into `campaign.py`.** `fit_campaign_models` reads each objective's +`mean_function` block and builds one `StructuredMean` module per GP, so +`posterior()` already carries the trend and no caller adds it back. Any R1 +candidates generated before commit `600ef60` used the plain GP and are not +comparable with anything generated after it. + +The linear coefficients are refit **inside every fold**, on the 14 training rows +only (`scripts/thickness_permutation_and_mean.py`, in the fold loop). The held-out +value never touches them. The two predictors are fixed from physics before any +fitting, so this is not selection on the outcome. + +## Significance of the rank improvement + +Permutation test, 200 shuffles, permuting the nm measurements and redoing the +full leave-one-out fit plus transform. When the structured mean is used, its +linear coefficients are refit inside every null fold too, so the null is not +flattered. + +| Pipeline | Shuffles | Observed ρ | Null mean | Null sd | p | 95% CI | +|---|---:|---:|---:|---:|---:|---| +| plain GP | 200 | +0.243 | −0.241 | 0.349 | 0.12 | — | +| structured mean | 200 | +0.461 | −0.167 | 0.295 | 0.020 | [0.006, 0.050] | +| **structured mean** | **1800** | **+0.461** | −0.131 | 0.305 | **0.0350** | **[0.0270, 0.0446]** | + +The 1800-shuffle run is reported **standalone**, not pooled with the earlier 200. +Pooling would be defensible but carries an optional-stopping flavour, since the +larger run was commissioned because the first result was borderline. Reporting +the fresh run alone sidesteps the question at no cost. + +**The result holds and the interval clears.** 63 exceedances in 1800, upper bound +0.0446, below 0.05. Note the point estimate moved 0.020 → 0.035: the 200-shuffle +figure was optimistic, which is exactly why the re-run was worth doing. Its +interval did contain the final value. + +The plain GP does not clear p < 0.05. **The structured mean does.** That earns +"thickness is genuinely predictive" rather than "directionally right". On R² the +structured mean reaches p = 0.144 (95% CI [0.129, 0.162]), not significant — but +rank drives candidate selection, and rank is significant. + +**This is where the thickness mean function's evidentiary weight rests, and it has +not moved.** The case is the rank permutation, p = 0.0350 with a 95% CI of +[0.0270, 0.0446] at 1800 shuffles. The R² swing is *consistent* with it and no +more: +0.201 on the rounded inputs this document used, +0.265 on the unrounded ones +the model now trains on, against a ±0.236 sampling floor either way. Nothing in the +2026-07-30 reconciliation touched the permutation result, because that result is +about ranks and the reconciliation was about a half-nanometre change in a +regression score. + +The null mean is −0.17, not 0: the leave-one-out shrinkage artifact drags it +negative, which is why a positive observed value carries information. + +## Sample 1 stays in. Do not exclude it. (decided, closed) + +**This section exists because "excluding the control nearly doubles R²" is a true +sentence that will get rediscovered and acted on. It is the wrong action.** + +Dropping sample 1 does improve the thickness fit — raw-nm LOO R² goes +0.384 → ++0.632, Spearman +0.682 → +0.824, and the score prediction reaches +0.207 against +a −0.160 null. Sample 1 is also the off-grid literature control, so there is a +ready-made provenance story for excluding it. + +That story is wrong. Ranking every point by how much dropping it improves the fit: + +| Dropped sample | Leverage | LOO R² after drop | Δ | +|---|---:|---:|---:| +| **12** | **0.462** | **+0.879** | **+0.429** | +| 1 | 0.297 | +0.676 | +0.226 | +| 13 | 0.199 | +0.465 | +0.015 | +| … | | | | +| 7 | 0.316 | +0.261 | −0.188 | + +Sample 1 is not the drag. **Sample 12 is, by nearly double**, and it has the +highest leverage in the design. + +The mechanism is visible in the inputs. Samples 1 and 12 are the *only* two +points at `speed_1 = 1000`, the minimum, so between them they anchor the entire +low-speed end of the strongest predictor — and they contradict each other: +sample 1 has the higher concentration (1.4 vs 1.1) but the *thinner* film +(687 vs 1155 nm), inverting the expected relationship. + +So the gain from dropping sample 1 is a **high-leverage-endpoint artifact**, not +a signal about its provenance. Acting on it would commit you to also dropping +sample 12 — an ordinary LHS point with no provenance justification at all. The +declared provenance difference is real; it is simply not what the residual was +reporting. + +A tempting explanation for the inversion — sample 1 runs `speed_2 = 5000` against +sample 12's 500, so a fast second stage could be thinning the film — **does not +survive testing**: adding `log(speed_2 + 1)` to the structured mean drops LOO R² +from +0.449 to −0.827. Do not add it. + +The low-speed corner therefore remains genuinely unexplained, with two +contradictory observations in it. That makes it something **R1 should probe**, +not something to model around. When the real R1 batch is generated: if the +acquisition function proposes nothing near `speed_1 = 1000`, notice it. It may +mean the model has concluded the region is bad when what it actually has is two +points that disagree. + +### Added 2026-07-29: sample 12's thickness is two readings that disagree 2.3x + +Sample 12's recorded 1155 nm is `ROUND(mean(1600, 709))` — its two thickness +points differ by a factor of 2.26. It is one of three rows whose thickness +readings are bimodal rather than scattered: sample 8 is `686, 740, 270, 250` +(ratio 2.96) and sample 15 is `596, 702, 784, 590`. Within-row sd of `log(T)` is +0.584, 0.576 and 0.137 for samples 8, 12 and 15, against 0.048 or less for the +other twelve rows. + +This does **not** reopen "sample 1 stays in". That decision was about which +observation to drop, and the answer is still neither. What it adds is a candidate +mechanism for sample 12's leverage of 0.462: its thickness value is the midpoint +of a bimodal measurement, so the low-speed end of the strongest predictor is +anchored by a number with an unusually weak claim to being a single measurement. + +It also makes the low-speed corner a **measurement** question before it is a +physics question. Probing `speed_1 = 1000` in R1 is still right, and the specific +thing to collect there is more thickness points per film — not only more films. + +### Resolved by the group, 2026-07-31: the means are the intended summary + +The group confirms that **the within-film thickness variation on samples 8, 12 and +15 is real, and the mean of the readings is the intended summary** for each. So +`mean_of_present` stays, sample 12's 1155 nm stays, and nothing above is a defect +to be corrected. + +**Do not read this as the findings being retracted.** `spread_warning_ratio: 0.25` +still fires on those three rows and should keep firing: a film whose readings split +2.3-fold is a different kind of observation from one whose readings agree to 3%, +and a reader comparing leverage across rows needs to know which is which. What is +settled is the *action* — no re-derivation, no exclusion, no re-weighting — not the +*fact*. Sample 12's leverage of 0.462 is still the highest in the design and still +worth knowing when its region is discussed. + +The `T anom` exclusion is confirmed on the same basis: those readings (sample 4's +1618 against its own 650/655/670/680, sample 14's 630) were judged anomalous by the +operator, and the operator's judgement is the intended filter. They stay out of the +mean and their presence stays reported. + +## Open + +- **Does linear-mean-plus-GP beat linear-mean-alone?** +0.355 against +0.244 is + 0.47 sd of the ±0.236 floor, so the observed gap is not evidence either way. + The test rides along with the optoelectronic permutation run. + +## Closed + +- **The R1 baseline mis-encoding, found and fixed 2026-07-31. Not a floor + question.** `run_r1_ucb` handed `ObjectiveTransform.transform` measurement-space + nanometres, which exponentiated them a second time and pinned every + observation's thickness utility to exactly 0.0 — baseline hypervolume 0.004659 + against a true 0.436442. Full account in `CAMPAIGN_STATUS.md` issue 9; the batch + it produced was withdrawn (`R1_BATCH_WITHDRAWAL.md`). + + **It is recorded here only to keep it out of the wrong category.** The three + floors above are about differences too small to be real. This was not a small + difference and not a noisy one: it was a deterministic, reproducible, *wrong* + number, off by a factor of 94. A floor tells you when to stop arguing about a + gap; it never licenses accepting one. The rule this project already had — + *a deterministic difference on the same rows must be explained, not absorbed + into a floor* — is what would have caught it, had anyone had a second number to + compare the baseline against. Nobody did, which is the actual lesson. + +- **The 0.089 optoelectronic gap, closed 2026-07-30 as a numerical artifact.** + The two pipelines specify the *same model*: a zero-mean GP on `y - trend` and a + fixed-mean GP on `y` with mean `trend` have identical marginal likelihoods, + since a fixed mean only shifts the data. Measured, about a fifth of the gap is + the outcome transform standardizing different quantities in the two routes + (removing it moves the gap 0.0881 → 0.0715) and the rest is the MLL optimiser + landing at slightly different hyperparameters on an identical surface — median + lengthscale differing by up to 9.6% across folds, which at N=15 is worth 0.07 of + LOO R². Seeding was ruled out separately: bit-identical across four seeds. + **0.0881 is well inside the ±0.236 resolution floor and was never evidence of + anything.** Full numbers in `CAMPAIGN_STATUS.md`, issue 1. +- The structured mean is wired into `campaign.py` (`fit_campaign_models`, and the + `_fit_models` it delegates to), commit `600ef60`. +- Hypervolume reference: fixed. `configs/campaign_d2d_perovskite.yaml` declares + `reference_point_utility` in utility space after the transforms, so no axis + dominates. The old raw-scale `[-0.01, -10.0, -0.01]` gave the optoelectronic + axis 4.01x the uniformity axis. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md new file mode 100644 index 0000000..ede0db4 --- /dev/null +++ b/docs/HANDOFF.md @@ -0,0 +1,255 @@ +# Handoff + +Read this first in a new session. Updated 2026-09-02, when the final workbook +arrived and the score contract moved to v4. + +## What this repository is doing right now + +**There are three objective contracts, and only the last one is real.** + +| | v2 — test data | v3 — test data | v4 — **the real campaign** | +|---|---|---|---| +| config | `campaign_d2d_perovskite.yaml` (archived) | `campaign_d2d_perovskite_test.yaml` (archived) | `campaign_d2d_perovskite_final.yaml` | +| contract | `d2d-objectives-v2-nm-thickness` | `d2d-objectives-v3-test` | `d2d-objectives-v4-final` | +| workbook | `Summary Table.xlsx` | `Summary Table Test.xlsx` | `Final Summary Table.xlsx` | +| sheet | `Sheet1` | `Sheet1` | `R0` | +| purpose | early toolkit testing | rehearsing this contract's shape | **the experiment being run** | + +v2 proved the loop worked. v3 rehearsed the shape of this contract on a workbook +literally called "Test". **v4 is the campaign that produces films.** Uniformity +and optoelectronic have been renormalised twice since v2, so none of the earlier +fitted numbers transfer; every document about an earlier contract carries a banner +saying so. + +**In v4 those two objectives are FROZEN** — read from the workbook as stored, with +no recomputation, because the group is still revising the definitions. That is a +deliberate reversal of this project's usual polarity and it removes a cross-check; +`formula_fingerprint` is the partial replacement, and it notices a changed +*definition* rather than a stale *value*. Thickness is still computed. + +**The workbook's sheet is now `R0`**, not `Sheet1`, so the source sheet is a config +key (`campaign.source_sheet`) rather than a constant. The workbook also carries an +`R1` sheet; it is deliberately not read. The round contract is unchanged — each +round's worklist goes to a NEW file beside the workbook and the source is never +opened for writing. + +The launcher, `intake_new_data.py`, `permutation_rank_test.py`, +`generate_round_report.py`, `plot_round_simulation.py` and `plot_boxplot_sweep.py` +all default to v4. **Archiving a config without moving the launcher's default is +how a user once got a missing-column error on an intact workbook**; a test pins +that the launcher's default names an active campaign. + +All workbooks live under `local_inputs/`, which is gitignored and never travels by +git. Copy them by hand on any move. + +## Read these, in this order (~25 minutes) + +1. **`README.md`** — what the toolkit is, the three contracts, the three-round loop, + how an experimentalist runs a round without writing code, and how `beta` and + `radius` were chosen. +2. **`docs/CAMPAIGN_STATUS.md`** — the working guide and the longest of the three. + Its live-campaign section is at the top; everything below the divider + describes an earlier contract. +3. **`docs/GP_MODEL_DECISION.md`** — why the model is the way it is. It is **v2's** + record and carries a banner saying so. What still applies + is the *method* — the floors, the null, refitting a trend inside every fold, the + two degenerate fitting modes — and none of its LOO numbers. + +Then verify the state yourself: + +```bash +pytest -q +``` + +Expect **601 passed, 0 failed, 28 warnings** (~185 s). Nothing in the suite needs a +private workbook; the tests that would use one skip when it is absent. + +**`--capture=sys` in `addopts` is load-bearing, not a preference.** pytest's +default fd-level capture swaps file descriptors 1 and 2, and a Tk interpreter built +while that is in force holds descriptors that are gone by the time the next one is +built — so the second or third launcher window in a process dies reading its own +`init.tcl` and reports the unhelpful message `No error`. It read as a race in the +launcher for a while and is neither a race nor a launcher defect. Measured: 6 +failures in 9 runs of one launcher test under `--capture=fd`, none under +`--capture=sys`. Only `capsys` is used in this suite, never `capfd`. The +`open_window` fixture in `tests/test_launcher.py` carries the full account. + +## The instruments, and the one command each + +```bash +# audit new or corrected data, and re-decide every mean function on it +python scripts/intake_new_data.py --workbook "local_inputs/Final Summary Table.xlsx" + +# adjudicate a mean function on RANK when R2 cannot resolve it +python scripts/permutation_rank_test.py --objective thickness --permutations 1800 + +# the six figures a round produces, from a terminal instead of the button +python scripts/generate_round_report.py --workbook "local_inputs/Final Summary Table.xlsx" + +# the campaign loop against a frozen oracle, at the ratified knobs +python scripts/plot_round_simulation.py --workbook "local_inputs/Final Summary Table.xlsx" --cell 0.25,4 +``` + +`launch_mobo_kit.bat` / `.command` is the one-button path: check the workbook, +propose the next round, and get the figures. It writes a worklist and a `Review` +sheet **beside** the workbook and never into it. + +**There is one leave-one-out fold loop, `mobo_kit.loocv`, and three callers share +it.** Intake is canonical for LOO numbers, the round report plots them, and the +permutation test builds a null out of them. They were briefly three +implementations; a test now asserts they are the same function object rather than +that they agree. + +## Where the live campaign stands + +Measured on v4's 15 rows by `intake_new_data.py`, against a leave-one-out null of +**-0.1480** and a resolution floor of **+-0.236**: + +| objective | plain GP | mean function | verdict | +|---|---:|---:|---| +| uniformity | **-0.4688** | none | below the null → **exploration only** | +| optoelectronic | **-0.7038** | none | below the null → **exploration only** | +| thickness | **+0.5814** | **none — withdrawn 2026-09-06** | **learnable**, on its rank permutation | + +**No objective carries a physics prior any more.** The thickness mean function +`log T ~ log(speed_1)+log(precur_conc)` was withdrawn on 2026-09-06: its +justification was that the fitted speed exponent agreed with theory's -0.5, and +the 95% interval on that exponent is [-0.385, -0.126], which excludes -0.5 by 4.1 +standard errors. Fixing the exponents at the theoretical values scores +0.5600, +worse than no trend at all. Full table in CAMPAIGN_STATUS.md. + +**Still only one learnable axis**, as on v3 — but that no longer argues for heavy +exploration. `beta = 36` was chosen on the premise that exploring wider was how the +two dead axes would come alive. The extended C1&C2 sheet (45 rows = 15 recipes made +three times) shows it is not: optoelectronic is 84.5% between-campaign drift with a +recipe ICC of **0.000**, and uniformity is reproducible (ICC **0.730**) but not +predictable from ten inputs at fifteen distinct recipes. Neither is reachable by +any beta. + +**The campaign runs `beta = 4.0` and `radius = 0.25`** as of 2026-09-03. At +beta = 36 the radius knob was provably inert — radii 0.15, 0.25 and 0.35 return +bit-identical batches — and 18 of 50 proposed coordinates sat on a grid bound; +at beta = 4 / radius 0.25 that falls to 11. See CAMPAIGN_STATUS.md, "Are +beta = 4.0 and radius = 0.25 defensible?", including why the hypervolume column +of that table must not be read as a ranking. + +**Uniformity and optoelectronic are read from the workbook, not computed.** No +independent recomputation exists under this contract. The formula fingerprints +notice a changed *definition*; nothing here can notice a value that has gone +stale. That is the price of the freeze, and it is paid deliberately. + +**The v3 photoconductance inversion is fixed.** Its normalised column ranked +backwards against its own raw measurement (Spearman -0.5484, p = 0.0343); on v4 +the same comparison gives **+1.0000**. Issue 10 is closed. The diagnostic stays on +because the failure is silent when it recurs. + +**Thickness keeps its mean function on the rank permutation**, not on R². Intake +leaves it *inconclusive on R²* — the swing sits inside the floor, which is a +statement that R² cannot resolve it at N=15 rather than a verdict. Rank is what +the acquisition consumes; it never sees R². **Do not quote the swing as +evidence.** Measured on v4: observed rank ρ **+0.6500**, null mean −0.1892 +(sd 0.2944), **9 exceedances in 1800**, **p = 0.0056, 95% CI [0.0021, 0.0090]**. + +## What is actually open + +1. **No batch has been proposed on the live campaign yet.** Pressing **Propose + R1** writes the worklist, the Review sheet and six figures. Fifteen films is a + real cost, and whether to fabricate is a human decision that is not automated. +2. **The frozen scores are temporary.** The group will settle how uniformity and + optoelectronic are computed and then unfreeze them. The v3 recipes (`mean`, + `clamped_complement`, `capped_ratio`) remain in `scores.py`, unwired, so that + is an edit rather than a rebuild. Unfreezing means a new `contract_version`. +3. **Phase 4 waits on the R1 triplicates.** `replicate_variance.py` is wired and + tested; enabling it is one config key, `model.observation_noise: + replicate_pooled`. The `replicate_variance.sanity_floor` for thickness is still + v3's 0.006374 and should be recomputed on v4's readings, which changed. +4. **`anneal_temp` sits at a range edge in proposed conditions.** If the group + would never anneal below some temperature, that belongs in `constraints:` — + now a live list with three entries, so adding one is a two-line change. + +## Three floors. Check all three before comparing any two numbers. + +These are method, and they carry across both campaigns. + +- **Null, −0.148 at N=15 — AND IT IS NOT A SIGNIFICANCE THRESHOLD.** Measured + 2026-09-04, 300 permutations with the campaign's own model: the fitted GP's + null has median −0.4210 and 95th percentile **+0.2890**, and **28.7% of + pure-noise shuffles beat −0.1480**. Below it a model has certainly learned + nothing; above it means nothing on its own. Use the rank permutation test, + or `scripts/raw_component_screen.py --calibrate` for a candidate's own bar. + Predicting the leave-one-out mean gives + `1 − (N/(N−1))²`. A model below it learned nothing, and a negative LOOCV + Spearman is that signature rather than a sign bug. It moves with N — recompute. +- **Sampling, ±0.236.** Parametric bootstrap, 4000 resamples at N=15. Two LOO R² + values less than about half a point apart are not a comparison at this N. +- **Numerical reproducibility, ≈0.07.** Two perturbations that change nothing + meaningful each move LOO R² by that much. A second-decimal difference is not a + measurement. + +**When a comparison lands inside a floor, that is not a verdict — it is a +statement that the instrument cannot decide, and a different instrument should.** +For a mean function that instrument is the rank permutation. This project argued +inside a floor twice before adopting that rule. + +## Settled, do not reopen + +- **Each contract's objectives are different quantities.** A shared + `contract_version` would make their hypervolumes look comparable when they + measure different spaces. That is why every redefinition arrives as a new + config file rather than an edit -- three times now. +- **`ObjectiveTransform.transform` takes MODEL-space values, not measurements.** + It decodes the link itself, so handing it thickness in nanometres exponentiates + a value that was never a logarithm. Use `transform.transform_measurements` at + any call site holding workbook values. This defect has now arrived by three + separate routes; the third was caught in a draft of the permutation script only + because saturating the Gaussian to 0.0 made a column constant. +- **Uniformity has no learnable signal** on either campaign's data — the first by + permutation (p = 0.82 on *that* score), the second by leave-one-out (−0.6447). + Exploration-only by measurement, not by choice. +- **openpyxl discards cached formula values on save**, which is why candidate + sheets are written to a *sibling file* and the source workbook is never opened + for writing. Do not "simplify" that. + +## The failure shape that keeps recurring + +**A wrong answer that is finite, ordinary-looking, and compared against nothing.** +Four instances so far: the hypervolume auto-reference, the silently swallowed +`train_Yvar`, the R1 baseline mis-encoding, and a timing measured under CPU +contention that nearly shipped as a documented number. + +No guard catches these — each passed every guard it met. What works is **making +the quantity observable and reproducing it by a second route**. So: every figure +in a round report writes the CSV behind it; the parity numbers are literally +intake's function; the batch figure reads the Review artifact rather than +recomputing it; `validate_batch` re-checks constraints the candidate pool already +filtered. If you add a number that steers a decision, add its comparator with it. + +## Four tooling facts that will bite you + +- **BoTorch's `Hypervolume` assumes maximisation and silently drops points that do + not dominate the reference.** No warning, no exception — a smaller number, or + 0.0. `metrics.compute_ref_pareto_hv` refuses that case and requires an explicit + reference. +- **BoTorch silently ignores `train_Yvar` when a `likelihood` is also passed.** + Verified on 0.15.1. Pass one or the other, never both. +- **`Standardize` rescales `train_Yvar` along with the targets**, so measured + variance must arrive in the target's own units — and in the *model's* space, + which for thickness is `log T`, not nanometres. +- **`tight_layout` does not support 3-D axes or colorbars** and warns that its + result may be wrong. `round_report._save` takes `tight=False` for those figures + rather than ignoring the warning. + +## Working advice + +Develop against **DTLZ2** where you can: `tests/test_dtlz2_acceptance.py` runs the +whole loop on a synthetic problem with a known Pareto front, so the algorithm can +be checked with no dependence on whether the measurements are right. Anything +data-specific lives in config, so a new dataset means a new YAML, not new code. + +Two process rules this project learned the hard way, both worth keeping: +**verification gates the commit** — run the tests as their own step, never in the +same breath as `git commit` — and **an order-dependent or timing-sensitive test +failure is a real defect until proven otherwise**, in the test or in the product. + +And one learned at the audit: **a number measured under load is an unreproduced +number.** Re-measure on an idle machine before writing it down. diff --git a/docs/R1_BATCH_WITHDRAWAL.md b/docs/R1_BATCH_WITHDRAWAL.md new file mode 100644 index 0000000..ee6b8bf --- /dev/null +++ b/docs/R1_BATCH_WITHDRAWAL.md @@ -0,0 +1,99 @@ +# The R1 batch was withdrawn and reissued, 2026-07-31 + +> **This document describes TEST DATA.** The workbook and contract it reports on +> (`d2d-objectives-v2-nm-thickness`, `Summary Table.xlsx`) existed to develop and +> check the toolkit, not to run an experiment. **The real campaign is v4** -- +> `configs/campaign_d2d_perovskite_final.yaml` on +> `local_inputs/Final Summary Table.xlsx`, contract `d2d-objectives-v4-final`. +> Uniformity and optoelectronic have been renormalised twice since, so **no +> number below transfers**; they describe quantities that were redefined. Start +> from `docs/CAMPAIGN_STATUS.md` for the real campaign. + +**No films were fabricated from the withdrawn batch.** The defect was caught while +the batch was still awaiting human review, which is what the review gate is for. + +## What was wrong + +`campaign.run_r1_ucb` handed its observed hypervolume-improvement baseline to +`ObjectiveTransform.transform` in **measurement space**. That transform is a +model-output decoder: it applies `exp()` to a log-link objective before computing +utility. Thickness in nanometres was therefore exponentiated a second time. +`exp(360…1303)` saturates the 650 nm Gaussian to exactly `0.0` — a finite number, +so neither the transform's own finiteness check nor the caller's fired. + +Every one of the 15 observations scored **thickness utility 0.0**, so R1 chose its +candidates against a baseline front with no thickness axis at all. + +| | withdrawn | reissued | +|---|---:|---:| +| observed baseline hypervolume | **0.004659** | **0.436442** | +| baseline Pareto set | 2 points | 5 points | +| minimum pairwise spacing | 0.9209 | 0.6337 | +| boundary coordinates | 13 | 12 | + +Fixed in commit `4b76670` by `ObjectiveTransform.encode_measurements`, with +`transform_measurements` as the one-call safe route. Annie Xu had already found +and fixed this independently on `ax_plots_simulation`, as +`_physical_to_model_output`, before we knew it existed. + +## What actually changed in the batch + +**Four of the five conditions are identical.** One was replaced: + +| | speed_1 | time_1 | speed_2 | time_2 | precur_conc | precur_vol | anneal_temp | anneal_time | anti_vol | anti_time | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| **dropped** | 4000 | 45 | 1500 | 30 | 1.70 | 40 | 105 | 10 | 170 | 15 | +| **added** | 2500 | 50 | 3500 | 35 | 1.45 | 70 | 105 | 15 | 135 | 13 | + +The reissued batch in full: + +| # | speed_1 | time_1 | speed_2 | time_2 | precur_conc | precur_vol | anneal_temp | anneal_time | anti_vol | anti_time | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| 1 | 2500 | 50 | 1000 | 50 | 1.45 | 50 | 100 | 15 | 100 | 11 | +| 2 | 2500 | 50 | 3500 | 35 | 1.45 | 70 | 105 | 15 | 135 | 13 | +| 3 | 1500 | 50 | 3500 | 45 | 1.30 | 50 | 105 | 25 | 200 | 11 | +| 4 | 3000 | 50 | 2000 | 20 | 1.55 | 80 | 105 | 60 | 130 | 11 | +| 5 | 2000 | 50 | 2500 | 10 | 1.35 | 90 | 100 | 10 | 110 | 13 | + +The withdrawn batch was more spread out — 0.9209 against 0.6337 — which is worth +saying plainly: **the defect made the batch look better diversified than the model +actually justified.** With the thickness axis of the baseline pinned at zero, +candidates were being separated on a distorted score. + +## What did not change + +Two standing observations survive the fix, so nothing that rests on them needs +revisiting: + +- **The `speed_1 = 1000` corner is still skipped.** The reissued batch's minimum + `speed_1` is 1500, as before. The low-speed corner remains a measurement + question — samples 1 and 12 still contradict each other and sample 12's 1155 nm + is still `ROUND(mean(1600, 709))`. +- **`anneal_temp` still pins to its lower bound**, at 100–105 across all five + conditions. That is the monotone linear mean function speaking, exactly as + recorded, and the open question remains chemical rather than numerical. + +## Numbers that came from the withdrawn batch + +Anything quoting the old batch's *diagnostics* is void and has been corrected in +place: + +- the minimum spacing of **0.921** in `CAMPAIGN_STATUS.md`, which was used to argue + that `radius` is "probably inert" on the live campaign. It is not — see the + measured staircase in that file. +- the probe numbers in issue 4 (thickness utility 0.786 → 0.223 at an sd ratio of + 1.02). Those came from the withdrawn review artifact and must be re-read from + the reissued one. + +## Where the artifacts are + +`local_inputs/Summary Table_R1_Candidates.xlsx`, regenerated through +`launcher.generate_next_round` — the same path the double-click launcher uses — +with the `R1_Candidates` worklist and the `Review` sheet. The withdrawal is +declared in `configs/campaign_d2d_perovskite.yaml` under `review.notes`, so it +travels with the Review sheet if that is forwarded on its own. **Delete that note +once R1 is measured.** + +No prior candidate workbook existed on disk to archive: the withdrawn batch was +described in `CAMPAIGN_STATUS.md` and echoed to the launcher pane, but +`Summary Table_R1_Candidates.xlsx` had never been written. diff --git a/docs/ROUND_SIM_DELTA.md b/docs/ROUND_SIM_DELTA.md new file mode 100644 index 0000000..03ef9b8 --- /dev/null +++ b/docs/ROUND_SIM_DELTA.md @@ -0,0 +1,139 @@ +# Round simulation: Annie's branch → `colin` + +> **This document describes TEST DATA.** The workbook and contract it reports on +> (`d2d-objectives-v2-nm-thickness`, `Summary Table.xlsx`) existed to develop and +> check the toolkit, not to run an experiment. **The real campaign is v4** -- +> `configs/campaign_d2d_perovskite_final.yaml` on +> `local_inputs/Final Summary Table.xlsx`, contract `d2d-objectives-v4-final`. +> Uniformity and optoelectronic have been renormalised twice since, so **no +> number below transfers**; they describe quantities that were redefined. Start +> from `docs/CAMPAIGN_STATUS.md` for the real campaign. + +For Annie Xu. This is a review of `examples/round_simulations.py` on +`annie/ax_plots_simulation` against the current `colin` branch, and a record of +what `scripts/plot_round_simulation.py` changed and why. + +Your branch forks at `33f101f`. Twelve commits landed on `colin` after that, and +most of this list is API drift rather than anything you got wrong. Two items go +the other way: one thing you fixed is still broken on `colin`, and one thing the +brief asked me to "correct" was already correct in your code. + +## 1. You found a real bug, and `colin` still has it + +`campaign.run_r1_ucb` passes `observed_Y_raw` — thickness in **nanometres** — +into `ObjectiveTransform.transform`, which applies `exp()` to log-link +objectives. `exp(360…1303)` overflows the 650 nm Gaussian to exactly `0.0`. It is +finite, so the non-finite guard never fires and nothing raises. + +Measured on the real workbook, R0: + +| observed baseline | hypervolume | Pareto size | +|---|---:|---:| +| as `run_r1_ucb` encodes it | 0.004659 | 2 | +| link decoded once (your `_physical_to_model_output`) | 0.436442 | 5 | + +Every candidate's HVI is scored against a baseline whose thickness axis is pinned +at zero. Your `_run_r1_corrected` is the fix. + +**Update, later the same day: `colin` no longer has it.** The group decided the +fix was its own change, and commit `4b76670` promotes the concept in your +`_physical_to_model_output` to a public contract — +`ObjectiveTransform.encode_measurements`, with `transform_measurements` as the +one-call safe route — and has `run_r1_ucb` encode before it proposes. The +acquisition modules are untouched: `ucb_hvi.py` stays byte-identical, because the +defect was in `campaign.py` orchestration. + +The R1 batch built on the mis-encoded baseline was **withdrawn and reissued** +(`R1_BATCH_WITHDRAWAL.md`): four of five conditions survived, one was replaced, +and the batch's minimum spacing fell 0.9209 → 0.6337. No films had been made. +`scripts/plot_round_simulation.py` now simply calls the public `run_r1_ucb` — +verified to reproduce its own private version hash-for-hash — and keeps the +contrast in the manifest as a standing tripwire. + +R2 is unaffected: `run_r2_qlognehvi` passes `train_X_norm`, and qLogNEHVI derives +its baseline through the model in model space. + +## 2. Your "physical mean" label was right; the change is substantive + +The brief I was given said your colorbar said "physical mean" without the +`exp(μ + v/2)` correction. It does not — `_simulate_oracle` and +`_objective_surface_values` both apply `torch.exp(mu + 0.5 * variance)`, which is +the lognormal mean, so your label was accurate for what you computed. + +The new script still switches to the **median** `exp(μ)`, for a different reason +than the one in the brief. An oracle built on `exp(μ + v/2)` has a value that +depends on the posterior *variance*, which is largest exactly where the 15 real +films are sparse. The simulated ground truth would then bulge in the regions the +optimiser is about to explore, so the landscape would encode where R0 happened to +look rather than what the model believes. `exp(μ)` depends on the mean surface +alone. Both are labelled "posterior median" everywhere, and a test +(`test_the_oracle_reports_the_median_not_the_lognormal_mean`) pins it so nobody +switches it back without reading the reason. + +Same for the slice-fixing convention: the brief said to standardise on the median +because a PDF snippet said "average". Your `_objective_surface_values` already +used `np.median(...)` with a grid snap. No change — it is kept, snap included. + +## 3. API drift since `33f101f` + +| your code | current API | why | +|---|---|---| +| `pd.read_excel(...)` + `campaign.model_source_columns(config)` | `read_campaign_workbook(path, config)` → `contents.model_values` | three of the workbook's derived score cells are **pasted literals**, not formulas, so they do not update when the measurements behind them change. `scores.py` now recomputes all three objectives from the raw measurement columns and demotes the stored cells to cross-checks. `CAMPAIGN_STATUS.md` issue 2 has the audit. | +| — | `assert contents.errors == ()` before fitting | fail closed. `contents.findings` also carries cross-check mismatches, operator-excluded readings, and films whose thickness readings disagree. | +| thickness from column `X` = `ROUND(mean(T1..T4))` | unrounded mean of whichever of `T1..T4` were measured | 7 of 15 rows change, by ≤0.50 nm. That alone moves LOO R² by 0.067, so the number matters even though the utility barely moves. | +| `getattr(campaign, "_fit_models")`, 5 positional args, returns a model | `fit_campaign_models(config, X_phys, Y_raw, seed=...)` → **`(model, warnings)`** | `_fit_models` is private, now takes `Yvar_model`, and returns a 3-tuple. The public function returns the fit guard's own findings, which must be read: a fit can succeed and still deserve distrust. | +| — | abort if the oracle fit warns | a grid built on a collapsed oracle must not render silently. The new script hard-stops rather than bannering, because every downstream number would be built on that fit. | +| `config.get("reference_point_utility")` read directly | `metrics.compute_ref_pareto_hv(Y, ref)` | it now **raises** on `ref_point_np=None` instead of using `mins - 1e-8` (measured 6e-8 against 1.448 on the same data), and raises when nothing dominates the reference instead of returning BoTorch's silent `0.0`. | +| `run_r2_qnehvi` + `src/mobo_kit/qnehvi_batch.py` | qLogNEHVI only | scope decision from the brief. qLogNEHVI is the numerically stable formulation of the same acquisition; your `campaign.py` edit is not carried over, so `colin`'s `campaign.py` stays untouched. | + +## 4. Structural change: where the 2-D lives + +Your script builds a **pair-slice config** per input pair — the other eight inputs +collapse to single-value grids at the experimental midpoint, the pair is +restricted to the workbook min/max — and runs a whole LHS→R1→R2 campaign inside +that 2-D slice. So each figure is its own optimisation. + +The new script runs the campaign **once per parameter cell in full 10-D**, and +the 45 input pairs are *views* of that one result. Three consequences worth +knowing: + +- R0 is the **real 15 recipes**, oracle-scored, not a fresh LHS. The loop then + lives on one consistent landscape instead of mixing a measured R0 with a + simulated R1/R2. +- the batch-identity question ("did radius 0.05 and radius 0.45 propose the same + five conditions?") is answerable, because there is one batch per cell rather + than 45 unrelated ones. +- it is ~45× cheaper, which is what makes 13 parameter cells affordable. + +Your layout is preserved: `{pair}/qlognehvi/radius_*__beta_*/` for the surfaces, +your `_slug_number` rule (`0.25` → `0p25`), and your round legend verbatim — +"R0 LHS (GP_exp scored)", "R1 simulated", "R2 simulated". Per-condition artifacts +that have no pair (boxplots, the HV line, `all_rounds.csv`) go under +`by_condition/qlognehvi/radius_*__beta_*/`. + +## 5. Smaller things + +- **Outputs are gitignored.** Everything lands under `local_outputs/`, not + `results/`. Your committed `results/` PNGs are fine on your fork and would be + bloat on `colin`; they are not merged. +- **Boxplot fliers are off.** Every raw point is already overlaid, so matplotlib's + flier markers drew a second, differently-styled copy of the same observation. + The overlay itself is your convention and is kept — a box over three numbers + reports little more than those numbers. +- **Palette** is `scripts/plot_dtlz2_report.py`'s, so every figure this project + ships reads as one set. R0's categorical blue is the same hue the magnitude ramp + is built from, so markers carry a white stroke around a dark edge; a single + white edge disappears at the dark end of the ramp. +- **Footer.** Two fixed caveat lines on every figure. The oracle caveat is on all + of them; the other line is whichever is true of that figure — the slice caveat + on slice figures, a small-n caveat on the round summaries. The brief asked for + one identical footer everywhere, but printing a slice caveat on a boxplot puts a + false statement where a reader looks for true ones. +- **`min_batch_distance` is pinned at 0.15** in every cell, so "spacing" means one + thing across the sweep. Only `radius` and `beta` move. + +## 6. What did not need changing + +`_safe_filename`, `_slug_number`, the directory convention, the round legend, the +overlaid boxplot points, the median-with-grid-snap slice fixing, and the decision +to fit the oracle once and freeze it. All carried over. diff --git a/docs/ROUND_SIM_MANIFEST.md b/docs/ROUND_SIM_MANIFEST.md new file mode 100644 index 0000000..8c89623 --- /dev/null +++ b/docs/ROUND_SIM_MANIFEST.md @@ -0,0 +1,74 @@ +# `manifest.csv` schema + +> **This document describes TEST DATA.** The workbook and contract it reports on +> (`d2d-objectives-v2-nm-thickness`, `Summary Table.xlsx`) existed to develop and +> check the toolkit, not to run an experiment. **The real campaign is v4** -- +> `configs/campaign_d2d_perovskite_final.yaml` on +> `local_inputs/Final Summary Table.xlsx`, contract `d2d-objectives-v4-final`. +> Uniformity and optoelectronic have been renormalised twice since, so **no +> number below transfers**; they describe quantities that were redefined. Start +> from `docs/CAMPAIGN_STATUS.md` for the real campaign. + +Written by `scripts/plot_round_simulation.py` to +`local_outputs/round_simulations/manifest.csv`. One row per parameter cell. + +This file, not the figures, is the decision instrument. The figures show what a +landscape looks like; the manifest answers whether changing a knob changed +anything, which is the question the sweep exists to settle. A knob that produces a +byte-identical batch at every setting is inert on this problem, and no amount of +looking at contour plots will tell you that. + +Column order is pinned by `MANIFEST_COLUMNS` in the script and asserted by +`tests/test_plot_round_simulation.py::test_manifest_row_has_exactly_the_declared_columns`. + +| column | type | meaning | +|---|---|---| +| `condition_id` | int | 1-based position in the run. Not stable across different `--conditions` filters; use the slug or `(radius, beta)` to join. | +| `arm` | str | `radius` (beta held at 4), `beta` (radius held at 0.25), `both` (the shared 0.25/4 anchor), or `grid` under `--full-grid`. | +| `radius` | float | `local_penalization.radius` for this cell. | +| `beta` | float | `rounds.r1.beta` for this cell. | +| `min_batch_distance` | float | Always 0.15. Pinned, never swept, so "spacing" means one thing in every row. | +| `seed` | int | 73 unless `--seed` overrides. The oracle, both acquisitions, and every pool draw use it. | +| `r1_batch_hash` | str | 16 hex chars. SHA-256 of the R1 conditions, **sorted** and rounded to 12 dp. Two cells sharing a hash proposed the same set of recipes; order is not part of the identity. | +| `r2_batch_hash` | str | The same for R2. | +| `r1_min_pairwise_distance` | float | Smallest normalised distance within the R1 batch. Compare against `radius` to see whether local penalisation had anything to act on. | +| `r2_min_pairwise_distance` | float | The same for R2. | +| `r1_boundary_coords_total` | int | How many coordinates across the whole R1 batch sit exactly at a range edge. On the live campaign this is driven by the monotone `anneal_temp` mean function, not by either knob. | +| `r2_boundary_coords_total` | int | The same for R2. | +| `r1_boundary_coords_per_condition` | JSON list | Per-condition breakdown, so one pinned condition is distinguishable from five mildly-pinned ones. | +| `r2_boundary_coords_per_condition` | JSON list | The same for R2. | +| `hv_r0` | float | Hypervolume of the 15 oracle-scored R0 points, utility space, at the campaign's declared `reference_point_utility`. | +| `hv_r0_r1` | float | After adding the 5 R1 conditions. | +| `hv_r0_r1_r2` | float | After adding the 3 R2 conditions. | +| `hv_gain_r1` | float | `hv_r0_r1 - hv_r0`. | +| `hv_gain_r2` | float | `hv_r0_r1_r2 - hv_r0_r1`. | +| `baseline_hv_reported_by_r1` | float | The observed HVI baseline the R1 acquisition actually used, from `run_r1_ucb`'s own diagnostics. | +| `baseline_hv_independent` | float | The same quantity recomputed by the script through `metrics.compute_ref_pareto_hv` — a different Pareto filter and a different call path. **`run_cell` raises if these two disagree.** | +| `baseline_hv_pareto_size` | int | How many observations sit on the baseline Pareto front. Under the historical mis-encoding this was 2; correctly encoded it is 5. | +| `baseline_hv_unencoded_contrast` | float | What the baseline *would* be if measurement-space values reached the transform directly — the size of the defect fixed in commit `4b76670`. Constant across rows, and **never expected to equal anything**. | +| `r1_fit_warnings` | int | Fit-guard warnings raised by the GP that proposed R1. Guard warnings only, not the ~18 numpy deprecation notices per fit. | +| `r2_fit_warnings` | int | The same for the R2 model. | +| `final_fit_warnings` | int | The same for the 23-point model the heatmaps render. Non-zero puts a banner on that cell's figures. | +| `mean_utility_r0` | float | Mean utility over all objectives and all 15 R0 points. A summary, not a ranking: it averages three objectives that are not commensurable. | +| `mean_utility_r1` | float | The same over the 5 R1 conditions. | +| `mean_utility_r2` | float | The same over the 3 R2 conditions. | + +## Reading it + +**Hypervolume rises monotonically by construction.** Adding points can only grow a +Pareto front, so `hv_r0 <= hv_r0_r1 <= hv_r0_r1_r2` holds in every row and proves +nothing on its own — random sampling satisfies it too. What carries information is +the *size* of `hv_gain_r1` compared across cells, and the batch hashes. + +**Cells with equal `(r1_batch_hash, r2_batch_hash)` are the same experiment.** +Their hypervolumes and utilities are then identical by construction, not by +agreement, and quoting them as independent replicates would be double counting. + +**The baseline columns are the standing tripwire for the encoding defect.** +`reported` comes from inside the acquisition; `independent` is recomputed by a +different route; `run_cell` raises rather than writing a manifest if they differ. +`unencoded_contrast` is the size of the historical mistake and is deliberately not +compared to anything — asserting all three equal would be an assertion that can +only ever fail, because the third column exists precisely to reproduce the wrong +answer. They are constant within a run, and recorded per row so a single row is +self-describing when pasted somewhere else. diff --git a/docs/SHAP_SUMMARY.md b/docs/SHAP_SUMMARY.md new file mode 100644 index 0000000..e64b831 --- /dev/null +++ b/docs/SHAP_SUMMARY.md @@ -0,0 +1,84 @@ +# `shap_summary.csv` schema + +> **This document describes TEST DATA.** The workbook and contract it reports on +> (`d2d-objectives-v2-nm-thickness`, `Summary Table.xlsx`) existed to develop and +> check the toolkit, not to run an experiment. **The real campaign is v4** -- +> `configs/campaign_d2d_perovskite_final.yaml` on +> `local_inputs/Final Summary Table.xlsx`, contract `d2d-objectives-v4-final`. +> Uniformity and optoelectronic have been renormalised twice since, so **no +> number below transfers**; they describe quantities that were redefined. Start +> from `docs/CAMPAIGN_STATUS.md` for the real campaign. + +Written by `scripts/plot_shap_attribution.py` to +`local_outputs/shap/shap_summary.csv`. One row per **feature × objective × model +state**. + +The beeswarms show shape; this file is what you sort, diff and quote. It is also +what makes the extreme-cell comparison possible, since ranking two pictures by eye +is not a measurement. + +## What is being explained + +`E[utility]` for one objective, through +`ObjectiveTransform.expected_transform` — so thickness goes through the lognormal +quadrature rather than a transformed posterior mean, and every SHAP value is in +**utility units where higher is better**, comparable across features within an +objective. + +**Not comparable across objectives.** Uniformity and thickness utilities are +different constructions (an affine product against a Gaussian on a 650 nm target), +so a larger mean |SHAP| on one does not mean that objective is more sensitive. +Compare rows within an objective, or compare the same objective across model +states. + +## Columns + +| column | type | meaning | +|---|---|---| +| `model_state` | str | `r0_only` (fitted to the 15 real measurements), `final` (23 points after a simulated R0→R1→R2 at radius 0.25, beta 4), or `final_radius_*__beta_*` for an extreme cell. If the two R2 acquisitions ever diverge, `final_qlognehvi` and `final_qnehvi` appear instead of `final`. | +| `objective` | str | `uniformity`, `optoelectronic` or `thickness`. | +| `feature` | str | One of the ten campaign inputs. | +| `mean_abs_shap` | float | Mean absolute SHAP value over the attributed instances — the magnitude the beeswarm ranks by. | +| `rank` | int | 1 = largest `mean_abs_shap` within that objective and model state. | +| `mean_shap` | float | Signed mean. Near zero with a large `mean_abs_shap` means the feature matters in both directions — a non-monotone effect, not a weak one. | +| `feature_min` / `feature_max` | float | The physical range spanned by the attributed instances, in that input's own units. Present because a beeswarm's colour is normalised **per feature row**, so one colorbar cannot carry physical units for ten inputs at once. | +| `in_mean_function` | bool | True if this feature appears in that objective's declared `mean_function`. **A True row is partly a restatement of the model's declared physics, not a discovery.** | +| `r2_acquisition` | str | `identical` when qLogNEHVI and qNEHVI proposed the same R2 batch (so the row covers both), otherwise the acquisition that produced the state. | + +## `shap_extreme_cell_shift.csv` + +Written alongside when `--extreme-cells` is passed. One row per extreme cell × +objective, answering **whether the attributions describe the model or the search**. + +| column | meaning | +|---|---| +| `cell` | the extreme cell, e.g. `radius_0p05__beta_4` | +| `max_abs_shift` | largest change in `mean_abs_shap` for any feature, against the default cell | +| `max_shift_feature` | which feature moved most | +| `max_shift_fraction_of_largest` | that shift as a fraction of the objective's largest default-cell attribution | +| `top_feature_changed` | whether the rank-1 feature differs from the default cell | +| `default_top_feature` / `cell_top_feature` | the two rank-1 features | + +**The verdict rule was fixed before the cells were run**: sweeping the acquisition +parameters is warranted only if an extreme cell moves the top feature, or moves any +attribution by more than **10%** of that objective's largest. Otherwise the +attributions are a property of the fitted model rather than of how the batch was +selected, and there is nothing to sweep. + +## Three things to read carefully + +**A large attribution is not evidence of a physical effect.** SHAP explains the +model. Where `in_mean_function` is True, the model was *told* that relationship by +`configs/campaign_d2d_perovskite.yaml`; SHAP recovering it is a consistency check, +not a discovery. + +**Uniformity attributions are not signal.** Uniformity does not beat the +leave-one-out null (LOO R² −0.681, permutation p = 0.82). Its GP still fits ARD +lengthscales and has a posterior mean that varies, so SHAP reports structure with +real magnitude. That structure is fitted noise. It is included rather than +suppressed because a reader who sees only the beeswarm would otherwise conclude +the opposite — and every uniformity figure says so in its footer. + +**`final` rows describe a simulated campaign.** Only the 15 R0 conditions were +measured; the other 8 carry oracle predictions. `r0_only` is the state fitted +entirely to real data and is the anchor for anything quoted outside this analysis. diff --git a/docs/figures/round_simulation/01_thickness_radius_0p05_tight_batch.png b/docs/figures/round_simulation/01_thickness_radius_0p05_tight_batch.png new file mode 100644 index 0000000..b936ef0 Binary files /dev/null and b/docs/figures/round_simulation/01_thickness_radius_0p05_tight_batch.png differ diff --git a/docs/figures/round_simulation/02_thickness_radius_0p25_anchor.png b/docs/figures/round_simulation/02_thickness_radius_0p25_anchor.png new file mode 100644 index 0000000..9abcfdb Binary files /dev/null and b/docs/figures/round_simulation/02_thickness_radius_0p25_anchor.png differ diff --git a/docs/figures/round_simulation/03_thickness_radius_0p45_saturated.png b/docs/figures/round_simulation/03_thickness_radius_0p45_saturated.png new file mode 100644 index 0000000..0685c01 Binary files /dev/null and b/docs/figures/round_simulation/03_thickness_radius_0p45_saturated.png differ diff --git a/docs/figures/round_simulation/04_optoelectronic_anneal_temp_range_edge.png b/docs/figures/round_simulation/04_optoelectronic_anneal_temp_range_edge.png new file mode 100644 index 0000000..39ddf3b Binary files /dev/null and b/docs/figures/round_simulation/04_optoelectronic_anneal_temp_range_edge.png differ diff --git a/docs/figures/round_simulation/05_utility_by_round_anchor.png b/docs/figures/round_simulation/05_utility_by_round_anchor.png new file mode 100644 index 0000000..6817906 Binary files /dev/null and b/docs/figures/round_simulation/05_utility_by_round_anchor.png differ diff --git a/docs/figures/round_simulation/06_hypervolume_by_round_anchor.png b/docs/figures/round_simulation/06_hypervolume_by_round_anchor.png new file mode 100644 index 0000000..93da90c Binary files /dev/null and b/docs/figures/round_simulation/06_hypervolume_by_round_anchor.png differ diff --git a/docs/figures/round_simulation/README.md b/docs/figures/round_simulation/README.md new file mode 100644 index 0000000..fb678c7 --- /dev/null +++ b/docs/figures/round_simulation/README.md @@ -0,0 +1,42 @@ +# Curated round-simulation figures + +> **This document describes TEST DATA.** The workbook and contract it reports on +> (`d2d-objectives-v2-nm-thickness`, `Summary Table.xlsx`) existed to develop and +> check the toolkit, not to run an experiment. **The real campaign is v4** -- +> `configs/campaign_d2d_perovskite_final.yaml` on +> `local_inputs/Final Summary Table.xlsx`, contract `d2d-objectives-v4-final`. +> Uniformity and optoelectronic have been renormalised twice since, so **no +> number below transfers**; they describe quantities that were redefined. Start +> from `docs/CAMPAIGN_STATUS.md` for the real campaign. + +Six figures from one run of `scripts/plot_round_simulation.py`, seed 73, kept as +examples of what the script produces. The full run writes 1,781 figures and 222 MB +to the gitignored `local_outputs/round_simulations/`; these are the ones worth +looking at without re-running it. + +**Every value in these figures is a model prediction, not a measurement.** The +oracle is a GP fitted to the 15 real R0 films and then frozen, so a condition that +scores well here has scored well against MOBO-Kit's own beliefs. This validates +the optimiser loop on a data-shaped landscape; it says nothing about the +chemistry. Each figure repeats that in its footer. + +| file | what it shows | +|---|---| +| `01_thickness_radius_0p05_tight_batch.png` | radius 0.05. R1 (orange) clusters — two conditions land on `speed_1 = 2500`. Achieved batch spacing 0.455. | +| `02_thickness_radius_0p25_anchor.png` | radius 0.25, the campaign's current setting. The same five conditions have been pushed apart; spacing 0.720. | +| `03_thickness_radius_0p45_saturated.png` | radius 0.45. Spacing 0.921, identical to radius 0.30–0.40 — the knob has saturated and stops doing anything. | +| `04_optoelectronic_anneal_temp_range_edge.png` | why every proposed condition pins `anneal_temp` to its lower bound. The surface is monotone in temperature because the objective carries a monotone linear mean function, so its optimum is at a range edge by construction. This reproduces `CAMPAIGN_STATUS.md` issue 4 from the model side. | +| `05_utility_by_round_anchor.png` | utility by round, three objectives, n = 15 / 5 / 3, with every raw point drawn over its box. | +| `06_hypervolume_by_round_anchor.png` | cumulative hypervolume at the campaign-fixed reference. It rises monotonically **by construction** — adding points can only grow a Pareto front — so this panel shows the size of each step, not that optimisation happened. | + +Figures 01 → 02 → 03 are the same slice of the same input pair at three radii, and +are the visual form of the sweep's main finding: on this landscape `radius` +**binds** below about 0.30 and is inert above it. That contradicts the expectation +carried over from the DTLZ2 sweep, where achieved spacings of 0.72–0.98 left the +knob nothing to act on. + +Reproduce any of them with, for example: + +```bash +python scripts/plot_round_simulation.py --workbook "local_inputs/Summary Table.xlsx" --conditions radius_0p05__beta_4 --pairs speed_1,precur_conc +``` diff --git a/docs/figures/shap/01_thickness_r0_only.png b/docs/figures/shap/01_thickness_r0_only.png new file mode 100644 index 0000000..fb1f629 Binary files /dev/null and b/docs/figures/shap/01_thickness_r0_only.png differ diff --git a/docs/figures/shap/02_optoelectronic_r0_only.png b/docs/figures/shap/02_optoelectronic_r0_only.png new file mode 100644 index 0000000..02df1ef Binary files /dev/null and b/docs/figures/shap/02_optoelectronic_r0_only.png differ diff --git a/docs/figures/shap/03_uniformity_r0_only_is_fitted_noise.png b/docs/figures/shap/03_uniformity_r0_only_is_fitted_noise.png new file mode 100644 index 0000000..056f63e Binary files /dev/null and b/docs/figures/shap/03_uniformity_r0_only_is_fitted_noise.png differ diff --git a/docs/figures/shap/04_thickness_final23.png b/docs/figures/shap/04_thickness_final23.png new file mode 100644 index 0000000..d2e3b2a Binary files /dev/null and b/docs/figures/shap/04_thickness_final23.png differ diff --git a/docs/figures/shap/05_optoelectronic_final23.png b/docs/figures/shap/05_optoelectronic_final23.png new file mode 100644 index 0000000..f9edcc7 Binary files /dev/null and b/docs/figures/shap/05_optoelectronic_final23.png differ diff --git a/docs/figures/shap/06_uniformity_final23.png b/docs/figures/shap/06_uniformity_final23.png new file mode 100644 index 0000000..11c9807 Binary files /dev/null and b/docs/figures/shap/06_uniformity_final23.png differ diff --git a/docs/figures/shap/README.md b/docs/figures/shap/README.md new file mode 100644 index 0000000..8643404 --- /dev/null +++ b/docs/figures/shap/README.md @@ -0,0 +1,56 @@ +# SHAP attribution figures + +> **This document describes TEST DATA.** The workbook and contract it reports on +> (`d2d-objectives-v2-nm-thickness`, `Summary Table.xlsx`) existed to develop and +> check the toolkit, not to run an experiment. **The real campaign is v4** -- +> `configs/campaign_d2d_perovskite_final.yaml` on +> `local_inputs/Final Summary Table.xlsx`, contract `d2d-objectives-v4-final`. +> Uniformity and optoelectronic have been renormalised twice since, so **no +> number below transfers**; they describe quantities that were redefined. Start +> from `docs/CAMPAIGN_STATUS.md` for the real campaign. + +Six figures from one run of `scripts/plot_shap_attribution.py`, seed 73, 1,000 +on-grid instances. Schema and reading notes: `docs/SHAP_SUMMARY.md`. + +Each answers: **which process inputs move this objective's expected utility, and +in which direction?** They explain the *model*, which is the only thing SHAP can +explain. + +| file | what it shows | +|---|---| +| `01_thickness_r0_only.png` | The clearest real result. `precur_conc` and `speed_1` lead by 3.7× over the third feature, with the sign pattern the fitted physics predicts: high concentration and low spin speed both push the film thicker, away from the 650 nm target, so both reduce utility. | +| `02_optoelectronic_r0_only.png` | `anneal_temp` leads, monotone and negative — the declared linear trend (marginal ρ = −0.651, p = 0.009). This is why every proposed condition pins the temperature to its lower bound. | +| `03_uniformity_r0_only_is_fitted_noise.png` | **The cautionary figure.** It looks like a textbook result — a clean monotone gradient on `time_1`, an orderly ranking, magnitudes of ±0.15. Uniformity does not beat the leave-one-out null (LOO R² −0.681, permutation p = 0.82). Every bit of that structure is fitted noise. | +| `04`–`06` | The same three objectives after a simulated R0 → R1 → R2 pass, refitted on 23 conditions. Rankings are unchanged; magnitudes grow slightly. Only 15 of those 23 conditions were ever measured. | + +## Three things these figures are not + +**Not evidence of a physical effect.** Where a feature appears in that objective's +declared `mean_function` — `speed_1` and `precur_conc` for thickness, +`anneal_temp` for optoelectronic — the model was *told* that relationship by the +config. SHAP recovering it is a consistency check, not a discovery. The +`in_mean_function` column in `shap_summary.csv` marks exactly which rows those are. + +**Not a cross-objective comparison.** Thickness attributions are larger than +uniformity's, but the two utilities are different constructions (a Gaussian on a +650 nm target against an affine product). Compare within an objective. + +**Not sensitive to how the batch was chosen.** Measured across three extreme +acquisition cells (radius 0.05, radius 0.45, beta 25), no objective changed its +top feature and the largest attribution shift was **0.0996 of that objective's +largest** — and that near-miss was on uniformity, whose attributions are noise +anyway. The two objectives carrying real structure moved by at most 6.3%. So these +are properties of the fitted model, not of the search. + +## qLogNEHVI and qNEHVI give the same model + +The brief expected two distinct final states, one per R2 acquisition. They propose +the **identical R2 batch** — verified across four cells (default, radius 0.05, +radius 0.45, beta 25) — so figures `04`–`06` cover both, and each says so in its +footer. BoTorch itself warns against qNEHVI in favour of qLogNEHVI. + +Reproduce with: + +```bash +python scripts/plot_shap_attribution.py --workbook "local_inputs/Summary Table.xlsx" --instances 1000 --extreme-cells +``` diff --git a/launch_mobo_kit.bat b/launch_mobo_kit.bat new file mode 100644 index 0000000..c15675d --- /dev/null +++ b/launch_mobo_kit.bat @@ -0,0 +1,32 @@ +@echo off +rem Double-click this to propose the next round. +rem +rem It opens a small window: choose the campaign workbook, press "Check +rem workbook", then press "Propose R1" (or R2). The proposed conditions are +rem written to a NEW file beside the workbook; the workbook itself is never +rem modified. +rem +rem If the window does not appear, the message left in this console says why. + +setlocal +cd /d "%~dp0" + +set "MOBO_PYTHON=.venv\Scripts\python.exe" +if not exist "%MOBO_PYTHON%" set "MOBO_PYTHON=python" + +"%MOBO_PYTHON%" -m mobo_kit.launcher %* + +if errorlevel 1 ( + echo. + echo The launcher stopped with an error. The workbook was not modified. + echo. + echo If it says "No module named mobo_kit", the environment is not installed + echo yet. From this folder, run: + echo. + echo py -3.12 -m venv .venv + echo .venv\Scripts\python -m pip install -r requirements\dev.txt + echo. + pause +) + +endlocal diff --git a/launch_mobo_kit.command b/launch_mobo_kit.command new file mode 100644 index 0000000..92a78be --- /dev/null +++ b/launch_mobo_kit.command @@ -0,0 +1,37 @@ +#!/bin/sh +# Double-click this to propose the next round (macOS). +# +# It opens a small window: choose the campaign workbook, press "Check workbook", +# then press "Propose R1" (or R2). The proposed conditions are written to a NEW +# file beside the workbook; the workbook itself is never modified. +# +# macOS will not run a .command file until it is marked executable. Once, in +# Terminal, from this folder: +# +# chmod +x launch_mobo_kit.command + +cd "$(dirname "$0")" || exit 1 + +MOBO_PYTHON=.venv/bin/python +if [ ! -x "$MOBO_PYTHON" ]; then + MOBO_PYTHON=python3 +fi + +"$MOBO_PYTHON" -m mobo_kit.launcher "$@" +status=$? + +if [ "$status" -ne 0 ]; then + echo + echo "The launcher stopped with an error. The workbook was not modified." + echo + echo 'If it says "No module named mobo_kit", the environment is not installed' + echo "yet. From this folder, run:" + echo + echo " python3 -m venv .venv" + echo " .venv/bin/python -m pip install -r requirements/dev.txt" + echo + echo "Press return to close." + read -r _ +fi + +exit "$status" diff --git a/notebooks/MOBO_demo_annotated.ipynb b/notebooks/MOBO_demo_annotated.ipynb index 5ac60db..be3d81a 100644 --- a/notebooks/MOBO_demo_annotated.ipynb +++ b/notebooks/MOBO_demo_annotated.ipynb @@ -30,6 +30,42 @@ "- [9. Save Results](#9-save-outputs)\n" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "> ## Which model this notebook builds\n", + ">\n", + "> **This is a general demo of the toolkit API on an arbitrary CSV. It is not\n", + "> the campaign path, and it does not build the campaign's model.**\n", + ">\n", + "> The GPs below come from `models.fit_gp_models` and `models.loocv_select_models`,\n", + "> which construct `ScaleKernel(MaternKernel(nu=2.5, ard))` with **no lengthscale\n", + "> prior**. On 15 observations in 10 dimensions that fit is degenerate: measured\n", + "> ARD lengthscales ran from 0.13 to 38,000 with 6-9 of 10 directions switched off\n", + "> and the noise pinned at its floor, meaning the model believed the data were\n", + "> noiseless. See `docs/GP_MODEL_DECISION.md`.\n", + ">\n", + "> The campaign uses `model_validation.fit_model_variant` with the\n", + "> `dim_scaled_prior` variant, which restores BoTorch's dimension-scaled LogNormal\n", + "> lengthscale prior and its LogNormal noise prior, plus a runtime guard against\n", + "> the two degenerate fits those priors do not by themselves prevent. Reach it\n", + "> through `campaign.fit_campaign_models`, or run a whole round with\n", + "> `campaign.run_r1_ucb`.\n", + ">\n", + "> Two more differences worth knowing before borrowing code from here:\n", + ">\n", + "> - The campaign **computes** its objective values from raw measurement columns\n", + "> (`scores.py`) rather than reading stored score cells, because three of those\n", + "> cells in the real workbook are pasted literals that do not update.\n", + "> - `metrics.compute_ref_pareto_hv` now **requires** an explicit reference point.\n", + "> A reference inferred from the data in hand moves between rounds, which makes\n", + "> hypervolumes incomparable across them.\n", + ">\n", + "> Saved outputs below were produced by an earlier run on a CUDA machine and are\n", + "> kept for reading; re-execute to regenerate them.\n" + ] + }, { "cell_type": "markdown", "id": "c22b92d9", @@ -1433,28 +1469,20 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "e54ea0a6", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Hypervolume: 109.02000125445022 | Pareto count: 5\n", - "tensor([[ 0.0000, 0.0000, 10.0000],\n", - " [10.6900, 1.3500, 3.3333],\n", - " [16.4300, 5.6800, 0.9524],\n", - " [17.2200, 0.9000, 0.7353],\n", - " [16.8300, 4.6800, 1.2821]], device='cuda:0', dtype=torch.float64)\n" - ] - } - ], + "outputs": [], "source": [ "from mobo_kit.metrics import compute_ref_pareto_hv\n", "\n", - "_, pareto_Y_t, hv_val = compute_ref_pareto_hv(Y_t)\n", - "ref_point_t = torch.tensor([-0.01, -0.01, -0.01], dtype=X_t.dtype, device=X_t.device)\n", + "# The reference point is fixed for the whole campaign and must be passed\n", + "# explicitly. A reference inferred from whatever data is in hand moves between\n", + "# rounds, and hypervolumes measured against a moving reference are not\n", + "# comparable across them -- which is the only reason to track hypervolume.\n", + "ref_point_np = np.array([-0.01, -0.01, -0.01])\n", + "\n", + "ref_point_t, pareto_Y_t, hv_val = compute_ref_pareto_hv(Y_t, ref_point_np)\n", "print(\"Hypervolume:\", hv_val, \"| Pareto count:\", pareto_Y_t.shape[0])\n", "print(pareto_Y_t)" ] diff --git a/pyproject.toml b/pyproject.toml index d37e35b..f416c0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,39 +32,34 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Scientific/Engineering :: Chemistry", "Topic :: Scientific/Engineering :: Physics", ] -requires-python = ">=3.10" +requires-python = ">=3.11,<3.13" dependencies = [ - "numpy>=1.21.0", - "pandas>=1.3.0", - "scikit-learn>=1.0.0", - "matplotlib>=3.5.0", - "seaborn>=0.11.0", - "pyyaml>=6.0", - "scipy>=1.7.0", - "torch>=1.12.0", - "gpytorch>=1.8.0", - "botorch>=0.8.0", - "emukit>=0.4.10", - "shap>=0.41.0", - "pyDOE>=0.3.8", - "jupyter>=1.0.0", - "ipykernel>=6.0.0", + "numpy>=1.26,<3", + "pandas>=2.1,<3", + "scikit-learn>=1.4,<2", + "matplotlib>=3.8,<4", + "seaborn>=0.13,<1", + "pyyaml>=6.0,<7", + "scipy>=1.12,<2", + "torch>=2.8,<2.9", + "gpytorch==1.14", + "botorch==0.15.1", + "shap>=0.46,<1", + "openpyxl>=3.1,<4", + "pillow>=10,<13", ] [project.optional-dependencies] dev = [ - "pytest>=6.0", - "pytest-cov>=2.0", - "black>=22.0", - "flake8>=4.0", - "mypy>=0.950", + "pytest>=8.2,<9", + "pytest-cov>=5,<7", + "black>=24,<26", ] jupyter = [ "jupyter>=1.0.0", @@ -72,18 +67,19 @@ jupyter = [ "notebook>=6.0.0", ] all = [ - "mobo-fom[dev,jupyter]", + "mobo-kit[dev,jupyter]", ] [project.urls] -Homepage = "https://github.com/PV-Lab/MOBO-FOM" -Repository = "https://github.com/PV-Lab/MOBO-FOM" -Documentation = "https://github.com/PV-Lab/MOBO-FOM#readme" -"Bug Tracker" = "https://github.com/PV-Lab/MOBO-FOM/issues" +Homepage = "https://github.com/PV-Lab/MOBO-Kit" +Repository = "https://github.com/PV-Lab/MOBO-Kit" +Documentation = "https://github.com/PV-Lab/MOBO-Kit#readme" +"Bug Tracker" = "https://github.com/PV-Lab/MOBO-Kit/issues" [project.scripts] mobo-kit = "mobo_kit.cli:main" mobo-kit-run = "mobo_kit.main:main" +mobo-kit-launcher = "mobo_kit.launcher:main" [tool.setuptools.packages.find] where = ["src"] @@ -93,7 +89,7 @@ where = ["src"] [tool.black] line-length = 88 -target-version = ['py310'] +target-version = ['py311'] include = '\.pyi?$' extend-exclude = ''' /( @@ -114,10 +110,18 @@ testpaths = ["tests"] python_files = ["test_*.py", "*_test.py"] python_classes = ["Test*"] python_functions = ["test_*"] -addopts = "-v --tb=short" - -[tool.mypy] -python_version = "3.10" -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = true +# --capture=sys is load-bearing, not a preference. pytest's default fd-level +# capture swaps file descriptors 1 and 2 for temp files, and a Tk interpreter +# created while that is in force holds descriptors that are gone by the time the +# next one is built -- the second or third window in a process then dies reading +# its own init.tcl, reporting "No error". It looked like a race in the launcher +# and is not one. Measured: 6 failures in 9 runs of one launcher test under +# --capture=fd, 0 in 24 runs under --capture=sys, with an identical 28-warning +# tail and no extra console output. Only `capsys` is used in this suite, never +# `capfd`, so nothing here depends on fd-level capture. See the `open_window` +# fixture in tests/test_launcher.py for the full measurement. +addopts = "-v --tb=short --capture=sys" +markers = [ + "local_input: optional integration test requiring an ignored local_inputs artifact", + "slow: multi-seed acceptance test; deselect with -m 'not slow'", +] diff --git a/requirements.txt b/requirements.txt index 045d123..56341a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,24 +1,4 @@ -# Core scientific stack -numpy>=1.21.0 -pandas>=1.3.0 -scikit-learn>=1.0.0 -matplotlib>=3.5.0 -seaborn>=0.11.0 -pyyaml>=6.0 -scipy>=1.7.0 - -# PyTorch & probabilistic programming -torch>=1.12.0 -gpytorch>=1.8.0 -botorch>=0.8.0 - -# Experimental design / optimization -emukit>=0.4.10 - -# Optional: Jupyter for notebooks -jupyter>=1.0.0 -ipykernel>=6.0.0 - -# Additional dependencies -shap>=0.41.0 -pyDOE>=0.3.8 +# Reproducible runtime install from the repository root: +# python -m pip install -r requirements.txt +-c requirements/constraints.txt +. diff --git a/requirements/constraints.txt b/requirements/constraints.txt new file mode 100644 index 0000000..7112979 --- /dev/null +++ b/requirements/constraints.txt @@ -0,0 +1,36 @@ +# The CPU-tested dependency stack. Keep these versions synchronized with the +# ranges in pyproject.toml, which are deliberately wider: this file is what was +# actually tested, pyproject is what is allowed. +# +# linear_operator is pinned although pyproject does not declare it -- it is a +# gpytorch/botorch transitive dependency whose version affects fitted numbers, and +# on this stack a second-decimal change in LOO R2 is inside the numerical floor +# (see docs/GP_MODEL_DECISION.md). Pin it so a reproduction is a reproduction. +# +# shap is pinned for the same reason: scripts/plot_shap_attribution.py relies on +# KernelExplainer enumerating ALL 2**10 coalitions at ten inputs, which is what +# makes its attributions exact and reproducible rather than sampled. That sample +# budget is a library default, not an API guarantee -- a version that changed it +# would silently turn every attribution into an approximation, and nothing would +# fail. The suite's additivity check would catch it; the pin is so it never gets +# the chance on a fresh install. +# +# (docs/STEP1_HANDOFF.md, referenced here until 2026-07-30, was removed in the +# cleanup commit 33f101f. docs/HANDOFF.md is the current entry point.) +numpy==2.2.6 +pandas==2.3.1 +scikit-learn==1.7.1 +matplotlib==3.10.3 +seaborn==0.13.2 +PyYAML==6.0.2 +scipy==1.16.0 +torch==2.8.0 +gpytorch==1.14 +botorch==0.15.1 +linear_operator==0.6 +shap==0.48.0 +openpyxl==3.1.5 +Pillow==12.3.0 +pytest==8.4.1 +pytest-cov==6.2.1 +black==25.1.0 diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..a5a17e7 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,4 @@ +# From the repository root: +# python -m pip install -r requirements/dev.txt +-c constraints.txt +-e .[dev] diff --git a/results/demo/batch_1.csv b/results/demo/batch_1.csv deleted file mode 100644 index 087ded1..0000000 --- a/results/demo/batch_1.csv +++ /dev/null @@ -1,23 +0,0 @@ -Unnamed: 0,speed_inorg,speed_org,inkfl_inorg,inkfl_org,conc_inorg,conc_org,temperature_c,absolute_humidity,Unnamed: 9,PCE,Stability,Repeatability -units,m/min,m/min,uL/min,uL/min,M,M,F,g/m^3,,,, -start,0.25,0.25,80,100,0.8,0.4,20,2,,,, -stop,1,1,240,280,1.4,1.2,50,37,,,, -step,0.01,0.01,1,1,0.05,0.05,1,1,,,, -,,,,,,,,,,,, -,0.58,0.3,190,134,0.85,0.75,24.7,3,,0.0,0.0,10.0 -,0.95,0.58,170,246,0.9,0.85,44,19,,0.0,0.0,10.0 -,0.67,0.95,150,179,1.05,1.15,31.8,6,,0.0,0.0,10.0 -,0.77,0.39,130,269,1.15,0.65,28.5,6,,0.0,0.0,10.0 -,0.39,0.67,230,201,1.2,0.55,41.6,15,,10.69,1.35,3.333333333 -,0.3,0.86,90,224,1.3,0.95,41.6,19,,0.0,0.0,10.0 -,0.86,0.48,210,111,1.35,1.05,36.3,16,,16.43,5.68,0.952380952 -,0.3,0.3,82.5,120,1.4,0.4,23.2,2,,17.22,0.9,0.735294118 -,0.92,0.39,213,181,1.35,1,45.1,9,,0.54,1.09,0.392156863 -,0.89,0.36,228,280,1.4,0.65,46.5,15,,0.0,0.0,10.0 -,0.3,0.67,240,213,1.4,1.05,38.2,2,,16.83,4.68,1.282051282 -,0.43,0.85,213,254,1.35,1.15,42,9,,2.18,0.0,3.125 -,0.72,1.0,107.0,114.0,0.8,1.15,36.0,17.0,,17.35706971953076,5.219312558290997,8.984170432675196 -,0.67,1.0,80.0,106.0,1.4,0.95,35.0,16.0,,15.976962026424701,5.193390711635139,7.455737159814987 -,1.0,1.0,163.0,124.0,0.8,0.65,34.0,17.0,,16.11921708962232,4.583029335484693,8.897908609111669 -,0.26,1.0,240.0,104.0,0.8,1.1,46.0,18.0,,14.729113480381507,5.0374920889118515,7.303781029245997 -,0.99,0.79,80.0,131.0,0.8,0.6,43.0,19.0,,14.511073685461238,3.8791072174518737,8.652695960741147 diff --git a/results/demo/hv_demo_with_predictions.png b/results/demo/hv_demo_with_predictions.png deleted file mode 100644 index df3b12b..0000000 Binary files a/results/demo/hv_demo_with_predictions.png and /dev/null differ diff --git a/results/demo/lhs_corr.png b/results/demo/lhs_corr.png deleted file mode 100644 index 8a85dc0..0000000 Binary files a/results/demo/lhs_corr.png and /dev/null differ diff --git a/results/demo/lhs_dist.png b/results/demo/lhs_dist.png deleted file mode 100644 index 3bdb9da..0000000 Binary files a/results/demo/lhs_dist.png and /dev/null differ diff --git a/results/demo/lhs_pca.png b/results/demo/lhs_pca.png deleted file mode 100644 index 3814ec3..0000000 Binary files a/results/demo/lhs_pca.png and /dev/null differ diff --git a/results/demo/parity_train.png b/results/demo/parity_train.png deleted file mode 100644 index 588a46b..0000000 Binary files a/results/demo/parity_train.png and /dev/null differ diff --git a/results/demo/proposed_bar.png b/results/demo/proposed_bar.png deleted file mode 100644 index fdacc36..0000000 Binary files a/results/demo/proposed_bar.png and /dev/null differ diff --git a/results/demo/shap.png b/results/demo/shap.png deleted file mode 100644 index 8a5fd02..0000000 Binary files a/results/demo/shap.png and /dev/null differ diff --git a/results/experiment/next_batch.csv b/results/experiment/next_batch.csv deleted file mode 100644 index 3ca2c84..0000000 --- a/results/experiment/next_batch.csv +++ /dev/null @@ -1,21 +0,0 @@ -Unnamed: 0,speed_inorg,speed_org,inkfl_inorg,inkfl_org,conc_inorg,conc_org,temperature_c,absolute_humidity,Unnamed: 9,PCE,Stability,Repeatability -units,m/min,m/min,uL/min,uL/min,M,M,F,g/m^3,,,, -start,0.25,0.25,80,100,0.8,0.4,20,2,,,, -stop,1,1,240,280,1.4,1.2,50,37,,,, -step,0.01,0.01,1,1,0.05,0.05,1,1,,,, -,,,,,,,,,,,, -,0.58,0.3,190,134,0.85,0.75,24.7,3,,0.0,0.0,10.0 -,0.95,0.58,170,246,0.9,0.85,44,19,,0.0,0.0,10.0 -,0.67,0.95,150,179,1.05,1.15,31.8,6,,0.0,0.0,10.0 -,0.77,0.39,130,269,1.15,0.65,28.5,6,,0.0,0.0,10.0 -,0.39,0.67,230,201,1.2,0.55,41.6,15,,10.69,1.35,3.333333333 -,0.3,0.86,90,224,1.3,0.95,41.6,19,,0.0,0.0,10.0 -,0.86,0.48,210,111,1.35,1.05,36.3,16,,16.43,5.68,0.952380952 -,0.3,0.3,82.5,120,1.4,0.4,23.2,2,,17.22,0.9,0.735294118 -,0.92,0.39,213,181,1.35,1,45.1,9,,0.54,1.09,0.392156863 -,0.89,0.36,228,280,1.4,0.65,46.5,15,,0.0,0.0,10.0 -,0.3,0.67,240,213,1.4,1.05,38.2,2,,16.83,4.68,1.282051282 -,0.43,0.85,213,254,1.35,1.15,42,9,,2.18,0.0,3.125 -,0.85,1.0,105.0,114.0,0.8,1.0,39.0,16.0,,,, -,0.67,1.0,80.0,124.0,1.25,1.15,47.0,17.0,,,, -,0.5,0.84,80.0,104.0,0.8,0.6,46.0,17.0,,,, diff --git a/results/experiment/parity_plots.png b/results/experiment/parity_plots.png deleted file mode 100644 index c8daff3..0000000 Binary files a/results/experiment/parity_plots.png and /dev/null differ diff --git a/scripts/dtlz2_parameter_sweep.py b/scripts/dtlz2_parameter_sweep.py new file mode 100644 index 0000000..9434802 --- /dev/null +++ b/scripts/dtlz2_parameter_sweep.py @@ -0,0 +1,282 @@ +"""Is beta = 4.0 with radius = 0.25 a defensible default, or just the first guess? + +Sweeps UCB ``beta`` against the local-penalization ``radius`` on DTLZ2 -- a +synthetic problem with a known Pareto front, so the answer does not depend on +whether the campaign's measurements are right. + +**The decision rule is pre-committed, and it is written here before the numbers +exist so that reading them cannot move it.** Keep 4.0 / 0.25 unless a cell beats +the current mean hypervolume gain by MORE than the per-seed standard deviation of +gains, AND does not reduce batch spacing. A sweep that finds everything flat +within noise is a pass, not a failure: it says the default is not a lucky pick and +the knob does not need attention. + +Each cell runs the real campaign path per seed -- ``run_r0_lhs`` -> ``run_r1_ucb`` +-> ``run_r2_qlognehvi`` -- and is scored on hypervolume gain over the R0 start, +against a random on-grid baseline at the same budget, exactly as the acceptance +test does. + +Boundary-coordinate counts are reported as a secondary readout because of what the +review artifact found on the live campaign: every proposed condition pinned +``anneal_temp`` to its range edge. That was traced to a monotone mean function +rather than to the acquisition, but a beta or radius that pushes batches onto +range edges by itself is worth seeing. + + python scripts/dtlz2_parameter_sweep.py # 3 x 3 cells, 8 seeds + python scripts/dtlz2_parameter_sweep.py --seeds 3 # a quicker look +""" + +from __future__ import annotations + +import argparse +import warnings +from dataclasses import dataclass + +import numpy as np +import torch +from botorch.test_functions.multi_objective import DTLZ2 +from botorch.utils.multi_objective.hypervolume import Hypervolume +from botorch.utils.multi_objective.pareto import is_non_dominated + +from mobo_kit.campaign import ( + build_objective_transform, + run_r0_lhs, + run_r1_ucb, + run_r2_qlognehvi, +) +from mobo_kit.candidate_pool import sample_discrete_candidate_pool +from mobo_kit.design import build_design_from_config + +INPUT_DIM = 10 +OBJECTIVES = 3 +R0_SIZE = 15 +R1_SIZE = 5 +R2_SIZE = 3 +ADDED = R1_SIZE + R2_SIZE + +CURRENT_BETA = 4.0 +CURRENT_RADIUS = 0.25 +BETAS = (2.0, 4.0, 8.0) +RADII = (0.15, 0.25, 0.35) +#: Fixed across the sweep on purpose: it is a hard floor on batch spacing, not a +#: tuning knob, and moving it would change what "spacing" even means per cell. +MIN_BATCH_DISTANCE = 0.15 + + +def _problem() -> DTLZ2: + return DTLZ2(dim=INPUT_DIM, num_objectives=OBJECTIVES, negate=True).to( + dtype=torch.double + ) + + +def _config(beta: float, radius: float, *, pool: int = 1024, mc_samples: int = 32) -> dict: + return { + "inputs": [ + {"name": f"x{i}", "start": 0.0, "stop": 1.0, "step": 0.05} + for i in range(INPUT_DIM) + ], + "objectives": { + "contract_version": "TEST_ONLY-dtlz2-sweep-v1", + "scaling_mode": "fixed_affine", + "specs": [ + { + "name": f"f{i}", + "goal": "maximize", + "transform": "affine", + "model_source_column": f"f{i}", + "lower_anchor": -2.0, + "upper_anchor": 0.0, + } + for i in range(OBJECTIVES) + ], + }, + "reference_point_utility": [-0.01] * OBJECTIVES, + "rounds": { + "r1": { + "method": "ucb_hvi", + "batch_size": R1_SIZE, + "replicates_per_condition": 3, + "beta": beta, + "candidate_pool_size": pool, + "posterior_samples": 256, + "moment_method": "monte_carlo", + }, + "r2": { + "method": "qlognehvi", + "batch_size": R2_SIZE, + "replicates_per_condition": 3, + "candidate_pool_size": pool, + "mc_samples": mc_samples, + }, + }, + "local_penalization": { + "radius": radius, + "min_batch_distance": MIN_BATCH_DISTANCE, + "min_observed_distance": 0.0, + "dimension_weights": None, + }, + "model": {"variant": "dim_scaled_prior"}, + "reproducibility": {"seed": 73}, + "constraints": [], + } + + +def _evaluate(problem: DTLZ2, X: np.ndarray) -> np.ndarray: + return problem(torch.tensor(np.asarray(X, float), dtype=torch.double)).numpy() + + +def _hypervolume(config: dict, Y: np.ndarray) -> float: + transform = build_objective_transform(config) + reference = torch.tensor(config["reference_point_utility"], dtype=torch.double) + utility = transform(torch.tensor(np.asarray(Y, float), dtype=torch.double)) + if not bool((utility >= reference).all(dim=-1).any()): + # BoTorch would silently drop every point and return 0.0 + raise AssertionError("no point dominates the reference") + return float(Hypervolume(ref_point=reference).compute(utility[is_non_dominated(utility)])) + + +def _boundary_counts(X: np.ndarray) -> int: + """How many coordinates across the batch sit at 0 or 1, the grid's edges.""" + values = np.asarray(X, float) + return int((np.isclose(values, 0.0) | np.isclose(values, 1.0)).sum()) + + +@dataclass +class CellResult: + beta: float + radius: float + bo_gain: list[float] + random_gain: list[float] + spacing: list[float] + boundary: list[int] + + @property + def mean_gain(self) -> float: + return float(np.mean(self.bo_gain)) + + @property + def sd_gain(self) -> float: + return float(np.std(self.bo_gain, ddof=1)) + + @property + def mean_random(self) -> float: + return float(np.mean(self.random_gain)) + + @property + def mean_spacing(self) -> float: + return float(np.mean(self.spacing)) + + @property + def mean_boundary(self) -> float: + return float(np.mean(self.boundary)) + + +def _one_seed(config: dict, seed: int) -> tuple[float, float, float, int]: + """Returns (bo gain, random gain, min batch spacing, boundary coords).""" + problem = _problem() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r0 = run_r0_lhs(config, n=R0_SIZE, seed=seed) + X0 = r0.conditions.to_numpy(float) + Y0 = _evaluate(problem, X0) + start = _hypervolume(config, Y0) + + r1 = run_r1_ucb(config, X0, Y0, seed=seed) + X1 = r1.conditions.to_numpy(float) + Y1 = _evaluate(problem, X1) + X01, Y01 = np.vstack([X0, X1]), np.vstack([Y0, Y1]) + + r2 = run_r2_qlognehvi(config, X01, Y01, seed=seed) + X2 = r2.conditions.to_numpy(float) + Y2 = _evaluate(problem, X2) + bo = _hypervolume(config, np.vstack([Y01, Y2])) - start + + # random on-grid baseline at the same budget + design = build_design_from_config(dict(config)) + pool = sample_discrete_candidate_pool(design, ADDED, seed=seed + 9999) + Yr = _evaluate(problem, np.asarray(pool.X_phys, float)[:ADDED]) + random_gain = _hypervolume(config, np.vstack([Y0, Yr])) - start + + spacing = min( + float(r1.diagnostics["validity"]["min_pairwise_distance"]), + float(r2.diagnostics["validity"]["min_pairwise_distance"]), + ) + return bo, random_gain, spacing, _boundary_counts(X1) + _boundary_counts(X2) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--seeds", type=int, default=8) + parser.add_argument("--pool", type=int, default=1024) + args = parser.parse_args() + seeds = [73 + 11 * i for i in range(args.seeds)] + + print(f"DTLZ2 sweep: beta x radius, {len(seeds)} seeds per cell, pool {args.pool}") + print(f"min_batch_distance fixed at {MIN_BATCH_DISTANCE} (a floor, not a knob)\n") + print("PRE-COMMITTED RULE: keep beta=4.0 / radius=0.25 unless a cell beats its") + print("mean HV gain by more than the per-seed sd of gains, without reducing") + print("spacing. Flat within noise is a PASS.\n") + + cells: list[CellResult] = [] + for beta in BETAS: + for radius in RADII: + config = _config(beta, radius, pool=args.pool) + rows = [_one_seed(config, seed) for seed in seeds] + cell = CellResult( + beta=beta, + radius=radius, + bo_gain=[r[0] for r in rows], + random_gain=[r[1] for r in rows], + spacing=[r[2] for r in rows], + boundary=[r[3] for r in rows], + ) + cells.append(cell) + print( + f" beta={beta:<4g} radius={radius:<5g} " + f"gain {cell.mean_gain:+.4f} (sd {cell.sd_gain:.4f}) " + f"random {cell.mean_random:+.4f} " + f"spacing {cell.mean_spacing:.3f} " + f"edge coords {cell.mean_boundary:.1f}" + ) + + baseline = next( + c for c in cells if c.beta == CURRENT_BETA and c.radius == CURRENT_RADIUS + ) + threshold = baseline.mean_gain + baseline.sd_gain + + print("\n" + "=" * 78) + print( + f"current default beta={CURRENT_BETA} radius={CURRENT_RADIUS}: " + f"mean gain {baseline.mean_gain:+.4f}, per-seed sd {baseline.sd_gain:.4f}" + ) + print(f"a challenger must exceed {threshold:+.4f} AND not reduce spacing below " + f"{baseline.mean_spacing:.3f}") + + challengers = [ + c + for c in cells + if c.mean_gain > threshold and c.mean_spacing >= baseline.mean_spacing + ] + if challengers: + best = max(challengers, key=lambda c: c.mean_gain) + print( + f"\nRULE TRIGGERED: beta={best.beta} radius={best.radius} gives " + f"{best.mean_gain:+.4f} at spacing {best.mean_spacing:.3f}." + ) + else: + near = [c for c in cells if c.mean_gain > baseline.mean_gain] + print( + f"\nNO CHANGE. {len(near)} of {len(cells)} cells have a higher mean gain, " + "none by more than one per-seed sd while holding spacing. The default is " + "flat within noise, which is the outcome that says it was not a lucky pick." + ) + + print( + f"\nBO beats random in {sum(c.mean_gain > c.mean_random for c in cells)} " + f"of {len(cells)} cells on the mean." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_round_report.py b/scripts/generate_round_report.py new file mode 100644 index 0000000..28a6c8b --- /dev/null +++ b/scripts/generate_round_report.py @@ -0,0 +1,150 @@ +"""Render a round's figures from a terminal, exactly as the launcher does. + + python scripts/generate_round_report.py --workbook "local_inputs/Final Summary Table.xlsx" + python scripts/generate_round_report.py --workbook --data-only + +Two modes, matching the two buttons: + +* **default** re-derives the proposal from the config and the measured rows rather + than reading it back from the worklist, so what the figures describe is the + model's answer at this seed. **When a worklist for that round already exists, + the two are compared by batch hash and the result is printed as MATCH or + DRIFT.** They should match; if they do not, the config, the data or the seed has + moved since the sheet was written, and the figures describe the model rather + than the films anyone is about to run. +* **--data-only** renders everything that depends on measurements alone. This is + the mode to use the moment a round's results are entered. + +Nothing here writes to the source workbook, and nothing here approves anything. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from mobo_kit.campaign import load_campaign_config, run_r1_ucb, run_r2_qlognehvi +from mobo_kit.batch_review import build_batch_review +from mobo_kit.launcher import gather_observations, inspect_campaign +from mobo_kit.round_report import generate_round_report +from mobo_kit.workbook_io import read_campaign_workbook + + +def _worklist_drift(workbook, config, round_name: str, proposal) -> str: + """Does the re-derived proposal still match the worklist on disk? + + The figures describe a proposal computed here and now. The films someone runs + come from a sheet written earlier. Those are the same batch only if the + config, the data and the seed have not moved -- and if they have, the figures + are about a different experiment than the one on the bench, which is exactly + the sort of quiet divergence that is worth a line of output. + + Compared by ``batch_hash``, so ordering is not mistaken for a difference. + """ + from mobo_kit.candidate_diagnostics import batch_hash + from mobo_kit.workbook_io import candidate_workbook_path, sheet_name_for_round + + path = candidate_workbook_path(workbook, round_name) + if not path.exists(): + return f"no {path.name} on disk yet, so there is nothing to compare" + try: + from openpyxl import load_workbook + + sheet = load_workbook(path, data_only=True)[sheet_name_for_round(round_name)] + header = [str(cell.value).strip() if cell.value else "" for cell in sheet[1]] + names = [item["name"] for item in config["inputs"]] + columns = [header.index(name) for name in names] + seen: list[list[float]] = [] + for row in sheet.iter_rows(min_row=2, values_only=True): + if row[0] is None: + continue + values = [float(row[c]) for c in columns] + if values not in seen: + seen.append(values) + except Exception as exc: # noqa: BLE001 - a check must not break the report + return f"could not read {path.name} to compare ({type(exc).__name__}: {exc})" + + on_disk = batch_hash(seen) + derived = batch_hash(proposal.conditions.to_numpy(float)) + if on_disk == derived: + return f"MATCH - the re-derived batch is {path.name}'s ({derived})" + return ( + f"DRIFT - re-derived {derived} against {on_disk} in {path.name}. The " + "config, the data or the seed has moved since that sheet was written, so " + "these figures describe a different batch than the one on the bench." + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--workbook", required=True) + parser.add_argument("--config", default="configs/campaign_d2d_perovskite_final.yaml") + parser.add_argument("--outdir", default=None) + parser.add_argument("--seed", type=int, default=None) + parser.add_argument( + "--data-only", + action="store_true", + help="figures from the measurements alone; no batch is proposed", + ) + parser.add_argument( + "--shap-instances", + type=int, + default=15, + help=( + "rows to attribute. NOT the main runtime knob -- the leave-one-out " + "refits are about two thirds of the cost and are not optional. " + "Recorded in the manifest either way." + ), + ) + args = parser.parse_args(argv) + + config = load_campaign_config(args.config) + workbook = Path(args.workbook) + + proposal = None + review = None + if not args.data_only: + status = inspect_campaign(workbook, config) + if not status.can_generate: + print(f"No round is due: {status.reason}") + print("Rendering the data-only report instead.") + else: + round_name = str(status.next_round) + print(f"Proposing {round_name} to describe it...") + X, Y, Yvar, _ = gather_observations( + workbook, config, for_round=round_name + ) + runner = run_r1_ucb if round_name == "R1" else run_r2_qlognehvi + proposal = runner(config, X, Y, seed=args.seed, observed_Yvar=Yvar) + contents = read_campaign_workbook(workbook, config) + review = build_batch_review( + config, + X, + Y, + proposal.conditions, + round_name=round_name, + seed=proposal.diagnostics.get("seed"), + findings=contents.findings, + ) + drift = _worklist_drift(workbook, config, round_name, proposal) + print(f" worklist check: {drift}") + + manifest = generate_round_report( + workbook, + config, + proposal=proposal, + review=review, + outdir=args.outdir, + seed=args.seed, + shap_max_instances=args.shap_instances, + progress=lambda message: print(f" {message}", flush=True), + ) + print() + print(manifest.summary()) + print() + print(f"{manifest.runtime_seconds:.1f} s") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gp_diagnostic.py b/scripts/gp_diagnostic.py new file mode 100644 index 0000000..680ebba --- /dev/null +++ b/scripts/gp_diagnostic.py @@ -0,0 +1,392 @@ +"""Phase 1.1 baseline GP diagnostic. + +Answers one question: is the GP learning anything from the 15 R0 observations? + +For each objective and each model variant it reports + * the fitted ARD lengthscales, outputscale and noise; + * exact leave-one-out MAE / RMSE / R2 / Spearman / coverage; + * the spread of the posterior mean over a Sobol sample of the design space, + as a fraction of the observed range of that objective. + +A GP that has learned nothing shows large lengthscales, Spearman near zero and +near-zero posterior-mean spread: the posterior has collapsed to its prior mean. + +Reads the workbook read-only. Writes nothing except an optional CSV. + +Usage: + python scripts/gp_diagnostic.py --workbook "local_inputs/Summary Table.xlsx" + python scripts/gp_diagnostic.py --variants current dim_scaled_prior --csv out.csv +""" + +from __future__ import annotations + +import argparse +import warnings +from dataclasses import dataclass +from pathlib import Path + +import gpytorch +import numpy as np +import torch +from botorch.fit import fit_gpytorch_mll +from botorch.models import SingleTaskGP +from botorch.models.transforms.outcome import Standardize +from botorch.models.utils.gpytorch_modules import ( + get_covar_module_with_dim_scaled_prior, + get_gaussian_likelihood_with_lognormal_prior, +) +from gpytorch.constraints import GreaterThan +from gpytorch.kernels import MaternKernel, ScaleKernel +from gpytorch.likelihoods import GaussianLikelihood +from gpytorch.mlls import ExactMarginalLogLikelihood +from openpyxl import load_workbook +from scipy.stats import qmc + +from mobo_kit.model_validation import compute_prediction_metrics + +# Canonical grid, configs/campaign_d2d_perovskite.yaml. (name, start, stop) +DESIGN = ( + ("speed_1", 1000.0, 6000.0), + ("time_1", 5.0, 50.0), + ("speed_2", 0.0, 5000.0), + ("time_2", 10.0, 60.0), + ("precur_conc", 1.0, 2.0), + ("precur_vol", 40.0, 200.0), + ("anneal_temp", 100.0, 185.0), + ("anneal_time", 10.0, 60.0), + ("anti_vol", 100.0, 200.0), + ("anti_time", 9.0, 25.0), +) +INPUT_NAMES = tuple(d[0] for d in DESIGN) +OBJECTIVE_NAMES = ("Uniformity", "Optoelectronic", "Thickness") + +# 0-based workbook column offsets: inputs B:K, objectives Z/AA/AB. +INPUT_COLS = tuple(range(1, 11)) +OBJECTIVE_COLS = (25, 26, 27) + +DTYPE = torch.double + + +# --------------------------------------------------------------------------- # +# model variants +# --------------------------------------------------------------------------- # + + +def _build_current(X: torch.Tensor, y: torch.Tensor) -> SingleTaskGP: + """The retired contract, now model_validation.LEGACY_NO_PRIOR. + + ScaleKernel(Matern 2.5 ARD) with no lengthscale prior. Kept here so the + before/after comparison stays runnable from one script. + """ + covar_module = ScaleKernel(MaternKernel(nu=2.5, ard_num_dims=X.shape[1])) + likelihood = GaussianLikelihood(noise_constraint=GreaterThan(1e-3)) + return SingleTaskGP( + X, + y, + covar_module=covar_module, + likelihood=likelihood, + outcome_transform=Standardize(m=1), + ) + + +def _build_conservative(X: torch.Tensor, y: torch.Tensor) -> SingleTaskGP: + """The repo's 'conservative' variant: noise floor 1e-2, lengthscale floor 0.05.""" + covar_module = ScaleKernel( + MaternKernel( + nu=2.5, + ard_num_dims=X.shape[1], + lengthscale_constraint=GreaterThan(0.05), + ) + ) + likelihood = GaussianLikelihood(noise_constraint=GreaterThan(0.01)) + return SingleTaskGP( + X, + y, + covar_module=covar_module, + likelihood=likelihood, + outcome_transform=Standardize(m=1), + ) + + +def _build_dim_scaled_prior(X: torch.Tensor, y: torch.Tensor) -> SingleTaskGP: + """Keep Matern 2.5 ARD, add BoTorch's dimension-scaled LogNormal lengthscale prior. + + The prior is LogNormal(loc=sqrt(2) + log(d)/2, scale=sqrt(3)), which at d=10 + concentrates lengthscales around exp(loc) ~ 12.9 in raw units but with enough + mass at moderate values to stop the unbounded drift the prior-free fit shows. + """ + base = get_covar_module_with_dim_scaled_prior( + ard_num_dims=X.shape[1], use_rbf_kernel=False + ) + covar_module = ScaleKernel(base) + likelihood = get_gaussian_likelihood_with_lognormal_prior() + return SingleTaskGP( + X, + y, + covar_module=covar_module, + likelihood=likelihood, + outcome_transform=Standardize(m=1), + ) + + +def _build_lengthscale_prior_only(X: torch.Tensor, y: torch.Tensor) -> SingleTaskGP: + """Lengthscale prior but a bare noise floor: the degenerate configuration. + + Kept so the outputscale-collapse mode stays reproducible from this script. + """ + base = get_covar_module_with_dim_scaled_prior( + ard_num_dims=X.shape[1], use_rbf_kernel=False + ) + return SingleTaskGP( + X, + y, + covar_module=ScaleKernel(base), + likelihood=GaussianLikelihood(noise_constraint=GreaterThan(1e-3)), + outcome_transform=Standardize(m=1), + ) + + +def _build_botorch_default(X: torch.Tensor, y: torch.Tensor) -> SingleTaskGP: + """Pure BoTorch 0.15.1 default: RBF ARD + dim-scaled LogNormal lengthscale prior, + LogNormal(-4, 1) noise prior, no ScaleKernel. Nothing overridden.""" + return SingleTaskGP(X, y, outcome_transform=Standardize(m=1)) + + +BUILDERS = { + "current": _build_current, + "conservative": _build_conservative, + "dim_scaled_prior": _build_dim_scaled_prior, + "lengthscale_prior_only": _build_lengthscale_prior_only, + "botorch_default": _build_botorch_default, +} + + +def _fit(model: SingleTaskGP) -> SingleTaskGP: + mll = ExactMarginalLogLikelihood(model.likelihood, model) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + fit_gpytorch_mll(mll) + return model + + +# --------------------------------------------------------------------------- # +# hyperparameter readout +# --------------------------------------------------------------------------- # + + +@dataclass +class Hypers: + lengthscales: np.ndarray + outputscale: float | None + noise: float + + +def _read_hypers(model: SingleTaskGP) -> Hypers: + covar = model.covar_module + if isinstance(covar, ScaleKernel): + ls = covar.base_kernel.lengthscale + outputscale = float(covar.outputscale.detach().reshape(-1)[0]) + else: # BoTorch default returns a bare kernel + ls = covar.lengthscale + outputscale = None + noise = float(model.likelihood.noise.detach().reshape(-1)[0]) + return Hypers( + lengthscales=ls.detach().cpu().numpy().reshape(-1).copy(), + outputscale=outputscale, + noise=noise, + ) + + +def _posterior( + model: SingleTaskGP, X: torch.Tensor, *, observation_noise: bool = False +) -> tuple[np.ndarray, np.ndarray]: + """Posterior mean and sd. + + Interval coverage and NLPD must use the *predictive* sd, which includes the + observation noise; the latent sd alone understates the interval and makes a + well-calibrated model look overconfident. Posterior-mean spread uses the + latent sd, since noise is constant across the design space. + """ + model.eval() + with torch.no_grad(), gpytorch.settings.fast_pred_var(): + post = model.posterior(X, observation_noise=observation_noise) + mean = post.mean.detach().cpu().numpy().reshape(-1) + std = post.variance.clamp_min(1e-12).sqrt().detach().cpu().numpy().reshape(-1) + return mean, std + + +# --------------------------------------------------------------------------- # +# diagnostics +# --------------------------------------------------------------------------- # + + +def loocv( + X: np.ndarray, y: np.ndarray, builder, seed: int +) -> tuple[np.ndarray, np.ndarray]: + """Exact leave-one-out: N fits on N-1 rows, each predicting the held-out row.""" + n = X.shape[0] + mean = np.empty(n) + std = np.empty(n) + for i in range(n): + keep = np.ones(n, dtype=bool) + keep[i] = False + torch.manual_seed(seed) + Xt = torch.tensor(X[keep], dtype=DTYPE) + yt = torch.tensor(y[keep], dtype=DTYPE).unsqueeze(-1) + model = _fit(builder(Xt, yt)) + m, s = _posterior( + model, torch.tensor(X[i : i + 1], dtype=DTYPE), observation_noise=True + ) + mean[i], std[i] = m[0], s[0] + return mean, std + + +def posterior_spread( + model: SingleTaskGP, d: int, n: int, seed: int +) -> tuple[float, float]: + """(max-min, std) of the posterior mean over a Sobol sample of the unit cube.""" + sob = qmc.Sobol(d=d, scramble=True, seed=seed) + P = sob.random(n) + mean, _ = _posterior(model, torch.tensor(P, dtype=DTYPE)) + return float(mean.max() - mean.min()), float(mean.std()) + + +# --------------------------------------------------------------------------- # + + +def load_data(path: Path) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + ws = load_workbook(path, data_only=True)["Sheet1"] + rows = [] + for r in ws.iter_rows(min_row=2, values_only=True): + if r[0] is None: + break + rows.append(r) + ids = np.array([int(r[0]) for r in rows]) + X_phys = np.array([[float(r[j]) for j in INPUT_COLS] for r in rows]) + Y = np.array([[float(r[j]) for j in OBJECTIVE_COLS] for r in rows]) + lo = np.array([d[1] for d in DESIGN]) + hi = np.array([d[2] for d in DESIGN]) + X = (X_phys - lo) / (hi - lo) + if X.min() < -1e-9 or X.max() > 1 + 1e-9: + raise ValueError("Inputs fall outside the declared design bounds.") + return ids, X, Y + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--workbook", default="local_inputs/Summary Table.xlsx") + ap.add_argument( + "--variants", nargs="+", default=["current"], choices=sorted(BUILDERS) + ) + ap.add_argument("--sobol-n", type=int, default=1024) + ap.add_argument("--seed", type=int, default=73) + ap.add_argument("--csv", default=None, help="optional path for a tidy metrics CSV") + args = ap.parse_args() + + ids, X, Y = load_data(Path(args.workbook)) + n, d = X.shape + print(f"workbook : {args.workbook}") + print(f"data : {n} observations, {d} inputs, {Y.shape[1]} objectives") + print(f"samples : {ids.tolist()}") + + records = [] + for variant in args.variants: + builder = BUILDERS[variant] + print(f"\n{'='*78}\nVARIANT: {variant}\n{'='*78}") + + for k, obj_name in enumerate(OBJECTIVE_NAMES): + y = Y[:, k] + obs_range = float(y.max() - y.min()) + + torch.manual_seed(args.seed) + full = _fit( + builder( + torch.tensor(X, dtype=DTYPE), + torch.tensor(y, dtype=DTYPE).unsqueeze(-1), + ) + ) + hp = _read_hypers(full) + span, sd = posterior_spread(full, d, args.sobol_n, args.seed) + lo_mean, lo_std = loocv(X, y, builder, args.seed) + met = compute_prediction_metrics( + y, + lo_mean, + lo_std, + variant_name=variant, + objective_index=k, + objective_name=obj_name, + ) + + print(f"\n--- {obj_name} (observed range {obs_range:.4f}) ---") + print(" ARD lengthscales (normalised input space):") + for nm, v in zip(INPUT_NAMES, hp.lengthscales): + flag = " <-- flat" if v >= 10.0 else "" + print(f" {nm:>13}: {v:>12.4f}{flag}") + n_flat = int(np.sum(hp.lengthscales >= 10.0)) + print( + f" {'median':>13}: {np.median(hp.lengthscales):>12.4f}" + f" ({n_flat}/{d} at or above 10)" + ) + os_txt = "n/a" if hp.outputscale is None else f"{hp.outputscale:.4f}" + print(f" outputscale : {os_txt} noise : {hp.noise:.6f}") + print( + f" LOOCV : MAE {met.mae:.4f} RMSE {met.rmse:.4f} " + f"R2 {met.r_squared:+.4f} Spearman {met.spearman_rank_correlation:+.4f}" + ) + print( + f" coverage : 68% {met.coverage_68_percent:.3f} " + f"95% {met.coverage_95_percent:.3f} NLPD {met.mean_gaussian_nlpd:.3f}" + ) + print( + f" posterior mean spread over {args.sobol_n} Sobol pts: " + f"range {span:.5f} ({100*span/obs_range:.2f}% of observed range), " + f"sd {sd:.5f}" + ) + + records.append( + { + "variant": variant, + "objective": obj_name, + "median_lengthscale": float(np.median(hp.lengthscales)), + "n_lengthscale_ge_10": n_flat, + "outputscale": hp.outputscale, + "noise": hp.noise, + "loocv_mae": met.mae, + "loocv_rmse": met.rmse, + "loocv_r2": met.r_squared, + "loocv_spearman": met.spearman_rank_correlation, + "coverage_68": met.coverage_68_percent, + "coverage_95": met.coverage_95_percent, + "nlpd": met.mean_gaussian_nlpd, + "post_mean_range": span, + "post_mean_range_frac_of_observed": span / obs_range, + **{ + f"ls_{nm}": float(v) + for nm, v in zip(INPUT_NAMES, hp.lengthscales) + }, + } + ) + + if len(args.variants) > 1: + print(f"\n{'='*78}\nSUMMARY\n{'='*78}") + print( + f"{'variant':>18} {'objective':>15} {'med LS':>9} {'flat':>5} " + f"{'R2':>8} {'Spearman':>9} {'spread%':>9}" + ) + for r in records: + print( + f"{r['variant']:>18} {r['objective']:>15} " + f"{r['median_lengthscale']:>9.3f} {r['n_lengthscale_ge_10']:>5d} " + f"{r['loocv_r2']:>+8.3f} {r['loocv_spearman']:>+9.3f} " + f"{100*r['post_mean_range_frac_of_observed']:>9.2f}" + ) + + if args.csv: + import pandas as pd + + pd.DataFrame(records).to_csv(args.csv, index=False) + print(f"\nwrote {args.csv}") + + +if __name__ == "__main__": + main() diff --git a/scripts/intake_new_data.py b/scripts/intake_new_data.py new file mode 100644 index 0000000..53806e4 --- /dev/null +++ b/scripts/intake_new_data.py @@ -0,0 +1,268 @@ +"""One command to run when the experimental group returns new or corrected data. + + python scripts/intake_new_data.py --workbook "local_inputs/Final Summary Table.xlsx" + +The group has always described the current numbers as test data, so a replacement +was expected from the start. When it arrives, the question is not "does the code +still run" -- the tests answer that -- but "does the model this campaign committed +to still earn its place on THIS data". Several of those commitments were justified +by measurements on 15 specific rows, and a new dataset does not inherit them. + +So this checks, per objective: + +* whether the objectives can be computed at all, and what the read notices; +* whether each declared ``mean_function`` still beats the leave-one-out null by + more than the resolution floor -- and if it does not, names the exact config + block to delete; +* whether the fit guard has anything to say, including the case where the mean + function explains so much that the residual GP collapses; +* whether the fixed objective anchors still span the data; +* whether the campaign-fixed scaling guard still passes. + +**Both floors are recomputed at the new N rather than reused.** The null is +``1 - (N/(N-1))^2``, which moves with N: -0.148 at 15, -0.105 at 21, -0.069 at 31. +The resolution floor of +-0.236 was a parametric bootstrap at N=15 and shrinks +roughly as ``1/sqrt(N)``; the estimate printed here is scaled that way and is +labelled as an estimate, because the honest version is to re-run the bootstrap. + +Nothing here decides anything. It prints what the data supports so a human can. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import numpy as np + +from mobo_kit.campaign import ( + assert_scaling_is_campaign_fixed, + build_design_from_config, + build_objective_transform, + load_campaign_config, + objective_names, +) +from mobo_kit.constraints import constraint_violations, constraints_from_config +from mobo_kit.loocv import ( + RESOLUTION_SD_AT_15, + loo_predictions, + null_loo_r2, + resolution_sd, +) +from mobo_kit.model_validation import ModelFitError +from mobo_kit.scores import ScoreSeverity +from mobo_kit.structured_mean import mean_spec_from_config +from mobo_kit.workbook_io import read_campaign_workbook + +# The fold loop lives in `mobo_kit.loocv`, shared with the round report and the +# permutation test. It used to live here, and the moment a second caller needed it +# there were two copies of a number this document calls canonical. + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--workbook", required=True) + # Defaults to the ACTIVE campaign. The first campaign's config is archived, and + # defaulting to it would quietly audit new rows against a retired contract -- + # different recipes, different anchors, different grids. + parser.add_argument("--config", default="configs/campaign_d2d_perovskite_final.yaml") + parser.add_argument( + "--skip-model", + action="store_true", + help="audit and anchors only; skip the leave-one-out refits", + ) + args = parser.parse_args() + + config = load_campaign_config(args.config) + names = list(objective_names(config)) + print("=" * 78) + print(f"INTAKE: {Path(args.workbook).name}") + print(f"CONFIG: {Path(args.config).name} " + f"({config.get('objectives', {}).get('contract_version')})") + print("=" * 78) + if str(config.get("campaign", {}).get("status")) == "archived": + print("\n NOTE: this config is archived. Its recipes, anchors and grids") + print(" describe a retired contract, so every number below is about that") + print(" contract rather than about the active campaign.") + + # ---------------------------------------------------------------- audit -- + contents = read_campaign_workbook(args.workbook, config) + n = contents.n_rows + print(f"\n1. READ {n} rows, objectives {tuple(names)}") + errors = contents.errors + warnings_found = contents.warnings + notes = [f for f in contents.findings if f.severity is ScoreSeverity.NOTE] + print(f" errors {len(errors)} warnings {len(warnings_found)} notes {len(notes)}") + for finding in errors: + print(f" ERROR {finding}") + for finding in warnings_found: + print(f" warning {finding}") + if errors: + print("\n Objectives cannot be computed for every row. Stopping: every") + print(" number below would be about a subset nobody chose.") + return 1 + + # ------------------------------------------------------------- contract -- + print("\n2. CONTRACT") + try: + assert_scaling_is_campaign_fixed(config) + print(" scaling guard PASS (scales are campaign-fixed)") + except Exception as exc: + print(f" scaling guard FAIL: {exc}") + return 1 + + transform = build_objective_transform(config) + for index, spec in enumerate(transform.specs): + column = contents.model_values[names[index]] + low, high = float(column.min()), float(column.max()) + if spec.transform == "affine": + inside = spec.lower_anchor <= low and high <= spec.upper_anchor + verdict = "PASS" if inside else "OUT OF RANGE" + print( + f" {spec.name:<16} anchors [{spec.lower_anchor:g}, " + f"{spec.upper_anchor:g}] vs data [{low:.4g}, {high:.4g}] {verdict}" + ) + if not inside: + print( + " -> widen the anchors DELIBERATELY and bump " + "objectives.contract_version; do not let them track the data." + ) + else: + print(f" {spec.name:<16} target {spec.target:g} vs data [{low:.4g}, {high:.4g}]") + + # ------------------------------------------------------ design and rules -- + print("\n3. DESIGN AND CONSTRAINTS") + design = build_design_from_config(dict(config)) + X_observed = contents.inputs.to_numpy(float) + off_grid = [ + (contents.sample_ids[row], name, float(value)) + for column, name in enumerate(design.names) + for row, value in enumerate(X_observed[:, column]) + if not np.any( + np.isclose(design.var_array[column], value, rtol=0.0, atol=1e-9) + ) + ] + if off_grid: + # An off-grid observation stays in the GP and in the distance references, + # but it cannot take part in grid-index bookkeeping. Worth knowing which, + # because the usual cause is a grid that no longer describes the process. + print(f" on-grid check {len(off_grid)} observed value(s) OFF GRID") + for sample, name, value in off_grid: + print(f" sample {sample}: {name} = {value:g}") + else: + print(f" on-grid check PASS, all {n} rows land on the declared grid") + + constraints = constraints_from_config(dict(config), design) + if not constraints: + print(" constraints none declared") + else: + for item in constraints: + print(f" constraint {item.name}: {item.description}") + violations = constraint_violations(X_observed, design, constraints) + broken = [ + (contents.sample_ids[row], names_broken) + for row, names_broken in enumerate(violations) + if names_broken + ] + if broken: + # History is history: a row measured before a rule existed is not an + # error and must not block anything. It is worth saying, though -- a + # constraint that rejects a film the group actually ran is much more + # likely to be wrong than the film is. + print(f" observed rows {len(broken)} of {n} break a constraint") + for sample, names_broken in broken: + print(f" sample {sample}: {names_broken}") + print(" -> not an error. Check the RULE before the films.") + else: + print(f" observed rows PASS, all {n} satisfy every constraint") + + # ---------------------------------------------------------------- floors -- + null = null_loo_r2(n) + floor = resolution_sd(n) + print(f"\n4. FLOORS AT N={n}") + print(f" null LOO R2 {null:+.4f} (was {null_loo_r2(15):+.4f} at N=15)") + print(f" resolution sd +-{floor:.4f} (estimated by sqrt(15/N) from " + f"{RESOLUTION_SD_AT_15}; re-run the bootstrap if a call is close)") + + if args.skip_model: + print("\n5. MODEL skipped (--skip-model)") + return 0 + + # ----------------------------------------------------------------- model -- + # The rule has two parts, and printing only the first one is what made the + # thickness verdict read as a dead end rather than as a question for a + # different instrument. + print("\n5. PER-OBJECTIVE VERDICT") + print(f" (i) the structured fit must beat the null, {null:+.4f}") + print(f" (ii) if structured-vs-plain is inside the floor ({floor:.3f}), R2 cannot") + print(" decide and the RANK PERMUTATION adjudicates") + X_phys = contents.inputs.to_numpy(float) + + entries = config["objectives"]["specs"] + for index, (name, entry) in enumerate(zip(names, entries)): + y = contents.model_values[name].to_numpy(float) + mean_spec = mean_spec_from_config(entry) + print(f"\n {name}") + try: + plain_loo = loo_predictions( + config, entry, X_phys, y, use_mean_function=False + ) + plain, plain_warnings = plain_loo.r2, plain_loo.collapse_warnings + print(f" plain GP LOO R2 {plain:+.4f}") + except ModelFitError as exc: + print(f" plain GP REFUSED: {exc.cause}") + plain, plain_warnings = float("nan"), [] + + if mean_spec is None: + print(" no mean function declared") + verdict = "beats the null" if plain > null else "does NOT beat the null" + print(f" verdict {verdict} ({plain:+.4f} vs {null:+.4f})") + continue + + try: + structured_loo = loo_predictions(config, entry, X_phys, y) + structured = structured_loo.r2 + structured_warnings = structured_loo.collapse_warnings + print(f" with mean function LOO R2 {structured:+.4f}") + except ModelFitError as exc: + print(f" with mean function REFUSED: {exc.cause}") + print(" verdict DELETE the mean_function block: the fit") + print(f" is refused outright for {name}.") + continue + + for message in dict.fromkeys(plain_warnings + structured_warnings): + print(f" GUARD {message}") + if not structured_warnings: + print(" guard status clean") + + swing = structured - plain + clears_floor = swing > floor + beats_null = structured > null + print(f" swing {swing:+.4f} (floor {floor:.4f})") + if clears_floor and beats_null: + print(" verdict KEEP the mean function: it clears the") + print(" resolution floor and beats the null.") + elif beats_null: + print(" verdict INCONCLUSIVE ON R2: beats the null, but the") + print(" swing is inside the floor, so R2 cannot") + print(" resolve plain against structured at this N.") + print(" That is not a verdict. Adjudicate on RANK,") + print(" which is what the acquisition consumes:") + print(" python scripts/permutation_rank_test.py \\") + print(f" --objective {name} --permutations 1800") + else: + features = ", ".join(f.column for f in mean_spec.features) + print(" verdict DELETE the mean function. It does not beat") + print(f" the null at N={n}. Remove this block from") + print(f" {Path(args.config).name}:") + print(f" objectives.specs[{index}].mean_function") + print(f" (response: {mean_spec.response}, " + f"features: {features})") + + print("\n" + "=" * 78) + print("Nothing above is a decision. It is what the new data supports.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/permutation_rank_test.py b/scripts/permutation_rank_test.py new file mode 100644 index 0000000..1515020 --- /dev/null +++ b/scripts/permutation_rank_test.py @@ -0,0 +1,274 @@ +"""Does an objective's declared mean function beat chance on RANK? + + python scripts/permutation_rank_test.py --objective thickness --permutations 1800 + +`scripts/intake_new_data.py` answers "does the mean function beat the null by more +than the resolution floor". When the answer is INCONCLUSIVE -- the swing is real +but smaller than what LOO R2 can resolve at this N -- that is not a verdict, it is +a statement that R2 cannot decide. This is the instrument that decides. + +**Rank, not R2, because rank is what drives candidate selection.** The acquisition +ranks candidates; it never consumes R2. The first campaign settled its thickness +mean function on exactly this basis (p = 0.0350, 95% CI [0.0270, 0.0446] at 1800 +shuffles) and recorded that the R2 swing was consistent with it and no more. + +**The linear coefficients are refit inside every null fold too**, on the training +rows only, so the null is not flattered by a trend fitted to all the data. + +Config-driven, so it works on any contract. It deliberately does NOT replace +`scripts/thickness_permutation_and_mean.py`, which hard-codes the first campaign's +column positions and grid and is kept as that campaign's reproducible record. + +Sharding, because the cost is (permutations x N) GP fits: + + python scripts/permutation_rank_test.py --shards 12 --shard 0 --out # x12 + python scripts/permutation_rank_test.py --combine +""" + +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +from typing import Any, Mapping + +import numpy as np +import torch +from scipy.stats import spearmanr + +from mobo_kit.campaign import ( + build_objective_transform, + load_campaign_config, + normalise_inputs, + objective_names, +) +from mobo_kit.loocv import loo_predictions +from mobo_kit.structured_mean import mean_spec_from_config +from mobo_kit.workbook_io import read_campaign_workbook + + +def measured_utility(transform: Any, index: int, Y_measured: np.ndarray) -> np.ndarray: + """Utility of the measurements themselves, one column. + + `transform_measurements`, NOT `expected_transform`: the transform decodes the + link itself, so handing it measurement-space nanometres exponentiates a value + that was never a logarithm. That mistake cost this project an R1 batch, and it + reappeared in the first draft of this script -- caught only because saturating + the 650 nm Gaussian to 0.0 made the column constant and Spearman undefined. It + is the third route by which the same defect has arrived; use the safe call. + """ + block = torch.tensor(np.asarray(Y_measured, dtype=float), dtype=torch.double) + return transform.transform_measurements(block)[:, index].detach().cpu().numpy() + + +def expected_utility( + transform: Any, + index: int, + mu: np.ndarray, + var: np.ndarray, + Y_model_context: np.ndarray, +) -> np.ndarray: + """E[utility] for one objective, through the campaign's own transform. + + `mu`/`var` are MODEL-space posterior moments for objective `index`. The other + columns are filled with the measured model-space values at zero variance: the + transform is elementwise per objective, so they cannot affect the column read + back, and using real values rather than zeros keeps every column inside its + own link's domain. + """ + mean_block = torch.tensor( + np.asarray(Y_model_context, dtype=float), dtype=torch.double + ).clone() + var_block = torch.zeros_like(mean_block) + mean_block[:, index] = torch.tensor(mu, dtype=torch.double) + var_block[:, index] = torch.tensor(var, dtype=torch.double) + utility = transform.expected_transform(mean_block, var_block) + return utility[:, index].detach().cpu().numpy() + + +def _setup(args: argparse.Namespace) -> dict[str, Any]: + config = load_campaign_config(args.config) + names = list(objective_names(config)) + if args.objective not in names: + raise SystemExit(f"--objective must be one of {names}; got {args.objective!r}") + index = names.index(args.objective) + entry = config["objectives"]["specs"][index] + mean_spec = mean_spec_from_config(entry) + if mean_spec is None: + raise SystemExit( + f"{args.objective!r} declares no mean_function, so there is nothing to " + "adjudicate." + ) + + contents = read_campaign_workbook(args.workbook, config) + if contents.errors: + raise SystemExit("The workbook has errors; refusing to test a subset.") + + transform = build_objective_transform(config) + design_names = [item["name"] for item in config["inputs"]] + return { + "config": config, + "index": index, + "entry": entry, + "mean_spec": mean_spec, + "transform": transform, + "design_names": design_names, + "lowers": np.array([float(i["start"]) for i in config["inputs"]]), + "uppers": np.array([float(i["stop"]) for i in config["inputs"]]), + "X_phys": contents.inputs.to_numpy(float), + "X_norm": normalise_inputs(config, contents.inputs.to_numpy(float)), + "y": contents.model_values[args.objective].to_numpy(float), + "Y_measured": contents.model_values.to_numpy(float), + } + + +def _rho(state: Mapping[str, Any], y: np.ndarray, *, seed: int) -> float: + """LOO expected-utility rank correlation against the measured utility. + + Both sides are in UTILITY space, which is what the acquisition ranks. Under a + permutation the substituted column travels through the same transform as the + real one, so the null is built on the same quantity as the observation. + """ + transform, index = state["transform"], state["index"] + Y_measured = state["Y_measured"].copy() + Y_measured[:, index] = y + + # the shared fold loop, so this null is built on exactly the numbers + # intake reports and the round report plots + result = loo_predictions(state["config"], state["entry"], state["X_phys"], y, seed=seed) + mu, var = result.mean_model_space, result.variance_model_space + context = ( + transform.encode_measurements( + torch.tensor(Y_measured, dtype=torch.double) + ) + .detach() + .cpu() + .numpy() + ) + predicted = expected_utility(transform, index, mu, var, context) + measured = measured_utility(transform, index, Y_measured) + return float(spearmanr(measured, predicted).statistic) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", default="configs/campaign_d2d_perovskite_final.yaml") + parser.add_argument("--workbook", default="local_inputs/Final Summary Table.xlsx") + parser.add_argument("--objective", default="thickness") + parser.add_argument("--permutations", type=int, default=1800) + parser.add_argument("--seed", type=int, default=0) + parser.add_argument("--fit-seed", type=int, default=73) + parser.add_argument("--shards", type=int, default=1) + parser.add_argument("--shard", type=int, default=0) + parser.add_argument("--out", default=None, help="directory for shard results") + parser.add_argument("--combine", default=None, help="combine shards in this dir") + args = parser.parse_args(argv) + + if args.combine: + return combine(Path(args.combine)) + + state = _setup(args) + y = state["y"] + n = len(y) + + observed = _rho(state, y, seed=args.fit_seed) + print(f"objective {args.objective}") + print(f"rows {n}") + print(f"observed rank rho {observed:+.4f}") + + # Every shard draws the SAME permutation stream and takes a slice of it, so the + # union of shards is exactly the single-process run and shard boundaries cannot + # change the answer. + rng = np.random.default_rng(args.seed) + permutations = [rng.permutation(n) for _ in range(args.permutations)] + mine = [ + (i, order) + for i, order in enumerate(permutations) + if i % args.shards == args.shard + ] + print(f"shard {args.shard}/{args.shards} {len(mine)} of {args.permutations} shuffles") + + null_rhos: list[float] = [] + for position, (i, order) in enumerate(mine, start=1): + null_rhos.append(_rho(state, y[order], seed=args.fit_seed)) + if position % 10 == 0 or position == len(mine): + exceed = sum(1 for r in null_rhos if r >= observed) + print(f" {position}/{len(mine)} exceedances so far {exceed}", flush=True) + + payload = { + "objective": args.objective, + "n": n, + "observed_rho": observed, + "shard": args.shard, + "shards": args.shards, + "permutations_total": args.permutations, + "null_rhos": null_rhos, + "seed": args.seed, + "fit_seed": args.fit_seed, + } + if args.out: + directory = Path(args.out) + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"shard_{args.shard:03d}.json" + path.write_text(json.dumps(payload), encoding="utf-8") + print(f"wrote {path}") + else: + report(observed, null_rhos, args.permutations) + return 0 + + +def report(observed: float, null_rhos: list[float], total: int) -> None: + """The p-value, with the interval that says how well it is resolved.""" + drawn = len(null_rhos) + if drawn == 0: + # --permutations 0 is a legitimate "just tell me the observed statistic" + # mode; reporting a p-value from no shuffles would be inventing one. + print("") + print("no shuffles drawn, so there is no null and no p-value.") + return + exceed = sum(1 for r in null_rhos if r >= observed) + # (exceed + 1) / (drawn + 1): the observed statistic is itself one draw from the + # null under the null hypothesis, so a p-value of exactly 0 is not available and + # claiming one would overstate the evidence. + p = (exceed + 1) / (drawn + 1) + se = math.sqrt(max(p * (1 - p) / drawn, 0.0)) + low, high = max(0.0, p - 1.96 * se), min(1.0, p + 1.96 * se) + print() + print(f"shuffles drawn {drawn} of {total}") + print(f"exceedances {exceed}") + print(f"null mean rho {np.mean(null_rhos):+.4f}") + print(f"null sd {np.std(null_rhos, ddof=1):.4f}") + print(f"p {p:.4f} 95% CI [{low:.4f}, {high:.4f}]") + print() + if high < 0.05: + print("VERDICT KEEP. The interval clears 0.05, so the rank result is not") + print(" chance and the mean function has earned its place.") + elif p < 0.05: + print("VERDICT BORDERLINE. The point estimate clears 0.05 but the interval") + print(" does not. Draw more shuffles before deciding.") + else: + print("VERDICT DELETE. The rank result is inside what shuffling produces,") + print(" so nothing distinguishes this mean function from chance.") + + +def combine(directory: Path) -> int: + shards = sorted(directory.glob("shard_*.json")) + if not shards: + raise SystemExit(f"no shard_*.json under {directory}") + payloads = [json.loads(path.read_text(encoding="utf-8")) for path in shards] + observed = {round(p["observed_rho"], 12) for p in payloads} + if len(observed) != 1: + raise SystemExit( + f"shards disagree on the observed statistic: {sorted(observed)}. They " + "were not run against the same data." + ) + null_rhos = [r for payload in payloads for r in payload["null_rhos"]] + print(f"combined {len(shards)} shards, objective {payloads[0]['objective']}") + print(f"observed rank rho {payloads[0]['observed_rho']:+.4f}") + report(payloads[0]["observed_rho"], null_rhos, payloads[0]["permutations_total"]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plot_boxplot_sweep.py b/scripts/plot_boxplot_sweep.py new file mode 100644 index 0000000..6f809e9 --- /dev/null +++ b/scripts/plot_boxplot_sweep.py @@ -0,0 +1,446 @@ +"""Round-by-round utility boxplots across a beta x radius sweep, over three trials. + +Same construction as ``scripts/plot_round_simulation.py``'s boxplots -- utility by +round, one panel per objective, every raw point drawn over its box -- swept over a +wider grid and replicated over three starting designs. + +THE GRID. beta in {9, 25, 36, 49} x radius in {0.05 ... 0.45} = 36 cells, chosen +after beta 9 and 25 at radius 0.25 showed the behaviour the group wanted to see +more of. + +THE THREE TRIALS differ in ONE thing: where R0 comes from. + +* ``real`` -- the 15 real recipes from the workbook, oracle-scored. The anchor. +* ``lhs_a`` -- a fresh 15-point Latin hypercube, seed 101. +* ``lhs_b`` -- a fresh 15-point Latin hypercube, seed 202. + +Everything downstream is identical: the same frozen oracle scores every design, +the acquisitions run at the campaign seed 73 in every trial, and the candidate +pool is therefore the same 32768 recipes throughout. So a difference between +trials is attributable to the starting design and to nothing else. That is a +sensitivity check on R0, which is what was asked for -- it is NOT three +independent replicates of the whole pipeline, and the spread between trials +understates true run-to-run variability for that reason. + +WHAT THE NUMBERS ARE. Every value is a GP prediction. The oracle is fitted once +to the 15 real films and then frozen; R1 and R2 conditions were never fabricated. +This compares acquisition settings on a data-shaped landscape. It is not evidence +about the chemistry, and a tall box does not mean a good film. + +RUNNING IT. 108 cells at ~195 s each is about six hours in one process, so the +work is sharded:: + + # 12 workers, ~30 min wall clock + for i in 0..11: python scripts/plot_boxplot_sweep.py --workbook ... --shard i --num-shards 12 + python scripts/plot_boxplot_sweep.py --workbook ... --compose + +``--compose`` reads the per-cell files and renders 12 pages (3 trials x 4 betas), +each page holding 9 radii x 3 objectives, plus a combined PDF and a summary CSV. +Y-limits are shared per objective across ALL pages, so any two panels anywhere in +the deliverable are directly comparable. +""" + +from __future__ import annotations + +import argparse +import sys +import time +import warnings +from pathlib import Path +from typing import Any, Sequence + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 +import pandas as pd # noqa: E402 +import torch # noqa: E402 +from matplotlib.backends.backend_pdf import PdfPages # noqa: E402 + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import plot_round_simulation as prs # noqa: E402 + +from mobo_kit.campaign import ( # noqa: E402 + build_objective_transform, + fit_campaign_models, + load_campaign_config, + run_r0_lhs, +) +from mobo_kit.workbook_io import read_campaign_workbook # noqa: E402 + +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=FutureWarning) +warnings.filterwarnings("ignore", category=UserWarning, module="botorch") +warnings.filterwarnings("ignore", category=UserWarning, module="gpytorch") +warnings.filterwarnings("ignore", category=RuntimeWarning, module="numpy") +torch.set_num_threads(1) + +BETAS = (9.0, 25.0, 36.0, 49.0) +RADII = (0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45) + +#: (name, R0 source, LHS seed). The acquisition seed stays at the campaign's 73 +#: for every trial, so only the starting design moves. +TRIALS = ( + ("real", "workbook", None), + ("lhs_a", "lhs", 101), + ("lhs_b", "lhs", 202), +) + +ROUND_STYLE = prs.ROUND_STYLE +INK, INK_MUTED, SURFACE, SPINE = prs.INK, prs.INK_MUTED, prs.SURFACE, prs.SPINE + +FOOTER = ( + "Every value is a GP prediction, not a measurement: the oracle is fitted once " + "to the 15 real films and frozen, and no R1/R2 condition was ever fabricated. " + "This compares acquisition settings on a data-shaped landscape, not chemistry.\n" + "Rounds hold 15 / 5 / 3 conditions. A box over three numbers reports little " + "more than those numbers, which is why every raw point is drawn on top." +) + + +def signal_caveat(config: Any) -> str: + """Name the dead axes from THIS config, never from a remembered campaign. + + The line here used to read "uniformity carries no validated signal + (permutation p = 0.82)", which is a fact about the first campaign's uniformity + score on the first campaign's films. On the v3 contract that objective is a + different construction and optoelectronic is dead as well, so a hard-coded + caveat would have shipped the wrong evidence attached to the right warning -- + which is worse than no caveat, because it looks checked. + """ + dead = [ + str(spec["name"]) + for spec in config["objectives"]["specs"] + if str(spec.get("signal_status", "")) not in ("learnable", "") + ] + if not dead: + return "" + listed = " and ".join(dead) if len(dead) < 3 else ", ".join(dead) + verb = "carries" if len(dead) == 1 else "carry" + return ( + f"\n{listed} {verb} no learnable signal on this contract " + f"({config['objectives']['contract_version']}): the model does not beat " + "the leave-one-out null, so read those panels as exploration and not as a " + "result." + ) + + +def cell_key(trial: str, beta: float, radius: float) -> str: + return f"{trial}__beta_{beta:g}__radius_{radius:g}".replace(".", "p") + + +def all_cells( + betas: Sequence[float] | None = None, + radii: Sequence[float] | None = None, +) -> list[tuple[str, float, float]]: + """Every (trial, beta, radius) to run. Filters keep the trial axis intact. + + Restricting the knobs never drops a trial: the trials are what turn three + numbers per round into a distribution worth boxing, so a "one cell" run is + still three campaigns from three starting designs. + """ + return [ + (trial, beta, radius) + for trial, _source, _seed in TRIALS + for beta in (BETAS if betas is None else tuple(float(b) for b in betas)) + for radius in (RADII if radii is None else tuple(float(r) for r in radii)) + ] + + +def r0_for_trial( + config: Any, trial: str, source: str, lhs_seed: int | None, workbook: Path +) -> np.ndarray: + """The 15 starting conditions for a trial, in physical units.""" + if source == "workbook": + contents = read_campaign_workbook(workbook, config) + if contents.errors: + raise SystemExit(f"Workbook read failed: {contents.errors}") + return contents.inputs.to_numpy(float) + return run_r0_lhs(config, n=15, seed=int(lhs_seed)).conditions.to_numpy(float) + + +# --------------------------------------------------------------------------- # +# worker +# --------------------------------------------------------------------------- # + + +def run_shard(args: argparse.Namespace) -> int: + config = load_campaign_config(args.config) + seed = int((config.get("reproducibility") or {}).get("seed", 0)) + transform = build_objective_transform(config) + reference = np.asarray(config["reference_point_utility"], dtype=float) + + contents = read_campaign_workbook(args.workbook, config) + if contents.errors: + for finding in contents.errors: + print(f" ERROR {finding}") + return 1 + X_real = contents.inputs.to_numpy(float) + + oracle, oracle_warnings = fit_campaign_models( + config, X_real, contents.model_values.to_numpy(float), seed=seed + ) + if oracle_warnings: + print("ABORTING -- the oracle fit raised guard warnings:") + for message in oracle_warnings: + print(f" {message}") + return 1 + + r0_by_trial = { + name: r0_for_trial(config, name, source, lhs_seed, args.workbook) + for name, source, lhs_seed in TRIALS + } + + cells = all_cells(args.betas, args.radii) + mine = cells[args.shard :: args.num_shards] + out_dir = Path(args.output_dir) / "cells" + out_dir.mkdir(parents=True, exist_ok=True) + print(f"shard {args.shard}/{args.num_shards}: {len(mine)} of {len(cells)} cells") + + started = time.time() + for position, (trial, beta, radius) in enumerate(mine, start=1): + key = cell_key(trial, beta, radius) + target = out_dir / f"{key}.npz" + if target.exists() and not args.overwrite: + print(f" [{position}/{len(mine)}] {key} exists, skipping") + continue + cell_started = time.time() + cell = prs.run_cell( + config, oracle, r0_by_trial[trial], transform, reference, + radius=radius, beta=beta, seed=seed, + ) + np.savez_compressed( + target, + U_R0=cell["U"]["R0"], U_R1=cell["U"]["R1"], U_R2=cell["U"]["R2"], + X_R0=cell["X"]["R0"], X_R1=cell["X"]["R1"], X_R2=cell["X"]["R2"], + hv=np.array([cell["hv"]["R0"], cell["hv"]["R0+R1"], cell["hv"]["R0+R1+R2"]]), + r1_spacing=float(cell["r1"].diagnostics["validity"]["min_pairwise_distance"]), + r2_spacing=float(cell["r2"].diagnostics["validity"]["min_pairwise_distance"]), + r1_edge=int(sum(cell["r1"].diagnostics["validity"]["boundary_coords_per_condition"])), + r2_edge=int(sum(cell["r2"].diagnostics["validity"]["boundary_coords_per_condition"])), + r1_hash=prs.batch_hash(cell["r1"].conditions), + r2_hash=prs.batch_hash(cell["r2"].conditions), + fit_warnings=len(cell["final_fit_warnings"]), + ) + elapsed = time.time() - cell_started + print(f" [{position}/{len(mine)}] {key} {elapsed:.0f}s", flush=True) + if position == 1: + remaining = elapsed * (len(mine) - 1) / 60.0 + print(f" shard estimate: ~{remaining:.0f} min remaining", flush=True) + print(f"shard {args.shard} done in {(time.time() - started) / 60:.1f} min") + return 0 + + +# --------------------------------------------------------------------------- # +# compose +# --------------------------------------------------------------------------- # + + +def _panel(axis, series, objective_index) -> None: + values = [series[name][:, objective_index] for name in ("R0", "R1", "R2")] + boxes = axis.boxplot( + values, + tick_labels=[f"{n}\nn={len(v)}" for n, v in zip(("R0", "R1", "R2"), values)], + showmeans=True, showfliers=False, widths=0.55, patch_artist=True, + ) + for patch, name in zip(boxes["boxes"], ("R0", "R1", "R2")): + patch.set_facecolor(ROUND_STYLE[name][0]) + patch.set_alpha(0.22) + patch.set_edgecolor(ROUND_STYLE[name][0]) + for key in ("whiskers", "caps", "medians"): + for artist in boxes[key]: + artist.set_color(INK_MUTED) + for marker in boxes.get("means", ()): + marker.set_markerfacecolor(INK) + marker.set_markeredgecolor(INK) + marker.set_markersize(5) + rng = np.random.default_rng(0) + for position, block in enumerate(values, start=1): + colour = ROUND_STYLE[("R0", "R1", "R2")[position - 1]][0] + axis.scatter( + np.full(block.shape, position) + rng.normal(0, 0.045, block.shape), + block, s=20, c=colour, edgecolors="white", linewidths=0.6, zorder=3, + ) + axis.tick_params(labelsize=7, colors=INK_MUTED, length=2) + axis.grid(axis="y", color="#e8e7e2", lw=0.7) + axis.set_axisbelow(True) + for spine in axis.spines.values(): + spine.set_color(SPINE) + + +def compose(args: argparse.Namespace) -> int: + config = load_campaign_config(args.config) + transform = build_objective_transform(config) + names = list(transform.names) + # Compose exactly what was run. Iterating the full sweep constants here while + # the run was filtered would draw a page of "missing" panels around the one + # cell anybody asked for. + betas_used = BETAS if args.betas is None else tuple(float(b) for b in args.betas) + radii_used = RADII if args.radii is None else tuple(float(r) for r in args.radii) + cells_dir = Path(args.output_dir) / "cells" + + loaded: dict[str, Any] = {} + missing: list[str] = [] + for trial, beta, radius in all_cells(args.betas, args.radii): + key = cell_key(trial, beta, radius) + path = cells_dir / f"{key}.npz" + if path.exists(): + loaded[key] = np.load(path, allow_pickle=False) + else: + missing.append(key) + print(f"loaded {len(loaded)} cells, missing {len(missing)}") + if missing: + for key in missing[:10]: + print(f" MISSING {key}") + if not args.allow_partial: + print("\nRefusing to compose an incomplete deliverable. Re-run the " + "missing shards, or pass --allow-partial.") + return 1 + + # Shared y-limits per objective across every page, so any two panels in the + # whole deliverable are directly comparable. Without this a flatter cell can + # look identical to a better one. + limits = [] + for index in range(len(names)): + stack = np.concatenate([ + np.concatenate([ + data["U_R0"][:, index], data["U_R1"][:, index], data["U_R2"][:, index] + ]) + for data in loaded.values() + ]) + low, high = float(stack.min()), float(stack.max()) + pad = 0.06 * (high - low if high > low else 1.0) + limits.append((low - pad, high + pad)) + + figures_dir = Path(args.output_dir) / "pages" + figures_dir.mkdir(parents=True, exist_ok=True) + rows: list[dict[str, Any]] = [] + pages = len(TRIALS) * len(betas_used) + pdf_path = Path(args.output_dir) / f"boxplot_sweep_{pages}pages.pdf" + + with PdfPages(pdf_path) as pdf: + for trial, _source, lhs_seed in TRIALS: + for beta in betas_used: + fig, axes = plt.subplots( + len(radii_used), len(names), + figsize=(12.5, max(5.0, 26.0 * len(radii_used) / len(RADII))), + facecolor=SURFACE, squeeze=False, + ) + for row, radius in enumerate(radii_used): + key = cell_key(trial, beta, radius) + data = loaded.get(key) + for column, objective in enumerate(names): + axis = axes[row, column] + axis.set_facecolor(SURFACE) + if data is None: + axis.text(0.5, 0.5, "missing", ha="center", va="center", + fontsize=9, color=INK_MUTED) + axis.set_xticks([]) + continue + series = { + "R0": data["U_R0"], "R1": data["U_R1"], "R2": data["U_R2"] + } + _panel(axis, series, column) + axis.set_ylim(*limits[column]) + if row == 0: + axis.set_title(objective, fontsize=11, color=INK, pad=8) + if column == 0: + axis.set_ylabel( + f"radius {radius:g}\nutility", + fontsize=9, color=INK, + ) + for round_name in ("R0", "R1", "R2"): + block = series[round_name][:, column] + rows.append({ + "trial": trial, "beta": beta, "radius": radius, + "objective": objective, "round": round_name, + "n": int(block.size), + "mean_utility": float(block.mean()), + "median_utility": float(np.median(block)), + "max_utility": float(block.max()), + "hv_r0": float(data["hv"][0]), + "hv_r0_r1": float(data["hv"][1]), + "hv_r0_r1_r2": float(data["hv"][2]), + "r1_min_spacing": float(data["r1_spacing"]), + "r1_edge_coords": int(data["r1_edge"]), + "r1_batch_hash": data["r1_hash"].item(), + "r2_batch_hash": data["r2_hash"].item(), + "final_fit_warnings": int(data["fit_warnings"]), + }) + source = "15 real recipes" if trial == "real" else f"LHS seed {lhs_seed}" + shown = sorted(radii_used) + span = ( + f"radius {shown[0]:g}" + if len(shown) == 1 + else f"radius {shown[0]:g} → {shown[-1]:g}" + ) + fig.suptitle( + f"Utility by round | trial {trial} ({source}) | " + f"beta = {beta:g} | {span}", + fontsize=15, color=INK, y=0.995, + ) + fig.tight_layout(rect=(0, 0.035, 1, 0.982)) + fig.text( + 0.008, 0.006, "seed 73 | " + FOOTER + signal_caveat(config), + fontsize=6.6, color=INK_MUTED, va="bottom", ha="left", wrap=True, + ) + stem = f"page_{trial}_beta_{beta:g}".replace(".", "p") + if len(radii_used) == 1: + stem += f"_radius_{radii_used[0]:g}".replace(".", "p") + page = figures_dir / f"{stem}.png" + fig.savefig(page, dpi=110, facecolor=SURFACE) + pdf.savefig(fig, facecolor=SURFACE) + plt.close(fig) + print(f" page {page.name}") + + summary = pd.DataFrame(rows).drop_duplicates() + summary_path = Path(args.output_dir) / "boxplot_sweep_summary.csv" + summary.to_csv(summary_path, index=False, encoding="utf-8-sig") + print(f"\nPDF {pdf_path}") + print(f"summary {summary_path} ({len(summary)} rows)") + return 0 + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--workbook", required=True, type=Path) + parser.add_argument( + "--config", type=Path, + default=Path("configs/campaign_d2d_perovskite_final.yaml"), + ) + parser.add_argument( + "--output-dir", type=Path, default=Path("local_outputs/boxplot_sweep") + ) + # A ratified cell needs no sweep. The grid is a decision, not a default: + # running 108 cells to look at one is not thoroughness, it is 36x the + # compute for the same answer. + parser.add_argument( + "--betas", nargs="+", type=float, default=None, + help="restrict to these betas; default is the full sweep set", + ) + parser.add_argument( + "--radii", nargs="+", type=float, default=None, + help="restrict to these radii; default is the full sweep set", + ) + parser.add_argument("--shard", type=int, default=0) + parser.add_argument("--num-shards", type=int, default=1) + parser.add_argument("--overwrite", action="store_true") + parser.add_argument("--compose", action="store_true") + parser.add_argument( + "--allow-partial", action="store_true", + help="Compose with cells missing; each gap is drawn as 'missing'.", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + if args.compose: + return compose(args) + return run_shard(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plot_dtlz2_report.py b/scripts/plot_dtlz2_report.py new file mode 100644 index 0000000..4c19a54 --- /dev/null +++ b/scripts/plot_dtlz2_report.py @@ -0,0 +1,329 @@ +"""Figures for the DTLZ2 acceptance run: how the optimiser actually moves. + +Renders the progression the campaign goes through -- initial design, GP fit, +acquisition surface, selected batch, refit, repeat -- plus objective-space and +hypervolume views. + + python scripts/plot_dtlz2_report.py --out local_outputs/dtlz2_report + +The design space is 10-dimensional, so every contour is a 2-D slice on +(x0, x1) with x2..x9 held at 0.5. That slice is chosen deliberately: for DTLZ2 +the last k=8 inputs are "distance" variables whose optimum is exactly 0.5, and +x0/x1 are the "position" variables that move you along the Pareto front. So the +slice contains the true optimal surface, and a well-behaved optimiser should be +seen concentrating on it. +""" + +from __future__ import annotations + +import argparse +import warnings +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import torch +from botorch.test_functions.multi_objective import DTLZ2 +from botorch.utils.multi_objective.hypervolume import Hypervolume +from botorch.utils.multi_objective.pareto import is_non_dominated +from matplotlib.colors import LinearSegmentedColormap + +from mobo_kit.campaign import ( + build_objective_transform, + fit_campaign_models, + run_r0_lhs, + run_r1_ucb, + run_r2_qlognehvi, +) +from mobo_kit.design import build_design_from_config +from mobo_kit.ucb_hvi import score_ucb_hvi_pool + +# Scoped rather than blanket: this is a figure script, and the GP fits emit +# numerical and deprecation chatter that would bury a real message. Anything the +# fit guard has to say still comes through, which is the point -- a plot built on a +# collapsed fit should not look like a plot built on a good one. +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=FutureWarning) +warnings.filterwarnings("ignore", category=UserWarning, module="botorch") +warnings.filterwarnings("ignore", category=UserWarning, module="gpytorch") +warnings.filterwarnings("ignore", category=RuntimeWarning, module="numpy") +torch.set_num_threads(1) + +# dataviz reference palette, categorical slots 1-3 (the documented all-pairs-safe +# set: CVD dE 9.2 light / 9.4 dark, normal-vision 24.0 / 20.9) +R0_COLOR, R1_COLOR, R2_COLOR = "#2a78d6", "#eb6834", "#1baf7a" +INK, INK_MUTED, SURFACE = "#0b0b0b", "#52514e", "#fcfcfb" +# sequential blue ramp, 100 -> 700, for magnitude +BLUE_RAMP = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"] +# second sequential context takes the next categorical hue (orange) +ORANGE_RAMP = ["#fbe3d5", "#f6c3a4", "#f0a074", "#eb6834", "#c14f22", "#933a17", "#66270e"] +SEQ = LinearSegmentedColormap.from_list("seq_blue", BLUE_RAMP) +ACQ = LinearSegmentedColormap.from_list("seq_orange", ORANGE_RAMP) + +D, M, GRID = 10, 3, 45 +SLICE_X, SLICE_Y = 0, 1 + + +def config(pool: int = 1024, mc: int = 32) -> dict: + return { + "inputs": [ + {"name": f"x{i}", "start": 0.0, "stop": 1.0, "step": 0.05} for i in range(D) + ], + "objectives": { + "contract_version": "TEST_ONLY-dtlz2-v1", + "scaling_mode": "fixed_affine", + "specs": [ + { + "name": f"f{i}", "goal": "maximize", "transform": "affine", + "model_source_column": f"f{i}", + "lower_anchor": -2.0, "upper_anchor": 0.0, + } + for i in range(M) + ], + }, + "reference_point_utility": [-0.01] * M, + "rounds": { + "r1": {"method": "ucb_hvi", "batch_size": 5, "replicates_per_condition": 3, + "beta": 4.0, "candidate_pool_size": pool, "posterior_samples": 256, + "moment_method": "monte_carlo"}, + "r2": {"method": "qlognehvi", "batch_size": 3, + "replicates_per_condition": 3, "candidate_pool_size": pool, + "mc_samples": mc}, + }, + "local_penalization": {"radius": 0.25, "min_batch_distance": 0.15, + "min_observed_distance": 0.0, "dimension_weights": None}, + "model": {"variant": "dim_scaled_prior"}, + "reproducibility": {"seed": 73}, + "constraints": [], + } + + +CFG = config() +PROBLEM = DTLZ2(dim=D, num_objectives=M, negate=True).to(dtype=torch.double) +TRANSFORM = build_objective_transform(CFG) +DESIGN = build_design_from_config(CFG) +REF = torch.tensor(CFG["reference_point_utility"], dtype=torch.double) + + +def evaluate(X): + return PROBLEM(torch.tensor(np.asarray(X, float), dtype=torch.double)).numpy() + + +def hypervolume(Y): + U = TRANSFORM(torch.tensor(np.asarray(Y, float), dtype=torch.double)) + return Hypervolume(ref_point=REF).compute(U[is_non_dominated(U)]) + + +def slice_grid(): + axis = np.linspace(0.0, 1.0, GRID) + xx, yy = np.meshgrid(axis, axis) + pts = np.full((GRID * GRID, D), 0.5) + pts[:, SLICE_X] = xx.ravel() + pts[:, SLICE_Y] = yy.ravel() + return axis, xx, yy, pts + + +def surfaces(X_phys, Y_raw, seed=73): + """Posterior utility mean/sd and the UCB-HVI acquisition over the slice.""" + axis, xx, yy, pts = slice_grid() + model, _fit_warnings = fit_campaign_models(CFG, X_phys, Y_raw, seed=seed) + grid_t = torch.tensor(pts, dtype=torch.double) + + model.eval() + with torch.no_grad(): + post = model.posterior(grid_t) + util = TRANSFORM.expected_transform(post.mean, post.variance).numpy() + sd = post.variance.sqrt().numpy() + + scored = score_ucb_hvi_pool( + model, grid_t, Y_raw, TRANSFORM, REF.numpy(), + beta=CFG["rounds"]["r1"]["beta"], mc_samples=64, seed=seed, + ) + # base_score is the raw hypervolume improvement per candidate + acq = np.asarray(scored.base_score, dtype=float) + return { + "axis": axis, "xx": xx, "yy": yy, + "mean": util.mean(axis=1).reshape(GRID, GRID), + "sd": sd.mean(axis=1).reshape(GRID, GRID), + "acq": acq.reshape(GRID, GRID), + } + + +def style(ax, title, xlabel=True, ylabel=True): + ax.set_title(title, fontsize=10, color=INK, pad=8) + ax.set_xlim(0, 1); ax.set_ylim(0, 1) + ax.set_xlabel("x0" if xlabel else "", fontsize=9, color=INK_MUTED) + ax.set_ylabel("x1" if ylabel else "", fontsize=9, color=INK_MUTED) + ax.tick_params(labelsize=8, colors=INK_MUTED, length=3) + for spine in ax.spines.values(): + spine.set_color("#d8d7d2") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", default="local_outputs/dtlz2_report") + ap.add_argument("--seed", type=int, default=73) + args = ap.parse_args() + out = Path(args.out); out.mkdir(parents=True, exist_ok=True) + + print("running the campaign...") + r0 = run_r0_lhs(CFG, n=15, seed=args.seed) + X0 = r0.conditions.to_numpy(float); Y0 = evaluate(X0) + r1 = run_r1_ucb(CFG, X0, Y0, seed=args.seed) + X1 = r1.conditions.to_numpy(float); Y1 = evaluate(X1) + X01, Y01 = np.vstack([X0, X1]), np.vstack([Y0, Y1]) + r2 = run_r2_qlognehvi(CFG, X01, Y01, seed=args.seed) + X2 = r2.conditions.to_numpy(float); Y2 = evaluate(X2) + X012, Y012 = np.vstack([X01, X2]), np.vstack([Y01, Y2]) + hv = [hypervolume(Y0), hypervolume(Y01), hypervolume(Y012)] + print(f" hypervolume: {hv[0]:.4f} -> {hv[1]:.4f} -> {hv[2]:.4f}") + + print("building surfaces (3 GP fits over a 45x45 slice)...") + stages = [ + ("After R0: 15 LHS points", surfaces(X0, Y0, args.seed), X0, X1, "R1"), + ("After R1: 20 points", surfaces(X01, Y01, args.seed), X01, X2, "R2"), + ("After R2: 23 points", surfaces(X012, Y012, args.seed), X012, None, None), + ] + + # ---------------- figure 1: the progression ---------------- + fig, axes = plt.subplots(3, 3, figsize=(13.5, 12.2), facecolor=SURFACE) + for col, (title, s, seen, chosen, label) in enumerate(stages): + # row 0 -- posterior mean utility + ax = axes[0, col] + cf = ax.contourf(s["xx"], s["yy"], s["mean"], levels=14, cmap=SEQ) + ax.scatter(seen[:, SLICE_X], seen[:, SLICE_Y], s=26, c="white", + edgecolors=INK, linewidths=0.9, zorder=3, label="observed") + style(ax, f"{title}\nGP posterior mean utility", xlabel=False) + fig.colorbar(cf, ax=ax, fraction=0.046, pad=0.03).ax.tick_params(labelsize=7) + + # row 1 -- posterior uncertainty + ax = axes[1, col] + cf = ax.contourf(s["xx"], s["yy"], s["sd"], levels=14, cmap=SEQ) + ax.scatter(seen[:, SLICE_X], seen[:, SLICE_Y], s=26, c="white", + edgecolors=INK, linewidths=0.9, zorder=3) + style(ax, "GP posterior uncertainty (sd)", xlabel=False) + fig.colorbar(cf, ax=ax, fraction=0.046, pad=0.03).ax.tick_params(labelsize=7) + + # row 2 -- acquisition + what it picked + ax = axes[2, col] + cf = ax.contourf(s["xx"], s["yy"], s["acq"], levels=14, cmap=ACQ) + ax.scatter(seen[:, SLICE_X], seen[:, SLICE_Y], s=20, c="white", + edgecolors=INK_MUTED, linewidths=0.7, zorder=3) + if chosen is not None: + colour = R1_COLOR if label == "R1" else R2_COLOR + ax.scatter(chosen[:, SLICE_X], chosen[:, SLICE_Y], s=150, marker="*", + c=colour, edgecolors="white", linewidths=1.4, zorder=4, + label=f"{label} selected ({len(chosen)})") + ax.legend(loc="upper right", fontsize=8, frameon=True, + facecolor="white", edgecolor="#d8d7d2") + style(ax, f"UCB-HVI acquisition -> {label} batch") + else: + style(ax, "UCB-HVI acquisition (final model)") + fig.colorbar(cf, ax=ax, fraction=0.046, pad=0.03).ax.tick_params(labelsize=7) + + fig.suptitle( + "DTLZ2 acceptance run: 2-D slice at (x0, x1), x2..x9 = 0.5\n" + "Stars mark the batch each acquisition surface selected", + fontsize=12.5, color=INK, y=0.985, + ) + fig.tight_layout(rect=(0, 0, 1, 0.955)) + fig.savefig(out / "01_progression.png", dpi=150, facecolor=SURFACE) + plt.close(fig) + + # ---------------- figure 2: objective space ---------------- + fig = plt.figure(figsize=(12.5, 5.4), facecolor=SURFACE) + U0 = TRANSFORM(torch.tensor(Y0)).numpy() + U1 = TRANSFORM(torch.tensor(Y1)).numpy() + U2 = TRANSFORM(torch.tensor(Y2)).numpy() + pairs = [(0, 1), (0, 2), (1, 2)] + for i, (a, b) in enumerate(pairs): + ax = fig.add_subplot(1, 3, i + 1, facecolor=SURFACE) + for U, c, name in ((U0, R0_COLOR, "R0 (15)"), (U1, R1_COLOR, "R1 (5)"), + (U2, R2_COLOR, "R2 (3)")): + ax.scatter(U[:, a], U[:, b], s=52, c=c, edgecolors="white", + linewidths=1.2, label=name, zorder=3) + # Ring the Pareto-optimal points instead of connecting them: this is a + # 2-D projection of a 3-D front, so the points carry no ordering along + # either axis and a connecting line would invent one. + allU = np.vstack([U0, U1, U2]) + front = allU[is_non_dominated(torch.tensor(allU)).numpy()] + ax.scatter(front[:, a], front[:, b], s=170, facecolors="none", + edgecolors=INK, linewidths=1.5, zorder=2, + label="Pareto optimal (3-D)" if i == 0 else None) + ax.set_xlabel(f"utility f{a}", fontsize=9, color=INK_MUTED) + ax.set_ylabel(f"utility f{b}", fontsize=9, color=INK_MUTED) + ax.tick_params(labelsize=8, colors=INK_MUTED, length=3) + for spine in ax.spines.values(): + spine.set_color("#d8d7d2") + if i == 0: + ax.legend(fontsize=8, frameon=True, facecolor="white", + edgecolor="#d8d7d2", loc="lower left") + fig.suptitle("Objective space: where each round landed (higher is better)", + fontsize=12.5, color=INK) + fig.tight_layout(rect=(0, 0, 1, 0.93)) + fig.savefig(out / "02_objective_space.png", dpi=150, facecolor=SURFACE) + plt.close(fig) + + # ---------------- figure 3: hypervolume vs random ---------------- + print("running the random baseline across 5 seeds...") + grids = [np.asarray(g, float) for g in DESIGN.var_array] + bo_curves, rand_curves = [], [] + for seed in (1, 2, 3, 4, 5): + a0 = run_r0_lhs(CFG, n=15, seed=seed) + Xa = a0.conditions.to_numpy(float); Ya = evaluate(Xa) + b1 = run_r1_ucb(CFG, Xa, Ya, seed=seed) + Yb = evaluate(b1.conditions.to_numpy(float)) + Xab = np.vstack([Xa, b1.conditions.to_numpy(float)]) + Yab = np.vstack([Ya, Yb]) + b2 = run_r2_qlognehvi(CFG, Xab, Yab, seed=seed) + Yc = evaluate(b2.conditions.to_numpy(float)) + bo_curves.append([hypervolume(Ya), hypervolume(Yab), + hypervolume(np.vstack([Yab, Yc]))]) + rng = np.random.default_rng(seed) + Xr1 = np.column_stack([rng.choice(g, size=5) for g in grids]) + Xr2 = np.column_stack([rng.choice(g, size=3) for g in grids]) + Yr1, Yr2 = evaluate(Xr1), evaluate(Xr2) + rand_curves.append([hypervolume(Ya), hypervolume(np.vstack([Ya, Yr1])), + hypervolume(np.vstack([Ya, Yr1, Yr2]))]) + bo = np.array(bo_curves); rand = np.array(rand_curves) + + fig, ax = plt.subplots(figsize=(8.2, 5.2), facecolor=SURFACE) + x = np.array([15, 20, 23]) + for arr, colour, name in ((bo, R1_COLOR, "Bayesian optimisation"), + (rand, R0_COLOR, "Random on-grid search")): + ax.fill_between(x, arr.min(axis=0), arr.max(axis=0), color=colour, alpha=0.14) + ax.plot(x, arr.mean(axis=0), color=colour, lw=2.0, marker="o", ms=8, + markeredgecolor="white", markeredgewidth=1.4, label=name, zorder=3) + ax.set_xticks(x) + ax.set_xticklabels(["R0\n15 points", "+R1\n20 points", "+R2\n23 points"], + fontsize=9, color=INK_MUTED) + ax.set_ylabel("hypervolume (utility space)", fontsize=9.5, color=INK_MUTED) + ax.tick_params(labelsize=8, colors=INK_MUTED, length=3) + ax.grid(axis="y", color="#e8e7e2", lw=0.8) + ax.set_axisbelow(True) + for spine in ax.spines.values(): + spine.set_color("#d8d7d2") + ax.legend(fontsize=9, frameon=True, facecolor="white", edgecolor="#d8d7d2", + loc="upper left") + ax.set_title( + f"Hypervolume gain at equal budget, 5 seeds (band = min-max)\n" + f"mean gain: BO +{(bo[:,2]-bo[:,0]).mean():.3f} " + f"random +{(rand[:,2]-rand[:,0]).mean():.3f}", + fontsize=11.5, color=INK, pad=10, + ) + fig.tight_layout() + fig.savefig(out / "03_hypervolume.png", dpi=150, facecolor=SURFACE) + plt.close(fig) + + np.savetxt(out / "hypervolume_bo.csv", bo, delimiter=",", + header="hv_R0,hv_R1,hv_R2", comments="") + np.savetxt(out / "hypervolume_random.csv", rand, delimiter=",", + header="hv_R0,hv_R1,hv_R2", comments="") + print(f"\nwrote 3 figures + 2 CSVs to {out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/plot_extended_replicates.py b/scripts/plot_extended_replicates.py new file mode 100644 index 0000000..e3709b4 --- /dev/null +++ b/scripts/plot_extended_replicates.py @@ -0,0 +1,556 @@ +"""What 45 rows of REPEATED recipes say about the three scores. + +The sheet behind this script (``Extended Summary Table C1C2``) is the first one in +the project where the same recipe appears more than once: samples 1-15, 16-30 and +31-45 carry identical inputs, recipe for recipe, and block 1 is bit-identical to +the current campaign workbook on thickness. So the three blocks are the SAME 15 +RECIPES REMADE, not 45 designs. + +That buys the one thing no amount of modelling can buy: a separation of + + "the score changed because the recipe changed" (what BO can chase) + +from + + "the score changed because the film was made and measured again" (what it cannot). + +TWO FIGURES. + +``boxplot_extended`` is the measurement, not the model. The top row boxes each +score by campaign block, which is where a systematic between-campaign shift shows +up as three boxes that do not overlap. The bottom row boxes each RECIPE's three +repeats, sorted by recipe mean: tall boxes that overlap everything mean the repeat +spread swamps the recipe spread, and no model can beat that. + +``01_loo_parity_extended`` is the model. Both rows are leave-one-out predictions +plotted against the measurement, but they leave out different things, and the +difference is the point: + + * ROW-WISE (top) holds out one ROW. Its two repeats stay in the training set + carrying the same inputs, so the GP interpolates its own repeat. That number + measures REPRODUCIBILITY and is not a prediction score. It is plotted because + it is what a naive run on this sheet reports, and it looks excellent. + * RECIPE-WISE (bottom) holds out all THREE rows of a recipe. Nothing with those + inputs remains. That is the honest question -- can the model predict a recipe + it has never made -- and it is the number to quote. + +Run:: + + python scripts/plot_extended_replicates.py \ + --workbook "local_inputs/Extended Summary Table C1C2.xlsx" \ + --config configs/campaign_d2d_perovskite_extended_c1c2.yaml \ + --outdir local_inputs/extended_c1c2_reports + +Outputs stay local; the workbook is gitignored and so is its report directory. +""" + +from __future__ import annotations + +import argparse +import json +import warnings +from pathlib import Path +from typing import Sequence + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 +import pandas as pd # noqa: E402 +import torch # noqa: E402 +from scipy.stats import f as fdist # noqa: E402 +from scipy.stats import spearmanr # noqa: E402 + +from mobo_kit.campaign import load_campaign_config, normalise_inputs # noqa: E402 +from mobo_kit.model_validation import DIM_SCALED_PRIOR, fit_model_variant # noqa: E402 +from mobo_kit.round_report import _save, _style # noqa: E402 +from mobo_kit.workbook_io import read_campaign_workbook # noqa: E402 + +warnings.filterwarnings("ignore") + +#: One colour per campaign block. Deliberately NOT the round palette: these are +#: three makings of the same designs, not three rounds of a campaign. +BLOCK_COLORS = ("#2a78d6", "#eb6834", "#1baf7a") +BLOCK_LABELS = ("block 1 (= current campaign)", "block 2", "block 3") +DEAD = "#8a3b2f" + + +# --------------------------------------------------------------------------- # +# statistics +# --------------------------------------------------------------------------- # + + +def variance_decomposition(y: np.ndarray, recipe: np.ndarray, block: np.ndarray) -> dict: + """One-way ANOVA with recipe as the factor, plus the block's share. + + ``icc`` is the fraction of variance the RECIPE owns. It is the ceiling on any + model that sees only the recipe: predict every repeat by its recipe's true + mean and the leftover is repeat variance, by construction. A score with an ICC + near zero cannot be optimised, however good the optimiser. + """ + y = np.asarray(y, float) + groups = sorted(set(recipe.tolist())) + grand = y.mean() + means = np.array([y[recipe == g].mean() for g in groups]) + counts = np.array([int((recipe == g).sum()) for g in groups]) + ss_between = float((counts * (means - grand) ** 2).sum()) + ss_within = float( + sum(((y[recipe == g] - means[i]) ** 2).sum() for i, g in enumerate(groups)) + ) + df_b, df_w = len(groups) - 1, len(y) - len(groups) + ms_b, ms_w = ss_between / df_b, ss_within / df_w + n0 = counts.mean() + block_ids = sorted(set(block.tolist())) + block_means = np.array([y[block == b].mean() for b in block_ids]) + block_counts = np.array([int((block == b).sum()) for b in block_ids]) + return { + "sd_total": float(y.std(ddof=1)), + "sd_within_recipe": float(np.sqrt(ms_w)), + "sd_between_recipe": float(np.sqrt(max(0.0, (ms_b - ms_w) / n0))), + "icc": float(max(0.0, (ms_b - ms_w) / (ms_b + (n0 - 1) * ms_w))), + "f": float(ms_b / ms_w), + "p": float(1.0 - fdist.cdf(ms_b / ms_w, df_b, df_w)), + "block_variance_share": float( + (block_counts * (block_means - grand) ** 2).sum() / ((y - grand) ** 2).sum() + ), + } + + +def fold_predictions(config, X_phys, y, groups, *, seed=73): + """Predict every row from a model fitted without ANY row of its group. + + A fold that will not fit is not skipped and not retried on a looser variant: + it falls back to the training mean and is COUNTED. A collapsed fold means the + GP explained that objective as pure noise, which is a result about the data + and must not be hidden behind a model that happens to fit. + """ + Xn = normalise_inputs(config, np.asarray(X_phys, float)) + y = np.asarray(y, float) + n = len(y) + mu, sd = np.empty(n), np.empty(n) + collapsed: list[int] = [] + previous = torch.get_num_threads() + torch.set_num_threads(1) + try: + for g in sorted(set(np.asarray(groups).tolist())): + held = [i for i in range(n) if groups[i] == g] + keep = [i for i in range(n) if groups[i] != g] + torch.manual_seed(seed) + try: + record = fit_model_variant( + torch.tensor(Xn[keep], dtype=torch.double), + torch.tensor(y[keep], dtype=torch.double).unsqueeze(-1), + sample_ids=tuple(range(len(keep))), + objective_names=("y",), + variant=DIM_SCALED_PRIOR, + seed=seed, + ) + except Exception: + collapsed.append(int(g)) + mu[held] = y[keep].mean() + sd[held] = y[keep].std(ddof=1) + continue + gp = record.model.models[0] + gp.eval() + with torch.no_grad(): + posterior = gp.posterior(torch.tensor(Xn[held], dtype=torch.double)) + mu[held] = posterior.mean.reshape(-1).numpy() + sd[held] = np.sqrt( + np.clip(posterior.variance.reshape(-1).numpy(), 0.0, None) + ) + finally: + torch.set_num_threads(previous) + return mu, sd, tuple(collapsed) + + +def r_squared(observed: np.ndarray, predicted: np.ndarray) -> float: + observed = np.asarray(observed, float) + residual = ((observed - predicted) ** 2).sum() + total = ((observed - observed.mean()) ** 2).sum() + return float(1.0 - residual / total) + + +def grouped_null(n: int, n_folds: int) -> float: + """What predicting the held-out-group mean scores, at this fold size. + + The familiar ``1 - (N/(N-1))^2`` is the k=1 case. Dropping k rows at a time + shifts the training mean further, so the bar to clear MOVES with the fold + size and the two rows of the parity figure do not share one null. + """ + k = n / n_folds + return float(1.0 - (n / (n - k)) ** 2) + + +# --------------------------------------------------------------------------- # +# figures +# --------------------------------------------------------------------------- # + + +def _display(name: str, values: np.ndarray) -> tuple[np.ndarray, str, bool]: + """Optoelectronic spans five orders of magnitude; plot it in log10.""" + if name == "optoelectronic": + return np.log10(np.clip(values, 1e-300, None)), "log10(optoelectronic score)", True + return values, f"{name} score", False + + +def plot_boxes(directory: Path, names, Y, recipe, block, stats) -> Path: + fig, axes = plt.subplots(2, len(names), figsize=(5.6 * len(names), 9.6)) + for column, name in enumerate(names): + values, label, _ = _display(name, Y[:, column]) + stat = stats[name] + + top = axes[0, column] + _style(top) + data = [values[block == b] for b in range(3)] + boxes = top.boxplot(data, patch_artist=True, widths=0.55, showfliers=False) + for patch, colour in zip(boxes["boxes"], BLOCK_COLORS): + patch.set_facecolor(colour) + patch.set_alpha(0.22) + patch.set_edgecolor(colour) + for key in ("whiskers", "caps", "medians"): + for line in boxes[key]: + line.set_color("#555555") + for g in sorted(set(recipe.tolist())): + top.plot( + [1, 2, 3], + [values[(recipe == g) & (block == b)][0] for b in range(3)], + color="#c4c3bf", + linewidth=0.7, + zorder=2, + ) + for b in range(3): + jitter = np.random.default_rng(73 + b).normal(0, 0.04, size=len(data[b])) + top.scatter( + 1 + b + jitter, data[b], s=26, color=BLOCK_COLORS[b], zorder=3, alpha=0.9 + ) + top.set_xticks([1, 2, 3]) + top.set_xticklabels( + ["block 1\n(current campaign)", "block 2", "block 3"], fontsize=9 + ) + top.set_ylabel(label) + share = stat["block_variance_share"] + top.set_title( + f"{name}\nthe block owns {share:.1%} of the variance", + fontsize=11, + color=DEAD if share > 0.25 else "#222222", + ) + if share > 0.25: + top.text( + 0.5, + 0.955, + "SYSTEMATIC BETWEEN-CAMPAIGN SHIFT", + transform=top.transAxes, + ha="center", + va="top", + fontsize=9.5, + color=DEAD, + bbox=dict(boxstyle="round,pad=0.35", fc="#fdeeea", ec="#e0b4a8"), + ) + + bottom = axes[1, column] + _style(bottom) + order = list(np.argsort([values[recipe == g].mean() for g in sorted(set(recipe.tolist()))])) + per_recipe = [values[recipe == g] for g in order] + boxes = bottom.boxplot(per_recipe, patch_artist=True, widths=0.6, showfliers=False) + for patch in boxes["boxes"]: + patch.set_facecolor("#9a9894") + patch.set_alpha(0.18) + patch.set_edgecolor("#9a9894") + for key in ("whiskers", "caps", "medians"): + for line in boxes[key]: + line.set_color("#555555") + for position, g in enumerate(order, start=1): + for b in range(3): + mask = (recipe == g) & (block == b) + bottom.scatter( + np.full(int(mask.sum()), position), + values[mask], + s=24, + color=BLOCK_COLORS[b], + zorder=3, + alpha=0.9, + ) + bottom.set_xticks(range(1, len(order) + 1)) + bottom.set_xticklabels([str(int(g) + 1) for g in order], fontsize=8) + bottom.set_xlabel("recipe, sorted by its mean") + bottom.set_ylabel(label) + learnable = stat["icc"] >= 0.5 + bottom.set_title( + f"repeat spread {stat['sd_within_recipe']:.3g} " + f"recipe spread {stat['sd_between_recipe']:.3g}\n" + f"ICC {stat['icc']:.3f} F {stat['f']:.2f} p {stat['p']:.4f}", + fontsize=10, + color="#222222" if learnable else DEAD, + ) + if not learnable: + bottom.text( + 0.5, + 0.955, + "REPEATS SWAMP THE RECIPE", + transform=bottom.transAxes, + ha="center", + va="top", + fontsize=9.5, + color=DEAD, + bbox=dict(boxstyle="round,pad=0.35", fc="#fdeeea", ec="#e0b4a8"), + ) + + fig.suptitle( + "The same 15 recipes, made three times: what actually moves the score", + fontsize=13, + y=0.985, + ) + caveats = [ + "These are MEASUREMENTS, not model output. Nothing here has been fitted.", + "Top row: one box per campaign block, grey lines joining the three makings " + "of one recipe. Boxes at different heights with the lines all sloping the " + "same way is a systematic shift between campaigns, not repeat scatter.", + "Bottom row: one box per recipe over its three repeats. ICC is the share of " + "variance the RECIPE owns and it is a CEILING on any model -- an ICC near " + "zero means the recipe explains none of the score and no optimiser, " + "acquisition or kernel can chase it.", + "Optoelectronic is drawn in log10 because it spans five orders of magnitude " + "on this sheet.", + ] + path = directory / "boxplot_extended.png" + _save(fig, path, caveats) + return path + + +def plot_parity(directory: Path, names, Y, recipe, block, folds) -> Path: + n = len(Y) + n_recipes = len(set(recipe.tolist())) + fig, axes = plt.subplots(2, len(names), figsize=(5.4 * len(names), 10.6)) + rows = [ + ("row-wise LOO", "rowwise", grouped_null(n, n), n), + ("leave-one-RECIPE-out", "recipe", grouped_null(n, n_recipes), n_recipes), + ] + for r, (title, key, null, n_folds) in enumerate(rows): + for column, name in enumerate(names): + ax = axes[r, column] + _style(ax) + observed, _, logged = _display(name, Y[:, column]) + fold = folds[key][name] + predicted = np.asarray(fold["predicted"], float) + if logged: + predicted = np.log10(np.clip(predicted, 1e-300, None)) + dead = bool(fold["collapsed"]) + for b in range(3): + mask = block == b + ax.scatter( + observed[mask], + predicted[mask], + s=34, + color=BLOCK_COLORS[b], + alpha=0.35 if dead else 0.9, + zorder=3, + label=BLOCK_LABELS[b] if (r == 0 and column == 0) else None, + ) + lo = float(min(observed.min(), predicted.min())) + hi = float(max(observed.max(), predicted.max())) + pad = 0.07 * (hi - lo if hi > lo else 1.0) + line = np.array([lo - pad, hi + pad]) + ax.plot(line, line, color="#666666", linewidth=1.0, linestyle="--", zorder=2) + ax.set_xlim(*line) + ax.set_ylim(*line) + ax.set_xlabel(f"measured {name}" + (" (log10)" if logged else "")) + ax.set_ylabel(f"{title} prediction") + beats = fold["r2"] > null and not dead + ax.set_title( + f"{name} - {title}\n" + f"R2 {fold['r2']:+.4f} null {null:+.4f} rho {fold['spearman']:+.3f}", + fontsize=10.5, + color="#222222" if beats else DEAD, + ) + if dead: + banner = f"MODEL COLLAPSED IN {len(fold['collapsed'])}/{n_folds} FOLDS" + elif not beats: + banner = "DOES NOT BEAT THE NULL" + else: + banner = "" + if banner: + ax.text( + 0.5, + 0.955, + banner, + transform=ax.transAxes, + ha="center", + va="top", + fontsize=9.5, + color=DEAD, + bbox=dict(boxstyle="round,pad=0.35", fc="#fdeeea", ec="#e0b4a8"), + ) + # Inside the first panel rather than on the figure: a figure-level legend at + # the top right lands on the suptitle, and one at the bottom lands on the + # caveats, both of which `_save` has already reserved space for. + axes[0, 0].legend(loc="lower right", frameon=False, fontsize=8.5) + fig.suptitle( + "Predicted against measured. The top row leaks a repeat; the bottom row does not.", + fontsize=13, + y=0.985, + ) + caveats = [ + "TOP ROW IS NOT A PREDICTION SCORE. Holding out one row leaves that " + "recipe's other two repeats in the training set with identical inputs, so " + "the GP interpolates its own repeat. It measures reproducibility, and it is " + "shown because it is what a naive leave-one-out on this sheet reports.", + "BOTTOM ROW is the honest question: all three repeats of a recipe held out " + "together, so the model has never seen those inputs. Quote this one.", + "The two rows have DIFFERENT nulls. Dropping 3 rows of 45 moves the training " + "mean further than dropping 1, so the bar is lower for the top row. " + "Predicting the held-out mean scores exactly the null, whatever the data.", + "A collapsed fold is one whose GP explained the objective as pure noise and " + "refused to fit; it falls back to the training mean and is counted rather " + "than retried on a looser model.", + ] + path = directory / "01_loo_parity_extended.png" + _save(fig, path, caveats) + return path + + +# --------------------------------------------------------------------------- # + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--workbook", required=True, type=Path) + parser.add_argument( + "--config", + type=Path, + default=Path("configs/campaign_d2d_perovskite_extended_c1c2.yaml"), + ) + parser.add_argument("--outdir", type=Path, required=True) + parser.add_argument("--seed", type=int, default=None) + parser.add_argument( + "--replicates", + type=int, + default=3, + help="repeats per recipe; the sheet must be that many equal blocks", + ) + parser.add_argument( + "--align-blocks-to-first", + action="store_true", + help=( + "copy block 1's inputs onto the later blocks where they disagree. " + "On this sheet that applies ONE correction the group already made: " + "sample 2 was re-encoded from `speed_2 = 0, time_2 = 60` to " + "`time_2 = 0`, and the edit reached block 1 only. A second stage of " + "60 s at 0 rpm is not a second stage, so the later blocks describe " + "the same film under the old encoding. Without this flag the " + "mismatch is reported and the rows are grouped by POSITION anyway -- " + "the flag changes what the GP is told, not what is grouped." + ), + ) + args = parser.parse_args(argv) + + config = load_campaign_config(args.config) + seed = args.seed if args.seed is not None else int(config["reproducibility"]["seed"]) + contents = read_campaign_workbook(args.workbook, config) + X = contents.inputs.to_numpy(float) + Y = contents.model_values.to_numpy(float) + names = list(contents.model_values.columns) + n = len(X) + k = args.replicates + if n % k: + raise SystemExit(f"{n} rows is not {k} equal blocks.") + per_block = n // k + + recipe = np.tile(np.arange(per_block), k) + block = np.repeat(np.arange(k), per_block) + # Verify the assumed layout rather than trusting it. Grouping is by POSITION, + # which is what makes a block a block; input equality is the check on that + # assumption, and a failure is reported by name rather than absorbed. + input_names = [item["name"] for item in config["inputs"]] + drift: list[str] = [] + for g in range(per_block): + rows = np.flatnonzero(recipe == g) + if not np.allclose(X[rows], X[rows[0]]): + differing = [ + input_names[c] + for c in range(X.shape[1]) + if not np.allclose(X[rows, c], X[rows[0], c]) + ] + drift.append( + f" recipe {g + 1}: sheet rows {(rows + 1).tolist()} disagree on " + f"{', '.join(differing)} -- " + + " vs ".join( + "/".join(f"{X[r, c]:g}" for c in range(X.shape[1]) if input_names[c] in differing) + for r in rows + ) + ) + if drift: + print("INPUT DRIFT BETWEEN BLOCKS (grouped by position regardless):") + print("\n".join(drift)) + if args.align_blocks_to_first: + for g in range(per_block): + rows = np.flatnonzero(recipe == g) + X[rows] = X[rows[0]] + print(" --align-blocks-to-first: later blocks re-encoded to block 1.") + else: + print( + " Not aligned. The GP is told these are different recipes, which " + "understates the repeat evidence. Re-run with " + "--align-blocks-to-first to apply block 1's encoding." + ) + + args.outdir.mkdir(parents=True, exist_ok=True) + # On the DISPLAYED scale, which for optoelectronic is log10. A variance share + # computed on the raw product and printed on a log axis describes a different + # quantity from the one the reader is looking at: raw gives the block 49.3% and + # log10 gives it 84.5%, because the raw scale is dominated by the handful of + # largest values. The panel's numbers must be about the panel. + stats = { + name: variance_decomposition(_display(name, Y[:, j])[0], recipe, block) + for j, name in enumerate(names) + } + + folds: dict[str, dict] = {"rowwise": {}, "recipe": {}} + for key, groups in (("rowwise", np.arange(n)), ("recipe", recipe)): + for j, name in enumerate(names): + mu, sd, collapsed = fold_predictions(config, X, Y[:, j], groups, seed=seed) + folds[key][name] = { + "predicted": mu.tolist(), + "predictive_sd": sd.tolist(), + "r2": r_squared(Y[:, j], mu), + "spearman": float(spearmanr(Y[:, j], mu).statistic), + "collapsed": list(collapsed), + } + print( + f" {key:8} {name:16} R2 {folds[key][name]['r2']:+.4f}" + f" collapsed {len(collapsed)} folds", + flush=True, + ) + + frame = pd.DataFrame( + { + "recipe": recipe + 1, + "block": block + 1, + **{f"measured_{name}": Y[:, j] for j, name in enumerate(names)}, + **{ + f"{key}_pred_{name}": folds[key][name]["predicted"] + for key in folds + for name in names + }, + } + ) + frame.to_csv(args.outdir / "01_loo_parity_extended.csv", index=False) + pd.DataFrame(stats).T.rename_axis("objective").to_csv( + args.outdir / "boxplot_extended.csv" + ) + (args.outdir / "extended_folds.json").write_text( + json.dumps({"stats": stats, "folds": folds, "seed": seed}, indent=1), + encoding="utf-8", + ) + + for path in ( + plot_boxes(args.outdir, names, Y, recipe, block, stats), + plot_parity(args.outdir, names, Y, recipe, block, folds), + ): + print("wrote", path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plot_raw_vs_score_parity.py b/scripts/plot_raw_vs_score_parity.py new file mode 100644 index 0000000..c970b85 --- /dev/null +++ b/scripts/plot_raw_vs_score_parity.py @@ -0,0 +1,190 @@ +"""Does the model learn better from a RAW measurement than from a combined score? + +THE QUESTION, IN THE GROUP'S OWN WORDS. R1 and R2 stopped improving on uniformity +and optoelectronic, and the parity plot showed the model learning neither. Both +are composites of several raw measurements. So: is the COMBINATION the problem? +Would feeding the GP one raw measurement per axis -- just photoconductance, just +Voc, just phase purity -- give it something it can learn? + +This draws the answer. Twelve panels, one per candidate objective, every one an +exact leave-one-out parity plot on the same 15 films with the same model: + + row 1 the three COMPOSITE SCORES the campaign runs, plus raw thickness as + the positive control -- the one axis that does work + row 2 the OPTOELECTRONIC score taken apart: Voc, photoconductance (raw and + log), photosensitivity + row 3 the UNIFORMITY score taken apart: coverage, 1-uniformity, phase + purity, and raw uniformity + +Every point is a film predicted by a model that never saw it. Points on the +dashed line are perfect. A cloud with no slope is a model that has learned +nothing, whatever its R2 says. + +HOW TO READ THE NUMBER, which is not how this project read it until 2026-09-04. +``1-(N/(N-1))^2 = -0.1480`` is NOT a significance threshold. It is the score of +one specific predictor -- predict every held-out film with the average of the +other fourteen -- and a fitted GP does not behave like it. Measured on this +campaign, **28.7% of pure-noise shuffles score above -0.1480**. The honest bar is +each candidate's own permutation p95, roughly +0.23 here, and the adjudicator for +a real verdict is the rank permutation test. Panels are therefore marked from the +permutation verdict, not from a comparison against -0.1480. + + python scripts/plot_raw_vs_score_parity.py \ + --workbook "local_inputs/Final Summary Table.xlsx" \ + --outdir local_outputs/raw_vs_score +""" + +from __future__ import annotations + +import argparse +import importlib.util +import sys +from pathlib import Path +from typing import Sequence + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 +import pandas as pd # noqa: E402 + +from mobo_kit.campaign import load_campaign_config # noqa: E402 +from mobo_kit.round_report import _save, _style # noqa: E402 + +LEARNS = "#1baf7a" +FAILS = "#8a3b2f" +GREY = "#9a9894" + +#: (panel title, expression, plain-English label, verdict) +#: `verdict` is "learns" only where the RANK PERMUTATION TEST said so. Nothing is +#: marked learnable on the strength of clearing -0.1480, which a quarter of pure +#: noise does. +PANELS = [ + # row 1 -- what the campaign runs, plus the control + ("Uniformity SCORE", "score_uniformity", "the composite you run now", "fails"), + ("Optoelectronic SCORE", "score_opto", "the composite you run now", "fails"), + ("Thickness SCORE", "score_thickness", "the composite you run now", "fails"), + ("Thickness, RAW nm", "thickness_nm", "CONTROL: the axis that works", "learns"), + # row 2 -- the optoelectronic score, taken apart + ("Voc (raw)", "voc_raw", "optoelectronic part", "fails"), + ("Photoconductance", "photocond", "optoelectronic part", "fails"), + ("log Photoconductance", "np.log(photocond)", "optoelectronic part, log scale", "fails"), + ("Photosensitivity", "photosens_ratio", "optoelectronic part", "fails"), + # row 3 -- the uniformity score, taken apart + ("Coverage", "coverage", "uniformity part", "fails"), + ("1 - Uniformity", "one_minus_unif", "uniformity part", "fails"), + ("Phase purity", "phase_purity", "uniformity part, the best of them", "fails"), + ("Uniformity (raw)", "uniformity_raw", "uniformity part", "fails"), +] + + +def _load_screen(): + path = Path("scripts") / "raw_component_screen.py" + spec = importlib.util.spec_from_file_location("_screen_for_parity", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--workbook", default="local_inputs/Final Summary Table.xlsx") + parser.add_argument("--config", default="configs/campaign_d2d_perovskite_final.yaml") + parser.add_argument("--sheet", default="R0") + parser.add_argument("--seed", type=int, default=73) + parser.add_argument("--outdir", type=Path, default=Path("local_outputs/raw_vs_score")) + args = parser.parse_args(argv) + + screen = _load_screen() + config = load_campaign_config(args.config) + space = screen.read_measurements(Path(args.workbook), args.sheet) + X = np.column_stack([space[item["name"]] for item in config["inputs"]]) + n = len(X) + args.outdir.mkdir(parents=True, exist_ok=True) + + fig, axes = plt.subplots(3, 4, figsize=(19.5, 14.4)) + rows: list[dict] = [] + for index, (title, expr, blurb, verdict) in enumerate(PANELS): + ax = axes[index // 4][index % 4] + _style(ax) + y = screen.evaluate(expr, space) + result = screen.loo_r2(config, X, y, seed=args.seed) + predicted = np.asarray(result["predicted"], float) + learns = verdict == "learns" + colour = LEARNS if learns else FAILS + ax.scatter(y, predicted, s=42, color=colour, alpha=0.85, zorder=3, + edgecolor="white", linewidth=0.6) + lo = float(min(y.min(), predicted.min())) + hi = float(max(y.max(), predicted.max())) + pad = 0.08 * (hi - lo if hi > lo else 1.0) + line = np.array([lo - pad, hi + pad]) + ax.plot(line, line, color="#666666", linewidth=1.0, linestyle="--", zorder=2) + ax.set_xlim(*line) + ax.set_ylim(*line) + for position in range(n): + ax.annotate(str(position + 1), (y[position], predicted[position]), + fontsize=6, color="#555555", xytext=(4, 3), + textcoords="offset points") + ax.set_xlabel("measured") + ax.set_ylabel("predicted (never saw this film)") + ax.set_title( + f"{title}\n{blurb}\nLOO R2 {result['r2']:+.4f} rank {result['spearman']:+.3f}", + fontsize=10.5, + color="#1a6b4c" if learns else FAILS, + ) + ax.text( + 0.5, 0.965, + "MODEL LEARNS THIS" if learns else "MODEL LEARNS NOTHING", + transform=ax.transAxes, ha="center", va="top", fontsize=9.5, + color="#1a6b4c" if learns else FAILS, + bbox=dict( + boxstyle="round,pad=0.35", + fc="#e8f7f1" if learns else "#fdeeea", + ec="#9fd8c3" if learns else "#e0b4a8", + ), + ) + rows.append({ + "panel": title, "expression": expr, "loo_r2": result["r2"], + "spearman": result["spearman"], "verdict": verdict, + "collapsed_folds": result["collapsed_folds"], + }) + + fig.suptitle( + "Would raw measurements work better than the combined scores? " + "One panel per candidate objective, all on the same 15 films.", + fontsize=14, y=0.988, + ) + caveats = [ + "Every point is a film predicted by a model that never saw it " + "(exact leave-one-out). Points on the dashed line are perfect; a " + "shapeless cloud is a model that learned nothing. Numbers are the film's " + "sample number.", + "THE ANSWER: taking the scores apart does not help. Every part of the " + "optoelectronic score fails on its own, and so does every part of the " + "uniformity score. Only raw thickness -- unchanged from what you already " + "run -- is learnable. The combination was not the problem on these two " + "axes; the underlying measurements are.", + "-0.1480 IS NOT THE BAR, though this project long read it as one. It is " + "the score of predicting the average of the other 14 films, and 28.7% of " + "pure-noise shuffles beat it. Panels are marked from the rank permutation " + "test instead.", + "Phase purity is the closest thing to an exception (R2 +0.0571, and " + "+0.3244 once a precursor-concentration trend is declared) but it was " + "refuted on verification: the whole effect is three films below 1.25 M, " + "and among the ten high-purity films the model ranks them BACKWARDS " + "(rank -0.754).", + ] + path = args.outdir / "raw_vs_score_parity.png" + _save(fig, path, caveats) + frame = pd.DataFrame(rows) + frame.to_csv(args.outdir / "raw_vs_score_parity.csv", index=False) + print(frame.to_string(index=False)) + print("wrote", path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plot_round_simulation.py b/scripts/plot_round_simulation.py new file mode 100644 index 0000000..d02890b --- /dev/null +++ b/scripts/plot_round_simulation.py @@ -0,0 +1,1207 @@ +"""Simulate the campaign loop against a frozen GP oracle, and plot what it did. + +Derived from Annie Xu's ``examples/round_simulations.py`` on her +``ax_plots_simulation`` branch, which established the approach, the output +directory convention (``{pair}/qlognehvi/radius_*__beta_*/``), the round legend, +and the boxplot-with-overlaid-points figure. See ``docs/ROUND_SIM_DELTA.md`` for +what changed between her branch and this one, and why. + +WHAT THIS IS. There is exactly one round of real measurements (15 R0 films), so +the loop from R0 to R2 has never been run end to end on this campaign. This +script runs it against an *oracle*: a GP fitted once on the 15 real observations, +then frozen and used to answer "what would this recipe have measured?" for every +condition the optimiser proposes. + +WHAT IT IS NOT. The oracle is a model, so every number downstream of it is a +model prediction. A condition that scores well here has scored well against +MOBO-Kit's own beliefs -- which is a test of the optimiser loop on a data-shaped +landscape, and is not evidence about the chemistry. Both figures and manifest say +so; do not quote a thickness from this script as a measurement. + +THE LOOP, per parameter cell:: + + GP_exp fit once on the 15 real rows (fit_campaign_models, seed 73) + -> R0 the 15 REAL recipes, re-scored by the oracle + -> R1 UCB-HVI, 5 conditions, this cell's beta and radius + -> oracle score them + -> R2 qLogNEHVI, 3 conditions + -> oracle score them + -> final GP refit on all 23, which is what the heatmaps render + +qLogNEHVI only. It is the numerically stable formulation of qNEHVI and the one +``campaign.py`` ships; Annie's branch carried a ``run_r2_qnehvi`` alternative, +which is deliberately not used here. + +THE R1 BASELINE, and why the manifest carries three numbers for it. When this +script was written, ``campaign.run_r1_ucb`` handed its observed HVI baseline to +the objective transform in the WRONG SPACE -- measurement-space nanometres to a +transform that applies ``exp()`` to log-link objectives. That pinned every +observation's thickness utility to exactly 0.0 and made the baseline hypervolume +**0.004659 against a true 0.436442**. The script carried its own corrected R1 +until the defect was fixed in ``campaign.py`` (commit ``4b76670``, promoting the +fix Annie's branch already carried as ``_physical_to_model_output``). + +It now calls the public ``run_r1_ucb``, verified to reproduce the private +version's batches hash-for-hash, and keeps the contrast in the manifest as a +standing tripwire: the baseline the acquisition REPORTS must equal the one +recomputed here by an independent route, and both must stay far away from the +unencoded value. The assertion runs on every cell of every sweep. + +Usage:: + + python scripts/plot_round_simulation.py --workbook "local_inputs/Summary Table.xlsx" + python scripts/plot_round_simulation.py --workbook ... --no-figures # manifest only + python scripts/plot_round_simulation.py --workbook ... --pairs speed_1,precur_conc + python scripts/plot_round_simulation.py --workbook ... --full-grid # 45 cells +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import time +import warnings +from copy import deepcopy +from itertools import combinations +from pathlib import Path +from typing import Any, Mapping, Sequence + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.patheffects as path_effects # noqa: E402 +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 +import pandas as pd # noqa: E402 +import torch # noqa: E402 +from matplotlib.colors import LinearSegmentedColormap # noqa: E402 + +from mobo_kit.candidate_diagnostics import batch_hash +from mobo_kit.campaign import ( # noqa: E402 + build_objective_transform, + fit_campaign_models, + load_campaign_config, + normalise_inputs, + objective_names, + run_r1_ucb, + run_r2_qlognehvi, +) +from mobo_kit.design import Design, build_design_from_config # noqa: E402 +from mobo_kit.metrics import compute_ref_pareto_hv # noqa: E402 +from mobo_kit.objectives import ObjectiveTransform # noqa: E402 +from mobo_kit.workbook_io import read_campaign_workbook # noqa: E402 + +# Scoped rather than blanket, exactly as scripts/plot_dtlz2_report.py does it: the +# GP fits emit numerical and deprecation chatter that would bury a real message. +# Anything the fit guard says still comes through, which is the point. +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=FutureWarning) +warnings.filterwarnings("ignore", category=UserWarning, module="botorch") +warnings.filterwarnings("ignore", category=UserWarning, module="gpytorch") +warnings.filterwarnings("ignore", category=RuntimeWarning, module="numpy") +torch.set_num_threads(1) + +# --------------------------------------------------------------------------- # +# figure style -- the scripts/plot_dtlz2_report.py palette, so every figure this +# project ships reads as one set +# --------------------------------------------------------------------------- # +R0_COLOR, R1_COLOR, R2_COLOR = "#2a78d6", "#eb6834", "#1baf7a" +INK, INK_MUTED, SURFACE = "#0b0b0b", "#52514e", "#fcfcfb" +BLUE_RAMP = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"] +SEQ = LinearSegmentedColormap.from_list("seq_blue", BLUE_RAMP) +SPINE = "#d8d7d2" + +# R0 here is the REAL measured recipes re-scored by the oracle, not an LHS draw. +# The label said "R0 LHS" because Annie's branch generated a fresh LHS start; this +# script deliberately uses the measured recipes so the whole loop lives on one +# landscape, and the legend has to say which of those two a reader is looking at. +ROUND_STYLE = { + "R0": (R0_COLOR, "R0 measured recipes (oracle-scored)"), + "R1": (R1_COLOR, "R1 simulated"), + "R2": (R2_COLOR, "R2 simulated"), +} + +#: Every figure carries two caveat lines. The oracle caveat is on all of them -- +#: it is the one that silently produces a wrong conclusion. The other line is +#: whichever caveat is TRUE of that figure: a slice figure gets the slice caveat, +#: which is the one that silently produces a wrong reading; a round summary gets +#: the small-n caveat instead. +#: +#: The brief asked for one fixed two-line footer everywhere. Printing the slice +#: caveat on a boxplot, which has no slice, would be a false statement in the +#: place a reader looks for true ones -- and this project's own rule (see +#: CAMPAIGN_STATUS.md issue 6) is that padding a warning channel with +#: inapplicable text is how people learn to ignore it. Both lines are still +#: fixed and still on every figure. +SLICE_CAVEAT = ( + "Slice: the other 8 inputs are held at the fixed values named above. Plotted " + "points are shown at their own (x, y) only -- their remaining coordinates are " + "generally NOT on this slice." +) +ROUND_N_CAVEAT = ( + "Small n: the rounds hold 15 / 5 / 3 points. A box over three numbers reports " + "little more than those numbers, which is why every raw point is drawn on top." +) +ORACLE_CAVEAT = ( + "Oracle: surface and point values are predictions from a GP fitted to 15 real " + "films, not measurements. This validates the optimiser loop on a data-shaped " + "landscape, not the chemistry." +) + +#: OFAT, per the brief. The two arms share the (0.25, 4.0) cell, so the union is +#: 13 distinct cells rather than 14. +OFAT_RADII = (0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45) +OFAT_BETAS = (1.0, 4.0, 9.0, 16.0, 25.0) +ANCHOR_RADIUS, ANCHOR_BETA = 0.25, 4.0 + +#: The parameter sweep pinned this and so does the brief, so that "spacing" means +#: one thing in every cell. It is NOT swept, and a cell that changed it would not +#: be comparable with the others. +PINNED_MIN_BATCH_DISTANCE = 0.15 + + +# --------------------------------------------------------------------------- # +# small helpers +# --------------------------------------------------------------------------- # + + +def _safe_filename(value: str) -> str: + """Annie's slug rule, kept so her output paths stay recognisable.""" + cleaned = "".join(c.lower() if c.isalnum() else "_" for c in str(value)) + return "_".join(part for part in cleaned.split("_") if part) + + +def _slug_number(value: float) -> str: + """Annie's number slug: 0.25 -> '0p25', 4.0 -> '4'.""" + return f"{value:g}".replace("-", "m").replace(".", "p") + + +def cell_slug(radius: float, beta: float) -> str: + return f"radius_{_slug_number(radius)}__beta_{_slug_number(beta)}" + + +def to_model_space(Y_physical: np.ndarray, transform: ObjectiveTransform) -> np.ndarray: + """Measurement space -> model space, as a numpy convenience. + + Delegates to ``ObjectiveTransform.encode_measurements``, which is the public + contract for this step since commit ``4b76670``. It exists as a separate + function here only because the rest of this script works in numpy. + """ + values = torch.tensor(np.asarray(Y_physical, dtype=float), dtype=torch.double) + with torch.no_grad(): + return transform.encode_measurements(values).detach().cpu().numpy() + + +def utilities(Y_physical: np.ndarray, transform: ObjectiveTransform) -> np.ndarray: + """Campaign utility from measurement-space values. Higher is better, always.""" + values = torch.tensor(np.asarray(Y_physical, dtype=float), dtype=torch.double) + with torch.no_grad(): + return transform.transform_measurements(values).detach().cpu().numpy() + + +def hypervolume(Y_physical: np.ndarray, transform: ObjectiveTransform, + reference: np.ndarray) -> float: + """Hypervolume at the campaign's declared reference, in utility space.""" + U = torch.tensor(utilities(Y_physical, transform), dtype=torch.double) + _ref, _pareto, volume = compute_ref_pareto_hv(U, reference) + return float(volume) + + +# --------------------------------------------------------------------------- # +# the oracle +# --------------------------------------------------------------------------- # + + +def oracle_predict( + model: Any, + config: Mapping[str, Any], + X_phys: np.ndarray, + transform: ObjectiveTransform, +) -> np.ndarray: + """Deterministic measurement-space prediction for each row of ``X_phys``. + + Returns values in the same space the workbook reports and the campaign trains + on: nanometres for thickness, the score itself for the other two. + + THICKNESS IS THE POSTERIOR MEDIAN, ``exp(mu)``, and is labelled median + everywhere. Two other choices were considered and rejected: + + * ``exp(mu + v/2)`` is the lognormal *mean*, and is what Annie's branch used. + It is the correct mean, and her "physical mean" colorbar label was accurate + for it. It is nonetheless the wrong choice for an ORACLE, because it makes + the oracle's value a function of the posterior VARIANCE -- which is large + wherever the 15 real films are sparse. The simulated ground truth would then + bulge in exactly the regions the optimiser is about to explore, and the + landscape would encode where R0 happened to look rather than what the model + believes. ``exp(mu)`` depends on the mean surface alone. + * Drawing a posterior sample makes the oracle stochastic, so two cells that + propose the same batch could still be scored differently, and the batch + identity question this sweep exists to answer would be unanswerable. + + Determinism matters beyond tidiness: the manifest compares batches ACROSS + cells, and that comparison is only meaningful if the oracle is a fixed + function. + """ + X_norm = normalise_inputs(config, np.asarray(X_phys, dtype=float)) + model.eval() + with torch.no_grad(): + posterior = model.posterior( + torch.tensor(X_norm, dtype=torch.double), observation_noise=False + ) + mean = posterior.mean.detach().cpu().double().numpy() + if mean.ndim != 2 or mean.shape[1] != transform.objective_count: + raise RuntimeError(f"Oracle posterior mean has unexpected shape {mean.shape}.") + out = np.empty_like(mean) + for index, spec in enumerate(transform.specs): + out[:, index] = np.exp(mean[:, index]) if spec.model_link == "log" else mean[:, index] + return out + + +# --------------------------------------------------------------------------- # +# one parameter cell +# --------------------------------------------------------------------------- # + + +def cell_config(base: Mapping[str, Any], *, radius: float, beta: float) -> dict[str, Any]: + """Base config with this cell's two knobs set and min_batch_distance pinned.""" + config = deepcopy(dict(base)) + penalization = config.setdefault("local_penalization", {}) + penalization["radius"] = float(radius) + penalization["min_batch_distance"] = PINNED_MIN_BATCH_DISTANCE + config.setdefault("rounds", {}).setdefault("r1", {})["beta"] = float(beta) + return config + + +def run_cell( + base_config: Mapping[str, Any], + oracle: Any, + X_r0: np.ndarray, + transform: ObjectiveTransform, + reference: np.ndarray, + *, + radius: float, + beta: float, + seed: int, +) -> dict[str, Any]: + """R0 -> R1 -> R2 for one (radius, beta), everything scored by the oracle.""" + config = cell_config(base_config, radius=radius, beta=beta) + + # R0: the REAL 15 recipes, re-scored by the oracle so the whole loop lives on + # one consistent landscape. Using the real measured Y here instead would mix a + # measured R0 with a simulated R1/R2 and make the round comparison incoherent. + Y_r0 = oracle_predict(oracle, config, X_r0, transform) + + r1 = run_r1_ucb(config, X_r0, Y_r0, seed=seed) + r1_warnings = tuple(r1.diagnostics.get("model_fit_warnings", ())) + + # STANDING TRIPWIRE for the defect this script was written alongside. + # run_r1_ucb reports the HVI baseline it actually used; `hypervolume` recomputes + # it here through metrics.compute_ref_pareto_hv, a different Pareto filter and a + # different call path. They agree only if the observed values were encoded into + # model space before being transformed. If that encoding is ever dropped again, + # this fires on the first cell of the next sweep instead of quietly producing a + # plausible manifest. + reported_baseline = float(r1.diagnostics["observed_baseline_hypervolume"]) + independent_baseline = hypervolume(Y_r0, transform, reference) + if not math.isclose(reported_baseline, independent_baseline, rel_tol=1e-9): + raise RuntimeError( + "The R1 acquisition's observed baseline does not match an independent " + f"computation: reported {reported_baseline!r} against " + f"{independent_baseline!r}. The most likely cause is measurement-space " + "values reaching ObjectiveTransform.transform without going through " + "encode_measurements first -- see docs/ROUND_SIM_DELTA.md." + ) + + X_r1 = r1.conditions.to_numpy(dtype=float) + Y_r1 = oracle_predict(oracle, config, X_r1, transform) + + X_01 = np.vstack([X_r0, X_r1]) + Y_01 = np.vstack([Y_r0, Y_r1]) + + r2 = run_r2_qlognehvi(config, X_01, Y_01, seed=seed) + X_r2 = r2.conditions.to_numpy(dtype=float) + Y_r2 = oracle_predict(oracle, config, X_r2, transform) + + X_all = np.vstack([X_01, X_r2]) + Y_all = np.vstack([Y_01, Y_r2]) + + # The model the heatmaps render: refitted on all 23 oracle-scored points. + final_model, final_warnings = fit_campaign_models(config, X_all, Y_all, seed=seed) + + return { + "radius": float(radius), + "beta": float(beta), + "slug": cell_slug(radius, beta), + "config": config, + "X": {"R0": X_r0, "R1": X_r1, "R2": X_r2, "all": X_all}, + "Y": {"R0": Y_r0, "R1": Y_r1, "R2": Y_r2, "all": Y_all}, + "U": { + "R0": utilities(Y_r0, transform), + "R1": utilities(Y_r1, transform), + "R2": utilities(Y_r2, transform), + }, + "r1": r1, + "r2": r2, + "final_model": final_model, + "final_fit_warnings": tuple(final_warnings), + "r1_fit_warnings": r1_warnings, + "r2_fit_warnings": tuple(r2.diagnostics.get("model_fit_warnings", ())), + "baseline": { + "reported": reported_baseline, + "independent": independent_baseline, + "pareto_size": int(r1.diagnostics["observed_baseline_pareto_size"]), + }, + "hv": { + "R0": hypervolume(Y_r0, transform, reference), + "R0+R1": hypervolume(Y_01, transform, reference), + "R0+R1+R2": hypervolume(Y_all, transform, reference), + }, + } + + +# --------------------------------------------------------------------------- # +# figures +# --------------------------------------------------------------------------- # + + +def fixed_slice_values(design: Design, X_r0: np.ndarray) -> np.ndarray: + """Median of the 15 R0 values per input, snapped onto the declared grid. + + Median rather than mean: a mean can land between grid values in a way that no + recipe could realise, and the campaign's own diagnostics use the median. The + snap keeps the held-fixed slice a recipe the group could actually run. + """ + fixed = np.median(np.asarray(X_r0, dtype=float), axis=0) + for index, grid in enumerate(design.var_array): + allowed = np.asarray(grid, dtype=float) + fixed[index] = allowed[np.argmin(np.abs(allowed - fixed[index]))] + return fixed + + +def surface_grid( + model: Any, + config: Mapping[str, Any], + design: Design, + transform: ObjectiveTransform, + pair: tuple[str, str], + fixed: np.ndarray, + *, + points: int, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Posterior median surface over one input pair, other inputs held at ``fixed``.""" + names = list(design.names) + xi, yi = names.index(pair[0]), names.index(pair[1]) + x_values = np.linspace(design.lowers[xi], design.uppers[xi], points) + y_values = np.linspace(design.lowers[yi], design.uppers[yi], points) + mesh_x, mesh_y = np.meshgrid(x_values, y_values) + + rows = np.repeat(fixed[None, :], mesh_x.size, axis=0) + rows[:, xi] = mesh_x.ravel() + rows[:, yi] = mesh_y.ravel() + + # normalise_inputs, not an observed-range rescale: the model was told about + # config-grid coordinates and must be asked about the same ones. + X_norm = normalise_inputs(config, rows) + model.eval() + with torch.no_grad(): + posterior = model.posterior( + torch.tensor(X_norm, dtype=torch.double), observation_noise=False + ) + mean = posterior.mean.detach().cpu().double().numpy() + + surfaces = np.empty((mesh_x.size, transform.objective_count)) + for index, spec in enumerate(transform.specs): + surfaces[:, index] = ( + np.exp(mean[:, index]) if spec.model_link == "log" else mean[:, index] + ) + return mesh_x, mesh_y, surfaces.reshape(*mesh_x.shape, transform.objective_count) + + +def _footer( + fig: plt.Figure, seed: int, first_caveat: str, extra: str | None = None +) -> None: + text = f"{first_caveat}\n{ORACLE_CAVEAT}" + if extra is not None: + text = f"{extra}\n{text}" + fig.text( + 0.008, + 0.008, + f"seed {seed} | {text}", + fontsize=6.4, + color=INK_MUTED, + va="bottom", + ha="left", + wrap=True, + ) + + +def _objective_axis_label(spec: Any) -> str: + if spec.model_link == "log": + return f"{spec.name} -- posterior median (nm)" + return f"{spec.name} -- posterior mean" + + +def plot_surface( + path: Path, + mesh_x: np.ndarray, + mesh_y: np.ndarray, + surface: np.ndarray, + pair: tuple[str, str], + spec: Any, + rounds: Mapping[str, np.ndarray], + design: Design, + fixed: np.ndarray, + *, + radius: float, + beta: float, + seed: int, + warning_banner: str | None, +) -> None: + names = list(design.names) + xi, yi = names.index(pair[0]), names.index(pair[1]) + + fig, axis = plt.subplots(figsize=(8.8, 7.4), facecolor=SURFACE) + axis.set_facecolor(SURFACE) + filled = axis.contourf(mesh_x, mesh_y, surface, levels=16, cmap=SEQ) + bar = fig.colorbar(filled, ax=axis, fraction=0.046, pad=0.03) + bar.set_label(_objective_axis_label(spec), fontsize=8.5, color=INK_MUTED) + bar.ax.tick_params(labelsize=7, colors=INK_MUTED) + + # R0's categorical colour is the same blue the magnitude ramp is built from, so + # on the dark end of the surface a plain blue marker disappears into it. A white + # stroke around a dark marker edge reads on both ends of the ramp; a single + # white edge does not, which is what the first draft of this figure showed. + halo = [path_effects.withStroke(linewidth=3.0, foreground="white")] + for round_name in ("R0", "R1", "R2"): + points = rounds[round_name] + colour, label = ROUND_STYLE[round_name] + axis.scatter( + points[:, xi], + points[:, yi], + s=70 if round_name == "R0" else 104, + c=colour, + edgecolors=INK, + linewidths=0.9, + path_effects=halo, + zorder=3 + ("R0", "R1", "R2").index(round_name), + label=f"{label} (n={len(points)})", + ) + + fixed_text = ", ".join( + f"{name}={fixed[index]:g}" + for index, name in enumerate(names) + if name not in pair + ) + axis.set_xlabel(pair[0], fontsize=9.5, color=INK_MUTED) + axis.set_ylabel(pair[1], fontsize=9.5, color=INK_MUTED) + axis.tick_params(labelsize=8, colors=INK_MUTED, length=3) + for spine in axis.spines.values(): + spine.set_color(SPINE) + axis.legend( + fontsize=8, frameon=True, facecolor="white", edgecolor=SPINE, loc="best" + ) + + fig.suptitle( + f"Final GP after R0+R1+R2 -- {spec.name} | radius {radius:g}, beta {beta:g}", + fontsize=12, color=INK, y=0.985, + ) + axis.set_title( + "other 8 inputs fixed at the median of the 15 R0 values, snapped to grid:\n" + + fixed_text, + fontsize=7.6, color=INK_MUTED, pad=8, + ) + fig.tight_layout(rect=(0, 0.085, 1, 0.96)) + _footer(fig, seed, SLICE_CAVEAT, warning_banner) + fig.savefig(path, dpi=150, facecolor=SURFACE) + plt.close(fig) + + +def plot_boxplots( + path: Path, + cell: Mapping[str, Any], + transform: ObjectiveTransform, + *, + seed: int, + warning_banner: str | None, +) -> None: + """Utility by round, three panels, with every raw point drawn over the box. + + The overlay is not decoration. R2 has n = 3: a box drawn from three numbers + reports quartiles that are essentially the three numbers, and reads as a + distribution when it is a handful of points. Annie's branch drew the points + for the same reason and the convention is kept. + """ + fig, axes = plt.subplots(1, 3, figsize=(13.6, 5.4), facecolor=SURFACE) + rng = np.random.default_rng(0) + + for index, spec in enumerate(transform.specs): + axis = axes[index] + axis.set_facecolor(SURFACE) + series = [cell["U"][name][:, index] for name in ("R0", "R1", "R2")] + boxes = axis.boxplot( + series, + tick_labels=[ + f"{name}\nn={len(series[position])}" + for position, name in enumerate(("R0", "R1", "R2")) + ], + showmeans=True, + # Every raw point is drawn below, so matplotlib's flier markers would + # draw a second, differently-styled copy of the same observation. + showfliers=False, + widths=0.55, + patch_artist=True, + ) + for patch, name in zip(boxes["boxes"], ("R0", "R1", "R2")): + patch.set_facecolor(ROUND_STYLE[name][0]) + patch.set_alpha(0.22) + patch.set_edgecolor(ROUND_STYLE[name][0]) + for key in ("whiskers", "caps", "medians"): + for artist in boxes[key]: + artist.set_color(INK_MUTED) + # the default mean marker is green, which is R2's categorical colour + for marker in boxes.get("means", ()): + marker.set_markerfacecolor(INK) + marker.set_markeredgecolor(INK) + marker.set_markersize(6) + + for position, values in enumerate(series, start=1): + colour = ROUND_STYLE[("R0", "R1", "R2")[position - 1]][0] + axis.scatter( + np.full(values.shape, position) + rng.normal(0, 0.045, values.shape), + values, + s=34, c=colour, edgecolors="white", linewidths=0.8, zorder=3, + ) + axis.set_title(spec.name, fontsize=10.5, color=INK, pad=6) + axis.set_ylabel("utility (higher is better)", fontsize=9, color=INK_MUTED) + axis.tick_params(labelsize=8, colors=INK_MUTED, length=3) + axis.grid(axis="y", color="#e8e7e2", lw=0.8) + axis.set_axisbelow(True) + for spine in axis.spines.values(): + spine.set_color(SPINE) + + fig.suptitle( + f"Objective utility by round | radius {cell['radius']:g}, " + f"beta {cell['beta']:g}", + fontsize=12.5, color=INK, y=0.98, + ) + fig.tight_layout(rect=(0, 0.12, 1, 0.94)) + _footer(fig, seed, ROUND_N_CAVEAT, warning_banner) + fig.savefig(path, dpi=150, facecolor=SURFACE) + plt.close(fig) + + +def plot_hypervolume( + path: Path, + cell: Mapping[str, Any], + reference: np.ndarray, + *, + seed: int, + warning_banner: str | None, +) -> None: + fig, axis = plt.subplots(figsize=(7.6, 5.0), facecolor=SURFACE) + axis.set_facecolor(SURFACE) + stages = ("R0", "R0+R1", "R0+R1+R2") + values = [cell["hv"][stage] for stage in stages] + axis.plot( + range(len(stages)), values, color=R1_COLOR, lw=2.0, marker="o", ms=9, + markeredgecolor="white", markeredgewidth=1.4, zorder=3, + ) + for position, value in enumerate(values): + axis.annotate( + f"{value:.4f}", (position, value), textcoords="offset points", + xytext=(0, 11), ha="center", fontsize=8.5, color=INK, + ) + axis.set_xticks(range(len(stages))) + axis.set_xticklabels([f"{s}\n(n={n})" for s, n in zip(stages, (15, 20, 23))], + fontsize=9) + axis.set_ylabel("hypervolume (utility space)", fontsize=9.5, color=INK_MUTED) + axis.tick_params(labelsize=8, colors=INK_MUTED, length=3) + axis.grid(axis="y", color="#e8e7e2", lw=0.8) + axis.set_axisbelow(True) + for spine in axis.spines.values(): + spine.set_color(SPINE) + axis.set_title( + f"Cumulative hypervolume | radius {cell['radius']:g}, " + f"beta {cell['beta']:g}\n" + f"reference {np.asarray(reference).tolist()} (campaign-fixed, utility space)", + fontsize=10.5, color=INK, pad=10, + ) + # Cumulative hypervolume rises monotonically by construction: adding points can + # only grow a Pareto front. This panel shows the size of each step, not that + # optimisation happened. + fig.tight_layout(rect=(0, 0.13, 1, 1)) + _footer(fig, seed, ROUND_N_CAVEAT, warning_banner) + fig.savefig(path, dpi=150, facecolor=SURFACE) + plt.close(fig) + + +# --------------------------------------------------------------------------- # +# manifest +# --------------------------------------------------------------------------- # + +MANIFEST_COLUMNS: tuple[str, ...] = ( + "condition_id", + "arm", + "radius", + "beta", + "min_batch_distance", + "seed", + "r1_batch_hash", + "r2_batch_hash", + "r1_min_pairwise_distance", + "r2_min_pairwise_distance", + "r1_boundary_coords_total", + "r2_boundary_coords_total", + "r1_boundary_coords_per_condition", + "r2_boundary_coords_per_condition", + "hv_r0", + "hv_r0_measured", + "hv_r0_r1", + "hv_r0_r1_r2", + "hv_gain_r1", + "hv_gain_r2", + "baseline_hv_reported_by_r1", + "baseline_hv_independent", + "baseline_hv_pareto_size", + "baseline_hv_unencoded_contrast", + "r1_fit_warnings", + "r2_fit_warnings", + "final_fit_warnings", + "mean_utility_r0", + "mean_utility_r1", + "mean_utility_r2", +) + + +def manifest_row( + cell: Mapping[str, Any], + *, + condition_id: int, + arm: str, + seed: int, + baseline_unencoded: float, + hv_r0_measured: float, +) -> dict[str, Any]: + r1_validity = cell["r1"].diagnostics["validity"] + r2_validity = cell["r2"].diagnostics["validity"] + return { + "condition_id": condition_id, + "arm": arm, + "radius": cell["radius"], + "beta": cell["beta"], + "min_batch_distance": PINNED_MIN_BATCH_DISTANCE, + "seed": seed, + "r1_batch_hash": batch_hash(cell["r1"].conditions), + "r2_batch_hash": batch_hash(cell["r2"].conditions), + "r1_min_pairwise_distance": float(r1_validity["min_pairwise_distance"]), + "r2_min_pairwise_distance": float(r2_validity["min_pairwise_distance"]), + "r1_boundary_coords_total": int(sum(r1_validity["boundary_coords_per_condition"])), + "r2_boundary_coords_total": int(sum(r2_validity["boundary_coords_per_condition"])), + "r1_boundary_coords_per_condition": json.dumps( + r1_validity["boundary_coords_per_condition"] + ), + "r2_boundary_coords_per_condition": json.dumps( + r2_validity["boundary_coords_per_condition"] + ), + "hv_r0": cell["hv"]["R0"], + # The same 15 recipes scored by the workbook rather than by the oracle. + # Both numbers belong in the manifest: every simulated round is scored + # by the oracle, so hv_r0 is the baseline the simulation actually used, + # and hv_r0_measured is what the campaign starts from. They are close + # but not equal, and a reader comparing a simulated trajectory against + # a live round needs to know which one they are holding. + "hv_r0_measured": hv_r0_measured, + "hv_r0_r1": cell["hv"]["R0+R1"], + "hv_r0_r1_r2": cell["hv"]["R0+R1+R2"], + "hv_gain_r1": cell["hv"]["R0+R1"] - cell["hv"]["R0"], + "hv_gain_r2": cell["hv"]["R0+R1+R2"] - cell["hv"]["R0+R1"], + "baseline_hv_reported_by_r1": cell["baseline"]["reported"], + "baseline_hv_independent": cell["baseline"]["independent"], + "baseline_hv_pareto_size": cell["baseline"]["pareto_size"], + "baseline_hv_unencoded_contrast": baseline_unencoded, + "r1_fit_warnings": len(cell["r1_fit_warnings"]), + "r2_fit_warnings": len(cell["r2_fit_warnings"]), + "final_fit_warnings": len(cell["final_fit_warnings"]), + "mean_utility_r0": float(cell["U"]["R0"].mean()), + "mean_utility_r1": float(cell["U"]["R1"].mean()), + "mean_utility_r2": float(cell["U"]["R2"].mean()), + } + + +def rounds_frame( + cell: Mapping[str, Any], names: Sequence[str], transform: ObjectiveTransform +) -> pd.DataFrame: + """All 23 simulated design points, one row each, labelled by round. + + 23 = 15 R0 + 5 R1 + 3 R2, distinct CONDITIONS rather than films. The campaign + runs each condition in triplicate, so the same 23 rows correspond to 39 films; + plotting films would overplot three identical markers per condition. + """ + frames = [] + for round_name in ("R0", "R1", "R2"): + frame = pd.DataFrame(cell["X"][round_name], columns=list(names)) + frame.insert(0, "round", round_name) + for index, spec in enumerate(transform.specs): + frame[f"oracle_{spec.name}"] = cell["Y"][round_name][:, index] + frame[f"utility_{spec.name}"] = cell["U"][round_name][:, index] + frames.append(frame) + return pd.concat(frames, ignore_index=True) + + +def check_expectations( + manifest: pd.DataFrame, + cells: Sequence[Mapping[str, Any]], + transform: ObjectiveTransform, + config: Mapping[str, Any] | None = None, + worklist_hash: str | None = None, +) -> list[dict[str, Any]]: + """Pre-registered expectations, stated before the run and checked after. + + Each returns HELD / FAILED / NOT APPLICABLE plus the evidence, so a reader can + disagree with the rule rather than only with the conclusion. + + **A rule with no data reports NOT APPLICABLE, never FAILED.** The two sweep + rules below ask about a radius arm and a beta arm; on a single ratified cell + those arms are empty by decision, and calling that a failure would put two + red lines under a run that did exactly what was asked. That is worse than + silence, because it looks checked. + + **The no-signal rule is read from the config, not remembered.** It used to name + uniformity, which was the only dead axis on the first campaign. On the v3 + contract optoelectronic is dead too, and the old rule -- "uniformity moves + least of the three" -- would report FAILED for the entirely correct reason + that the two dead axes move by similar small amounts. + """ + results: list[dict[str, Any]] = [] + index_by_name = {spec.name: i for i, spec in enumerate(transform.specs)} + + if config is None: + dead = {"uniformity"} + else: + dead = { + str(spec["name"]) + for spec in config["objectives"]["specs"] + if str(spec.get("signal_status", "")) not in ("learnable", "") + } + live = [name for name in index_by_name if name not in dead] + + deltas: dict[str, list[float]] = {name: [] for name in index_by_name} + for cell in cells: + for name, index in index_by_name.items(): + deltas[name].append( + float(cell["U"]["R2"][:, index].mean() - cell["U"]["R0"][:, index].mean()) + ) + medians = {name: float(np.median(values)) for name, values in deltas.items()} + evidence = ", ".join(f"{n} {v:+.4f}" for n, v in medians.items()) + + if not dead or not live: + results.append({ + "expectation": "objectives with no learnable signal do not climb", + "rule": "every no-signal axis moves less than every learnable axis", + "verdict": "NOT APPLICABLE", + "evidence": f"dead axes {sorted(dead)}, learnable axes {sorted(live)}", + }) + else: + worst_dead = max(abs(medians[n]) for n in dead) + best_live = min(abs(medians[n]) for n in live) + results.append({ + "expectation": "objectives with no learnable signal do not climb", + "rule": ( + f"every axis in {sorted(dead)} moves less, in |median delta " + f"R0->R2|, than every axis in {sorted(live)}" + ), + "verdict": "HELD" if worst_dead < best_live else "FAILED", + "evidence": evidence, + }) + + # The cross-instrument identity. The simulation proposing R1 from the same 15 + # rows at the same seed and knobs IS the live proposal; if the hashes differ, + # something has drifted between the instrument and the campaign and the + # instrument should be the one to say so. + if worklist_hash is None: + results.append({ + "expectation": "the simulated R1 reproduces the shipped worklist", + "rule": "batch_hash of the simulated R1 equals the worklist's", + "verdict": "NOT APPLICABLE", + "evidence": "no worklist for this round exists on disk to compare", + }) + else: + simulated = sorted(set(manifest["r1_batch_hash"])) + held = len(simulated) == 1 and simulated[0] == worklist_hash + results.append({ + "expectation": "the simulated R1 reproduces the shipped worklist", + "rule": "batch_hash of the simulated R1 equals the worklist's", + "verdict": "HELD" if held else "FAILED", + "evidence": f"simulated {simulated} against worklist {worklist_hash}", + }) + + for arm_name, label, rule, expected_distinct in ( + ("radius", "radius produces identical batches (the knob is inert here)", + "one distinct R1 hash and one distinct R2 hash across the radius arm", 1), + ("beta", "beta changes the R1 batch", + "more than one distinct R1 hash across the beta arm", None), + ): + keys = ("radius", "both") if arm_name == "radius" else ("beta", "both") + arm = manifest[manifest["arm"].isin(keys)] + if len(arm) < 2: + results.append({ + "expectation": label, + "rule": rule, + "verdict": "NOT APPLICABLE", + "evidence": ( + f"the {arm_name} arm holds {len(arm)} cell(s); this run is a " + "single ratified cell by decision, so there is no arm to vary" + ), + }) + continue + r1_unique = sorted(set(arm["r1_batch_hash"])) + r2_unique = sorted(set(arm["r2_batch_hash"])) + if expected_distinct is None: + held = len(r1_unique) > 1 + detail = ( + f"{len(arm)} cells -> {len(r1_unique)} distinct R1 batch(es) " + f"at betas {sorted(set(arm['beta']))}" + ) + else: + held = len(r1_unique) == 1 and len(r2_unique) == 1 + detail = ( + f"{len(arm)} cells -> {len(r1_unique)} distinct R1 batch(es), " + f"{len(r2_unique)} distinct R2 batch(es)" + ) + results.append({ + "expectation": label, + "rule": rule, + "verdict": "HELD" if held else "FAILED", + "evidence": detail, + }) + return results + + +def ofat_conditions() -> list[tuple[float, float, str]]: + """13 distinct cells: 9 radii at beta 4, 5 betas at radius 0.25, sharing one.""" + cells: list[tuple[float, float, str]] = [] + for radius in OFAT_RADII: + arm = "both" if radius == ANCHOR_RADIUS else "radius" + cells.append((radius, ANCHOR_BETA, arm)) + for beta in OFAT_BETAS: + if beta == ANCHOR_BETA: + continue # already present as the shared anchor cell + cells.append((ANCHOR_RADIUS, beta, "beta")) + return cells + + +def full_grid_conditions() -> list[tuple[float, float, str]]: + return [(r, b, "grid") for r in OFAT_RADII for b in OFAT_BETAS] + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--workbook", required=True, type=Path) + parser.add_argument( + "--config", type=Path, + default=Path("configs/campaign_d2d_perovskite_final.yaml"), + ) + parser.add_argument( + "--output-dir", type=Path, default=Path("local_outputs/round_simulations") + ) + parser.add_argument("--seed", type=int, default=None) + parser.add_argument("--slice-points", type=int, default=41) + # A ratified cell needs no sweep, and the OFAT set cannot express one: + # its betas stop at 25. This runs exactly the cell that was decided. + parser.add_argument( + "--cell", default=None, metavar="RADIUS,BETA", + help="run one arbitrary cell instead of the sweep, e.g. 0.25,4", + ) + parser.add_argument( + "--full-grid", action="store_true", + help="45-cell radius x beta cross instead of the 13-cell OFAT set.", + ) + parser.add_argument( + "--pairs", nargs="+", default=None, + help="Restrict heatmaps to these input pairs, each 'input_x,input_y'.", + ) + parser.add_argument( + "--conditions", nargs="+", default=None, + help="Restrict to these cell slugs, e.g. radius_0p25__beta_4.", + ) + parser.add_argument( + "--no-figures", action="store_true", + help="Manifest only. The whole sweep in a couple of minutes.", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + started = time.time() + + config = load_campaign_config(args.config) + seed = ( + int((config.get("reproducibility") or {}).get("seed", 0)) + if args.seed is None + else int(args.seed) + ) + design = build_design_from_config(dict(config)) + transform = build_objective_transform(config) + reference = np.asarray(config["reference_point_utility"], dtype=float) + names = list(design.names) + + print("=" * 79) + print("ROUND SIMULATION -- campaign loop against a frozen GP oracle") + print("=" * 79) + + # ------------------------------------------------------------------ read -- + contents = read_campaign_workbook(args.workbook, config) + if contents.errors: + for finding in contents.errors: + print(f" ERROR {finding}") + print("\nRefusing to fit: the objectives cannot be computed for every row.") + return 1 + X_r0 = contents.inputs.to_numpy(float) + Y_r0_measured = contents.model_values.to_numpy(float) + print(f"\n1. WORKBOOK {len(X_r0)} rows, objectives {objective_names(config)}") + print(f" errors 0, warnings {len(contents.warnings)}") + + # ---------------------------------------------------------------- oracle -- + print("\n2. ORACLE fit_campaign_models on the real rows, then frozen") + oracle, oracle_warnings = fit_campaign_models(config, X_r0, Y_r0_measured, seed=seed) + if oracle_warnings: + print("\n ABORTING -- the oracle fit raised guard warnings:") + for message in oracle_warnings: + print(f" {message}") + print( + "\n Every surface and every simulated measurement below would be built\n" + " on this fit. A collapsed oracle must not render silently, so this is\n" + " a hard stop rather than a banner on the figures." + ) + return 1 + print(" fit guard clean") + + # The unencoded contrast: what the R1 baseline WOULD be if measurement-space + # values reached the transform directly. It is a property of the observed set, + # not of any cell, so it is computed once. It is never asserted equal to + # anything -- it is the size of a mistake, kept on record. The equality that IS + # asserted, per cell, is reported == independent, inside run_cell. + def _unencoded_baseline(Y: np.ndarray) -> float: + with torch.no_grad(): + raw = transform.transform(torch.tensor(Y, dtype=torch.double)) + values = raw.detach().cpu().numpy() + if not bool((values > reference).all(axis=1).any()): + return 0.0 + return float( + compute_ref_pareto_hv(torch.tensor(values, dtype=torch.double), reference)[2] + ) + + Y_r0_oracle = oracle_predict(oracle, config, X_r0, transform) + baseline_unencoded = _unencoded_baseline(Y_r0_oracle) + hv_r0_measured = hypervolume(Y_r0_measured, transform, reference) + + print("\n R1 observed baseline hypervolume") + print(f" on the oracle-scored R0 this sweep uses : " + f"{hypervolume(Y_r0_oracle, transform, reference):.6f}" + f" (unencoded would be {baseline_unencoded:.6f})") + print(f" on the real measured R0 : " + f"{hypervolume(Y_r0_measured, transform, reference):.6f}" + f" (unencoded would be {_unencoded_baseline(Y_r0_measured):.6f})") + print(" the unencoded figures are what run_r1_ucb produced before " + "commit 4b76670") + + # ------------------------------------------------------------ conditions -- + if args.cell: + try: + radius_text, beta_text = args.cell.split(",") + cells_spec = [(float(radius_text), float(beta_text), "ratified")] + except ValueError: + print(f"\n--cell must be 'RADIUS,BETA'; got {args.cell!r}.") + return 1 + else: + cells_spec = full_grid_conditions() if args.full_grid else ofat_conditions() + if args.conditions: + wanted = set(args.conditions) + cells_spec = [c for c in cells_spec if cell_slug(c[0], c[1]) in wanted] + if not cells_spec: + print(f"\nNo cell matched --conditions {args.conditions}.") + return 1 + + if args.pairs: + pairs = [] + for item in args.pairs: + parts = [p.strip() for p in item.split(",")] + if len(parts) != 2 or any(p not in names for p in parts): + print(f"\n--pairs entry {item!r} must be 'input_x,input_y' from {names}.") + return 1 + pairs.append((parts[0], parts[1])) + else: + pairs = list(combinations(names, 2)) + + n_figures = 0 if args.no_figures else len(cells_spec) * (len(pairs) * 3 + 2) + print(f"\n3. PLAN {len(cells_spec)} cells x {len(pairs)} input pairs") + print(f" min_batch_distance pinned at {PINNED_MIN_BATCH_DISTANCE} in every cell") + print(f" figures to render: {n_figures}") + + # ------------------------------------------------------------- first cell -- + fixed = fixed_slice_values(design, X_r0) + output_root = Path(args.output_dir) + output_root.mkdir(parents=True, exist_ok=True) + + rows: list[dict[str, Any]] = [] + cells: list[dict[str, Any]] = [] + failures: list[dict[str, str]] = [] + cell_seconds: float | None = None + + for position, (radius, beta, arm) in enumerate(cells_spec, start=1): + slug = cell_slug(radius, beta) + cell_started = time.time() + print(f"\n [{position}/{len(cells_spec)}] {slug} ({arm})", flush=True) + try: + cell = run_cell( + config, oracle, X_r0, transform, reference, + radius=radius, beta=beta, seed=seed, + ) + except Exception as error: # noqa: BLE001 -- one bad cell must not cost the sweep + # A cell can legitimately fail: validate_batch refuses a batch that + # breaches the spacing floor, and an extreme radius could in principle + # leave the selector nothing to pick. Losing the other twelve cells to + # that would be the wrong trade at ~3 minutes each, so record and move + # on -- and report at the end rather than only in the scrollback. + failures.append({"slug": slug, "arm": arm, "error": f"{type(error).__name__}: {error}"}) + print(f" FAILED: {type(error).__name__}: {error}") + continue + cells.append(cell) + rows.append(manifest_row( + cell, condition_id=position, arm=arm, seed=seed, + baseline_unencoded=baseline_unencoded, + hv_r0_measured=hv_r0_measured, + )) + elapsed = time.time() - cell_started + print( + f" R1 spacing {rows[-1]['r1_min_pairwise_distance']:.3f} " + f"HV {cell['hv']['R0']:.4f} -> {cell['hv']['R0+R1']:.4f} -> " + f"{cell['hv']['R0+R1+R2']:.4f} ({elapsed:.1f}s)" + ) + if cell_seconds is None: + cell_seconds = elapsed + if not args.no_figures: + remaining = cell_seconds * (len(cells_spec) - 1) + # ~0.35 s per rendered figure, measured on this stack + estimate = (remaining + n_figures * 0.35) / 60.0 + print(f" estimated total remaining: ~{estimate:.0f} min") + if estimate > 30: + print( + " NOTE: over 30 minutes. Narrow it with --pairs / " + "--conditions, or use --no-figures for the manifest alone." + ) + + if args.no_figures: + continue + + banner = None + if cell["final_fit_warnings"]: + banner = ( + "FIT GUARD: the final GP raised " + f"{len(cell['final_fit_warnings'])} warning(s) -- this surface is " + "drawn from a fit worth distrusting." + ) + + condition_dir = output_root / "by_condition" / "qlognehvi" / slug + condition_dir.mkdir(parents=True, exist_ok=True) + plot_boxplots( + condition_dir / "round_boxplots.png", cell, transform, + seed=seed, warning_banner=banner, + ) + plot_hypervolume( + condition_dir / "hypervolume_by_round.png", cell, reference, + seed=seed, warning_banner=banner, + ) + rounds_frame(cell, names, transform).to_csv( + condition_dir / "all_rounds.csv", index=False, encoding="utf-8-sig" + ) + + for pair in pairs: + mesh_x, mesh_y, surfaces = surface_grid( + cell["final_model"], cell["config"], design, transform, pair, fixed, + points=args.slice_points, + ) + pair_dir = ( + output_root + / f"{_safe_filename(pair[0])}__{_safe_filename(pair[1])}" + / "qlognehvi" + / slug + ) + pair_dir.mkdir(parents=True, exist_ok=True) + for index, spec in enumerate(transform.specs): + plot_surface( + pair_dir / f"final_surface_{_safe_filename(spec.name)}.png", + mesh_x, mesh_y, surfaces[..., index], pair, spec, + cell["X"], design, fixed, + radius=radius, beta=beta, seed=seed, warning_banner=banner, + ) + + # -------------------------------------------------------------- manifest -- + manifest = pd.DataFrame(rows, columns=list(MANIFEST_COLUMNS)) + manifest_path = output_root / "manifest.csv" + manifest.to_csv(manifest_path, index=False, encoding="utf-8-sig") + print(f"\n4. MANIFEST {manifest_path} ({len(manifest)} rows)") + + if failures: + # Said here, not only in the scrollback: a manifest with rows missing must + # not read as a manifest of every cell that was asked for. + print(f"\n {len(failures)} of {len(cells_spec)} cell(s) FAILED and are " + "absent from the manifest:") + for failure in failures: + print(f" {failure['slug']} ({failure['arm']}): {failure['error']}") + if not rows: + print("\nNo cell completed, so there is nothing to check. Stopping.") + return 1 + + identical: dict[tuple[str, str], list[str]] = {} + for row in rows: + key = (row["r1_batch_hash"], row["r2_batch_hash"]) + identical.setdefault(key, []).append(cell_slug(row["radius"], row["beta"])) + print("\n BATCH IDENTITY -- cells that proposed the same R1 and R2 batches") + for (r1_hash, r2_hash), members in sorted(identical.items(), key=lambda kv: -len(kv[1])): + print(f" R1 {r1_hash} / R2 {r2_hash} <- {len(members)} cell(s)") + print(f" {', '.join(members)}") + + print("\n5. PRE-REGISTERED EXPECTATIONS") + # The shipped worklist, if one exists, so the identity check has something + # to compare against. Read here rather than inside check_expectations so the + # expectation stays a pure function of what it is handed. + worklist_hash = None + try: + from mobo_kit.workbook_io import candidate_workbook_path, sheet_name_for_round + from openpyxl import load_workbook as _load + + sheet_path = candidate_workbook_path(args.workbook, "R1") + if sheet_path.exists(): + sheet = _load(sheet_path, data_only=True)[sheet_name_for_round("R1")] + header = [str(c.value).strip() if c.value else "" for c in sheet[1]] + columns = [header.index(name) for name in names] + seen: list[list[float]] = [] + for row in sheet.iter_rows(min_row=2, values_only=True): + if row[0] is None: + continue + values = [float(row[c]) for c in columns] + if values not in seen: + seen.append(values) + worklist_hash = batch_hash(seen) + except Exception as exc: # noqa: BLE001 - an absent worklist is not an error + print(f" (could not read a worklist to compare: {type(exc).__name__}: {exc})") + + for check in check_expectations( + manifest, cells, transform, config=config, worklist_hash=worklist_hash + ): + print(f"\n {check['verdict']} {check['expectation']}") + print(f" rule {check['rule']}") + print(f" evidence {check['evidence']}") + + print(f"\nDone in {(time.time() - started) / 60.0:.1f} min. Outputs under {output_root}") + print("Every number above is a model prediction, not a measurement.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/plot_shap_attribution.py b/scripts/plot_shap_attribution.py new file mode 100644 index 0000000..71c90cd --- /dev/null +++ b/scripts/plot_shap_attribution.py @@ -0,0 +1,624 @@ +"""SHAP attributions for what the campaign's models actually use. + +Answers one question per figure: **which process inputs move this objective's +expected utility, and in which direction?** It explains the MODEL, which is the +only thing SHAP can explain -- see the caveats below and on every figure. + +MODEL STATES. Two, not three. + +* ``r0_only`` -- the GP fitted to the **15 real measurements**. This is the anchor: + it is the only model here trained on measured data, it does not depend on any + acquisition function, and it is the same object the round simulation uses as its + oracle. +* ``final`` -- refitted on all 23 conditions after a simulated R0 -> R1 -> R2 pass + at the default cell (radius 0.25, beta 4.0). Its R1 and R2 conditions were never + fabricated, so its extra 8 points carry oracle predictions rather than + measurements. + +The brief asked for three states, splitting ``final`` by R2 acquisition. Measured +here, **qLogNEHVI and qNEHVI propose the identical R2 batch**, so those two states +are one model and their figures would be bit-identical. The script detects that +per run rather than assuming it, prints it, records both hashes in the summary, +and stamps it on every affected figure. If they ever diverge, both sets are +produced automatically. + +WHAT SHAP DOES AND DOES NOT SHOW HERE. + +* It explains ``E[utility]`` per objective, through + ``ObjectiveTransform.expected_transform`` -- so thickness goes through the + lognormal quadrature rather than a transformed mean, and every value is in + utility units where higher is better. +* **A large attribution is not evidence of a physical effect.** For thickness and + optoelectronic the model carries a declared physics mean function, so + ``speed_1``, ``precur_conc`` and ``anneal_temp`` attributions partly restate that + declaration rather than discovering it. +* **Uniformity has no validated predictive signal** (LOO R2 -0.681, permutation + p = 0.82). Its GP still has ARD lengthscales and a posterior mean that varies, so + SHAP will report structure. That structure is fitted noise. It is shown because + hiding it would be worse, and every uniformity figure says so. + +Usage:: + + python scripts/plot_shap_attribution.py --workbook "local_inputs/Final Summary Table.xlsx" + python scripts/plot_shap_attribution.py --workbook ... --instances 200 + python scripts/plot_shap_attribution.py --workbook ... --no-figures + python scripts/plot_shap_attribution.py --workbook ... --extreme-cells +""" + +from __future__ import annotations + +import argparse +import time +import warnings +from pathlib import Path +from typing import Any, Mapping, Sequence + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 +import pandas as pd # noqa: E402 +import shap # noqa: E402 -- still used directly for summary_plot +import torch # noqa: E402 + +from mobo_kit.attribution import ( # noqa: E402 + expected_utility_fn, + shap_values_for, +) +from mobo_kit.campaign import ( # noqa: E402 + build_objective_transform, + fit_campaign_models, + load_campaign_config, + normalise_inputs, + run_r1_ucb, + run_r2_qlognehvi, +) +from mobo_kit.candidate_pool import sample_discrete_candidate_pool # noqa: E402 +from mobo_kit.constraints import constraints_from_config # noqa: E402 +from mobo_kit.design import build_design_from_config # noqa: E402 +from mobo_kit.objectives import ObjectiveTransform # noqa: E402 +from mobo_kit.research_qnehvi import run_r2_qnehvi_research # noqa: E402 +from mobo_kit.workbook_io import read_campaign_workbook # noqa: E402 + +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=FutureWarning) +warnings.filterwarnings("ignore", category=UserWarning, module="botorch") +warnings.filterwarnings("ignore", category=UserWarning, module="gpytorch") +warnings.filterwarnings("ignore", category=RuntimeWarning, module="numpy") +torch.set_num_threads(1) + +# house style, shared with plot_dtlz2_report.py and plot_round_simulation.py +INK, INK_MUTED, SURFACE = "#0b0b0b", "#52514e", "#fcfcfb" +SPINE = "#d8d7d2" + +DEFAULT_RADIUS, DEFAULT_BETA = 0.25, 4.0 +EXTREME_CELLS = ((0.05, 4.0), (0.45, 4.0), (0.25, 25.0)) + +#: The declared mean-function features, by objective. Attributions on these are +#: partly a restatement of the model's declared physics, not a discovery. +MEAN_FUNCTION_FEATURES = { + "thickness": ("speed_1", "precur_conc"), + "optoelectronic": ("anneal_temp",), +} + + +# --------------------------------------------------------------------------- # +# the function SHAP explains +# --------------------------------------------------------------------------- # + + +# --------------------------------------------------------------------------- # +# model states +# --------------------------------------------------------------------------- # + + +def oracle_predict( + model: Any, + config: Mapping[str, Any], + X_phys: np.ndarray, + transform: ObjectiveTransform, +) -> np.ndarray: + """Deterministic measurement-space prediction; thickness is the median exp(mu). + + Identical convention to ``scripts/plot_round_simulation.py``; see that file for + why the median rather than the lognormal mean. + """ + X_norm = normalise_inputs(config, np.asarray(X_phys, dtype=float)) + model.eval() + with torch.no_grad(): + mean = ( + model.posterior(torch.tensor(X_norm, dtype=torch.double), + observation_noise=False) + .mean.detach().cpu().double().numpy() + ) + out = np.empty_like(mean) + for index, spec in enumerate(transform.specs): + out[:, index] = ( + np.exp(mean[:, index]) if spec.model_link == "log" else mean[:, index] + ) + return out + + +def cell_config(base: Mapping[str, Any], *, radius: float, beta: float) -> dict: + from copy import deepcopy + + config = deepcopy(dict(base)) + penalization = config.setdefault("local_penalization", {}) + penalization["radius"] = float(radius) + penalization["min_batch_distance"] = 0.15 + config.setdefault("rounds", {}).setdefault("r1", {})["beta"] = float(beta) + return config + + +def batch_hash(conditions: pd.DataFrame) -> str: + import hashlib + + values = np.round(np.asarray(conditions, dtype=float), 12) + ordered = values[np.lexsort(values.T[::-1])] + return hashlib.sha256(ordered.tobytes()).hexdigest()[:16] + + +def build_final_state( + base_config: Mapping[str, Any], + oracle: Any, + X_r0: np.ndarray, + Y_r0_oracle: np.ndarray, + transform: ObjectiveTransform, + *, + radius: float, + beta: float, + seed: int, + acquisition: str, +) -> dict[str, Any]: + """One simulated campaign at a cell, refitted on all 23 oracle-scored points.""" + config = cell_config(base_config, radius=radius, beta=beta) + r1 = run_r1_ucb(config, X_r0, Y_r0_oracle, seed=seed) + X_r1 = r1.conditions.to_numpy(float) + Y_r1 = oracle_predict(oracle, config, X_r1, transform) + X_01, Y_01 = np.vstack([X_r0, X_r1]), np.vstack([Y_r0_oracle, Y_r1]) + + runner = run_r2_qlognehvi if acquisition == "qlognehvi" else run_r2_qnehvi_research + r2 = runner(config, X_01, Y_01, seed=seed) + X_r2 = r2.conditions.to_numpy(float) + Y_r2 = oracle_predict(oracle, config, X_r2, transform) + + X_all, Y_all = np.vstack([X_01, X_r2]), np.vstack([Y_01, Y_r2]) + model, fit_warnings = fit_campaign_models(config, X_all, Y_all, seed=seed) + return { + "model": model, + "config": config, + "X": X_all, + "fit_warnings": tuple(fit_warnings), + "r1_hash": batch_hash(r1.conditions), + "r2_hash": batch_hash(r2.conditions), + "acquisition": acquisition, + } + + +# --------------------------------------------------------------------------- # +# figures +# --------------------------------------------------------------------------- # + +ORACLE_CAVEAT = ( + "Oracle: this model's 8 non-R0 conditions carry GP predictions, not " + "measurements. It shows what the optimiser would believe, not what a film did." +) +REAL_DATA_CAVEAT = ( + "Fitted to the 15 real R0 measurements. SHAP still explains the MODEL's " + "behaviour, which is not the same as a measured effect." +) +CONSTRUCTION_CAVEAT = ( + "Construction: this objective carries a declared physics mean function on " + "{features}, so attributions there partly restate that declaration." +) +NO_SIGNAL_CAVEAT = ( + "No validated signal: uniformity does not beat the leave-one-out null " + "(LOO R2 -0.681, permutation p = 0.82). Structure below is fitted noise, " + "not physics." +) +IDENTICAL_BATCH_NOTE = ( + "qLogNEHVI and qNEHVI proposed the IDENTICAL R2 batch here, so this one " + "figure covers both acquisitions." +) + + +def _feature_labels(config: Mapping[str, Any], X: np.ndarray) -> list[str]: + """Name plus the physical range the colour scale actually spans, per feature. + + A beeswarm colours each row against **that feature's own** min-max, so one + shared colorbar cannot carry physical units for ten inputs measured in rpm, + seconds, molarity and microlitres at once. Putting each row's range in its + label states the physical scale without the colorbar claiming something false. + """ + labels = [] + for index, item in enumerate(config["inputs"]): + unit = str(item.get("unit", "")).strip() + low, high = float(np.min(X[:, index])), float(np.max(X[:, index])) + suffix = f" {unit}" if unit else "" + labels.append(f"{item['name']}\n{low:g}–{high:g}{suffix}") + return labels + + +def plot_beeswarm( + path: Path, + shap_values: np.ndarray, + instances: np.ndarray, + config: Mapping[str, Any], + objective: str, + state_label: str, + *, + seed: int, + caveats: Sequence[str], +) -> None: + plt.figure(figsize=(9.6, 6.4), facecolor=SURFACE) + shap.summary_plot( + shap_values, + instances, + feature_names=_feature_labels(config, instances), + plot_type="dot", + show=False, + color_bar=True, + sort=True, + # explicit generator: shap jitters overlapping points, and reading the + # global RNG would make a figure depend on whatever ran before it + rng=np.random.default_rng(seed), + ) + fig = plt.gcf() + fig.patch.set_facecolor(SURFACE) + axis = fig.axes[0] + axis.set_facecolor(SURFACE) + axis.set_xlabel( + "SHAP value (impact on expected utility, higher is better)", + fontsize=9.5, color=INK_MUTED, + ) + axis.tick_params(labelsize=8, colors=INK_MUTED, length=3) + for spine in axis.spines.values(): + spine.set_color(SPINE) + # objective named on the right as well as in the title, so a cropped or + # forwarded panel is still self-identifying + axis.set_ylabel(objective, fontsize=10.5, color=INK, rotation=270, labelpad=18) + axis.yaxis.set_label_position("right") + for extra in fig.axes[1:]: + extra.tick_params(labelsize=7, colors=INK_MUTED) + if extra.get_ylabel(): + extra.set_ylabel(extra.get_ylabel(), fontsize=8, color=INK_MUTED) + + fig.suptitle( + f"{objective} — SHAP attribution | {state_label}", + fontsize=12.5, color=INK, y=0.985, + ) + fig.tight_layout(rect=(0, 0.11, 1, 0.95)) + fig.text( + 0.008, 0.008, + f"seed {seed} | " + "\n".join(caveats), + fontsize=6.4, color=INK_MUTED, va="bottom", ha="left", wrap=True, + ) + fig.savefig(path, dpi=150, facecolor=SURFACE) + plt.close(fig) + + +def caveats_for(objective: str, state: str, identical: bool) -> list[str]: + """Exactly two true lines per figure, plus the identical-batch fact if it holds. + + The brief asked for one fixed footer everywhere. A slice caveat on a model + fitted to real data, or an oracle caveat on the R0-only anchor, would be false + where a reader looks for true statements, so line one states the model's + provenance and line two states the objective's own hazard. + """ + lines = [ORACLE_CAVEAT if state != "r0_only" else REAL_DATA_CAVEAT] + if objective == "uniformity": + lines.append(NO_SIGNAL_CAVEAT) + elif objective in MEAN_FUNCTION_FEATURES: + lines.append( + CONSTRUCTION_CAVEAT.format( + features=", ".join(MEAN_FUNCTION_FEATURES[objective]) + ) + ) + else: + lines.append("") + if identical and state != "r0_only": + lines.append(IDENTICAL_BATCH_NOTE) + return [line for line in lines if line] + + +# --------------------------------------------------------------------------- # +# driver +# --------------------------------------------------------------------------- # + + +#: Pre-registered before the extreme cells were run. Sweeping the acquisition +#: parameters for the SHAP work is warranted only if changing them changes what +#: the model attributes to -- either by moving a feature to the top, or by moving +#: any attribution by more than this fraction of that objective's largest one. +SHIFT_FRACTION_THRESHOLD = 0.10 + + +def extreme_cell_shifts( + default_mean_abs: Mapping[str, np.ndarray], + cell_mean_abs: Mapping[str, Mapping[str, np.ndarray]], + feature_names: Sequence[str], +) -> tuple[list[dict[str, Any]], str]: + """How much does the attribution move when the acquisition knobs move? + + The question behind this is whether the SHAP figures are a property of the + MODEL or of the SEARCH. If three very different acquisition settings put the + same features in the same order with similar magnitudes, the attributions are + telling us about the fitted physics rather than about how the batch was picked + -- and there is no reason to sweep. + """ + rows: list[dict[str, Any]] = [] + warranted = False + for slug, per_objective in cell_mean_abs.items(): + for objective, values in per_objective.items(): + reference = np.asarray(default_mean_abs[objective], dtype=float) + values = np.asarray(values, dtype=float) + scale = float(reference.max()) if reference.max() > 0 else 1.0 + deltas = np.abs(values - reference) + worst = int(np.argmax(deltas)) + top_moved = int(np.argmax(values)) != int(np.argmax(reference)) + fraction = float(deltas[worst] / scale) + if fraction > SHIFT_FRACTION_THRESHOLD or top_moved: + warranted = True + rows.append({ + "cell": slug, + "objective": objective, + "max_abs_shift": float(deltas[worst]), + "max_shift_feature": feature_names[worst], + "max_shift_fraction_of_largest": fraction, + "top_feature_changed": top_moved, + "default_top_feature": feature_names[int(np.argmax(reference))], + "cell_top_feature": feature_names[int(np.argmax(values))], + }) + verdict = ( + "SWEEP WARRANTED: an extreme cell moved the top feature or shifted an " + f"attribution by more than {SHIFT_FRACTION_THRESHOLD:.0%} of the largest." + if warranted + else "NO SWEEP NEEDED: every extreme cell kept the same top feature and " + f"moved every attribution by under {SHIFT_FRACTION_THRESHOLD:.0%} of the " + "largest, so the attributions describe the model rather than the search." + ) + return rows, verdict + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--workbook", required=True, type=Path) + parser.add_argument( + "--config", type=Path, default=Path("configs/campaign_d2d_perovskite_final.yaml") + ) + parser.add_argument("--output-dir", type=Path, default=Path("local_outputs/shap")) + parser.add_argument("--seed", type=int, default=None) + parser.add_argument( + "--instances", type=int, default=1000, + help="On-grid points to attribute. Cost is linear in this.", + ) + parser.add_argument( + "--extreme-cells", action="store_true", + help="Also measure attribution shift at radius 0.05 / 0.45 and beta 25.", + ) + parser.add_argument("--no-figures", action="store_true") + parser.add_argument( + "--objectives", nargs="+", default=None, + help="Restrict to these objective names.", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + started = time.time() + + config = load_campaign_config(args.config) + seed = ( + int((config.get("reproducibility") or {}).get("seed", 0)) + if args.seed is None + else int(args.seed) + ) + design = build_design_from_config(dict(config)) + transform = build_objective_transform(config) + names = list(transform.names) + + print("=" * 79) + print("SHAP ATTRIBUTION — what the campaign's models use") + print("=" * 79) + + contents = read_campaign_workbook(args.workbook, config) + if contents.errors: + for finding in contents.errors: + print(f" ERROR {finding}") + return 1 + X_r0 = contents.inputs.to_numpy(float) + Y_r0_measured = contents.model_values.to_numpy(float) + print(f"\n1. WORKBOOK {len(X_r0)} rows, objectives {tuple(names)}") + + print("\n2. R0-ONLY MODEL fit_campaign_models on the real measurements") + r0_model, r0_warnings = fit_campaign_models( + config, X_r0, Y_r0_measured, seed=seed + ) + if r0_warnings: + print("\n ABORTING — the anchor fit raised guard warnings:") + for message in r0_warnings: + print(f" {message}") + print("\n Every attribution below would be about this fit.") + return 1 + print(" fit guard clean") + + Y_r0_oracle = oracle_predict(r0_model, config, X_r0, transform) + + print("\n3. FINAL MODELS simulated R0 -> R1 -> R2 at " + f"radius {DEFAULT_RADIUS}, beta {DEFAULT_BETA}") + finals = {} + for acquisition in ("qlognehvi", "qnehvi"): + finals[acquisition] = build_final_state( + config, r0_model, X_r0, Y_r0_oracle, transform, + radius=DEFAULT_RADIUS, beta=DEFAULT_BETA, seed=seed, + acquisition=acquisition, + ) + built = finals[acquisition] + print(f" {acquisition:<10} R2 batch hash {built['r2_hash']}" + f" fit warnings {len(built['fit_warnings'])}") + # A collapsed final GP would attribute confidently to nothing at all, and + # the beeswarm would look no different. Say so rather than render quietly. + for message in built["fit_warnings"]: + print(f" FIT GUARD {message}") + identical = finals["qlognehvi"]["r2_hash"] == finals["qnehvi"]["r2_hash"] + print(f" IDENTICAL R2 BATCH: {identical}") + if identical: + print(" -> the two acquisitions give one model; one set of final figures.") + + states: dict[str, dict[str, Any]] = { + "r0_only": { + "model": r0_model, "config": config, "X": X_r0, + "label": "R0-only, fitted to the 15 real measurements", + } + } + if identical: + states["final"] = { + "model": finals["qlognehvi"]["model"], + "config": finals["qlognehvi"]["config"], + "X": finals["qlognehvi"]["X"], + "label": "final 23-point model (qLogNEHVI = qNEHVI)", + } + else: + for acquisition, built in finals.items(): + states[f"final_{acquisition}"] = { + "model": built["model"], "config": built["config"], + "X": built["X"], + "label": f"final 23-point model ({acquisition})", + } + + # ------------------------------------------------------------ instances -- + pool = sample_discrete_candidate_pool( + design, int(args.instances), seed=seed, + row_constraints=constraints_from_config(dict(config), design) or None, + ) + instances = np.asarray(pool.X_phys, dtype=float) + wanted = names if args.objectives is None else [ + n for n in names if n in set(args.objectives) + ] + n_runs = len(states) * len(wanted) + print(f"\n4. ATTRIBUTION {instances.shape[0]} on-grid instances x " + f"{len(wanted)} objectives x {len(states)} model states = {n_runs} runs") + + output_root = Path(args.output_dir) + output_root.mkdir(parents=True, exist_ok=True) + rows: list[dict[str, Any]] = [] + mean_abs_by_state: dict[str, dict[str, np.ndarray]] = {} + per_run_seconds: float | None = None + + for state_name, state in states.items(): + for objective in wanted: + index = names.index(objective) + run_started = time.time() + values = shap_values_for( + state["model"], state["config"], transform, index, + background=state["X"], instances=instances, seed=seed, + ) + elapsed = time.time() - run_started + mean_abs = np.abs(values).mean(axis=0) + mean_abs_by_state.setdefault(state_name, {})[objective] = mean_abs + order = np.argsort(-mean_abs) + for rank, feature_index in enumerate(order, start=1): + rows.append({ + "model_state": state_name, + "objective": objective, + "feature": design.names[feature_index], + "mean_abs_shap": float(mean_abs[feature_index]), + "rank": rank, + "mean_shap": float(values[:, feature_index].mean()), + "feature_min": float(instances[:, feature_index].min()), + "feature_max": float(instances[:, feature_index].max()), + "in_mean_function": design.names[feature_index] + in MEAN_FUNCTION_FEATURES.get(objective, ()), + "r2_acquisition": ( + "identical" if identical and state_name != "r0_only" + else state_name + ), + }) + top = design.names[order[0]] + print(f" {state_name:<12} {objective:<15} top {top:<12} " + f"mean|SHAP| {mean_abs[order[0]]:.4f} ({elapsed:.0f}s)") + + if per_run_seconds is None: + per_run_seconds = elapsed + print(f" estimated total: ~{elapsed * n_runs / 60:.0f} min") + + if not args.no_figures: + figure_dir = output_root / "figures" + figure_dir.mkdir(parents=True, exist_ok=True) + plot_beeswarm( + figure_dir / f"shap_{state_name}_{objective}.png", + values, instances, config, objective, state["label"], + seed=seed, + caveats=caveats_for(objective, state_name, identical), + ) + + # ------------------------------------------------------- extreme cells -- + shift_rows: list[dict[str, Any]] = [] + verdict = "" + if args.extreme_cells: + default_state = "final" if "final" in states else "final_qlognehvi" + print(f"\n5. EXTREME CELLS attribution shift against {default_state}") + cell_mean_abs: dict[str, dict[str, np.ndarray]] = {} + for radius, beta in EXTREME_CELLS: + slug = f"radius_{radius:g}__beta_{beta:g}".replace(".", "p") + built = build_final_state( + config, r0_model, X_r0, Y_r0_oracle, transform, + radius=radius, beta=beta, seed=seed, acquisition="qlognehvi", + ) + cell_mean_abs[slug] = {} + for objective in wanted: + index = names.index(objective) + values = shap_values_for( + built["model"], built["config"], transform, index, + background=built["X"], instances=instances, seed=seed, + ) + cell_mean_abs[slug][objective] = np.abs(values).mean(axis=0) + for rank, feature_index in enumerate( + np.argsort(-cell_mean_abs[slug][objective]), start=1 + ): + rows.append({ + "model_state": f"final_{slug}", + "objective": objective, + "feature": design.names[feature_index], + "mean_abs_shap": float( + cell_mean_abs[slug][objective][feature_index] + ), + "rank": rank, + "mean_shap": float(values[:, feature_index].mean()), + "feature_min": float(instances[:, feature_index].min()), + "feature_max": float(instances[:, feature_index].max()), + "in_mean_function": design.names[feature_index] + in MEAN_FUNCTION_FEATURES.get(objective, ()), + "r2_acquisition": "qlognehvi", + }) + print(f" {slug} done (R2 hash {built['r2_hash']})") + + shift_rows, verdict = extreme_cell_shifts( + mean_abs_by_state[default_state], cell_mean_abs, list(design.names) + ) + for row in shift_rows: + print(f" {row['cell']:<24} {row['objective']:<15} " + f"max shift {row['max_abs_shift']:.4f} " + f"({row['max_shift_fraction_of_largest']:.1%} of largest) " + f"on {row['max_shift_feature']}" + f"{' TOP MOVED' if row['top_feature_changed'] else ''}") + print(f"\n {verdict}") + pd.DataFrame(shift_rows).to_csv( + output_root / "shap_extreme_cell_shift.csv", + index=False, encoding="utf-8-sig", + ) + + summary = pd.DataFrame(rows) + summary_path = output_root / "shap_summary.csv" + summary.to_csv(summary_path, index=False, encoding="utf-8-sig") + print(f"\n6. SUMMARY {summary_path} ({len(summary)} rows)") + + print(f"\nDone in {(time.time() - started) / 60:.1f} min. Outputs under {output_root}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/pool_null_shards.py b/scripts/pool_null_shards.py new file mode 100644 index 0000000..1fa2a04 --- /dev/null +++ b/scripts/pool_null_shards.py @@ -0,0 +1,88 @@ +"""Pool permutation-null shards into one p-value with a binomial interval. + + python scripts/pool_null_shards.py --observed-rho 0.4607 --observed-r2 -0.0191 + +The binomial interval matters: at 200 shuffles a p of 0.020 is about 4 +exceedances, whose 95% interval reaches 0.05. More shuffles shrink that, and the +interval is what says whether the p-value is safe to quote. +""" + +from __future__ import annotations + +import argparse +import glob +import json +from pathlib import Path + +import numpy as np +from scipy.stats import beta + + +def clopper_pearson(successes: int, trials: int, alpha: float = 0.05): + lower = ( + 0.0 + if successes == 0 + else beta.ppf(alpha / 2, successes, trials - successes + 1) + ) + upper = ( + 1.0 + if successes == trials + else beta.ppf(1 - alpha / 2, successes + 1, trials - successes) + ) + return float(lower), float(upper) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--shards", default="local_outputs/null_shards/shard_*.json") + ap.add_argument("--observed-rho", type=float, required=True) + ap.add_argument("--observed-r2", type=float, default=None) + args = ap.parse_args() + + paths = sorted(glob.glob(args.shards)) + if not paths: + raise SystemExit(f"no shards matched {args.shards!r}") + + rho: list[float] = [] + r2: list[float] = [] + for path in paths: + payload = json.loads(Path(path).read_text(encoding="utf-8")) + rho.extend(payload["rho"]) + r2.extend(payload["r2"]) + print( + f" {Path(path).name}: n={len(payload['rho'])} " + f"mean_rho={np.mean(payload['rho']):+.4f}" + ) + + rho_a = np.array(rho) + n = len(rho_a) + print(f"\npooled shuffles: {n} from {len(paths)} shard(s)") + + for label, null, observed in ( + ("Spearman", rho_a, args.observed_rho), + ("R2", np.array(r2), args.observed_r2), + ): + if observed is None: + continue + exceed = int((null >= observed).sum()) + p = exceed / len(null) + lo, hi = clopper_pearson(exceed, len(null)) + verdict = ( + "CLEARS p<0.05" + if hi < 0.05 + else ( + "significant but interval touches 0.05" + if p < 0.05 + else "not significant" + ) + ) + print(f"\n {label}") + print(f" observed {observed:+.4f}") + print(f" null mean/sd {null.mean():+.4f} / {null.std():.4f}") + print(f" 95th percentile {np.percentile(null, 95):+.4f}") + print(f" exceedances {exceed}/{len(null)}") + print(f" p {p:.4f} 95% CI [{lo:.4f}, {hi:.4f}] {verdict}") + + +if __name__ == "__main__": + main() diff --git a/scripts/raw_component_screen.py b/scripts/raw_component_screen.py new file mode 100644 index 0000000..b4429f6 --- /dev/null +++ b/scripts/raw_component_screen.py @@ -0,0 +1,535 @@ +"""Screen candidate objectives built from RAW measurements instead of stored scores. + +WHY THIS EXISTS. Every contract so far has handed the GP a composite score -- +clamped, capped, normalised, averaged -- and every one of those composites has come +back unlearnable. The extended C1&C2 sheet showed the mechanism on the one axis +where both forms exist: leave-one-recipe-out R2 is **-0.2151** on the stored +thickness score and **+0.4082** on the same films' raw nanometres. The Gaussian +squash `EXP(-((T-650)/250)^2)` is non-monotone, so 500 nm and 800 nm map to the +same score and the GP is asked to learn a fold that is not there. + +This script asks whether the same is true of uniformity and optoelectronic: give +the model Coverage, 1-Uniformity, phase purity, Voc, photoconductance and +photosensitivity AS MEASURED, and see which of them it can predict. Composites +are then built in UTILITY space, after the GP, where a monotone squash costs +nothing. + + python scripts/raw_component_screen.py --list + python scripts/raw_component_screen.py --candidates coverage,phase_purity,log_photocond + python scripts/raw_component_screen.py --spec '[{"name":"x","expr":"np.log(g_light)"}]' + python scripts/raw_component_screen.py --candidates phase_purity --permutations 600 + +THE STATISTICS, AND THE TRAP. N = 15. + +**-0.1480 IS NOT A SIGNIFICANCE THRESHOLD, and this script used to imply it was.** +`1-(N/(N-1))^2` is the score of the leave-one-out MEAN predictor -- predict every +held-out film with the average of the other fourteen. A fitted GP does not do +that. Measured here on 2026-09-04, 300 permutations of `phase_purity` with the +campaign's own model: the fitted GP's null has median **-0.4210** and 95th +percentile **+0.2890**, and **28.7% of pure-noise shuffles score above -0.1480**. +Beating it is a one-in-four event under no signal at all. The honest +single-candidate bar is the 95th percentile of the candidate's OWN empirical +null, which `--calibrate` measures; it runs about 0.3 to 0.4 R2 units above the +number this project quoted for a year. + +A mean function LOWERS that null rather than raising it -- an OLS trend fitted on +14 rows of shuffled y is a noise fit, and extrapolating it to the held-out row +adds error. Median goes -0.4075 (no mean) -> -0.4368 (one feature) -> -0.5384 +(two). So a mean-function candidate is not flattered by its null; it was simply +being scored against a bar five times too low, like everything else. + +The bootstrap resolution sd is **+-0.236** -- wider than most effects anyone will +find here. Screening K candidates and reporting the best R2 is selection on the +outcome: at K = 30 several candidates clear any fixed bar by chance alone. So + + * every run prints how many candidates were screened, in the summary, always; + * `--permutations` runs the rank permutation test, which is the project's + adjudicator because rank is what the acquisition consumes; + * `--family-size K` Bonferroni-adjusts that p for a screen of K candidates. + Report the ADJUSTED p when the candidate was chosen by looking at this data. + +A candidate that beats the null but whose adjusted p is not significant is a +hypothesis for the next batch of films, not a finding. +""" + +from __future__ import annotations + +import argparse +import ast +import json +import sys +import warnings +from pathlib import Path +from typing import Any, Mapping, Sequence + +import numpy as np +import openpyxl +import torch +from scipy.stats import spearmanr + +from mobo_kit.campaign import load_campaign_config, normalise_inputs +from mobo_kit.loocv import RESOLUTION_SD_AT_15, null_loo_r2, resolution_sd +from mobo_kit.model_validation import DIM_SCALED_PRIOR, fit_model_variant +from mobo_kit.structured_mean import ( + MeanFeature, + StructuredMeanSpec, + build_structured_mean, +) + +warnings.filterwarnings("ignore") + +DEFAULT_WORKBOOK = "local_inputs/Final Summary Table.xlsx" +DEFAULT_CONFIG = "configs/campaign_d2d_perovskite_final.yaml" + +#: The raw measurement namespace, by workbook column. Names are what a candidate +#: expression may refer to; the letters are where they live on the R0 sheet. +#: Deliberately includes BOTH the raw and the normalised form of everything, so a +#: candidate can be written either way and the difference measured rather than +#: assumed. +COLUMNS: dict[str, str] = { + # design + "speed_1": "B", "time_1": "C", "speed_2": "D", "time_2": "E", + "precur_conc": "F", "precur_vol": "G", "anneal_temp": "H", + "anneal_time": "I", "anti_vol": "J", "anti_time": "K", + # uniformity family + "coverage": "L", + "uniformity_raw": "M", + "uniformity_clamped": "N", + "one_minus_unif": "O", + "phase_purity": "P", + # optoelectronic family + "voc_raw": "Q", + "voc_clamped": "R", + "voc_norm": "S", + "g_light": "T", + "g_dark": "U", + "g_dark_floor": "V", + "photocond_raw": "W", + "photocond": "X", + "photocond_norm": "Y", + "photosens_ratio": "Z", + "photosens_capped": "AA", + "photosens_norm": "AB", + # thickness family + "thickness_nm": "AH", + "thickness_norm": "AI", + # the stored composites, for reference only + "score_uniformity": "AJ", + "score_opto": "AK", + "score_thickness": "AL", +} + +#: The baseline screen. Every RAW component on its own, then the stored scores it +#: is being compared against, then the handful of composites that can be argued +#: for from the chemistry rather than fitted from the data. +BUILT_IN: list[dict[str, str]] = [ + # --- uniformity family, raw --- + {"name": "coverage", "expr": "coverage", "family": "uniformity"}, + {"name": "one_minus_unif", "expr": "one_minus_unif", "family": "uniformity"}, + {"name": "uniformity_raw", "expr": "uniformity_raw", "family": "uniformity"}, + {"name": "log_uniformity_raw", "expr": "np.log(uniformity_raw)", "family": "uniformity"}, + {"name": "phase_purity", "expr": "phase_purity", "family": "uniformity"}, + {"name": "logit_phase_purity", "expr": "np.log(phase_purity / (1 - phase_purity))", + "family": "uniformity"}, + # --- optoelectronic family, raw --- + {"name": "voc_raw", "expr": "voc_raw", "family": "optoelectronic"}, + {"name": "g_light", "expr": "g_light", "family": "optoelectronic"}, + {"name": "log_g_light", "expr": "np.log(g_light)", "family": "optoelectronic"}, + {"name": "photocond", "expr": "photocond", "family": "optoelectronic"}, + {"name": "log_photocond", "expr": "np.log(photocond)", "family": "optoelectronic"}, + {"name": "photosens_ratio", "expr": "photosens_ratio", "family": "optoelectronic"}, + {"name": "log_g_dark_floor", "expr": "np.log(g_dark_floor)", "family": "optoelectronic"}, + # --- thickness family, the known-good control --- + {"name": "thickness_nm", "expr": "thickness_nm", "family": "thickness"}, + {"name": "log_thickness_nm", "expr": "np.log(thickness_nm)", "family": "thickness"}, + # --- the stored composites, as the thing to beat --- + {"name": "STORED_score_uniformity", "expr": "score_uniformity", "family": "stored"}, + {"name": "STORED_score_opto", "expr": "score_opto", "family": "stored"}, + {"name": "STORED_score_thickness", "expr": "score_thickness", "family": "stored"}, +] + +#: AST nodes a candidate expression may contain. No attribute access except the +#: `np.` namespace, no calls except to numpy, no comprehensions, no names outside +#: the measurement namespace. These expressions come from the analyst (or from an +#: agent proposing candidates), not from the workbook, but a screen that silently +#: evaluates arbitrary text is a bad instrument regardless of who is typing. +_ALLOWED_NODES = ( + ast.Expression, ast.BinOp, ast.UnaryOp, ast.Constant, ast.Name, ast.Load, + ast.Call, ast.Attribute, ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, + ast.USub, ast.UAdd, ast.Mod, ast.Tuple, ast.keyword, +) +_ALLOWED_NP = { + "log", "log10", "log1p", "exp", "sqrt", "abs", "clip", "minimum", "maximum", + "power", "square", "cbrt", "sign", "arctan", "tanh", "mean", "prod", "sum", +} + + +def _check_expression(expr: str) -> None: + tree = ast.parse(expr, mode="eval") + for node in ast.walk(tree): + if not isinstance(node, _ALLOWED_NODES): + raise ValueError(f"{type(node).__name__} is not allowed in a candidate expression") + if isinstance(node, ast.Attribute): + if not (isinstance(node.value, ast.Name) and node.value.id == "np"): + raise ValueError("only the `np.` namespace may be attribute-accessed") + if node.attr not in _ALLOWED_NP: + raise ValueError(f"np.{node.attr} is not on the allowed list") + if isinstance(node, ast.Name) and node.id not in COLUMNS and node.id != "np": + raise ValueError( + f"unknown name {node.id!r}; the measurement namespace is " + + ", ".join(sorted(COLUMNS)) + ) + + +def read_measurements(workbook: Path, sheet: str, n_rows: int | None = None) -> dict[str, np.ndarray]: + """Every named column, as float, stopping at the first blank sample number.""" + ws = openpyxl.load_workbook(workbook, data_only=True)[sheet] + last = 1 + for row in range(2, ws.max_row + 1): + if ws[f"A{row}"].value is None: + break + last = row + if n_rows is not None: + last = min(last, 1 + n_rows) + out: dict[str, np.ndarray] = {} + for name, letter in COLUMNS.items(): + values = [ws[f"{letter}{row}"].value for row in range(2, last + 1)] + out[name] = np.array( + [np.nan if v is None else float(v) for v in values], dtype=float + ) + return out + + +def evaluate(expr: str, space: Mapping[str, np.ndarray]) -> np.ndarray: + _check_expression(expr) + value = eval( # noqa: S307 - namespace is whitelisted by _check_expression + compile(ast.parse(expr, mode="eval"), "", "eval"), + {"__builtins__": {}, "np": np}, + dict(space), + ) + return np.asarray(value, dtype=float) + + +def _mean_spec(item: Mapping[str, Any], response: str) -> StructuredMeanSpec | None: + """Build a candidate's structured mean, if it declares one. + + A feature may be a bare column name (identity transform) or + ``{"column": ..., "transform": "log"}`` -- the campaign config's own shape. + Without the second form this screen COULD NOT EXPRESS the mean function the + live campaign actually runs, ``log(speed_1) + log(precur_conc)`` on a log + response, so every "beats the incumbent" comparison it made was against a + different model. Found 2026-09-04 by an adversarial verifier; the incumbent + measures +0.7423, matching the config's own recorded +0.7422, against the + +0.7633 the screen had been calling it. + """ + features = item.get("mean_features") + if not features: + return None + return StructuredMeanSpec( + response=response, + features=tuple( + MeanFeature(feature) + if isinstance(feature, str) + else MeanFeature( + str(feature["column"]), str(feature.get("transform", "identity")) + ) + for feature in features + ), + ) + + +def loo_r2( + config: Mapping[str, Any], + X_phys: np.ndarray, + y: np.ndarray, + *, + seed: int = 73, + mean_spec: StructuredMeanSpec | None = None, +) -> dict[str, Any]: + """Exact leave-one-out under the campaign's own model variant. + + Refits the structured mean INSIDE every fold when one is given. Fitting it + once on everything leaks the held-out value into the mean function, which is + the single easiest way to manufacture a result on 15 rows. + """ + X_norm = normalise_inputs(config, np.asarray(X_phys, float)) + y = np.asarray(y, float) + n = len(y) + design_names = [item["name"] for item in config["inputs"]] + lowers = np.array([float(item["start"]) for item in config["inputs"]]) + uppers = np.array([float(item["stop"]) for item in config["inputs"]]) + mu = np.empty(n) + collapsed = 0 + previous = torch.get_num_threads() + torch.set_num_threads(1) + try: + for held in range(n): + keep = [i for i in range(n) if i != held] + target = y[keep] + module = None + if mean_spec is not None: + module, target = build_structured_mean( + np.asarray(X_phys, float)[keep], y[keep], mean_spec, + design_names, lowers, uppers, + ) + torch.manual_seed(seed) + try: + record = fit_model_variant( + torch.tensor(X_norm[keep], dtype=torch.double), + torch.tensor(target, dtype=torch.double).unsqueeze(-1), + sample_ids=tuple(range(len(keep))), + objective_names=("y",), + variant=DIM_SCALED_PRIOR, + seed=seed, + mean_module=module, + ) + except Exception: + collapsed += 1 + mu[held] = target.mean() + continue + gp = record.model.models[0] + gp.eval() + with torch.no_grad(): + value = float( + gp.posterior( + torch.tensor(X_norm[held: held + 1], dtype=torch.double) + ).mean.reshape(-1)[0] + ) + mu[held] = np.exp(value) if (mean_spec and mean_spec.response == "log") else value + finally: + torch.set_num_threads(previous) + if collapsed == n: + # THE FALLBACK'S OWN SCORE IS THE NUMBER THIS PROJECT USED AS ITS NULL. + # Every fold falling back to its training mean makes `mu` the + # leave-one-out mean predictor exactly, whose R2 is 1-(n/(n-1))^2 -- so a + # totally broken run would report -0.1480 and rho -1.0000 and look like an + # ordinary no-signal result. Refuse instead. Found 2026-09-04 by an + # adversarial verifier that reproduced it with a 1e-8 perturbation of the + # design matrix, which pushes `normalise_inputs` a few parts in a billion + # outside [0, 1] and makes every fit raise. + raise RuntimeError( + f"All {n} folds failed to fit, so every prediction is its fold's " + "training mean. That degenerate predictor scores exactly " + f"{1.0 - (n / (n - 1)) ** 2:+.4f} with Spearman -1.0000, which is " + "indistinguishable from an ordinary no-signal result. Check the " + "candidate for non-finite values, a constant response, or inputs " + "outside their declared grids." + ) + residual = ((y - mu) ** 2).sum() + total = ((y - y.mean()) ** 2).sum() + return { + "r2": float(1.0 - residual / total), + "spearman": float(spearmanr(y, mu).statistic), + "collapsed_folds": collapsed, + "predicted": mu.tolist(), + } + + +def permutation_p( + config, X_phys, y, observed_rho, *, permutations: int, seed: int = 73, mean_spec=None +) -> dict[str, Any]: + """Rank permutation null: shuffle y, redo the whole fold loop, count exceedances. + + Rank rather than R2 because rank is what the acquisition consumes -- it never + sees R2 -- and because a rank null is unaffected by the heavy tails that make + R2 unstable at N = 15. + """ + rng = np.random.default_rng(seed) + y = np.asarray(y, float) + null = [] + for index in range(permutations): + shuffled = rng.permutation(y) + null.append(loo_r2(config, X_phys, shuffled, seed=seed, mean_spec=mean_spec)["spearman"]) + if (index + 1) % 50 == 0: + print(f" permutation {index + 1}/{permutations}", file=sys.stderr, flush=True) + null = np.asarray(null, float) + exceed = int((null >= observed_rho).sum()) + p = (exceed + 1) / (permutations + 1) + return { + "permutations": permutations, + "null_mean": float(null.mean()), + "null_sd": float(null.std(ddof=1)), + "exceedances": exceed, + "p": float(p), + } + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--workbook", default=DEFAULT_WORKBOOK) + parser.add_argument("--config", default=DEFAULT_CONFIG) + parser.add_argument("--sheet", default="R0") + parser.add_argument("--seed", type=int, default=73) + parser.add_argument("--list", action="store_true", help="print the measurement namespace and exit") + parser.add_argument( + "--candidates", + default=None, + help="comma-separated names from the built-in screen; default is all of them", + ) + parser.add_argument( + "--spec", + default=None, + help=( + 'JSON list of {"name","expr"[,"family"][,"mean_features"]} to screen ' + "INSTEAD of the built-ins. `expr` is a numpy expression over the " + "measurement namespace (see --list). `mean_features` is a list of " + 'design column names to fit a linear mean on, e.g. ["precur_conc"].' + ), + ) + parser.add_argument("--mean-response", default="identity", choices=("identity", "log")) + parser.add_argument("--permutations", type=int, default=0) + parser.add_argument( + "--calibrate", + type=int, + default=0, + metavar="DRAWS", + help=( + "measure the EMPIRICAL null for each candidate instead of screening " + "it: permute y this many times, rerun the whole fold loop, and report " + "the null's median and 95th percentile. The 95th percentile is the " + "honest single-candidate bar. Use it before quoting any R2. " + "SLOW and single-threaded: every draw is a full fold loop, about " + "3 s per draw per candidate at N=15, so 500 draws is ~25 min. " + "Parallelise across processes if you need more." + ), + ) + parser.add_argument( + "--family-size", + type=int, + default=None, + help="K for the Bonferroni adjustment; defaults to the number screened", + ) + parser.add_argument("--out", default=None, help="write the full result table as JSON") + args = parser.parse_args(argv) + + if args.list: + print("measurement namespace (name -> R0 column):") + for name, letter in COLUMNS.items(): + print(f" {name:24} {letter}") + print("\nbuilt-in candidates:") + for item in BUILT_IN: + print(f" {item['name']:26} = {item['expr']}") + return 0 + + config = load_campaign_config(args.config) + space = read_measurements(Path(args.workbook), args.sheet) + X = np.column_stack([space[item["name"]] for item in config["inputs"]]) + n = len(X) + null = null_loo_r2(n) + + if args.spec: + candidates = json.loads(args.spec) + else: + candidates = list(BUILT_IN) + if args.candidates: + wanted = {name.strip() for name in args.candidates.split(",")} + unknown = wanted - {item["name"] for item in candidates} + if unknown: + raise SystemExit(f"unknown candidate(s): {sorted(unknown)}") + candidates = [item for item in candidates if item["name"] in wanted] + + family_size = args.family_size if args.family_size is not None else len(candidates) + + print(f"workbook {args.workbook} sheet {args.sheet} N = {n}") + print(f"null LOO R2 {null:+.4f} resolution sd +-{resolution_sd(n):.3f} " + f"(bootstrapped {RESOLUTION_SD_AT_15} at N=15)") + print(f"screening {len(candidates)} candidates Bonferroni family size K = {family_size}") + print() + header = f"{'candidate':30} {'LOO R2':>9} {'rho':>7} {'beats null':>11} expression" + print(header) + print("-" * (len(header) + 10)) + + if args.calibrate: + print( + f"CALIBRATION: {args.calibrate} permutations per candidate. The " + "theoretical -0.1480 is the score of the leave-one-out MEAN " + "predictor, which is NOT what a fitted GP does; the empirical null " + "below is." + ) + print() + header = ( + f"{'candidate':30} {'observed':>9} {'null med':>9} {'null p95':>9} " + f"{'p':>7} {'>-0.1480':>9}" + ) + print(header) + print("-" * len(header)) + rng = np.random.default_rng(args.seed) + for item in candidates: + y = evaluate(item["expr"], space) + mean_spec = _mean_spec(item, args.mean_response) + observed = loo_r2(config, X, y, seed=args.seed, mean_spec=mean_spec) + draws = np.array( + [ + loo_r2( + config, X, rng.permutation(y), seed=args.seed, + mean_spec=mean_spec, + )["r2"] + for _ in range(args.calibrate) + ] + ) + p_value = float((int((draws >= observed["r2"]).sum()) + 1) / (args.calibrate + 1)) + print( + f"{item['name']:30} {observed['r2']:>+9.4f} " + f"{np.median(draws):>+9.4f} {np.percentile(draws, 95):>+9.4f} " + f"{p_value:>7.4f} {(draws > null).mean():>8.1%}" + ) + print() + print( + "The last column is how often PURE NOISE beats -0.1480. If it is not " + "near zero, -0.1480 is not a significance threshold for this model." + ) + return 0 + + results = [] + for item in candidates: + y = evaluate(item["expr"], space) + if not np.isfinite(y).all(): + print(f"{item['name']:30} {'SKIPPED':>9} non-finite values") + continue + mean_spec = _mean_spec(item, args.mean_response) + outcome = loo_r2(config, X, y, seed=args.seed, mean_spec=mean_spec) + beats = outcome["r2"] > null + row = {**item, "n": n, "null_loo_r2": null, **outcome} + if args.permutations and beats: + print(f" permuting {item['name']} ...", file=sys.stderr, flush=True) + perm = permutation_p( + config, X, y, outcome["spearman"], + permutations=args.permutations, seed=args.seed, mean_spec=mean_spec, + ) + perm["p_bonferroni"] = float(min(1.0, perm["p"] * family_size)) + row["permutation"] = perm + results.append(row) + flag = "YES" if beats else "no" + note = " COLLAPSED" if outcome["collapsed_folds"] else "" + print( + f"{item['name']:30} {outcome['r2']:>+9.4f} {outcome['spearman']:>+7.3f} " + f"{flag:>11} {item['expr']}{note}" + ) + if "permutation" in row: + perm = row["permutation"] + print( + f"{'':30} {'permutation':>9}: p {perm['p']:.4f} " + f"Bonferroni x{family_size} = {perm['p_bonferroni']:.4f} " + f"(null rho {perm['null_mean']:+.3f} sd {perm['null_sd']:.3f})" + ) + + beat = [r for r in results if r["r2"] > null] + print() + print(f"{len(beat)} of {len(results)} screened candidates beat the null.") + if beat: + best = max(beat, key=lambda r: r["r2"]) + print(f"best: {best['name']} R2 {best['r2']:+.4f} rho {best['spearman']:+.3f}") + print( + "REMINDER: these candidates were chosen by looking at this data. At N=15 " + f"the resolution sd is +-{resolution_sd(n):.3f}, so a screen of " + f"{family_size} will produce apparent winners by chance. Quote the " + "Bonferroni-adjusted permutation p, not the R2." + ) + if args.out: + Path(args.out).write_text(json.dumps(results, indent=1), encoding="utf-8") + print(f"wrote {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/scripts.md b/scripts/scripts.md deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/thickness_null_chunk.py b/scripts/thickness_null_chunk.py new file mode 100644 index 0000000..dd4ead0 --- /dev/null +++ b/scripts/thickness_null_chunk.py @@ -0,0 +1,66 @@ +"""One shard of the thickness permutation null. + +Emits JSON so shards can be pooled into a single null distribution: + + python scripts/thickness_null_chunk.py --n 250 --seed 0 --out shard0.json + +Each shard permutes the nm measurements, redoes the full leave-one-out fit +(refitting the structured linear mean inside every fold, so the null is not +flattered), applies the utility transform, and records the resulting Spearman +and R2. +""" + +from __future__ import annotations + +import argparse +import json +import warnings +from pathlib import Path + +import numpy as np +from scipy.stats import spearmanr + +from thickness_permutation_and_mean import ( # noqa: E402 + expected_score, + load, + loo_nm, + r2, +) + +warnings.filterwarnings("ignore") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--workbook", default="local_inputs/Summary Table.xlsx") + ap.add_argument("--n", type=int, required=True) + ap.add_argument("--seed", type=int, required=True) + ap.add_argument("--out", required=True) + ap.add_argument("--structured", action="store_true", default=True) + args = ap.parse_args() + + X, Xphys, T_nm, score = load(Path(args.workbook)) + n = len(T_nm) + rng = np.random.default_rng(args.seed) + + rhos, r2s = [], [] + for _ in range(args.n): + perm = rng.permutation(n) + mu_p, var_p = loo_nm( + X, T_nm[perm], structured_mean=args.structured, Xphys=Xphys + ) + e_p = expected_score(mu_p, var_p) + rhos.append(float(spearmanr(score[perm], e_p).statistic)) + r2s.append(float(r2(score[perm], e_p))) + + Path(args.out).write_text( + json.dumps({"seed": args.seed, "n": args.n, "rho": rhos, "r2": r2s}), + encoding="utf-8", + ) + print( + f"shard seed={args.seed} n={args.n} mean_rho={np.mean(rhos):+.4f} -> {args.out}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/thickness_objective_check.py b/scripts/thickness_objective_check.py new file mode 100644 index 0000000..bf5c759 --- /dev/null +++ b/scripts/thickness_objective_check.py @@ -0,0 +1,181 @@ +"""Acceptance check for plan rev. 2 section 1.2. + +Compares two ways of getting a thickness *score* prediction out of a GP: + + A. train on the score directly (what the repo does today) + B. train on raw nanometres, then push the posterior through the 650 nm + Gaussian analytically via ObjectiveTransform.expected_transform + +Both are scored against the true score by exact leave-one-out, so the comparison +is like for like. Also reported is the naive variant of B that transforms only +the posterior mean, to show what ignoring the variance costs. + + python scripts/thickness_objective_check.py +""" + +from __future__ import annotations + +import argparse +import math +import warnings +from pathlib import Path + +import numpy as np +import torch +from openpyxl import load_workbook +from scipy.stats import spearmanr + +from mobo_kit.model_validation import ( + DIM_SCALED_PRIOR, + LEGACY_NO_PRIOR, + fit_model_variant, +) +from mobo_kit.objectives import ObjectiveSpec, ObjectiveTransform + +DESIGN = ( + ("speed_1", 1000.0, 6000.0), + ("time_1", 5.0, 50.0), + ("speed_2", 0.0, 5000.0), + ("time_2", 10.0, 60.0), + ("precur_conc", 1.0, 2.0), + ("precur_vol", 40.0, 200.0), + ("anneal_temp", 100.0, 185.0), + ("anneal_time", 10.0, 60.0), + ("anti_vol", 100.0, 200.0), + ("anti_time", 9.0, 25.0), +) +TARGET_NM = 650.0 +# workbook: =EXP(-(((X-650)/250)^2)), which is exp(-0.5*((X-650)/s)^2) with s = 250/sqrt(2) +SIGMA_NM = 250.0 / math.sqrt(2.0) + +THICKNESS_UTILITY = ObjectiveTransform( + [ + ObjectiveSpec( + "thickness", "target", "gaussian_target", target=TARGET_NM, sigma=SIGMA_NM + ) + ], + version="D2D-thickness-nm-v1", +) + + +def load(path: Path): + ws = load_workbook(path, data_only=True)["Sheet1"] + rows = [r for r in ws.iter_rows(min_row=2, values_only=True) if r[0] is not None] + lo = np.array([d[1] for d in DESIGN]) + hi = np.array([d[2] for d in DESIGN]) + X = (np.array([[r[j] for j in range(1, 11)] for r in rows], float) - lo) / (hi - lo) + T_nm = np.array([r[23] for r in rows], float) # X: Thickness (avg) + score = np.array([r[27] for r in rows], float) # AB: Thickness score + ids = tuple(int(r[0]) for r in rows) + return ids, X, T_nm, score + + +def loo_posterior(X, y, ids, variant, seed=73): + """Exact LOO posterior mean and variance for a single objective.""" + n = len(y) + mean = np.empty(n) + var = np.empty(n) + for i in range(n): + keep = [j for j in range(n) if j != i] + torch.manual_seed(seed) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + rec = fit_model_variant( + torch.tensor(X[keep], dtype=torch.double), + torch.tensor(y[keep], dtype=torch.double).unsqueeze(-1), + sample_ids=tuple(ids[j] for j in keep), + objective_names=("y",), + variant=variant, + seed=seed, + ) + rec.model.eval() + with torch.no_grad(): + post = rec.model.posterior( + torch.tensor(X[i : i + 1], dtype=torch.double) + ) + mean[i] = float(post.mean.reshape(-1)[0]) + var[i] = float(post.variance.reshape(-1)[0]) + return mean, var + + +def r2(y, p): + return 1.0 - np.sum((y - p) ** 2) / np.sum((y - y.mean()) ** 2) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--workbook", default="local_inputs/Summary Table.xlsx") + ap.add_argument( + "--variant", + default="dim_scaled_prior", + choices=["dim_scaled_prior", "legacy_matern_no_prior"], + ) + args = ap.parse_args() + + variant = ( + DIM_SCALED_PRIOR if args.variant == "dim_scaled_prior" else LEGACY_NO_PRIOR + ) + ids, X, T_nm, score = load(Path(args.workbook)) + n = len(ids) + null = 1.0 - (n / (n - 1)) ** 2 + + # sanity: the workbook score must be reproducible from raw nm + recomputed = ( + THICKNESS_UTILITY(torch.tensor(T_nm, dtype=torch.double).unsqueeze(-1)) + .numpy() + .reshape(-1) + ) + print( + f"score == exp(-((T-650)/250)^2) from raw nm ? " + f"max|diff| = {np.abs(recomputed - score).max():.2e}" + ) + print(f"model variant: {variant.name}") + print(f"N = {n}, LOO-mean null R2 = {null:+.4f}\n") + + # --- A: train on the score directly ----------------------------------- + a_mean, _ = loo_posterior(X, score, ids, variant) + + # --- B: train on nanometres, transform the posterior ------------------- + b_mu, b_var = loo_posterior(X, T_nm, ids, variant) + mu_t = torch.tensor(b_mu, dtype=torch.double).unsqueeze(-1) + var_t = torch.tensor(b_var, dtype=torch.double).unsqueeze(-1) + b_expected = THICKNESS_UTILITY.expected_transform(mu_t, var_t).numpy().reshape(-1) + b_meanonly = THICKNESS_UTILITY(mu_t).numpy().reshape(-1) + + print("=== predicting the THICKNESS SCORE, exact leave-one-out ===") + print(f"{'approach':>46} {'LOO R2':>9} {'Spearman':>10}") + for label, pred in ( + ("A train on score directly", a_mean), + ("B train on nm -> E[score] (analytic)", b_expected), + ("B' train on nm -> score(mean) only", b_meanonly), + ): + print( + f"{label:>46} {r2(score, pred):>+9.4f} " + f"{spearmanr(score, pred).statistic:>+10.4f}" + ) + print(f"{'null (LOO mean)':>46} {null:>+9.4f} {-1.0:>+10.4f}") + + print("\n=== the underlying raw-nm model ===") + print( + f" LOO R2 = {r2(T_nm, b_mu):+.4f} Spearman = " + f"{spearmanr(T_nm, b_mu).statistic:+.4f}" + ) + print( + f" posterior sd over the 15 folds: min {np.sqrt(b_var).min():.1f} nm, " + f"median {np.median(np.sqrt(b_var)):.1f} nm, max {np.sqrt(b_var).max():.1f} nm" + ) + + print("\n=== what the uncertainty penalty is doing ===") + print( + f"{'sample':>7} {'true nm':>9} {'pred nm':>9} {'sd nm':>8} " + f"{'true score':>11} {'E[score]':>10} {'score(mean)':>12}" + ) + for i, sid in enumerate(ids): + print( + f"{sid:>7} {T_nm[i]:>9.0f} {b_mu[i]:>9.0f} {math.sqrt(b_var[i]):>8.0f} " + f"{score[i]:>11.4f} {b_expected[i]:>10.4f} {b_meanonly[i]:>12.4f}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/thickness_permutation_and_mean.py b/scripts/thickness_permutation_and_mean.py new file mode 100644 index 0000000..b2fb77b --- /dev/null +++ b/scripts/thickness_permutation_and_mean.py @@ -0,0 +1,252 @@ +"""Two evaluations that gate R1 generation. + +1. Permutation test on the route-B Spearman: permute the thickness measurements, + redo the full leave-one-out fit plus analytic transform, and build the null + distribution. Turns "rank improved" into a p-value. + +2. Structured mean function. Spin-coating physics says T ~ speed^-0.5, and + log T ~ log(speed_1) + log(precur_conc) reaches LOO R2 +0.449 while the plain + 10-input GP reaches +0.183. Test whether giving the GP a linear mean on those + two log inputs closes the gap. Selection of the two inputs is from physics, + fixed before fitting; the linear coefficients are refitted inside every fold. + + python scripts/thickness_permutation_and_mean.py --permutations 200 +""" + +from __future__ import annotations + +import argparse +import math +import warnings +from pathlib import Path + +import numpy as np +import torch +from botorch.fit import fit_gpytorch_mll +from botorch.models import SingleTaskGP +from botorch.models.transforms.outcome import Standardize +from botorch.models.utils.gpytorch_modules import ( + get_covar_module_with_dim_scaled_prior, + get_gaussian_likelihood_with_lognormal_prior, +) +from gpytorch.kernels import ScaleKernel +from gpytorch.means import LinearMean +from gpytorch.mlls import ExactMarginalLogLikelihood +from openpyxl import load_workbook +from scipy.stats import spearmanr +from sklearn.linear_model import LinearRegression + +from mobo_kit.objectives import ObjectiveSpec, ObjectiveTransform + +warnings.filterwarnings("ignore") +torch.set_default_dtype(torch.double) + +DESIGN = [ + ("speed_1", 1000, 6000), + ("time_1", 5, 50), + ("speed_2", 0, 5000), + ("time_2", 10, 60), + ("precur_conc", 1, 2), + ("precur_vol", 40, 200), + ("anneal_temp", 100, 185), + ("anneal_time", 10, 60), + ("anti_vol", 100, 200), + ("anti_time", 9, 25), +] +TARGET_NM, SIGMA_NM = 650.0, 250.0 / math.sqrt(2.0) +UTILITY = ObjectiveTransform( + [ + ObjectiveSpec( + "thickness", "target", "gaussian_target", target=TARGET_NM, sigma=SIGMA_NM + ) + ], + version="D2D-thickness-nm-v1", +) +SPEED_1, PRECUR_CONC = 0, 4 + + +def load(path: Path): + ws = load_workbook(path, data_only=True)["Sheet1"] + rows = [r for r in ws.iter_rows(min_row=2, values_only=True) if r[0] is not None] + lo = np.array([d[1] for d in DESIGN], float) + hi = np.array([d[2] for d in DESIGN], float) + Xp = np.array([[r[j] for j in range(1, 11)] for r in rows], float) + return ( + (Xp - lo) / (hi - lo), + Xp, + np.array([r[23] for r in rows], float), + np.array([r[27] for r in rows], float), + ) + + +def _gp(X, y, *, mean_features=None): + """SingleTaskGP under the dim_scaled_prior contract, optional linear mean.""" + base = get_covar_module_with_dim_scaled_prior( + ard_num_dims=X.shape[1], use_rbf_kernel=False + ) + model = SingleTaskGP( + X, + y, + covar_module=ScaleKernel(base), + likelihood=get_gaussian_likelihood_with_lognormal_prior(), + outcome_transform=Standardize(m=1), + ) + if mean_features is not None: + model.mean_module = LinearMean(input_size=mean_features, bias=True) + fit_gpytorch_mll(ExactMarginalLogLikelihood(model.likelihood, model)) + return model + + +def loo_nm(X, y, *, structured_mean=False, Xphys=None, seed=73): + """LOO posterior over raw nm. With structured_mean, a physics linear trend on + log(speed_1) and log(precur_conc) is removed first and added back after.""" + n = len(y) + mu = np.empty(n) + var = np.empty(n) + for i in range(n): + keep = [j for j in range(n) if j != i] + torch.manual_seed(seed) + if structured_mean: + F = np.c_[np.log(Xphys[:, SPEED_1]), np.log(Xphys[:, PRECUR_CONC])] + lin = LinearRegression().fit(F[keep], np.log(y[keep])) + resid = np.log(y[keep]) - lin.predict(F[keep]) + m = _gp(torch.tensor(X[keep]), torch.tensor(resid).unsqueeze(-1)) + m.eval() + with torch.no_grad(): + p = m.posterior(torch.tensor(X[i : i + 1])) + r_mu = float(p.mean.reshape(-1)[0]) + r_var = float(p.variance.reshape(-1)[0]) + log_mu = float(lin.predict(F[i : i + 1])[0]) + r_mu + # lognormal moments back to nm + mu[i] = math.exp(log_mu + r_var / 2.0) + var[i] = (math.exp(r_var) - 1.0) * math.exp(2 * log_mu + r_var) + else: + m = _gp(torch.tensor(X[keep]), torch.tensor(y[keep]).unsqueeze(-1)) + m.eval() + with torch.no_grad(): + p = m.posterior(torch.tensor(X[i : i + 1])) + mu[i] = float(p.mean.reshape(-1)[0]) + var[i] = float(p.variance.reshape(-1)[0]) + return mu, var + + +def r2(y, p): + return 1.0 - np.sum((y - p) ** 2) / np.sum((y - y.mean()) ** 2) + + +def expected_score(mu, var): + return ( + UTILITY.expected_transform( + torch.tensor(mu).unsqueeze(-1), torch.tensor(var).unsqueeze(-1) + ) + .numpy() + .reshape(-1) + ) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--workbook", default="local_inputs/Summary Table.xlsx") + ap.add_argument("--permutations", type=int, default=200) + ap.add_argument("--seed", type=int, default=0) + ap.add_argument( + "--structured", action="store_true", help="permute the structured-mean pipeline" + ) + args = ap.parse_args() + + X, Xphys, T_nm, score = load(Path(args.workbook)) + n = len(T_nm) + null_r2 = 1.0 - (n / (n - 1)) ** 2 + + print("=== 2. structured mean on log(speed_1), log(precur_conc) ===") + plain_mu, plain_var = loo_nm(X, T_nm) + struct_mu, struct_var = loo_nm(X, T_nm, structured_mean=True, Xphys=Xphys) + print(f"{'raw-nm model':>34} {'LOO R2':>9} {'Spearman':>10}") + for label, mu in ( + ("plain GP, 10 inputs", plain_mu), + ("GP + physics linear mean", struct_mu), + ): + print( + f"{label:>34} {r2(T_nm, mu):>+9.4f} " + f"{spearmanr(T_nm, mu).statistic:>+10.4f}" + ) + print(f"{'2-input log-log reference':>34} {'+0.4494':>9} {'+0.7143':>10}") + + print(f"\n{'resulting score prediction':>34} {'LOO R2':>9} {'Spearman':>10}") + plain_s = expected_score(plain_mu, plain_var) + struct_s = expected_score(struct_mu, struct_var) + for label, s in ( + ("plain GP -> E[score]", plain_s), + ("structured mean -> E[score]", struct_s), + ): + print( + f"{label:>34} {r2(score, s):>+9.4f} " + f"{spearmanr(score, s).statistic:>+10.4f}" + ) + print(f"{'null':>34} {null_r2:>+9.4f} {-1.0:>+10.4f}") + + print("\n=== 3. how much of the structured-mean result rests on sample 1? ===") + print(" sample 1 is the off-grid literature control and the one") + print(" extrapolation point, so LOO metrics are sensitive to it") + keep1 = np.arange(1, n) + mu_x, var_x = loo_nm( + X[keep1], T_nm[keep1], structured_mean=True, Xphys=Xphys[keep1] + ) + s_x = expected_score(mu_x, var_x) + n_x = len(keep1) + print(f"{'':>34} {'LOO R2':>9} {'Spearman':>10}") + print( + f"{'raw nm, all 15':>34} {r2(T_nm, struct_mu):>+9.4f} " + f"{spearmanr(T_nm, struct_mu).statistic:>+10.4f}" + ) + print( + f"{'raw nm, sample 1 excluded (N=14)':>34} {r2(T_nm[keep1], mu_x):>+9.4f} " + f"{spearmanr(T_nm[keep1], mu_x).statistic:>+10.4f}" + ) + print( + f"{'score, sample 1 excluded':>34} {r2(score[keep1], s_x):>+9.4f} " + f"{spearmanr(score[keep1], s_x).statistic:>+10.4f}" + ) + print(f"{'null at N=14':>34} {1.0 - (n_x/(n_x-1))**2:>+9.4f} {-1.0:>+10.4f}") + + print(f"\n=== 1. permutation test, {args.permutations} shuffles ===") + print(" permuting the nm measurements and redoing LOO + transform") + print( + f" structured mean: {args.structured} " + "(when true the linear mean is refit inside every null fold too)" + ) + rng = np.random.default_rng(args.seed) + observed = struct_s if args.structured else plain_s + obs_rho = spearmanr(score, observed).statistic + obs_r2 = r2(score, observed) + null_rho, null_r2s = [], [] + for k in range(args.permutations): + perm = rng.permutation(n) + T_p, s_p = T_nm[perm], score[perm] + mu_p, var_p = loo_nm(X, T_p, structured_mean=args.structured, Xphys=Xphys) + e_p = expected_score(mu_p, var_p) + null_rho.append(spearmanr(s_p, e_p).statistic) + null_r2s.append(r2(s_p, e_p)) + if (k + 1) % 25 == 0: + print( + f" {k+1}/{args.permutations} done, running null mean rho = " + f"{np.mean(null_rho):+.4f}" + ) + null_rho = np.array(null_rho) + null_r2s = np.array(null_r2s) + print(f"\n observed Spearman = {obs_rho:+.4f}") + print( + f" null Spearman : mean {null_rho.mean():+.4f}, sd {null_rho.std():.4f}, " + f"95th pct {np.percentile(null_rho, 95):+.4f}" + ) + print(f" p(null >= observed) = {np.mean(null_rho >= obs_rho):.4f}") + print(f"\n observed R2 = {obs_r2:+.4f}") + print(f" null R2 : mean {null_r2s.mean():+.4f}, sd {null_r2s.std():.4f}") + print(f" p(null >= observed) = {np.mean(null_r2s >= obs_r2):.4f}") + print("\n Note the null mean for Spearman is NOT zero: the leave-one-out") + print(" shrinkage artifact drags it negative, which is exactly why a") + print(" positive observed value is meaningful.") + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_structured_means.py b/scripts/validate_structured_means.py new file mode 100644 index 0000000..64ce9ad --- /dev/null +++ b/scripts/validate_structured_means.py @@ -0,0 +1,149 @@ +"""Confirm the declarative structured means reproduce the hand-rolled results. + +Targets, exact leave-one-out, null = -0.1480: + + thickness (nm) plain GP +0.183 -> structured +0.384 + optoelectronic plain GP ? -> structured +0.244 (anneal_temp alone) + + python scripts/validate_structured_means.py +""" + +from __future__ import annotations + +import argparse +import warnings +from pathlib import Path + +import numpy as np +import torch +from openpyxl import load_workbook +from scipy.stats import spearmanr + +from mobo_kit.campaign import load_campaign_config +from mobo_kit.model_validation import DIM_SCALED_PRIOR, fit_model_variant +from mobo_kit.structured_mean import ( + MeanFeature, + StructuredMeanSpec, + apply_structured_mean, + fit_structured_mean, +) + +warnings.filterwarnings("ignore") +torch.set_num_threads(1) + +THICKNESS = StructuredMeanSpec( + response="log", + features=(MeanFeature("speed_1", "log"), MeanFeature("precur_conc", "log")), +) +OPTO = StructuredMeanSpec( + response="identity", features=(MeanFeature("anneal_temp", "identity"),) +) + + +def _gp_residual_posterior(X_norm, resid, test_norm, seed=73): + rec = fit_model_variant( + torch.tensor(X_norm, dtype=torch.double), + torch.tensor(resid, dtype=torch.double).unsqueeze(-1), + sample_ids=tuple(range(len(X_norm))), + objective_names=("residual",), + variant=DIM_SCALED_PRIOR, + seed=seed, + ) + rec.model.eval() + with torch.no_grad(): + post = rec.model.posterior(torch.tensor(test_norm, dtype=torch.double)) + return ( + float(post.mean.reshape(-1)[0]), + float(post.variance.reshape(-1)[0]), + ) + + +def loo(X_phys, X_norm, y, names, spec): + """Exact LOO. Mean coefficients refit on the 14 training rows each fold.""" + n = len(y) + mean = np.empty(n) + var = np.empty(n) + for i in range(n): + keep = [j for j in range(n) if j != i] + if spec is None: + r_mu, r_var = _gp_residual_posterior( + X_norm[keep], np.asarray(y, float)[keep], X_norm[i : i + 1] + ) + mean[i], var[i] = r_mu, r_var + else: + coef, resid = fit_structured_mean( + X_phys[keep], np.asarray(y, float)[keep], spec, names + ) + r_mu, r_var = _gp_residual_posterior(X_norm[keep], resid, X_norm[i : i + 1]) + post = apply_structured_mean( + coef, + X_phys[i : i + 1], + spec, + names, + np.array([r_mu]), + np.array([r_var]), + ) + if post.link == "log": + # report on the original scale for comparability + mean[i] = float(np.exp(post.mean[0] + post.variance[0] / 2.0)) + var[i] = float( + (np.exp(post.variance[0]) - 1.0) + * np.exp(2 * post.mean[0] + post.variance[0]) + ) + else: + mean[i], var[i] = float(post.mean[0]), float(post.variance[0]) + return mean, var + + +def r2(y, p): + y = np.asarray(y, float) + return 1.0 - np.sum((y - p) ** 2) / np.sum((y - y.mean()) ** 2) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--workbook", default="local_inputs/Summary Table.xlsx") + ap.add_argument("--config", default="configs/campaign_d2d_perovskite.yaml") + args = ap.parse_args() + + config = load_campaign_config(args.config) + names = [item["name"] for item in config["inputs"]] + lo = np.array([item["start"] for item in config["inputs"]], float) + hi = np.array([item["stop"] for item in config["inputs"]], float) + + ws = load_workbook(Path(args.workbook), data_only=True)["Sheet1"] + rows = [r for r in ws.iter_rows(min_row=2, values_only=True) if r[0] is not None] + X_phys = np.array([[float(r[j]) for j in range(1, 11)] for r in rows]) + X_norm = (X_phys - lo) / (hi - lo) + thickness_nm = np.array([float(r[23]) for r in rows]) + optoelectronic = np.array([float(r[26]) for r in rows]) + + n = len(rows) + null = 1.0 - (n / (n - 1)) ** 2 + print(f"N = {n}, null LOO R2 = {null:+.4f}\n") + print( + f"{'objective':>16} {'mean function':>34} {'LOO R2':>9} {'Spearman':>10} {'target':>9}" + ) + + cases = [ + ("thickness nm", "none (plain GP)", thickness_nm, None, "+0.183"), + ( + "thickness nm", + "log T ~ log(speed_1)+log(conc)", + thickness_nm, + THICKNESS, + "+0.384", + ), + ("optoelectronic", "none (plain GP)", optoelectronic, None, "-"), + ("optoelectronic", "linear anneal_temp", optoelectronic, OPTO, "+0.244"), + ] + for label, mean_label, y, spec, target in cases: + mu, _ = loo(X_phys, X_norm, y, names, spec) + print( + f"{label:>16} {mean_label:>34} {r2(y, mu):>+9.4f} " + f"{spearmanr(y, mu).statistic:>+10.4f} {target:>9}" + ) + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py index 6618195..9635433 100644 --- a/setup.py +++ b/setup.py @@ -1,43 +1,9 @@ -from setuptools import setup, find_packages +"""Compatibility shim for tools that still invoke ``setup.py`` directly. -# Read requirements from requirements.txt -with open("requirements.txt", "r") as f: - requirements = [line.strip() for line in f if line.strip() and not line.startswith("#")] +All package metadata and dependencies live in ``pyproject.toml``. +""" -# Read README for long description -with open("README.md", "r", encoding="utf-8") as f: - long_description = f.read() +from setuptools import setup -setup( - name="mobo-kit", - version="0.1.0", - description="Multi-objective Bayesian optimization toolkit for functional thin-film fabrication", - long_description=long_description, - long_description_content_type="text/markdown", - author="Ethan Schwartz, Daniel Abdoue, Nicky Evans, Tonio Buonassisi", - author_email="ebuddy23@uw.edu", - url="https://github.com/PV-Lab/MOBO-FOM", - packages=find_packages(where="src"), - package_dir={"": "src"}, - install_requires=requirements, - classifiers=[ - "Development Status :: 3 - Alpha", - "Intended Audience :: Science/Research", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - "Topic :: Scientific/Engineering :: Chemistry", - "Topic :: Scientific/Engineering :: Physics", - ], - python_requires=">=3.10", - keywords="bayesian-optimization, multi-objective, materials-science, thin-films, machine-learning", - project_urls={ - "Bug Reports": "https://github.com/PV-Lab/MOBO-FOM/issues", - "Source": "https://github.com/PV-Lab/MOBO-FOM", - "Documentation": "https://github.com/PV-Lab/MOBO-FOM#readme", - }, -) + +setup() diff --git a/src/__pycache__/__init__.cpython-310.pyc b/src/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 71dde1b..0000000 Binary files a/src/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/src/__pycache__/__init__.cpython-311.pyc b/src/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 7d63e47..0000000 Binary files a/src/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/src/__pycache__/acquisition.cpython-310.pyc b/src/__pycache__/acquisition.cpython-310.pyc deleted file mode 100644 index e927c13..0000000 Binary files a/src/__pycache__/acquisition.cpython-310.pyc and /dev/null differ diff --git a/src/__pycache__/constraints.cpython-310.pyc b/src/__pycache__/constraints.cpython-310.pyc deleted file mode 100644 index 8dbb414..0000000 Binary files a/src/__pycache__/constraints.cpython-310.pyc and /dev/null differ diff --git a/src/__pycache__/data.cpython-310.pyc b/src/__pycache__/data.cpython-310.pyc deleted file mode 100644 index 0669ae6..0000000 Binary files a/src/__pycache__/data.cpython-310.pyc and /dev/null differ diff --git a/src/__pycache__/design.cpython-310.pyc b/src/__pycache__/design.cpython-310.pyc deleted file mode 100644 index 9524e65..0000000 Binary files a/src/__pycache__/design.cpython-310.pyc and /dev/null differ diff --git a/src/__pycache__/design.cpython-311.pyc b/src/__pycache__/design.cpython-311.pyc deleted file mode 100644 index 98452b0..0000000 Binary files a/src/__pycache__/design.cpython-311.pyc and /dev/null differ diff --git a/src/__pycache__/lhs.cpython-310.pyc b/src/__pycache__/lhs.cpython-310.pyc deleted file mode 100644 index 037a58e..0000000 Binary files a/src/__pycache__/lhs.cpython-310.pyc and /dev/null differ diff --git a/src/__pycache__/metrics.cpython-310.pyc b/src/__pycache__/metrics.cpython-310.pyc deleted file mode 100644 index cc37c35..0000000 Binary files a/src/__pycache__/metrics.cpython-310.pyc and /dev/null differ diff --git a/src/__pycache__/model.cpython-311.pyc b/src/__pycache__/model.cpython-311.pyc deleted file mode 100644 index 4cbc300..0000000 Binary files a/src/__pycache__/model.cpython-311.pyc and /dev/null differ diff --git a/src/__pycache__/models.cpython-310.pyc b/src/__pycache__/models.cpython-310.pyc deleted file mode 100644 index ef5dc49..0000000 Binary files a/src/__pycache__/models.cpython-310.pyc and /dev/null differ diff --git a/src/__pycache__/plotting.cpython-310.pyc b/src/__pycache__/plotting.cpython-310.pyc deleted file mode 100644 index 63ab100..0000000 Binary files a/src/__pycache__/plotting.cpython-310.pyc and /dev/null differ diff --git a/src/__pycache__/run_pipeline.cpython-311.pyc b/src/__pycache__/run_pipeline.cpython-311.pyc deleted file mode 100644 index ee469f9..0000000 Binary files a/src/__pycache__/run_pipeline.cpython-311.pyc and /dev/null differ diff --git a/src/__pycache__/utils.cpython-310.pyc b/src/__pycache__/utils.cpython-310.pyc deleted file mode 100644 index 5aea0f0..0000000 Binary files a/src/__pycache__/utils.cpython-310.pyc and /dev/null differ diff --git a/src/__pycache__/utils.cpython-311.pyc b/src/__pycache__/utils.cpython-311.pyc deleted file mode 100644 index 7e9b06a..0000000 Binary files a/src/__pycache__/utils.cpython-311.pyc and /dev/null differ diff --git a/src/mobo_kit/attribution.py b/src/mobo_kit/attribution.py new file mode 100644 index 0000000..358e6e6 --- /dev/null +++ b/src/mobo_kit/attribution.py @@ -0,0 +1,121 @@ +"""Shapley attribution over the campaign's own fitted models. + +**What is explained is ``E[utility]``, not the posterior mean.** For thickness the +posterior is lognormal and the utility is a peaked Gaussian on a 650 nm target, so +transforming the mean is biased by Jensen's inequality and blind to the variance +that a target-seeking utility depends on. ``expected_transform`` is the correct +route and is what the acquisition consumes. + +**Attributions explain the MODEL, not the world.** Where a feature appears in an +objective's declared ``mean_function``, the model was *told* that relationship by +the config; SHAP recovering it is a consistency check, not a discovery. And on an +objective with no learnable signal the attributions are structure fitted to noise: +they have real magnitude and orderly ranking and mean nothing. Both campaigns have +produced exactly that picture for uniformity, and it is the most persuasive figure +in the set. + +This module owns the explainer. ``scripts/plot_shap_attribution.py`` owns the +beeswarm figures and the extreme-cell comparison, and imports from here; nothing is +duplicated between them. +""" + +from __future__ import annotations + +from typing import Any, Mapping + +import numpy as np +import torch + +from .campaign import normalise_inputs +from .objectives import ObjectiveTransform + +__all__ = [ + "EXACT_ENUMERATION_FEATURE_LIMIT", + "expected_utility_fn", + "mean_absolute_shap", + "shap_values_for", +] + +#: ``KernelExplainer`` enumerates every one of ``2**d`` coalitions at or below this +#: many features, which makes the result the EXACT Shapley decomposition rather +#: than a sampled approximation -- and therefore independent of the seed. Above +#: it, the values become a sample and the seed starts to matter. +EXACT_ENUMERATION_FEATURE_LIMIT = 10 + + +def expected_utility_fn( + model: Any, + config: Mapping[str, Any], + transform: ObjectiveTransform, + objective_index: int, + *, + batch_rows: int = 65536, +): + """``X_phys -> E[utility]`` for one objective, batched and deterministic. + + ``batch_rows`` must stay ABOVE one KernelExplainer block, which is + ``coalitions x background rows`` -- 1022 x 23 = 23506 for a 23-point model. + Splitting a block is not merely twice the work: measured on this stack, the + same 23506 rows cost 0.03 s in one call and 0.59 s in two, a 20x penalty that + turned a 37 s attribution run into 470 s. Raise it if the background grows. + """ + + def f(X_phys: np.ndarray) -> np.ndarray: + values = np.atleast_2d(np.asarray(X_phys, dtype=float)) + out = np.empty(values.shape[0], dtype=float) + model.eval() + with torch.no_grad(): + for start in range(0, values.shape[0], batch_rows): + block = values[start : start + batch_rows] + X_norm = normalise_inputs(config, block) + posterior = model.posterior( + torch.tensor(X_norm, dtype=torch.double), + observation_noise=False, + ) + utility = transform.expected_transform( + posterior.mean, posterior.variance.clamp_min(0.0) + ) + out[start : start + block.shape[0]] = ( + utility[..., objective_index].detach().cpu().double().numpy() + ) + return out + + return f + + +def shap_values_for( + model: Any, + config: Mapping[str, Any], + transform: ObjectiveTransform, + objective_index: int, + background: np.ndarray, + instances: np.ndarray, + *, + seed: int, +) -> np.ndarray: + """Exact Shapley values over the campaign inputs. + + ``KernelExplainer`` with the default sample budget enumerates **every** one of + the ``2**10 = 1024`` coalitions at this feature count, so the result is the + exact Shapley decomposition rather than a sampled approximation -- and is + therefore reproducible without depending on the seed. The seed is set anyway, + because that stops being true the moment anyone adds an eleventh input. + """ + import shap # imported here: a heavy dependency only this path needs + + np.random.seed(int(seed)) + f = expected_utility_fn(model, config, transform, objective_index) + explainer = shap.KernelExplainer(f, np.asarray(background, dtype=float)) + values = explainer.shap_values(np.asarray(instances, dtype=float), silent=True) + return np.asarray(values, dtype=float) + + +def mean_absolute_shap(values: np.ndarray) -> np.ndarray: + """Mean ``|SHAP|`` per feature -- the magnitude a bar chart ranks by. + + Separate from the signed mean on purpose. A feature with a large mean + ``|SHAP|`` and a near-zero signed mean matters in both directions: that is a + non-monotone effect, not a weak one, and averaging the signed values would + report it as nothing. + """ + return np.abs(np.asarray(values, dtype=float)).mean(axis=0) diff --git a/src/mobo_kit/batch_review.py b/src/mobo_kit/batch_review.py new file mode 100644 index 0000000..722d587 --- /dev/null +++ b/src/mobo_kit/batch_review.py @@ -0,0 +1,695 @@ +"""What a proposed batch actually says, before anyone fabricates it. + +Fifteen films is a real cost, and until 2026-07-30 nothing in this repo showed a +human what a batch meant -- only that it passed its validity checks. This module +builds one artifact: a table of the proposed conditions in physical units, what the +model predicts for each and how sure it is, how far each sits from anything already +measured, and which coordinates are pinned at a range edge. It is written as a +``Review`` sheet beside the worklist and echoed into the launcher window, so it can +be forwarded to the experimental group on its own. + +Three things it is careful about. + +**The predictions come from the same path the acquisition used.** +:func:`campaign.fit_campaign_models` reproduces the round's model bit for bit from +the same data and seed, and utility moments come from +``ucb_hvi.posterior_utility_moments`` -- the function the round itself called. A +review that computed utilities its own way could disagree with the batch it is +reviewing, which would be worse than no review. + +**It reports physical values as well as utilities.** A utility of 0.87 means +nothing to the person running the coater; "predicted 612 nm, 68% interval +480-780" does. For a log-link objective the decoded value is the posterior +*median*, because ``exp`` of a mean of logs is not a mean. + +**Probes are declared in config, not hardcoded here.** A probe asks a +counterfactual: hold everything else, force one input to a value worth +interrogating, and report what the model thinks there. What is worth +interrogating is campaign knowledge, so it lives in the campaign YAML under +``review.probes`` -- as do the standing notes under ``review.notes``. + +The artifact ends where it should: nothing here is approved. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Mapping, Sequence + +import numpy as np +import pandas as pd +import torch + +from .campaign import ( + build_objective_transform, + fit_campaign_models, + normalise_inputs, + objective_names, +) +from .design import build_design_from_config +from .scores import ScoreFinding, ScoreSeverity +from .ucb_hvi import posterior_utility_moments + +__all__ = [ + "BatchReview", + "NOT_APPROVED", + "ProbeSpec", + "SD_MATERIALITY_RATIO", + "build_batch_review", + "classify_probe_objective", + "probe_specs_from_config", + "review_notes_from_config", + "write_review_sheet", +] + +#: How much larger a probed region's posterior sd must be before it counts as +#: "the model finds this region uncertain" rather than "about the same". +#: +#: A bare ``>`` comparison is useless here: on the campaign's R0 fit the probed +#: sd came out 1-8% above the selected batch's on all three objectives, which a +#: strict inequality reads as "more uncertain" even while predicted thickness +#: utility falls from 0.79 to 0.22. A few percent of sd is not a reason to skip a +#: region; a 3.5x drop in predicted utility is. The ratio is printed either way, so +#: a reader who prefers a different line can draw it. +SD_MATERIALITY_RATIO = 1.25 + +def classify_probe_objective( + twin_utility: float, batch_utility: float, sd_ratio: float +) -> str | None: + """How to read one objective at a probed value. + + ``"known_and_bad"`` -- scores worse with no materially greater uncertainty. + Under UCB that is the interesting case: uncertainty is what UCB pays for, so a + region skipped despite equal uncertainty is being skipped on its predicted + value, which means the model believes it knows. + + ``"uncertain_tradeoff"`` -- scores worse but genuinely more uncertain, so the + skip is a trade-off against the other objectives. The benign reading. + + ``None`` -- does not score worse, so the model has no objection to the region + and its absence is about batch spacing, not merit. + """ + if not (twin_utility < batch_utility): + return None + return ( + "uncertain_tradeoff" if sd_ratio > SD_MATERIALITY_RATIO else "known_and_bad" + ) + + +NOT_APPROVED = ( + "Nothing here is approved. These conditions were proposed by an optimiser and " + "have not been reviewed by anyone. Read them, decide, and record the decision " + "outside this file." +) + + +# --------------------------------------------------------------------------- # +# configuration +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class ProbeSpec: + """A counterfactual to report beside the batch. + + ``column`` forced to ``value``, everything else held at each proposed + candidate's own coordinates. ``note`` is the campaign's reason for asking. + """ + + name: str + column: str + value: float + note: str = "" + + def __post_init__(self) -> None: + if not isinstance(self.name, str) or not self.name.strip(): + raise ValueError("A probe needs a non-empty name.") + if not isinstance(self.column, str) or not self.column.strip(): + raise ValueError(f"Probe {self.name!r} needs a column.") + object.__setattr__(self, "name", self.name.strip()) + object.__setattr__(self, "column", self.column.strip()) + value = float(self.value) + if not np.isfinite(value): + raise ValueError(f"Probe {self.name!r} needs a finite value.") + object.__setattr__(self, "value", value) + + +def probe_specs_from_config(config: Mapping[str, Any]) -> tuple[ProbeSpec, ...]: + """Read ``review.probes``; absent means no probes, which is fine.""" + review = config.get("review") or {} + if not isinstance(review, Mapping): + raise ValueError("config['review'] must be a mapping.") + raw = review.get("probes") or () + if isinstance(raw, Mapping): + raw = [raw] + specs = [] + for entry in raw: + if not isinstance(entry, Mapping): + raise ValueError("Each review probe must be a mapping.") + specs.append( + ProbeSpec( + name=str(entry.get("name", entry.get("column", "probe"))), + column=str(entry["column"]), + value=float(entry["value"]), + note=str(entry.get("note", "")).strip(), + ) + ) + return tuple(specs) + + +def review_notes_from_config(config: Mapping[str, Any]) -> tuple[str, ...]: + """Standing notes to print with every review of this campaign.""" + review = config.get("review") or {} + raw = review.get("notes") or () + if isinstance(raw, str): + raw = [raw] + return tuple(str(note).strip() for note in raw if str(note).strip()) + + +# --------------------------------------------------------------------------- # +# the review +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class BatchReview: + """Everything a human needs to judge one proposed batch.""" + + round_name: str + candidates: pd.DataFrame + """One row per condition: inputs, predicted utility and sd per objective, + decoded physical prediction, distance to nearest observed point, boundary + count and which coordinates are pinned.""" + probes: pd.DataFrame + """Counterfactual rows, one block per declared probe. Empty if none.""" + probe_verdicts: tuple[str, ...] = () + notes: tuple[str, ...] = () + findings: tuple[ScoreFinding, ...] = () + model_warnings: tuple[str, ...] = () + """Fits that succeeded but deserve distrust. Printed before anything else, + because they change how every number below should be read.""" + context: dict[str, Any] = field(default_factory=dict) + + def to_text(self) -> str: + """The whole artifact as text, for the launcher pane and the console.""" + width = 78 + lines: list[str] = [ + f"BATCH REVIEW - {self.round_name}", + "=" * width, + "", + ] + for key, value in self.context.items(): + lines.append(f"{key:<22} {value}") + if self.model_warnings: + # first, not last: these change how every number below reads + lines += ["", "!! READ THIS BEFORE THE NUMBERS", "-" * width] + for warning in self.model_warnings: + lines += _wrap(warning, width) + [""] + lines += ["", "PROPOSED CONDITIONS", "-" * width] + lines.append( + self.candidates.to_string( + index=False, float_format=lambda value: f"{value:g}" + ) + ) + if not self.probes.empty: + lines += ["", "PROBES", "-" * width] + lines.append( + self.probes.to_string( + index=False, float_format=lambda value: f"{value:g}" + ) + ) + if self.probe_verdicts: + lines += [""] + for verdict in self.probe_verdicts: + lines += _wrap(verdict, width) + [""] + if self.notes: + lines += ["NOTES", "-" * width] + for note in self.notes: + lines += _wrap(note, width) + [""] + if self.findings: + lines += ["CARRIED FROM THE MEASURED DATA", "-" * width] + for finding in _ordered(self.findings): + lines += _wrap(str(finding), width, hang=2) + lines += [""] + lines += ["=" * width] + _wrap(NOT_APPROVED, width) + return "\n".join(lines) + + +def _wrap(text: str, width: int, *, hang: int = 0) -> list[str]: + import textwrap + + out: list[str] = [] + for paragraph in str(text).split("\n"): + wrapped = textwrap.wrap(paragraph.strip(), width=width) or [""] + out.extend(wrapped[:1] + [" " * hang + line for line in wrapped[1:]]) + return out + + +def _ordered(findings: Sequence[ScoreFinding]) -> list[ScoreFinding]: + rank = {ScoreSeverity.ERROR: 0, ScoreSeverity.WARNING: 1, ScoreSeverity.NOTE: 2} + return sorted(findings, key=lambda f: (rank[f.severity], f.row_position)) + + +def _utility_moments( + config: Mapping[str, Any], + model: Any, + X_phys: np.ndarray, + *, + round_name: str, + seed: int, +) -> tuple[np.ndarray, np.ndarray]: + """Utility mean and sd through the acquisition's own posterior-sample path.""" + transform = build_objective_transform(config) + settings = (config.get("rounds") or {}).get(round_name.lower()) or {} + samples = int(settings.get("posterior_samples", settings.get("mc_samples", 256))) + X_norm = torch.tensor(normalise_inputs(config, X_phys), dtype=torch.double) + moments = posterior_utility_moments( + model, X_norm, transform, mc_samples=samples, seed=seed + ) + return moments.utility_mean, moments.utility_std + + +def _physical_predictions( + config: Mapping[str, Any], model: Any, X_phys: np.ndarray +) -> dict[str, np.ndarray]: + """Decoded model output per objective, in the measurement's own units. + + For a log-link objective this is ``exp(mu)``: the posterior median, not the + mean. Labelling it a median is the honest option -- the mean of a lognormal + is ``exp(mu + v/2)``, and quietly reporting one as the other is the kind of + small lie that gets quoted back later. + """ + transform = build_objective_transform(config) + X_norm = torch.tensor(normalise_inputs(config, X_phys), dtype=torch.double) + model.eval() + with torch.no_grad(): + posterior = model.posterior(X_norm) + mean = posterior.mean.detach().cpu().numpy() + sd = posterior.variance.clamp_min(0.0).sqrt().detach().cpu().numpy() + out: dict[str, np.ndarray] = {} + for index, spec in enumerate(transform.specs): + mu, sigma = mean[:, index], sd[:, index] + if spec.model_link == "log": + out[spec.name] = np.column_stack( + [np.exp(mu), np.exp(mu - sigma), np.exp(mu + sigma)] + ) + else: + out[spec.name] = np.column_stack([mu, mu - sigma, mu + sigma]) + return out + + +def build_batch_review( + config: Mapping[str, Any], + observed_X_phys: np.ndarray, + observed_Y_raw: np.ndarray, + conditions: pd.DataFrame, + *, + round_name: str, + seed: int | None = None, + findings: Sequence[ScoreFinding] = (), + context: Mapping[str, Any] | None = None, +) -> BatchReview: + """Assemble the review of ``conditions`` against the model that proposed them.""" + design = build_design_from_config(dict(config)) + input_names = list(design.names) + names = list(objective_names(config)) + resolved_seed = ( + int(config.get("reproducibility", {}).get("seed", 0)) if seed is None else seed + ) + + observed = np.asarray(observed_X_phys, dtype=float) + proposed = conditions[input_names].to_numpy(dtype=float) + + # validate the probes BEFORE fitting: a typo in a config column name should + # cost nothing, not three GP fits + probes = probe_specs_from_config(config) + for probe in probes: + if probe.column not in input_names: + raise ValueError( + f"Probe {probe.name!r} names {probe.column!r}, which is not a " + f"declared input. Declared inputs: {input_names}." + ) + + model, model_warnings = fit_campaign_models( + config, observed, observed_Y_raw, seed=resolved_seed + ) + + utility_mean, utility_sd = _utility_moments( + config, model, proposed, round_name=round_name, seed=resolved_seed + ) + physical = _physical_predictions(config, model, proposed) + + observed_norm = normalise_inputs(config, observed) + proposed_norm = normalise_inputs(config, proposed) + gaps = np.linalg.norm( + proposed_norm[:, None, :] - observed_norm[None, :, :], axis=-1 + ) + nearest = gaps.min(axis=1) + nearest_index = gaps.argmin(axis=1) + + at_lower = np.isclose(proposed_norm, 0.0, atol=1e-9) + at_upper = np.isclose(proposed_norm, 1.0, atol=1e-9) + pinned = at_lower | at_upper + + rows: dict[str, Any] = { + "candidate": [f"{round_name.upper()}_C{i:02d}" for i in range(1, len(conditions) + 1)] + } + for column in input_names: + rows[column] = conditions[column].to_numpy(dtype=float) + for index, name in enumerate(names): + rows[f"{name}_utility"] = utility_mean[:, index] + rows[f"{name}_sd"] = utility_sd[:, index] + # numeric, not a formatted range: this lands in a spreadsheet, where a + # string reads as text and cannot be sorted, plotted or compared + rows[f"{name}_predicted"] = physical[name][:, 0] + rows[f"{name}_lo68"] = physical[name][:, 1] + rows[f"{name}_hi68"] = physical[name][:, 2] + rows["distance_to_nearest"] = nearest + rows["nearest_observed_row"] = nearest_index + 1 + rows["n_at_range_edge"] = pinned.sum(axis=1) + rows["which_at_range_edge"] = [ + ", ".join( + f"{input_names[j]}={'min' if at_lower[i, j] else 'max'}" + for j in range(len(input_names)) + if pinned[i, j] + ) + or "-" + for i in range(len(conditions)) + ] + candidates = pd.DataFrame(rows) + + probe_frames: list[pd.DataFrame] = [] + verdicts: list[str] = [] + for probe in probes: + frame, verdict = _run_probe( + config, + model, + probe, + proposed=proposed, + observed=observed, + observed_Y_raw=np.asarray(observed_Y_raw, dtype=float), + names=names, + input_names=input_names, + batch_sd=utility_sd, + batch_utility=utility_mean, + round_name=round_name, + seed=resolved_seed, + ) + probe_frames.append(frame) + verdicts.append(verdict) + + return BatchReview( + round_name=round_name.upper(), + candidates=candidates, + probes=( + pd.concat(probe_frames, ignore_index=True) if probe_frames else pd.DataFrame() + ), + probe_verdicts=tuple(verdicts), + notes=review_notes_from_config(config), + findings=tuple(findings), + model_warnings=tuple(model_warnings), + context=dict(context or {}), + ) + + +def _run_probe( + config: Mapping[str, Any], + model: Any, + probe: ProbeSpec, + *, + proposed: np.ndarray, + observed: np.ndarray, + observed_Y_raw: np.ndarray, + names: list[str], + input_names: list[str], + batch_sd: np.ndarray, + batch_utility: np.ndarray, + round_name: str, + seed: int, +) -> tuple[pd.DataFrame, str]: + """Evaluate one counterfactual and say what its numbers mean. + + The comparison that matters is the *sd*, not the mean. UCB rewards + uncertainty, so a region the batch avoids while the model still calls it + uncertain is simply losing a trade-off. A region the batch avoids while the + model calls it *certain* is a different thing: the model has resolved it, and + if the data there is two observations that contradict each other, what it has + resolved is an average rather than a fact. + """ + if probe.column not in input_names: + raise ValueError( + f"Probe {probe.name!r} names {probe.column!r}, which is not a declared " + f"input. Declared inputs: {input_names}." + ) + position = input_names.index(probe.column) + + twins = proposed.copy() + twins[:, position] = probe.value + twin_mean, twin_sd = _utility_moments( + config, model, twins, round_name=round_name, seed=seed + ) + + here = np.isclose(observed[:, position], probe.value, rtol=0.0, atol=1e-9) + rows: list[dict[str, Any]] = [] + for i in range(len(twins)): + row: dict[str, Any] = { + "probe": probe.name, + "kind": f"{round_name.upper()}_C{i + 1:02d} moved to {probe.column}={probe.value:g}", + } + for index, name in enumerate(names): + row[f"{name}_utility"] = twin_mean[i, index] + row[f"{name}_sd"] = twin_sd[i, index] + row[f"{name}_utility_selected"] = batch_utility[i, index] + row[f"{name}_sd_selected"] = batch_sd[i, index] + rows.append(row) + + if here.any(): + observed_mean, observed_sd = _utility_moments( + config, model, observed[here], round_name=round_name, seed=seed + ) + observed_indices = np.flatnonzero(here) + for slot, original_row in enumerate(observed_indices): + row = { + "probe": probe.name, + "kind": f"observed row {original_row + 1} (already at {probe.column}={probe.value:g})", + } + for index, name in enumerate(names): + row[f"{name}_utility"] = observed_mean[slot, index] + row[f"{name}_sd"] = observed_sd[slot, index] + row[f"{name}_utility_selected"] = np.nan + row[f"{name}_sd_selected"] = np.nan + row["measured"] = ", ".join( + f"{name}={observed_Y_raw[original_row, index]:g}" + for index, name in enumerate(names) + ) + rows.append(row) + + frame = pd.DataFrame(rows) + observed_count = int(here.sum()) + + # Per objective, not pooled: the three utilities have sd on different scales + # here (optoelectronic near 0.06 against thickness near 0.23), so a median over + # the whole matrix can hide an objective whose uncertainty genuinely rises. + per_objective: list[str] = [] + quieter: list[str] = [] + noisier: list[str] = [] + for index, name in enumerate(names): + twin_u = float(np.median(twin_mean[:, index])) + base_u = float(np.median(batch_utility[:, index])) + twin_s = float(np.median(twin_sd[:, index])) + base_s = float(np.median(batch_sd[:, index])) + ratio = twin_s / base_s if base_s > 0 else float("inf") + per_objective.append( + f"{name}: utility {twin_u:.3f} vs {base_u:.3f} " + f"({twin_u - base_u:+.3f}), sd {twin_s:.3f} vs {base_s:.3f} " + f"(x{ratio:.2f})" + ) + verdict_kind = classify_probe_objective(twin_u, base_u, ratio) + if verdict_kind == "known_and_bad": + quieter.append(name) + elif verdict_kind == "uncertain_tradeoff": + noisier.append(name) + + verdict = [ + f"PROBE '{probe.name}' ({probe.column} = {probe.value:g}), probed value " + f"against the selected batch, medians -- {'; '.join(per_objective)}. " + f"{observed_count} observation(s) already sit there. An sd ratio above " + f"{SD_MATERIALITY_RATIO:g}x counts as materially more uncertain; anything " + "below that is the same uncertainty at a worse predicted value." + ] + + if quieter: + verdict.append( + f"For {', '.join(quieter)} the probed region scores WORSE with no " + "materially greater uncertainty than the batch that was selected. Since " + "UCB rewards uncertainty, that region is not being skipped because it " + "looks unexplored -- it is being skipped because it looks KNOWN AND BAD." + ) + if noisier: + verdict.append( + f"For {', '.join(noisier)} the region scores worse and does read as " + "materially more uncertain, so there its absence is a trade-off against " + "the other objectives rather than absorbed confidence." + ) + if not quieter and not noisier: + verdict.append( + "The probed region does not score worse than the selected batch on any " + "objective, so its absence from the batch is about the batch-spacing " + "penalty rather than about the model's opinion of the region." + ) + + trend_driven = _objectives_with_mean_feature(config, probe.column) + if trend_driven and quieter: + verdict.append( + f"Where that confidence comes from matters: {', '.join(trend_driven)} " + f"carries {probe.column} in its mean function, so the prediction here is " + "a fitted global trend evaluated at the edge of its range, not a local " + "average of the nearby observations. The trend can be confident at an " + "edge that holds almost no data, and it will be confidently wrong if the " + "few points there are unreliable. That makes this a question about the " + "measurements at the edge, not a settled model conclusion." + ) + if probe.note: + verdict.append(probe.note) + return frame, " ".join(verdict) + + +def _objectives_with_mean_feature( + config: Mapping[str, Any], column: str +) -> tuple[str, ...]: + """Objectives whose structured mean uses ``column`` as a feature. + + A probe on such a column is asking the fitted trend to extrapolate, which is a + different kind of claim from a GP interpolating between nearby points -- and + worth naming, because a monotone trend is confident at a range edge by + construction. + """ + from .structured_mean import mean_spec_from_config + + names: list[str] = [] + for entry in config["objectives"]["specs"]: + spec = mean_spec_from_config(entry) + if spec is not None and any(f.column == column for f in spec.features): + names.append(str(entry["name"])) + return tuple(names) + + +# --------------------------------------------------------------------------- # +# writing it beside the worklist +# --------------------------------------------------------------------------- # + + +def write_review_sheet( + candidate_workbook: str | Path, review: BatchReview, *, sheet_name: str = "Review" +) -> Path: + """Add the review to the candidate workbook this round just wrote. + + Safe to open for writing, unlike the source workbook: this file was created by + :func:`workbook_io.write_candidate_sheet` moments ago and contains no formulas, + so openpyxl has no cached values to discard. Never point this at + ``Summary Table.xlsx``. + """ + from openpyxl import load_workbook + from openpyxl.styles import Alignment, Font + from openpyxl.utils import get_column_letter + + path = Path(candidate_workbook) + workbook = load_workbook(path) + if sheet_name in workbook.sheetnames: + del workbook[sheet_name] + sheet = workbook.create_sheet(sheet_name) + + bold = Font(bold=True) + row = 1 + + def heading(text: str) -> None: + nonlocal row + sheet.cell(row=row, column=1, value=text).font = bold + row += 1 + + def blank() -> None: + nonlocal row + row += 1 + + def paragraph(text: str) -> None: + nonlocal row + cell = sheet.cell(row=row, column=1, value=text) + cell.alignment = Alignment(wrap_text=True, vertical="top") + sheet.row_dimensions[row].height = 14 * max(1, len(text) // 110 + 1) + row += 1 + + def table(frame: pd.DataFrame) -> None: + nonlocal row + for offset, column in enumerate(frame.columns, start=1): + sheet.cell(row=row, column=offset, value=str(column)).font = bold + row += 1 + for _, record in frame.iterrows(): + for offset, column in enumerate(frame.columns, start=1): + value = record[column] + if isinstance(value, (np.floating, np.integer)): + value = value.item() + if isinstance(value, float) and not np.isfinite(value): + value = None + sheet.cell(row=row, column=offset, value=value) + row += 1 + + heading(f"BATCH REVIEW - {review.round_name}") + blank() + for key, value in review.context.items(): + sheet.cell(row=row, column=1, value=key).font = bold + sheet.cell(row=row, column=2, value=str(value)) + row += 1 + blank() + + if review.model_warnings: + # above the table, for the same reason it is first in the text version + heading("READ THIS BEFORE THE NUMBERS") + for warning in review.model_warnings: + paragraph(warning) + blank() + + heading("PROPOSED CONDITIONS") + table(review.candidates) + blank() + + if not review.probes.empty: + heading("PROBES") + table(review.probes) + blank() + + if review.probe_verdicts: + heading("WHAT THE PROBES MEAN") + for verdict in review.probe_verdicts: + paragraph(verdict) + blank() + + if review.notes: + heading("NOTES") + for note in review.notes: + paragraph(note) + blank() + + if review.findings: + heading("CARRIED FROM THE MEASURED DATA") + table( + pd.DataFrame( + { + "severity": [f.severity.value for f in _ordered(review.findings)], + "sample": [f.sample_id for f in _ordered(review.findings)], + "objective": [f.objective for f in _ordered(review.findings)], + "message": [f.message for f in _ordered(review.findings)], + } + ) + ) + blank() + + heading("APPROVAL") + paragraph(NOT_APPROVED) + + sheet.column_dimensions["A"].width = 46 + for index in range(2, 40): + sheet.column_dimensions[get_column_letter(index)].width = 16 + sheet.freeze_panes = "A2" + workbook.save(path) + return path diff --git a/src/mobo_kit/batch_selection.py b/src/mobo_kit/batch_selection.py new file mode 100644 index 0000000..a97cf83 --- /dev/null +++ b/src/mobo_kit/batch_selection.py @@ -0,0 +1,398 @@ +"""Reusable sequential local penalization for discrete MOBO candidate pools.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from numbers import Real +from typing import Any, Callable + +import numpy as np + +from .candidate_pool import CandidatePool + + +@dataclass(frozen=True) +class LocalPenalizationConfig: + radius: float | None + min_batch_distance: float + min_observed_distance: float = 0.0 + dimension_weights: np.ndarray | None = None + epsilon: float = 1e-12 + + def __post_init__(self) -> None: + for field_name in ("min_batch_distance", "min_observed_distance", "epsilon"): + field_value = getattr(self, field_name) + if isinstance(field_value, (bool, np.bool_)) or not isinstance( + field_value, Real + ): + raise ValueError(f"{field_name} must be a real non-boolean number.") + if self.radius is None: + radius = None + else: + if isinstance(self.radius, (bool, np.bool_)) or not isinstance( + self.radius, Real + ): + raise ValueError("radius must be None or a real non-boolean number.") + radius = float(self.radius) + minimum_batch = float(self.min_batch_distance) + minimum_observed = float(self.min_observed_distance) + epsilon = float(self.epsilon) + if radius is not None and (not np.isfinite(radius) or radius <= 0): + raise ValueError("radius must be None or finite and strictly positive.") + if not np.isfinite(minimum_batch) or minimum_batch < 0: + raise ValueError("min_batch_distance must be finite and non-negative.") + if not np.isfinite(minimum_observed) or minimum_observed < 0: + raise ValueError("min_observed_distance must be finite and non-negative.") + if not np.isfinite(epsilon) or epsilon <= 0 or epsilon >= 1: + raise ValueError("epsilon must be finite and between zero and one.") + object.__setattr__(self, "radius", radius) + object.__setattr__(self, "min_batch_distance", minimum_batch) + object.__setattr__(self, "min_observed_distance", minimum_observed) + object.__setattr__(self, "epsilon", epsilon) + if self.dimension_weights is not None: + raw_weights = np.asarray(self.dimension_weights) + if np.issubdtype(raw_weights.dtype, np.bool_) or any( + not isinstance(value, Real) or isinstance(value, (bool, np.bool_)) + for value in raw_weights.ravel() + ): + raise ValueError( + "dimension_weights must contain real non-boolean numbers." + ) + weights = np.asarray(self.dimension_weights, dtype=float).copy() + if weights.ndim != 1 or weights.size == 0: + raise ValueError("dimension_weights must be a non-empty vector.") + if not np.all(np.isfinite(weights)) or np.any(weights <= 0): + raise ValueError( + "dimension_weights must be finite and strictly positive." + ) + weights.setflags(write=False) + object.__setattr__(self, "dimension_weights", weights) + + +@dataclass(frozen=True) +class BaseScoreResult: + base_log_score: np.ndarray + base_score: np.ndarray | None = None + diagnostics: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class SelectionStep: + order: int + pool_index: int + base_score: float | None + base_log_score: float + penalty_factor: float + log_penalty: float + penalized_log_score: float + nearest_selected_distance_before: float | None + nearest_observed_distance: float | None + + +@dataclass(frozen=True) +class BatchSelectionResult: + X_norm: np.ndarray + X_phys: np.ndarray + selected_pool_indices: np.ndarray + steps: tuple[SelectionStep, ...] + method_diagnostics: dict[str, Any] + distance_diagnostics: dict[str, Any] + + +class UndersizedBatchError(RuntimeError): + """Raised when an exact batch would require relaxing a hard rule.""" + + def __init__( + self, + *, + requested_size: int, + selected_size: int, + pool_size: int, + remaining_candidate_count: int, + min_batch_distance: float, + min_observed_distance: float, + selected_pool_indices: np.ndarray, + hard_valid_candidate_count: int | None = None, + ) -> None: + self.requested_size = requested_size + self.selected_size = selected_size + self.pool_size = pool_size + self.remaining_candidate_count = remaining_candidate_count + self.min_batch_distance = min_batch_distance + self.min_observed_distance = min_observed_distance + self.selected_pool_indices = selected_pool_indices.copy() + self.hard_valid_candidate_count = hard_valid_candidate_count + super().__init__( + "Unable to select the exact requested batch without violating an " + "eligibility or hard-distance rule: " + f"requested={requested_size}, selected={selected_size}, " + f"pool_size={pool_size}, remaining={remaining_candidate_count}, " + f"min_batch_distance={min_batch_distance}, " + f"min_observed_distance={min_observed_distance}." + ) + + +def _weights(config: LocalPenalizationConfig, dimension: int) -> np.ndarray: + if config.dimension_weights is None: + return np.ones(dimension, dtype=float) + if config.dimension_weights.shape != (dimension,): + raise ValueError( + "dimension_weights must have shape " + f"({dimension},); got {config.dimension_weights.shape}." + ) + return config.dimension_weights + + +def _distances( + X: np.ndarray, references: np.ndarray, weights: np.ndarray +) -> np.ndarray: + if references.shape[0] == 0: + return np.empty((X.shape[0], 0), dtype=float) + differences = X[:, None, :] - references[None, :, :] + return np.sqrt(np.sum(weights * differences**2, axis=-1)) + + +def soft_local_penalty( + distances: np.ndarray, *, radius: float, epsilon: float = 1e-12 +) -> tuple[np.ndarray, np.ndarray]: + """Return stabilized exclusion factors and their natural logarithms.""" + distance = np.asarray(distances, dtype=float) + if not np.all(np.isfinite(distance)) or np.any(distance < 0): + raise ValueError("distances must be finite and non-negative.") + if isinstance(radius, (bool, np.bool_)) or not isinstance(radius, Real): + raise ValueError("radius must be a real non-boolean number.") + if isinstance(epsilon, (bool, np.bool_)) or not isinstance(epsilon, Real): + raise ValueError("epsilon must be a real non-boolean number.") + radius_value = float(radius) + epsilon_value = float(epsilon) + if not np.isfinite(radius_value) or radius_value <= 0: + raise ValueError("radius must be finite and strictly positive.") + if not np.isfinite(epsilon_value) or epsilon_value <= 0 or epsilon_value >= 1: + raise ValueError("epsilon must be finite and between zero and one.") + factor = 1.0 - np.exp(-0.5 * (distance / radius_value) ** 2) + return factor, np.log(np.maximum(factor, epsilon_value)) + + +def _validate_pool(candidate_pool: CandidatePool) -> tuple[np.ndarray, np.ndarray]: + if not isinstance(candidate_pool, CandidatePool): + raise TypeError("candidate_pool must be a CandidatePool.") + X_norm = np.asarray(candidate_pool.X_norm, dtype=float) + X_phys = np.asarray(candidate_pool.X_phys, dtype=float) + if X_norm.ndim != 2 or X_phys.shape != X_norm.shape: + raise ValueError( + "Candidate pool physical and normalized arrays must be (N, D)." + ) + if X_norm.shape[0] == 0 or not np.all(np.isfinite(X_norm)): + raise ValueError("Candidate pool must contain finite rows.") + if not np.all(np.isfinite(X_phys)): + raise ValueError( + "Candidate pool physical rows must contain only finite values." + ) + if np.any(X_norm < -1e-12) or np.any(X_norm > 1 + 1e-12): + raise ValueError("Candidate pool normalized rows must lie in [0, 1].") + grid_indices = np.asarray(candidate_pool.grid_indices) + if grid_indices.shape != X_norm.shape or not np.issubdtype( + grid_indices.dtype, np.integer + ): + raise ValueError( + "Candidate pool grid_indices must be an integer array aligned with " + "physical and normalized rows." + ) + if np.unique(grid_indices, axis=0).shape[0] != X_norm.shape[0]: + raise ValueError("Candidate pool contains duplicate grid-index tuples.") + if np.unique(X_norm, axis=0).shape[0] != X_norm.shape[0]: + raise ValueError("Candidate pool contains duplicate normalized rows.") + if np.unique(X_phys, axis=0).shape[0] != X_phys.shape[0]: + raise ValueError("Candidate pool contains duplicate physical rows.") + return X_norm, X_phys + + +def select_local_penalized_batch( + candidate_pool: CandidatePool, + q: int, + score_remaining: Callable[[np.ndarray, np.ndarray], BaseScoreResult], + config: LocalPenalizationConfig, + *, + observed_pending_norm: np.ndarray | None = None, +) -> BatchSelectionResult: + """Sequentially select exactly ``q`` candidates or fail without relaxation.""" + X_norm, X_phys = _validate_pool(candidate_pool) + if isinstance(q, bool) or not isinstance(q, (int, np.integer)) or int(q) <= 0: + raise ValueError("q must be a positive integer.") + requested = int(q) + if requested > X_norm.shape[0]: + raise UndersizedBatchError( + requested_size=requested, + selected_size=0, + pool_size=X_norm.shape[0], + remaining_candidate_count=X_norm.shape[0], + min_batch_distance=config.min_batch_distance, + min_observed_distance=config.min_observed_distance, + selected_pool_indices=np.empty(0, dtype=int), + hard_valid_candidate_count=X_norm.shape[0], + ) + if not callable(score_remaining): + raise TypeError("score_remaining must be callable.") + weights = _weights(config, X_norm.shape[1]) + if observed_pending_norm is None: + observed = np.empty((0, X_norm.shape[1]), dtype=float) + else: + observed = np.asarray(observed_pending_norm, dtype=float) + if observed.ndim != 2 or observed.shape[1] != X_norm.shape[1]: + raise ValueError( + "observed_pending_norm must have shape " f"(N, {X_norm.shape[1]})." + ) + if not np.all(np.isfinite(observed)): + raise ValueError("observed_pending_norm must contain only finite values.") + if np.any(observed < 0.0) or np.any(observed > 1.0): + raise ValueError("observed_pending_norm must lie within [0, 1].") + + if observed.shape[0]: + observed_distances = _distances(X_norm, observed, weights) + nearest_observed_all = observed_distances.min(axis=1) + else: + nearest_observed_all = np.full(X_norm.shape[0], np.inf) + nearest_selected_all = np.full(X_norm.shape[0], np.inf) + cumulative_log_penalty = np.zeros(X_norm.shape[0], dtype=float) + + remaining = np.arange(X_norm.shape[0], dtype=int) + selected: list[int] = [] + steps: list[SelectionStep] = [] + score_diagnostics: list[dict[str, Any]] = [] + for order in range(1, requested + 1): + selected_array = np.asarray(selected, dtype=int) + scored = score_remaining(remaining.copy(), selected_array.copy()) + if not isinstance(scored, BaseScoreResult): + raise TypeError("score_remaining must return BaseScoreResult.") + base_log = np.asarray(scored.base_log_score, dtype=float) + if base_log.shape != (remaining.size,): + raise ValueError( + "base_log_score must align with remaining_indices; " + f"expected {(remaining.size,)}, got {base_log.shape}." + ) + if np.any(np.isnan(base_log)) or np.any(np.isposinf(base_log)): + raise ValueError("base_log_score may be finite or -inf, not NaN/+inf.") + base_score: np.ndarray | None = None + if scored.base_score is not None: + base_score = np.asarray(scored.base_score, dtype=float) + if base_score.shape != (remaining.size,) or not np.all( + np.isfinite(base_score) + ): + raise ValueError( + "base_score must be finite and align with remaining_indices." + ) + score_diagnostics.append(dict(scored.diagnostics)) + + if selected: + nearest_selected = nearest_selected_all[remaining] + log_penalty = cumulative_log_penalty[remaining] + else: + nearest_selected = np.full(remaining.size, np.inf) + log_penalty = np.zeros(remaining.size, dtype=float) + + nearest_observed = nearest_observed_all[remaining] + + hard_valid = np.ones(remaining.size, dtype=bool) + if selected: + hard_valid &= nearest_selected >= config.min_batch_distance + if observed.shape[0]: + # Exact observed/pending recipes are always forbidden, even when the + # configured distance threshold is explicitly zero. + hard_valid &= nearest_observed > 0.0 + if config.min_observed_distance > 0: + hard_valid &= nearest_observed >= config.min_observed_distance + penalized = base_log + log_penalty + penalized[~hard_valid] = -np.inf + valid_positions = np.flatnonzero(np.isfinite(penalized)) + if valid_positions.size == 0: + raise UndersizedBatchError( + requested_size=requested, + selected_size=len(selected), + pool_size=X_norm.shape[0], + remaining_candidate_count=int(valid_positions.size), + min_batch_distance=config.min_batch_distance, + min_observed_distance=config.min_observed_distance, + selected_pool_indices=np.asarray(selected, dtype=int), + hard_valid_candidate_count=int(np.count_nonzero(hard_valid)), + ) + # np.argmax returns the first maximum. Remaining pool indices preserve + # ascending/stable pool order, which is the documented tie-break. + chosen_position = int(np.argmax(penalized)) + chosen_pool_index = int(remaining[chosen_position]) + chosen_log_penalty = float(log_penalty[chosen_position]) + steps.append( + SelectionStep( + order=order, + pool_index=chosen_pool_index, + base_score=( + None if base_score is None else float(base_score[chosen_position]) + ), + base_log_score=float(base_log[chosen_position]), + penalty_factor=float(np.exp(chosen_log_penalty)), + log_penalty=chosen_log_penalty, + penalized_log_score=float(penalized[chosen_position]), + nearest_selected_distance_before=( + None if not selected else float(nearest_selected[chosen_position]) + ), + nearest_observed_distance=( + None + if not observed.shape[0] + else float(nearest_observed[chosen_position]) + ), + ) + ) + selected.append(chosen_pool_index) + remaining = np.delete(remaining, chosen_position) + new_distances = _distances( + X_norm, X_norm[chosen_pool_index : chosen_pool_index + 1], weights + )[:, 0] + nearest_selected_all = np.minimum(nearest_selected_all, new_distances) + if config.radius is not None: + _, new_log_penalty = soft_local_penalty( + new_distances, radius=config.radius, epsilon=config.epsilon + ) + cumulative_log_penalty += new_log_penalty + + selected_array = np.asarray(selected, dtype=int) + selected_distances = _distances( + X_norm[selected_array], X_norm[selected_array], weights + ) + if requested >= 2: + triangle = selected_distances[np.triu_indices(requested, k=1)] + within = { + "minimum_within_batch_distance": float(triangle.min()), + "mean_within_batch_distance": float(triangle.mean()), + "maximum_within_batch_distance": float(triangle.max()), + } + else: + within = { + "minimum_within_batch_distance": None, + "mean_within_batch_distance": None, + "maximum_within_batch_distance": None, + } + return BatchSelectionResult( + X_norm=X_norm[selected_array].copy(), + X_phys=X_phys[selected_array].copy(), + selected_pool_indices=selected_array, + steps=tuple(steps), + method_diagnostics={"score_steps": score_diagnostics}, + distance_diagnostics={ + "pairwise_distance_matrix": selected_distances, + **within, + "dimension_weights": weights.copy(), + "radius": config.radius, + "min_batch_distance": config.min_batch_distance, + "min_observed_distance": config.min_observed_distance, + }, + ) + + +__all__ = [ + "BaseScoreResult", + "BatchSelectionResult", + "LocalPenalizationConfig", + "SelectionStep", + "UndersizedBatchError", + "select_local_penalized_batch", + "soft_local_penalty", +] diff --git a/src/mobo_kit/campaign.py b/src/mobo_kit/campaign.py new file mode 100644 index 0000000..e004efc --- /dev/null +++ b/src/mobo_kit/campaign.py @@ -0,0 +1,955 @@ +"""The campaign path: three rounds, one function each. + + run_r0_lhs(config, n=15) -> space-filling initial worklist + run_r1_ucb(config, ..., n=5) -> UCB-HVI batch with local penalisation + run_r2_qlognehvi(config, ..., n=3) -> qLogNEHVI batch + +Each returns a :class:`RoundResult` carrying the proposed conditions in physical +units plus a diagnostics dict. These functions orchestrate; the mathematics +lives in ``lhs``, ``candidate_pool``, ``ucb_hvi``, ``batch_selection``, +``qlognehvi_batch`` and ``discrete_refinement``, which are not reimplemented +here. + +Two things are worth knowing before reading further. + +**Thickness trains on nanometres.** The campaign's thickness utility is a +Gaussian on a 650 nm target, and that map is 2-to-1: films at 400 nm and 900 nm +score alike from opposite sides of the peak. Training on the score forces the +GP to represent a folded ridge; training on nanometres leaves a smooth trend. +So ``model_source_column`` (what the GP sees) and the utility transform are +declared separately per objective. See docs/GP_MODEL_DECISION.md. + +**Utility moments come from posterior samples.** Because the thickness utility +is nonlinear, the mean of the transform is not the transform of the mean. +``ucb_hvi.posterior_utility_moments`` already applies the transform to posterior +samples, so ``moment_method="monte_carlo"`` is correct and required here; the +``analytic_identity`` fast path is only valid when every transform is identity. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Mapping, Sequence + +import numpy as np +import pandas as pd +import torch + +from .batch_selection import LocalPenalizationConfig +from .candidate_pool import CandidatePool, sample_discrete_candidate_pool +from .constraints import ( + RowConstraint, + constraint_violations, + constraints_from_config, +) +from .design import Design, build_design_from_config +from .lhs import lhs_dataframe_optimized +from botorch.models.model_list_gp_regression import ModelListGP + +from .model_validation import ( + SIGNAL_COLLAPSE_STAGE, + fit_model_variant, + model_variant_spec, +) +from .scores import MeasurementSpec, entry_columns, measurement_spec_from_config +from .structured_mean import build_structured_mean, mean_spec_from_config +from .objectives import ObjectiveSpec, ObjectiveTransform +from .qlognehvi_batch import propose_qlognehvi_penalized_batch +from .ucb_hvi import propose_ucb_hvi_batch + +__all__ = [ + "CampaignConfigError", + "EXCEL_CSV_ENCODING", + "write_worklist_csv", + "FIXED_SCALING_MODES", + "assert_scaling_is_campaign_fixed", + "RoundResult", + "build_objective_transform", + "expand_replicates", + "fit_campaign_models", + "load_campaign_config", + "normalise_inputs", + "measurement_entry_columns", + "measurement_specs", + "model_source_columns", + "objective_names", + "replicate_aggregates", + "REPLICATE_AGGREGATES", + "run_r0_lhs", + "run_r1_ucb", + "run_r2_qlognehvi", + "validate_batch", +] + + +#: Encoding for CSVs a human will open in Excel. Excel does not detect UTF-8 +#: without a BOM and falls back to the system ANSI codepage, which mangles any +#: non-ASCII cell -- and does so on the reader's machine, not the writer's, so +#: it is invisible during development. ``utf-8-sig`` writes the BOM; pandas and +#: every other reader strip it transparently. +EXCEL_CSV_ENCODING = "utf-8-sig" + + +def write_worklist_csv(frame: pd.DataFrame, path: str | Path) -> Path: + """Write a worklist CSV that Excel will open correctly.""" + destination = Path(path) + frame.to_csv(destination, index=False, encoding=EXCEL_CSV_ENCODING) + return destination + + +class CampaignConfigError(ValueError): + """The configuration cannot support the requested round.""" + + +class BatchValidityError(RuntimeError): + """A proposed batch failed a validity check and must not be issued.""" + + +@dataclass +class RoundResult: + """One round's proposal.""" + + round_name: str + conditions: pd.DataFrame + """Distinct proposed conditions, physical units, columns == design.names.""" + replicates: pd.DataFrame + """One row per physical film, with candidate_id and replicate_group.""" + diagnostics: dict[str, Any] = field(default_factory=dict) + + @property + def n_conditions(self) -> int: + return len(self.conditions) + + +# --------------------------------------------------------------------------- # +# configuration +# --------------------------------------------------------------------------- # + + +def load_campaign_config(path: str | Path) -> dict[str, Any]: + """Read a campaign YAML. UTF-8 is explicit: the default codec is locale + dependent and silently fails on non-ASCII under some Windows locales.""" + import yaml + + with open(path, "r", encoding="utf-8") as handle: + config = yaml.safe_load(handle) + if not isinstance(config, Mapping): + raise CampaignConfigError(f"{path} did not parse to a mapping.") + return dict(config) + + +def _objective_specs(config: Mapping[str, Any]) -> tuple[ObjectiveSpec, ...]: + objectives = config.get("objectives") + if not isinstance(objectives, Mapping) or "specs" not in objectives: + raise CampaignConfigError( + "config['objectives'] must be a mapping containing 'specs'. A config " + "with an empty objective list cannot propose candidates." + ) + raw_specs = objectives["specs"] + if not isinstance(raw_specs, Sequence) or not raw_specs: + raise CampaignConfigError("config['objectives']['specs'] must be non-empty.") + + specs: list[ObjectiveSpec] = [] + for entry in raw_specs: + if not isinstance(entry, Mapping): + raise CampaignConfigError("Every objective spec must be a mapping.") + if not entry.get("model_source_column"): + raise CampaignConfigError( + f"Objective {entry.get('name')!r} must declare " + "model_source_column: the GP trains on that column, which is not " + "always the final score." + ) + mean_spec = mean_spec_from_config(entry) + specs.append( + ObjectiveSpec( + name=str(entry["name"]), + goal=str(entry["goal"]), + transform=str(entry["transform"]), + # a log-response mean function means the GP emits log(y), so the + # utility must exponentiate and its posterior is lognormal + model_link=( + "log" + if mean_spec is not None and mean_spec.response == "log" + else "identity" + ), + source_column=str(entry["model_source_column"]), + lower_anchor=entry.get("lower_anchor"), + upper_anchor=entry.get("upper_anchor"), + target=entry.get("target"), + sigma=entry.get("sigma"), + scale=entry.get("scale"), + ) + ) + return tuple(specs) + + +#: Scaling modes that are fixed for the whole campaign. Anything else means the +#: scale would be re-derived from whatever data happens to exist this round. +FIXED_SCALING_MODES = frozenset({"already_normalized", "fixed_affine"}) + + +def assert_scaling_is_campaign_fixed(config: Mapping[str, Any]) -> None: + """Refuse objective scales that are re-derived from observed data. + + This is the single most consequential check inherited from + ``production_gate.py``, and it is not about approval. If an objective's + scale moves with the data each round -- observed min/max, a percentile, a + round-local standardisation -- then the utility space itself moves, and + hypervolume computed in round N is not comparable with round N+1. The + progress plot silently stops meaning anything. + + The temptation is immediate and specific: once R1 measurements land, the + observed ranges will look like better anchors than the declared ones. They + are not. Widen a declared range deliberately and version it; never let it + track the data. + """ + for spec in _objective_specs(config): + if spec.transform == "affine": + if spec.lower_anchor is None or spec.upper_anchor is None: + raise CampaignConfigError( + f"Objective {spec.name!r} uses an affine transform but does " + "not declare fixed lower_anchor/upper_anchor. Round-by-round " + "min/max scaling makes hypervolume incomparable across rounds." + ) + if not (spec.lower_anchor < spec.upper_anchor): + raise CampaignConfigError( + f"Objective {spec.name!r} requires lower_anchor < upper_anchor." + ) + + declared = (config.get("objectives") or {}).get("scaling_mode", "fixed_affine") + if declared not in FIXED_SCALING_MODES: + raise CampaignConfigError( + f"objectives.scaling_mode must be one of {sorted(FIXED_SCALING_MODES)}; " + f"got {declared!r}. Observed or data-derived scaling is forbidden " + "because it makes hypervolume incomparable between rounds." + ) + + +def build_objective_transform(config: Mapping[str, Any]) -> ObjectiveTransform: + """Build the versioned raw-output-to-utility contract from a campaign config.""" + version = config.get("objectives", {}).get("contract_version") + if not version: + raise CampaignConfigError( + "config['objectives']['contract_version'] is required so that " + "hypervolume stays comparable across rounds." + ) + assert_scaling_is_campaign_fixed(config) + return ObjectiveTransform(_objective_specs(config), version=str(version)) + + +def model_source_columns(config: Mapping[str, Any]) -> tuple[str, ...]: + """The workbook column declared per objective, in objective order. + + For an objective with a ``measurement`` block this is no longer what the GP + trains on -- the value is computed from the raw measurement columns instead, + and this column becomes the cross-check target. See :mod:`scores`. + """ + return tuple(spec.source_column for spec in _objective_specs(config)) + + +def objective_names(config: Mapping[str, Any]) -> tuple[str, ...]: + """Objective names in declaration order.""" + return tuple(spec.name for spec in _objective_specs(config)) + + +#: How the replicate films of one condition become one training observation. +#: ``mean`` is the arithmetic mean of the film values. ``mean_of_log`` is the +#: geometric mean, which is the arithmetic mean *in the space the GP trains in* +#: whenever that objective's mean function declares ``response: log``. +REPLICATE_AGGREGATES = frozenset({"mean", "mean_of_log"}) + + +def replicate_aggregates(config: Mapping[str, Any]) -> tuple[str, ...]: + """The replicate-aggregation rule per objective, in objective order. + + Declared per objective because the right answer depends on the space the + model works in, not on taste. Thickness trains on ``log T``, so averaging + three films in log space is what makes the aggregation and the Phase 4 + variance pooling consistent with each other; the other two objectives train + on their own scale and use the plain mean. + + The difference is second order in the replicate spread -- under 0.1% at the + 3% within-film spread most R0 rows show, but around 14% on a film set as + inconsistent as sample 12's. It is one config key, so it can be revisited + without touching code. + """ + specs = _objective_specs(config) + entries = config["objectives"]["specs"] + rules: list[str] = [] + for spec, entry in zip(specs, entries): + rule = str(entry.get("replicate_aggregate", "mean")) + if rule not in REPLICATE_AGGREGATES: + raise CampaignConfigError( + f"Objective {spec.name!r} declares replicate_aggregate {rule!r}; " + f"expected one of {sorted(REPLICATE_AGGREGATES)}." + ) + rules.append(rule) + return tuple(rules) + + +def measurement_specs( + config: Mapping[str, Any], +) -> tuple[MeasurementSpec | None, ...]: + """One measurement spec per objective, in objective order. + + ``None`` for an objective that has no ``measurement`` block and therefore + still reads its stored column as-is. + """ + specs = _objective_specs(config) # validates the objectives block first + entries = config["objectives"]["specs"] + return tuple( + measurement_spec_from_config(entry) for _, entry in zip(specs, entries) + ) + + +def measurement_entry_columns( + config: Mapping[str, Any], +) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Columns a worklist sheet must offer for entry, split required / optional. + + Objectives with a ``measurement`` block contribute their raw measurement + columns; objectives without one contribute their declared source column. + """ + specs = measurement_specs(config) + declared = model_source_columns(config) + required, optional = entry_columns([s for s in specs if s is not None]) + extra = tuple( + column + for spec, column in zip(specs, declared) + if spec is None and column not in required + ) + return required + extra, tuple(c for c in optional if c not in extra) + + +def _reference_point(config: Mapping[str, Any], n_objectives: int) -> np.ndarray: + raw = config.get("reference_point_utility") + if raw is None: + raise CampaignConfigError( + "reference_point_utility is required and must be declared in UTILITY " + "space, after the objective transforms." + ) + point = np.asarray(raw, dtype=float) + if point.shape != (n_objectives,): + raise CampaignConfigError( + f"reference_point_utility must have {n_objectives} entries; " + f"got {point.shape}." + ) + if not np.all(np.isfinite(point)): + raise CampaignConfigError("reference_point_utility must be finite.") + return point + + +def _penalization(config: Mapping[str, Any]) -> LocalPenalizationConfig: + raw = config.get("local_penalization") or {} + weights = raw.get("dimension_weights") + return LocalPenalizationConfig( + radius=raw.get("radius"), + min_batch_distance=float(raw.get("min_batch_distance", 0.0)), + min_observed_distance=float(raw.get("min_observed_distance", 0.0)), + dimension_weights=None if weights is None else np.asarray(weights, float), + ) + + +def _round_settings(config: Mapping[str, Any], key: str) -> dict[str, Any]: + rounds = config.get("rounds") or {} + settings = rounds.get(key) + if not isinstance(settings, Mapping): + raise CampaignConfigError(f"config['rounds']['{key}'] is required.") + return dict(settings) + + +# --------------------------------------------------------------------------- # +# validity +# --------------------------------------------------------------------------- # + + +def validate_batch( + conditions: pd.DataFrame, + design: Design, + *, + expected_count: int, + min_pairwise_distance: float = 0.0, + constraints: Sequence[RowConstraint] | None = None, +) -> dict[str, Any]: + """Refuse to issue a batch that is malformed. + + Six checks, all of which catch real bugs: the batch is the requested size, + its rows are distinct, every value sits exactly on the declared grid, every + value is finite and in bounds, the rows are at least + ``min_pairwise_distance`` apart in normalised space, and every row satisfies + the campaign's declared constraints. + + The constraint check is deliberately redundant. The candidate pool is already + filtered before any acquisition scores it, so a violating condition cannot be + proposed by that route -- which is exactly why the check belongs here too: the + pool filter is the mechanism, and this is the second, independent route to the + same answer. This project has now been bitten three times by a quantity that + nothing recomputed. + + This replaces the previous debug/production approval tiers. Whether a batch + is approved for fabrication is a human decision recorded outside the code; + it is not something this function can compute, so it does not pretend to. + """ + report: dict[str, Any] = {} + values = conditions.to_numpy(dtype=float) + + report["expected_count"] = expected_count + report["actual_count"] = len(conditions) + if len(conditions) != expected_count: + raise BatchValidityError( + f"Expected exactly {expected_count} conditions; got {len(conditions)}." + ) + + if not np.all(np.isfinite(values)): + raise BatchValidityError("Proposed conditions contain non-finite values.") + report["finite"] = True + + duplicates = pd.DataFrame(values).duplicated().to_numpy() + if duplicates.any(): + raise BatchValidityError( + f"Proposed conditions must be unique; {int(duplicates.sum())} duplicate " + "row(s) found." + ) + report["unique"] = True + + off_grid: list[str] = [] + for j, name in enumerate(design.names): + grid = np.asarray(design.var_array[j], dtype=float) + for value in values[:, j]: + if not np.any(np.isclose(grid, value, rtol=0.0, atol=1e-9)): + off_grid.append(f"{name}={value!r}") + if off_grid: + raise BatchValidityError( + "Proposed values must lie exactly on the declared grid; off-grid: " + f"{sorted(set(off_grid))}" + ) + report["on_grid"] = True + + lowers = np.asarray(design.lowers, dtype=float) + uppers = np.asarray(design.uppers, dtype=float) + if np.any(values < lowers - 1e-9) or np.any(values > uppers + 1e-9): + raise BatchValidityError("Proposed conditions fall outside design bounds.") + report["in_bounds"] = True + + span = np.where(uppers > lowers, uppers - lowers, 1.0) + norm = (values - lowers) / span + if len(norm) > 1: + from scipy.spatial.distance import pdist + + distances = pdist(norm) + report["min_pairwise_distance"] = float(distances.min()) + if min_pairwise_distance > 0 and distances.min() < min_pairwise_distance: + raise BatchValidityError( + f"Minimum pairwise distance {distances.min():.4f} is below the " + f"configured floor {min_pairwise_distance:.4f}." + ) + else: + report["min_pairwise_distance"] = float("inf") + + boundary = (np.isclose(norm, 0.0, atol=1e-9)) | (np.isclose(norm, 1.0, atol=1e-9)) + report["boundary_coords_per_condition"] = boundary.sum(axis=1).tolist() + + violations = constraint_violations(values, design, constraints) + report["constraints_declared"] = [ + getattr(item, "description", getattr(item, "name", "constraint")) + for item in (constraints or ()) + ] + report["constraint_violations_per_condition"] = violations + broken = [ + f"condition {position + 1} breaks {names}" + for position, names in enumerate(violations) + if names + ] + if broken: + raise BatchValidityError( + "Proposed conditions must satisfy the campaign constraints; " + + "; ".join(broken) + ) + report["constraints_satisfied"] = True + return report + + +def expand_replicates( + conditions: pd.DataFrame, *, replicates: int, round_name: str +) -> pd.DataFrame: + """One row per physical film, sharing a replicate_group per condition. + + The experimentalists run each proposed condition ``replicates`` times to + measure reproducibility. Those films are separate experimental rows, but + they are one design point: aggregate them to a condition-level mean before + the next round trains on them, and pool their within-condition variance + across conditions for an observation-noise estimate. + """ + if replicates < 1: + raise ValueError("replicates must be at least 1.") + rows = [] + for index, (_, condition) in enumerate(conditions.iterrows(), start=1): + candidate_id = f"{round_name}_C{index:02d}" + for replicate in range(1, replicates + 1): + row = dict(condition) + row["candidate_id"] = candidate_id + row["replicate_group"] = candidate_id + row["replicate_index"] = replicate + row["round"] = round_name + rows.append(row) + return pd.DataFrame(rows) + + +# --------------------------------------------------------------------------- # +# rounds +# --------------------------------------------------------------------------- # + + +def run_r0_lhs( + config: Mapping[str, Any], *, n: int = 15, seed: int | None = None +) -> RoundResult: + """Space-filling initial worklist. No model is involved.""" + design = build_design_from_config(dict(config)) + constraints = constraints_from_config(dict(config), design) + resolved_seed = ( + int(config.get("reproducibility", {}).get("seed", 0)) if seed is None else seed + ) + conditions = lhs_dataframe_optimized( + design, + n, + seed=resolved_seed, + snap_to_grids=True, + row_constraints=constraints or None, + ) + replicates_per = int( + _round_settings(config, "r1").get("replicates_per_condition", 1) + ) + report = validate_batch( + conditions, design, expected_count=n, constraints=constraints or None + ) + return RoundResult( + round_name="R0", + conditions=conditions, + replicates=expand_replicates( + conditions, replicates=replicates_per, round_name="R0" + ), + diagnostics={"seed": resolved_seed, "validity": report, "method": "lhs"}, + ) + + +def _fit_models( + config: Mapping[str, Any], + X_phys: np.ndarray, + X_norm: np.ndarray, + Y_raw: np.ndarray, + seed: int, + Yvar_model: np.ndarray | None = None, +) -> Any: + """One GP per objective, each with its declared structured mean. + + Objectives with a ``mean_function`` train on the response-space target with + the OLS trend frozen into the mean module, so ``posterior()`` already carries + it and no caller has to add it back. Objectives without one are unchanged. + + ``Yvar_model`` is measured observation variance in the MODEL TARGET space, one + column per objective -- so for thickness that is the variance of ``log T``, not + of nanometres, because ``response: log`` means the model trains on the log. + :func:`replicate_variance.pool_between_film_variance` produces it in exactly + that space, which is why aggregation and variance pooling are required to share + one space. + """ + variant = model_variant_spec(str(config.get("model", {}).get("variant"))) + specs = _objective_specs(config) + entries = config["objectives"]["specs"] + design = build_design_from_config(dict(config)) + lowers = np.asarray(design.lowers, dtype=float) + uppers = np.asarray(design.uppers, dtype=float) + names = list(design.names) + + models = [] + warnings: list[str] = [] + raw_warnings: list[str] = [] + for index, (spec, entry) in enumerate(zip(specs, entries)): + mean_spec = mean_spec_from_config(entry) + y = np.asarray(Y_raw, dtype=float)[:, index] + mean_module = None + if mean_spec is not None: + mean_module, y = build_structured_mean( + X_phys, y, mean_spec, names, lowers, uppers + ) + torch.manual_seed(seed) + record = fit_model_variant( + torch.tensor(X_norm, dtype=torch.double), + torch.tensor(y, dtype=torch.double).unsqueeze(-1), + sample_ids=tuple(range(len(X_norm))), + objective_names=(spec.name,), + variant=variant, + seed=seed, + mean_module=mean_module, + train_Yvar=( + None + if Yvar_model is None + else torch.tensor( + np.asarray(Yvar_model, dtype=float)[:, index], dtype=torch.double + ).unsqueeze(-1) + ), + ) + models.append(record.model.models[0]) + # A fit can succeed and still be worth distrusting -- most importantly when + # the GP's signal component collapsed but the mean function carried the + # trend. Discarding these is how such a fit reaches a batch silently. + # + # Only the guard's own warnings travel to a human. `record.warnings` also + # captures every Python warning raised during fitting, which on this stack + # means ~18 numpy-2.0 deprecation notices per fit; putting those in front of + # someone reviewing a batch is how people learn to ignore warnings. + warnings.extend( + warning.message + for warning in record.warnings + if warning.stage == SIGNAL_COLLAPSE_STAGE + ) + # The unfiltered list is kept, unsurfaced, because a scipy or BoTorch + # convergence warning that the filter dropped is exactly what someone needs + # when a fit looks strange six weeks from now. + raw_warnings.extend( + f"{warning.objective_name}|{warning.stage}|{warning.warning_category}: " + f"{warning.message}" + for warning in record.warnings + ) + return ModelListGP(*models), tuple(warnings), tuple(raw_warnings) + + +def fit_campaign_models( + config: Mapping[str, Any], + X_phys: np.ndarray, + Y_raw: np.ndarray, + *, + seed: int | None = None, + Yvar: np.ndarray | None = None, +) -> tuple[Any, tuple[str, ...]]: + """Fit one GP per objective exactly as a round does. + + Same normalisation, same structured means, same variant, same seeding -- so + calling this with the data and seed a round used reproduces that round's model + bit for bit. That is what makes a review of a proposed batch a review of the + model that proposed it, rather than of a similar one. + + ``Y_raw`` holds the MODEL SOURCE values in objective order, the same contract + as :func:`run_r1_ucb`. + + Returns ``(model, warnings)``, where ``warnings`` holds only the fit guard's + own findings -- the ones a human reviewing a batch must read. They are + returned rather than logged because a fit can succeed and still deserve + distrust: the loudest case is a GP whose signal component collapsed while its + mean function carried the trend, which leaves candidate ranking intact but + makes the reported intervals understated. + + The unfiltered list, including library warnings raised during fitting, is on + ``RoundResult.diagnostics["fit_warnings_raw"]``. + """ + design = build_design_from_config(dict(config)) + resolved_seed = ( + int(config.get("reproducibility", {}).get("seed", 0)) if seed is None else seed + ) + values = np.asarray(X_phys, dtype=float) + model, fit_warnings, _raw = _fit_models( + config, + values, + _normalise(design, values), + Y_raw, + resolved_seed, + Yvar_model=Yvar, + ) + return model, fit_warnings + + +def _normalise(design: Design, X_phys: np.ndarray) -> np.ndarray: + lowers = np.asarray(design.lowers, dtype=float) + uppers = np.asarray(design.uppers, dtype=float) + span = np.where(uppers > lowers, uppers - lowers, 1.0) + return (np.asarray(X_phys, dtype=float) - lowers) / span + + +def normalise_inputs( + config: Mapping[str, Any], X_phys: np.ndarray +) -> np.ndarray: + """Physical inputs to ``[0, 1]`` against the CONFIG GRID bounds. + + Not against the observed range: a model fitted on config bounds and evaluated + on observed-range coordinates is being asked about different points than it + was told about, and nothing errors. + """ + return _normalise(build_design_from_config(dict(config)), X_phys) + + +def _on_grid_mask(design: Design, X_phys: np.ndarray) -> np.ndarray: + """Which observed rows sit exactly on the declared grid. + + The R0 control is a declared off-grid exception (anti_time = 12 against a + 9/11/13... grid). It stays in the GP and in the distance references, but it + cannot take part in grid-index bookkeeping, and it does not need to: an + off-grid point can never collide with a pool candidate by construction. + """ + values = np.asarray(X_phys, dtype=float) + mask = np.ones(len(values), dtype=bool) + for j in range(values.shape[1]): + grid = np.asarray(design.var_array[j], dtype=float) + for i, value in enumerate(values[:, j]): + if not np.any(np.isclose(grid, value, rtol=0.0, atol=1e-9)): + mask[i] = False + return mask + + +def _constraint_diagnostics( + constraints: Sequence[RowConstraint], + pool: CandidatePool, + design: Design, + observed_X_phys: np.ndarray, +) -> dict[str, Any]: + """What the constraints did, in numbers a reviewer can check. + + Three things, none of which is a gate: + + ``constraint_pool_survival_rate`` is the share of drawn grid tuples the + constraints accepted. A mis-specified constraint that guts the pool still + produces a pool of exactly the requested size -- the sampler simply draws + longer -- so the batch looks entirely normal while being chosen from a + fraction of the space. A rate near zero is the signal, and without this it is + invisible. + + ``observed_rows_violating_constraints`` soft-checks the measured history. + History is history: a row that predates a rule is not an error and must not + block a round. It is worth SAYING, though, because a constraint that rejects + a film the group actually ran is much more likely to be wrong than the film is. + """ + accepted = int(pool.size) + rejected = int(pool.rejected_constraint) + considered = accepted + rejected + observed_violations = constraint_violations( + observed_X_phys, design, constraints or None + ) + return { + "constraints_declared": [ + getattr(item, "description", getattr(item, "name", "constraint")) + for item in (constraints or ()) + ], + "constraint_pool_rejected": rejected, + "constraint_pool_survival_rate": ( + float(accepted) / considered if considered else 1.0 + ), + "observed_rows_violating_constraints": [ + {"row": position, "constraints": names} + for position, names in enumerate(observed_violations) + if names + ], + } + + +def run_r1_ucb( + config: Mapping[str, Any], + observed_X_phys: np.ndarray, + observed_Y_raw: np.ndarray, + *, + n: int | None = None, + seed: int | None = None, + observed_Yvar: np.ndarray | None = None, +) -> RoundResult: + """UCB-HVI batch with local penalisation. + + ``observed_Y_raw`` holds the MODEL SOURCE values in objective order, not the + final scores: see :func:`model_source_columns`. For this campaign that means + thickness arrives in nanometres. + """ + design = build_design_from_config(dict(config)) + settings = _round_settings(config, "r1") + q = int(settings["batch_size"]) if n is None else int(n) + resolved_seed = ( + int(config.get("reproducibility", {}).get("seed", 0)) if seed is None else seed + ) + transform = build_objective_transform(config) + reference = _reference_point(config, transform.objective_count) + penalization = _penalization(config) + + observed_norm = _normalise(design, observed_X_phys) + model, fit_warnings, raw_fit_warnings = _fit_models( + config, + np.asarray(observed_X_phys, dtype=float), + observed_norm, + observed_Y_raw, + resolved_seed, + Yvar_model=observed_Yvar, + ) + + on_grid = _on_grid_mask(design, observed_X_phys) + constraints = constraints_from_config(dict(config), design) + pool = sample_discrete_candidate_pool( + design, + int(settings.get("candidate_pool_size", 32768)), + seed=resolved_seed, + observed_phys=np.asarray(observed_X_phys, dtype=float)[on_grid], + row_constraints=constraints or None, + ) + + # The HVI baseline is the utility of what has already been measured, so it must + # reach the transform in the MODEL's space -- the transform decodes the link + # itself. Passing measurement-space values here exponentiated thickness a + # second time and pinned every observation's thickness utility to exactly 0.0, + # silently: the baseline hypervolume was 0.004659 against a true 0.436442, so + # every candidate was scored against a front with no thickness axis at all. + # Fixed 2026-07-31; see ObjectiveTransform.encode_measurements. + observed_baseline = transform.encode_measurements( + torch.tensor(np.asarray(observed_Y_raw, dtype=float), dtype=torch.double) + ) + + proposal = propose_ucb_hvi_batch( + pool, + model, + observed_baseline, + transform, + reference, + q=q, + beta=float(settings.get("beta", 4.0)), + local_penalization_config=penalization, + mc_samples=int(settings.get("posterior_samples", 256)), + seed=resolved_seed, + moment_method=str(settings.get("moment_method", "monte_carlo")), + ) + + conditions = pd.DataFrame( + np.asarray(proposal.selection.X_phys, dtype=float), columns=list(design.names) + ) + report = validate_batch( + conditions, + design, + expected_count=q, + min_pairwise_distance=penalization.min_batch_distance, + constraints=constraints or None, + ) + replicates_per = int(settings.get("replicates_per_condition", 1)) + return RoundResult( + round_name="R1", + conditions=conditions, + replicates=expand_replicates( + conditions, replicates=replicates_per, round_name="R1" + ), + diagnostics={ + "method": "ucb_hvi", + "seed": resolved_seed, + "beta": float(settings.get("beta", 4.0)), + "pool_size": pool.size, + "objective_contract": transform.version, + "moment_method": str(settings.get("moment_method", "monte_carlo")), + # Surfaced so the baseline is checkable from outside rather than only + # inside the acquisition. It was wrong for the life of this campaign + # and nothing could see it; a number nobody can compare is how the + # previous two silent-failure bugs survived as well. + "observed_baseline_hypervolume": float( + proposal.scoring.baseline_hypervolume + ), + "observed_baseline_pareto_size": int( + proposal.scoring.pareto_utility.shape[0] + ), + "off_grid_observations_excluded_from_pool_bookkeeping": int( + (~on_grid).sum() + ), + **_constraint_diagnostics( + constraints, pool, design, np.asarray(observed_X_phys, dtype=float) + ), + "model_fit_warnings": list(fit_warnings), + # unsurfaced on purpose: everything the fit raised, for debugging a + # strange fit later, not for showing to a reviewer now + "fit_warnings_raw": list(raw_fit_warnings), + "validity": report, + }, + ) + + +def run_r2_qlognehvi( + config: Mapping[str, Any], + observed_X_phys: np.ndarray, + observed_Y_raw: np.ndarray, + *, + n: int | None = None, + seed: int | None = None, + observed_Yvar: np.ndarray | None = None, +) -> RoundResult: + """qLogNEHVI batch. ``observed_Y_raw`` follows the same contract as R1. + + qLogNEHVI is the numerically stable formulation of qNEHVI and is the correct + choice here; it is not a deviation from the brief. + """ + design = build_design_from_config(dict(config)) + settings = _round_settings(config, "r2") + q = int(settings["batch_size"]) if n is None else int(n) + resolved_seed = ( + int(config.get("reproducibility", {}).get("seed", 0)) if seed is None else seed + ) + transform = build_objective_transform(config) + reference = _reference_point(config, transform.objective_count) + penalization = _penalization(config) + + observed_norm = _normalise(design, observed_X_phys) + model, fit_warnings, raw_fit_warnings = _fit_models( + config, + np.asarray(observed_X_phys, dtype=float), + observed_norm, + observed_Y_raw, + resolved_seed, + Yvar_model=observed_Yvar, + ) + + on_grid = _on_grid_mask(design, observed_X_phys) + constraints = constraints_from_config(dict(config), design) + pool = sample_discrete_candidate_pool( + design, + int(settings.get("candidate_pool_size", 32768)), + seed=resolved_seed, + observed_phys=np.asarray(observed_X_phys, dtype=float)[on_grid], + row_constraints=constraints or None, + ) + + from .objectives import ConfiguredMCMultiOutputObjective + + proposal = propose_qlognehvi_penalized_batch( + pool, + model, + torch.tensor(observed_norm, dtype=torch.double), + ConfiguredMCMultiOutputObjective(transform), + reference, + q=q, + local_penalization_config=penalization, + mc_samples=int(settings.get("mc_samples", 128)), + seed=resolved_seed, + ) + + conditions = pd.DataFrame( + np.asarray(proposal.selection.X_phys, dtype=float), columns=list(design.names) + ) + report = validate_batch( + conditions, + design, + expected_count=q, + min_pairwise_distance=penalization.min_batch_distance, + constraints=constraints or None, + ) + replicates_per = int(settings.get("replicates_per_condition", 1)) + return RoundResult( + round_name="R2", + conditions=conditions, + replicates=expand_replicates( + conditions, replicates=replicates_per, round_name="R2" + ), + diagnostics={ + "method": "qlognehvi", + "seed": resolved_seed, + "pool_size": pool.size, + "objective_contract": transform.version, + "off_grid_observations_excluded_from_pool_bookkeeping": int( + (~on_grid).sum() + ), + **_constraint_diagnostics( + constraints, pool, design, np.asarray(observed_X_phys, dtype=float) + ), + "model_fit_warnings": list(fit_warnings), + # unsurfaced on purpose: everything the fit raised, for debugging a + # strange fit later, not for showing to a reviewer now + "fit_warnings_raw": list(raw_fit_warnings), + "validity": report, + }, + ) diff --git a/src/mobo_kit/candidate_diagnostics.py b/src/mobo_kit/candidate_diagnostics.py new file mode 100644 index 0000000..94d6b18 --- /dev/null +++ b/src/mobo_kit/candidate_diagnostics.py @@ -0,0 +1,387 @@ +"""Numerical and plotting diagnostics for discrete candidate batches. + +All distances in this module are defined in normalized input space. Plotting +helpers are intentionally file-oriented and use a headless Matplotlib backend +so they are safe in automated, CPU-only campaign checks. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Mapping, Sequence + +import matplotlib +import numpy as np +from sklearn.decomposition import PCA + +matplotlib.use("Agg") +from matplotlib import pyplot as plt # noqa: E402 + +from .design import Design + + +def _matrix(value: np.ndarray, *, name: str) -> np.ndarray: + array = np.asarray(value, dtype=float) + if array.ndim != 2: + raise ValueError(f"{name} must have shape (N, D); got {array.shape}.") + if not np.all(np.isfinite(array)): + raise ValueError(f"{name} must contain only finite values.") + return array + + +def _weights(dimension_weights: np.ndarray | None, dimension: int) -> np.ndarray: + if dimension_weights is None: + return np.ones(dimension, dtype=float) + weights = np.asarray(dimension_weights, dtype=float) + if weights.shape != (dimension,): + raise ValueError( + "dimension_weights must have shape " f"({dimension},); got {weights.shape}." + ) + if not np.all(np.isfinite(weights)) or np.any(weights <= 0): + raise ValueError("dimension_weights must be finite and strictly positive.") + return weights + + +def batch_hash(conditions: "np.ndarray | Any") -> str: + """Order-independent identity of a proposed batch. + + Sorted before hashing because the question is "did these two runs propose the + same SET of conditions", not "in the same order". Rounded to 12 decimals so a + float representation difference cannot masquerade as a different batch. + + Used for two different questions and it must be the same function for both: + whether a simulated trajectory reproduces the batch that was actually shipped, + and whether two sweep cells proposed the same experiment. + """ + import hashlib + + values = np.round(np.asarray(conditions, dtype=float), 12) + if values.ndim != 2: + raise ValueError(f"batch_hash needs a 2-D block; got shape {values.shape}.") + ordered = values[np.lexsort(values.T[::-1])] + return hashlib.sha256(ordered.tobytes()).hexdigest()[:16] + + +def pairwise_normalized_distances( + X_norm: np.ndarray, + *, + dimension_weights: np.ndarray | None = None, +) -> np.ndarray: + """Return the weighted Euclidean pairwise-distance matrix for ``(N, D)``.""" + X = _matrix(X_norm, name="X_norm") + weights = _weights(dimension_weights, X.shape[1]) + differences = X[:, None, :] - X[None, :, :] + return np.sqrt(np.sum(weights * differences**2, axis=-1)) + + +def nearest_reference_distances( + X_norm: np.ndarray, + reference_norm: np.ndarray | None, + *, + dimension_weights: np.ndarray | None = None, +) -> np.ndarray: + """Return each candidate's nearest observed/pending normalized distance.""" + X = _matrix(X_norm, name="X_norm") + weights = _weights(dimension_weights, X.shape[1]) + if reference_norm is None: + return np.full(X.shape[0], np.nan, dtype=float) + reference = _matrix(reference_norm, name="reference_norm") + if reference.shape[1] != X.shape[1]: + raise ValueError( + "reference_norm and X_norm must have the same final dimension." + ) + if reference.shape[0] == 0: + return np.full(X.shape[0], np.nan, dtype=float) + differences = X[:, None, :] - reference[None, :, :] + distances = np.sqrt(np.sum(weights * differences**2, axis=-1)) + return distances.min(axis=1) + + +def grid_membership_mask( + X_phys: np.ndarray, + design: Design, + *, + atol: float = 1e-9, +) -> np.ndarray: + """Return a per-row mask indicating exact membership in every design grid.""" + X = _matrix(X_phys, name="X_phys") + dimension = len(design.var_array) + if X.shape[1] != dimension: + raise ValueError( + f"X_phys has {X.shape[1]} columns but the design has {dimension}." + ) + if not np.isfinite(atol) or atol < 0: + raise ValueError("atol must be finite and non-negative.") + valid = np.ones(X.shape[0], dtype=bool) + for column, grid in enumerate(design.var_array): + grid_values = np.asarray(grid, dtype=float) + valid &= np.any( + np.isclose( + X[:, column, None], + grid_values[None, :], + rtol=0.0, + atol=atol, + ), + axis=1, + ) + return valid + + +def boundary_flags(X_norm: np.ndarray, *, atol: float = 1e-12) -> np.ndarray: + """Flag candidate dimensions lying on either normalized boundary.""" + X = _matrix(X_norm, name="X_norm") + if not np.isfinite(atol) or atol < 0: + raise ValueError("atol must be finite and non-negative.") + return np.isclose(X, 0.0, rtol=0.0, atol=atol) | np.isclose( + X, 1.0, rtol=0.0, atol=atol + ) + + +@dataclass(frozen=True) +class BatchDistanceDiagnostics: + """Compact distance and validity summary for a selected batch.""" + + pairwise_distance_matrix: np.ndarray + minimum_within_batch_distance: float | None + mean_within_batch_distance: float | None + maximum_within_batch_distance: float | None + nearest_observed_pending_distance: np.ndarray + duplicate_row_pairs: tuple[tuple[int, int], ...] + grid_valid_rows: np.ndarray | None + boundary_flags: np.ndarray + metadata: dict[str, Any] + + def as_dict(self) -> dict[str, Any]: + """Return a serialization-friendly shallow mapping.""" + return asdict(self) + + +def summarize_candidate_batch( + X_norm: np.ndarray, + *, + observed_pending_norm: np.ndarray | None = None, + X_phys: np.ndarray | None = None, + design: Design | None = None, + dimension_weights: np.ndarray | None = None, + duplicate_atol: float = 1e-12, + metadata: Mapping[str, Any] | None = None, +) -> BatchDistanceDiagnostics: + """Compute batch-only ``O(q^2 D)`` diagnostics and reference distances.""" + X = _matrix(X_norm, name="X_norm") + if not np.isfinite(duplicate_atol) or duplicate_atol < 0: + raise ValueError("duplicate_atol must be finite and non-negative.") + distances = pairwise_normalized_distances(X, dimension_weights=dimension_weights) + if X.shape[0] >= 2: + triangle = distances[np.triu_indices(X.shape[0], k=1)] + minimum = float(triangle.min()) + mean = float(triangle.mean()) + maximum = float(triangle.max()) + else: + minimum = mean = maximum = None + + duplicate_pairs: list[tuple[int, int]] = [] + for left in range(X.shape[0]): + for right in range(left + 1, X.shape[0]): + if np.allclose(X[left], X[right], rtol=0.0, atol=duplicate_atol): + duplicate_pairs.append((left, right)) + + if (X_phys is None) != (design is None): + raise ValueError("X_phys and design must be supplied together.") + if X_phys is not None: + physical = _matrix(X_phys, name="X_phys") + if physical.shape[0] != X.shape[0]: + raise ValueError("X_phys and X_norm must contain the same row count.") + else: + physical = None + grid_valid = ( + None + if physical is None + else grid_membership_mask(physical, design) # type: ignore[arg-type] + ) + return BatchDistanceDiagnostics( + pairwise_distance_matrix=distances, + minimum_within_batch_distance=minimum, + mean_within_batch_distance=mean, + maximum_within_batch_distance=maximum, + nearest_observed_pending_distance=nearest_reference_distances( + X, + observed_pending_norm, + dimension_weights=dimension_weights, + ), + duplicate_row_pairs=tuple(duplicate_pairs), + grid_valid_rows=grid_valid, + boundary_flags=boundary_flags(X), + metadata={} if metadata is None else dict(metadata), + ) + + +def _output_path(output_path: str | Path) -> Path: + path = Path(output_path) + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def _save_figure(fig: Any, path: Path, watermark: str | None) -> None: + metadata: dict[str, str] | None = None + if watermark is not None: + if not isinstance(watermark, str) or not watermark.strip(): + raise ValueError("watermark must be a nonblank string when provided.") + label = watermark.strip() + fig.text( + 0.5, + 0.015, + label, + ha="center", + va="bottom", + color="firebrick", + fontsize=9, + fontweight="bold", + bbox={"facecolor": "white", "edgecolor": "firebrick", "alpha": 0.9}, + ) + metadata = {"Description": label} + fig.tight_layout(rect=(0.0, 0.06, 1.0, 1.0) if watermark else None) + fig.savefig(path, dpi=160, metadata=metadata) + plt.close(fig) + + +def plot_candidate_pca( + observed_norm: np.ndarray, + selected_norm: np.ndarray, + output_path: str | Path, + *, + pool_norm: np.ndarray | None = None, + pool_sample_size: int = 1000, + seed: int = 0, + watermark: str | None = None, +) -> Path: + """Save a two-component PCA view of observed, pool, and selected points.""" + observed = _matrix(observed_norm, name="observed_norm") + selected = _matrix(selected_norm, name="selected_norm") + if observed.shape[1] != selected.shape[1]: + raise ValueError("observed_norm and selected_norm dimensions must match.") + groups: list[tuple[str, np.ndarray]] = [("Observed", observed)] + if pool_norm is not None: + pool = _matrix(pool_norm, name="pool_norm") + if pool.shape[1] != observed.shape[1]: + raise ValueError("pool_norm and observed_norm dimensions must match.") + if pool_sample_size <= 0: + raise ValueError("pool_sample_size must be positive.") + if pool.shape[0] > pool_sample_size: + rng = np.random.default_rng(seed) + indices = np.sort( + rng.choice(pool.shape[0], size=pool_sample_size, replace=False) + ) + pool = pool[indices] + groups.append(("Pool", pool)) + groups.append(("Selected", selected)) + combined = np.vstack([values for _, values in groups]) + if combined.shape[0] < 2 or combined.shape[1] < 2: + raise ValueError("PCA plot requires at least two rows and two inputs.") + projected = PCA(n_components=2).fit_transform(combined) + + path = _output_path(output_path) + fig, axis = plt.subplots(figsize=(7, 5)) + offset = 0 + styles: Mapping[str, Mapping[str, Any]] = { + "Observed": {"marker": "o", "alpha": 0.75, "s": 38}, + "Pool": {"marker": ".", "alpha": 0.2, "s": 14}, + "Selected": {"marker": "*", "alpha": 1.0, "s": 130}, + } + for label, values in groups: + count = values.shape[0] + points = projected[offset : offset + count] + axis.scatter(points[:, 0], points[:, 1], label=label, **styles[label]) + offset += count + axis.set(xlabel="PC1", ylabel="PC2", title="Candidate acquisition in input space") + axis.legend() + _save_figure(fig, path, watermark) + return path + + +def plot_parallel_coordinates( + selected_norm: np.ndarray, + input_names: Sequence[str], + output_path: str | Path, + *, + watermark: str | None = None, +) -> Path: + """Save normalized selected conditions as a parallel-coordinates plot.""" + selected = _matrix(selected_norm, name="selected_norm") + if len(input_names) != selected.shape[1]: + raise ValueError("input_names must match the selected input dimension.") + path = _output_path(output_path) + fig, axis = plt.subplots(figsize=(max(8, selected.shape[1]), 4.5)) + positions = np.arange(selected.shape[1]) + for row_index, row in enumerate(selected): + axis.plot(positions, row, marker="o", label=f"Selection {row_index + 1}") + axis.set_xticks(positions, input_names, rotation=40, ha="right") + axis.set_ylim(-0.03, 1.03) + axis.set_ylabel("Normalized condition") + axis.set_title("Selected candidate conditions") + axis.legend(ncol=min(3, max(1, selected.shape[0]))) + _save_figure(fig, path, watermark) + return path + + +def plot_distance_heatmap( + X_norm: np.ndarray, + output_path: str | Path, + *, + dimension_weights: np.ndarray | None = None, + watermark: str | None = None, +) -> Path: + """Save the within-batch normalized-distance matrix as a heatmap.""" + matrix = pairwise_normalized_distances(X_norm, dimension_weights=dimension_weights) + path = _output_path(output_path) + fig, axis = plt.subplots(figsize=(5.5, 4.8)) + image = axis.imshow(matrix, cmap="viridis") + labels = [str(index + 1) for index in range(matrix.shape[0])] + axis.set_xticks(range(matrix.shape[0]), labels) + axis.set_yticks(range(matrix.shape[0]), labels) + axis.set(xlabel="Selection", ylabel="Selection", title="Batch distances") + for row in range(matrix.shape[0]): + for column in range(matrix.shape[1]): + axis.text( + column, + row, + f"{matrix[row, column]:.2f}", + ha="center", + va="center", + color="white" if matrix[row, column] < matrix.max() * 0.55 else "black", + fontsize=8, + ) + fig.colorbar(image, ax=axis, label="Normalized Euclidean distance") + _save_figure(fig, path, watermark) + return path + + +def plot_selection_scores( + selection_order: Sequence[int], + base_log_scores: Sequence[float], + penalized_log_scores: Sequence[float], + output_path: str | Path, + *, + watermark: str | None = None, +) -> Path: + """Save base-versus-penalized acquisition values by selection order.""" + order = np.asarray(selection_order) + base = np.asarray(base_log_scores, dtype=float) + penalized = np.asarray(penalized_log_scores, dtype=float) + if order.ndim != 1 or base.shape != order.shape or penalized.shape != order.shape: + raise ValueError("selection order and score arrays must share shape (q,).") + if not np.all(np.isfinite(base)) or not np.all(np.isfinite(penalized)): + raise ValueError("selection scores must be finite.") + path = _output_path(output_path) + fig, axis = plt.subplots(figsize=(6.5, 4.2)) + axis.plot(order, base, marker="o", label="Base log acquisition") + axis.plot(order, penalized, marker="s", label="Penalized log acquisition") + axis.set( + xlabel="Selection order", + ylabel="Log acquisition", + title="Sequential acquisition and local penalty", + ) + axis.set_xticks(order) + axis.legend() + _save_figure(fig, path, watermark) + return path diff --git a/src/mobo_kit/candidate_pool.py b/src/mobo_kit/candidate_pool.py new file mode 100644 index 0000000..fd4927e --- /dev/null +++ b/src/mobo_kit/candidate_pool.py @@ -0,0 +1,247 @@ +"""Deterministic sampling of finite pools from very large discrete designs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import prod +from typing import Sequence + +import numpy as np + +from .constraints import RowConstraint, apply_row_constraints +from .design import Design + + +@dataclass(frozen=True) +class CandidatePool: + """A discrete candidate pool in grid-index, physical, and normalized spaces.""" + + grid_indices: np.ndarray + X_phys: np.ndarray + X_norm: np.ndarray + seed: int + draws: int + rejected_duplicate: int + rejected_avoid: int + rejected_constraint: int + + @property + def size(self) -> int: + return int(self.grid_indices.shape[0]) + + +class CandidatePoolSamplingError(RuntimeError): + """Raised when an exact-size discrete pool cannot be produced safely.""" + + def __init__( + self, + *, + requested: int, + accepted: int, + draws: int, + max_draws: int, + rejected_duplicate: int, + rejected_avoid: int, + rejected_constraint: int, + reason: str, + ) -> None: + self.requested = requested + self.accepted = accepted + self.draws = draws + self.max_draws = max_draws + self.rejected_duplicate = rejected_duplicate + self.rejected_avoid = rejected_avoid + self.rejected_constraint = rejected_constraint + self.reason = reason + super().__init__( + "Could not sample the requested discrete candidate pool: " + f"requested={requested}, accepted={accepted}, draws={draws}, " + f"max_draws={max_draws}, duplicate_rejections={rejected_duplicate}, " + f"avoid_rejections={rejected_avoid}, " + f"constraint_rejections={rejected_constraint}. {reason}" + ) + + +def _design_grids(design: Design) -> tuple[np.ndarray, ...]: + if not isinstance(design, Design): + raise TypeError("design must be a Design.") + grids = tuple(np.asarray(grid, dtype=float) for grid in design.var_array) + if not grids or len(grids) != len(design.names): + raise ValueError("design must contain one non-empty grid per input name.") + for name, grid in zip(design.names, grids): + if grid.ndim != 1 or grid.size == 0: + raise ValueError(f"Design grid {name!r} must be a non-empty vector.") + if not np.all(np.isfinite(grid)) or np.unique(grid).size != grid.size: + raise ValueError(f"Design grid {name!r} must be finite and unique.") + return grids + + +def _physical_matrix( + value: np.ndarray | None, *, name: str, dimension: int +) -> np.ndarray: + if value is None: + return np.empty((0, dimension), dtype=float) + array = np.asarray(value, dtype=float) + if array.ndim != 2 or array.shape[1] != dimension: + raise ValueError(f"{name} must have shape (N, {dimension}); got {array.shape}.") + if not np.all(np.isfinite(array)): + raise ValueError(f"{name} must contain only finite values.") + return array + + +def physical_rows_to_grid_indices( + X_phys: np.ndarray, + design: Design, + *, + atol: float = 0.0, +) -> np.ndarray: + """Convert physical rows to exact integer grid tuples or fail off-grid.""" + grids = _design_grids(design) + X = _physical_matrix(X_phys, name="X_phys", dimension=len(grids)) + if not np.isfinite(atol) or atol < 0: + raise ValueError("atol must be finite and non-negative.") + indices = np.empty(X.shape, dtype=np.int64) + for column, (name, grid) in enumerate(zip(design.names, grids)): + differences = np.abs(X[:, column, None] - grid[None, :]) + closest = differences.argmin(axis=1) + invalid = differences[np.arange(X.shape[0]), closest] > atol + if np.any(invalid): + rows = np.flatnonzero(invalid).tolist() + raise ValueError(f"Physical rows {rows} are off-grid for input {name!r}.") + indices[:, column] = closest + return indices + + +def _indices_to_physical( + grid_indices: np.ndarray, grids: tuple[np.ndarray, ...] +) -> np.ndarray: + physical = np.empty(grid_indices.shape, dtype=float) + for column, grid in enumerate(grids): + physical[:, column] = grid[grid_indices[:, column]] + return physical + + +def _normalize_physical(X_phys: np.ndarray, design: Design) -> np.ndarray: + lower = np.asarray(design.lowers, dtype=float) + upper = np.asarray(design.uppers, dtype=float) + spans = upper - lower + normalized = np.zeros_like(X_phys, dtype=float) + changing = spans > 0 + normalized[:, changing] = (X_phys[:, changing] - lower[changing]) / spans[changing] + return normalized + + +def sample_discrete_candidate_pool( + design: Design, + pool_size: int, + *, + seed: int, + observed_phys: np.ndarray | None = None, + pending_phys: np.ndarray | None = None, + avoid_phys: np.ndarray | None = None, + row_constraints: Sequence[RowConstraint] | None = None, + max_draws: int | None = None, +) -> CandidatePool: + """Sample an exact-size unique pool without allocating the Cartesian grid.""" + grids = _design_grids(design) + if isinstance(pool_size, bool) or not isinstance(pool_size, (int, np.integer)): + raise ValueError("pool_size must be a positive integer.") + requested = int(pool_size) + if requested <= 0: + raise ValueError("pool_size must be a positive integer.") + if ( + isinstance(seed, (bool, np.bool_)) + or not isinstance(seed, (int, np.integer)) + or int(seed) < 0 + ): + raise ValueError("seed must be a non-negative integer.") + if max_draws is None: + draw_limit = max(1000, requested * 50) + elif isinstance(max_draws, bool) or not isinstance(max_draws, (int, np.integer)): + raise ValueError("max_draws must be a positive integer.") + else: + draw_limit = int(max_draws) + if draw_limit <= 0: + raise ValueError("max_draws must be a positive integer.") + + dimension = len(grids) + avoid_rows = np.vstack( + [ + _physical_matrix(observed_phys, name="observed_phys", dimension=dimension), + _physical_matrix(pending_phys, name="pending_phys", dimension=dimension), + _physical_matrix(avoid_phys, name="avoid_phys", dimension=dimension), + ] + ) + avoid_indices = physical_rows_to_grid_indices(avoid_rows, design) + avoid_set = {tuple(int(value) for value in row) for row in avoid_indices} + total_grid_size = prod(int(grid.size) for grid in grids) + available_without_constraints = total_grid_size - len(avoid_set) + if requested > available_without_constraints: + raise CandidatePoolSamplingError( + requested=requested, + accepted=0, + draws=0, + max_draws=draw_limit, + rejected_duplicate=0, + rejected_avoid=0, + rejected_constraint=0, + reason=( + "The request exceeds the number of grid tuples remaining after " + f"explicit exclusions ({available_without_constraints})." + ), + ) + + generator = np.random.default_rng(int(seed)) + axis_sizes = np.asarray([grid.size for grid in grids], dtype=np.int64) + seen: set[tuple[int, ...]] = set() + accepted_indices: list[tuple[int, ...]] = [] + draws = rejected_duplicate = rejected_avoid = rejected_constraint = 0 + while len(accepted_indices) < requested and draws < draw_limit: + index_tuple = tuple(int(generator.integers(0, high)) for high in axis_sizes) + draws += 1 + if index_tuple in seen: + rejected_duplicate += 1 + continue + seen.add(index_tuple) + if index_tuple in avoid_set: + rejected_avoid += 1 + continue + row_indices = np.asarray(index_tuple, dtype=np.int64)[None, :] + row_physical = _indices_to_physical(row_indices, grids) + if not bool(apply_row_constraints(row_physical, design, row_constraints)[0]): + rejected_constraint += 1 + continue + accepted_indices.append(index_tuple) + + if len(accepted_indices) != requested: + raise CandidatePoolSamplingError( + requested=requested, + accepted=len(accepted_indices), + draws=draws, + max_draws=draw_limit, + rejected_duplicate=rejected_duplicate, + rejected_avoid=rejected_avoid, + rejected_constraint=rejected_constraint, + reason="Maximum draws reached; constraints and exclusions were not relaxed.", + ) + index_array = np.asarray(accepted_indices, dtype=np.int64) + physical = _indices_to_physical(index_array, grids) + normalized = _normalize_physical(physical, design) + return CandidatePool( + grid_indices=index_array, + X_phys=physical, + X_norm=normalized, + seed=int(seed), + draws=draws, + rejected_duplicate=rejected_duplicate, + rejected_avoid=rejected_avoid, + rejected_constraint=rejected_constraint, + ) + + +__all__ = [ + "CandidatePool", + "CandidatePoolSamplingError", + "physical_rows_to_grid_indices", + "sample_discrete_candidate_pool", +] diff --git a/src/mobo_kit/cli.py b/src/mobo_kit/cli.py index 331ccc9..bbe9386 100644 --- a/src/mobo_kit/cli.py +++ b/src/mobo_kit/cli.py @@ -15,7 +15,7 @@ def main(): """ Command line interface for MOBO-Kit. - + Usage examples: mobo-kit run --csv data/my_data.csv mobo-kit generate --config configs/my_config.yaml --n-samples 20 --out initial_experiments.csv @@ -37,87 +37,123 @@ def main(): # Run with specific device and seed mobo-kit run --csv data/my_data.csv --device cpu --seed 123 --verbose - # Run with custom batch size for acquisition - mobo-kit run --csv data/my_data.csv --batch-size 10 --num-restarts 50 - """ + # Step 2A analysis is allowed, but campaign proposal remains blocked until + # the reviewed Step 2B adapter is implemented. + mobo-kit run --csv data/my_data.csv --config configs/my_config.yaml + """, ) - - subparsers = parser.add_subparsers(dest='command', help='Available commands') - + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + # Generate subcommand - generate_parser = subparsers.add_parser('generate', help='Generate initial experiments using LHS') - generate_parser.add_argument('--config', required=True, help='Path to YAML configuration file') - generate_parser.add_argument('--n-samples', type=int, required=True, help='Number of initial experiments to generate') - generate_parser.add_argument('--out', required=True, help='Output CSV file path') - generate_parser.add_argument('--seed', type=int, default=42, help='Random seed for reproducibility (default: 42)') - generate_parser.add_argument('--max-corr', type=float, help='Maximum absolute correlation between variables') - generate_parser.add_argument('--max-attempts', type=int, default=100, help='Maximum attempts for LHS generation (default: 100)') - generate_parser.add_argument('--verbose', action='store_true', help='Enable verbose output') - + generate_parser = subparsers.add_parser( + "generate", help="Generate initial experiments using LHS" + ) + generate_parser.add_argument( + "--config", required=True, help="Path to YAML configuration file" + ) + generate_parser.add_argument( + "--n-samples", + type=int, + required=True, + help="Number of initial experiments to generate", + ) + generate_parser.add_argument("--out", required=True, help="Output CSV file path") + generate_parser.add_argument( + "--seed", + type=int, + default=42, + help="Random seed for reproducibility (default: 42)", + ) + generate_parser.add_argument( + "--max-corr", type=float, help="Maximum absolute correlation between variables" + ) + generate_parser.add_argument( + "--max-attempts", + type=int, + default=100, + help="Maximum attempts for LHS generation (default: 100)", + ) + generate_parser.add_argument( + "--verbose", action="store_true", help="Enable verbose output" + ) + # Run subcommand - run_parser = subparsers.add_parser('run', help='Run MOBO optimization with existing data') + run_parser = subparsers.add_parser( + "run", help="Run MOBO optimization with existing data" + ) run_parser.add_argument( - "--csv", - required=True, - help="Path to CSV file with experimental data" + "--csv", required=True, help="Path to CSV file with experimental data" ) - + # Optional arguments for run command run_parser.add_argument( "--config", - help="Path to YAML configuration file (optional, will auto-generate from CSV if not provided)" + help="Path to YAML configuration file (optional, will auto-generate from CSV if not provided)", ) - + run_parser.add_argument( "--out", - default="results/experiment", - help="Output directory for results (default: results/experiment)" + default="local_outputs/experiment", + help="Output directory for results (default: local_outputs/experiment)", ) - + run_parser.add_argument( "--seed", type=int, default=42, - help="Random seed for reproducibility (default: 42)" + help="Random seed for reproducibility (default: 42)", ) - + run_parser.add_argument( "--device", choices=["auto", "cpu", "cuda"], default="auto", - help="Device to use for computation (default: auto)" + help="Device to use for computation (default: auto)", + ) + + run_parser.add_argument( + "--verbose", action="store_true", help="Enable verbose output" ) - + + # Retained only to fail closed with a clear migration message. The legacy + # qNEHVI implementation is not a Step 2A production adapter. run_parser.add_argument( - "--verbose", + "--propose-candidates", action="store_true", - help="Enable verbose output" + help="Blocked in Step 2A; requires a reviewed Step 2B campaign adapter", ) - - # Advanced options (for future expansion) + + run_parser.add_argument( + "--reference-point", + type=float, + nargs="+", + help="One approved hypervolume reference value per transformed objective", + ) + run_parser.add_argument( "--batch-size", type=int, default=5, - help="Batch size for acquisition (default: 5)" + help="Batch size for acquisition (default: 5)", ) - + run_parser.add_argument( "--num-restarts", type=int, default=20, - help="Number of restarts for acquisition optimization (default: 20)" + help="Number of restarts for acquisition optimization (default: 20)", ) - + args = parser.parse_args() - + # Handle subcommands - if args.command == 'generate': + if args.command == "generate": # Validate inputs for generate command if not os.path.exists(args.config): print(f"Error: Config file not found: {args.config}") sys.exit(1) - + try: # Generate initial experiments results = generate_initial_experiments( @@ -127,42 +163,44 @@ def main(): seed=args.seed, verbose=args.verbose, max_abs_corr=args.max_corr, - max_attempts=args.max_attempts + max_attempts=args.max_attempts, ) - + if args.verbose: print("\n" + "=" * 60) print("GENERATION SUMMARY:") print(f" Config: {args.config}") print(f" Samples: {results['n_samples']}") print(f" Variables: {', '.join(results['variables'])}") - print(f" Constraints: {'Applied' if results['constraints_applied'] else 'None'}") + print( + f" Constraints: {'Applied' if results['constraints_applied'] else 'None'}" + ) print(f" Seed: {results['seed']}") print(f" Output: {results['save_path']}") print("=" * 60) else: - print(f"Success! Generated {results['n_samples']} experiments in {results['save_path']}") - + print( + f"Success! Generated {results['n_samples']} experiments in {results['save_path']}" + ) + except Exception as e: print(f"Error: {e}") if args.verbose: import traceback + traceback.print_exc() sys.exit(1) - - elif args.command == 'run': + + elif args.command == "run": # Validate inputs for run command if not os.path.exists(args.csv): print(f"Error: CSV file not found: {args.csv}") sys.exit(1) - + if args.config and not os.path.exists(args.config): print(f"Error: Config file not found: {args.config}") sys.exit(1) - - # Create output directory - os.makedirs(args.out, exist_ok=True) - + try: # Run the experiment results = run_mobo_experiment( @@ -172,9 +210,12 @@ def main(): seed=args.seed, device=args.device, verbose=args.verbose, - batch_size=args.batch_size + batch_size=args.batch_size, + propose_candidates=args.propose_candidates, + reference_point=args.reference_point, + num_restarts=args.num_restarts, ) - + # Print summary if args.verbose: print("\n" + "=" * 60) @@ -188,19 +229,20 @@ def main(): print(f" Device: {results['device']}") print(f" Seed: {results['seed']}") print(f" Results: {results['save_dir']}") - if results.get('candidates'): + if results.get("candidates"): print(f" Next batch: {args.batch_size} candidates proposed") print("=" * 60) else: success_msg = f"Success! Results saved to {results['save_dir']}" - if results.get('candidates'): + if results.get("candidates"): success_msg += f" ({args.batch_size} candidates proposed)" print(success_msg) - + except Exception as e: print(f"Error: {e}") if args.verbose: import traceback + traceback.print_exc() sys.exit(1) else: diff --git a/src/mobo_kit/constraints.py b/src/mobo_kit/constraints.py index 4e34860..07418bb 100644 --- a/src/mobo_kit/constraints.py +++ b/src/mobo_kit/constraints.py @@ -1,124 +1,320 @@ -# src/constraints.py +"""Opt-in physical-space constraints for campaign designs.""" + from __future__ import annotations -from typing import Callable, Dict, List, Optional, Sequence + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Sequence + import numpy as np -# Public type: row-wise constraint (PHYSICAL units in, boolean mask out) +if TYPE_CHECKING: + from .design import Design + + RowConstraint = Callable[[np.ndarray, "Design"], np.ndarray] -# ========================= -# Clausius–Clapeyron (C → K) -# ========================= +@dataclass(frozen=True) +class NamedConstraint: + """A row constraint that can say what it is when it rejects something. -def check_clausius_clapeyron_np(ah_vals, temp_c_vals) -> np.ndarray: + Callable, so it is a ``RowConstraint`` everywhere one is expected and the + candidate pool needs no knowledge of it. The name and description exist for + the review artifact: a reviewer being told a condition was rejected needs the + rule, not an index. """ - Return boolean mask for valid points satisfying Clausius–Clapeyron constraint. - Ensures absolute humidity (g/m^3) does not exceed saturation at given temperature. - Parameters - ---------- - ah_vals : array-like, absolute humidity [g/m^3] - temp_c_vals : array-like, temperature [°C] (converted to K internally) + name: str + description: str + check: RowConstraint - Returns - ------- - valid_mask : np.ndarray[bool] - """ - AH = np.asarray(ah_vals, dtype=float) - T_c = np.asarray(temp_c_vals, dtype=float) # Celsius in - T = T_c + 273.15 # Kelvin + def __call__(self, X: np.ndarray, design: "Design") -> np.ndarray: + return self.check(X, design) - # Saturation vapor pressure (kPa) - es = 0.6113 * np.exp((17.27 * (T - 273.15)) / (T - 35.86)) - # Max absolute humidity (g/m^3) - AH_max = es / (4.61e-4 * T) +def check_clausius_clapeyron_np(ah_vals, temp_c_vals) -> np.ndarray: + """Return a validity mask for the Clausius-Clapeyron constraint. + + Absolute humidity is expressed in g/m^3 and temperature in degrees C. + The calculation converts temperature to kelvin internally. + """ - return (AH <= AH_max) & np.isfinite(AH_max) + absolute_humidity = np.asarray(ah_vals, dtype=float) + temperature_k = np.asarray(temp_c_vals, dtype=float) + 273.15 + saturation_pressure = 0.6113 * np.exp( + (17.27 * (temperature_k - 273.15)) / (temperature_k - 35.86) + ) + maximum_absolute_humidity = saturation_pressure / (4.61e-4 * temperature_k) + return (absolute_humidity <= maximum_absolute_humidity) & np.isfinite( + maximum_absolute_humidity + ) -# ========================= -# Builders (one per supported constraint key) -# ========================= def _idx_for(name: str, design: "Design") -> int: try: return design.names.index(name) - except ValueError as e: + except ValueError as exc: raise KeyError( - f"Constraint refers to column '{name}', but it is not in design.names={design.names}" - ) from e + f"Constraint refers to column '{name}', but it is not in " + f"design.names={design.names}." + ) from exc -def _build_cc(spec: Dict, design: "Design") -> RowConstraint: - ah_col = spec.get("ah_col") - t_col = spec.get("temp_c_col") - if ah_col is None or t_col is None: +def _build_clausius_clapeyron(specification: Dict, design: "Design") -> RowConstraint: + absolute_humidity_column = specification.get("ah_col") + temperature_column = specification.get("temp_c_col") + if absolute_humidity_column is None or temperature_column is None: raise KeyError( - "Clausius–Clapeyron constraint needs 'absolute_humidity_col' and 'temperature_col'." + "Clausius-Clapeyron constraint requires 'ah_col' and 'temp_c_col'." ) - i_ah = _idx_for(ah_col, design) - i_tC = _idx_for(t_col, design) + absolute_humidity_index = _idx_for(absolute_humidity_column, design) + temperature_index = _idx_for(temperature_column, design) def row_constraint_fn(X: np.ndarray, _design: "Design") -> np.ndarray: - return check_clausius_clapeyron_np(X[:, i_ah], X[:, i_tC]) + return check_clausius_clapeyron_np( + X[:, absolute_humidity_index], X[:, temperature_index] + ) + + return row_constraint_fn + + +def _column_names(names, *, field: str) -> List[str]: + if isinstance(names, str): + names = [names] + if not isinstance(names, list) or not names: + raise KeyError( + f"Constraint field {field!r} must name one column or a non-empty list " + f"of columns; got {names!r}." + ) + return [str(name) for name in names] + + +def _build_zero_coupled(specification, design: "Design") -> RowConstraint: + """Two settings that describe one optional process step: both on, or both off. + + A second spin stage that runs for 0 s at 3500 rpm is not a slower stage, it is + a contradiction -- and so is one that runs for 30 s at 0 rpm. Exactly one of + the pair being zero is the invalid case; both zero means the step was skipped, + which is a real recipe (sample 2 of the v3 workbook is a one-step film). + + Stated as an iff rather than as two separate bounds because that is what makes + the skipped-step recipe reachable at all: a plain lower bound on either column + would delete it. + """ + names = _column_names(specification, field="zero_coupled") + if len(names) < 2: + raise KeyError("zero_coupled needs at least two columns to couple.") + indices = [_idx_for(name, design) for name in names] + + def row_constraint_fn(X: np.ndarray, _design: "Design") -> np.ndarray: + block = np.asarray(X, dtype=float)[:, indices] + zeros = np.isclose(block, 0.0, rtol=0.0, atol=1e-12) + return zeros.all(axis=1) | (~zeros).all(axis=1) + + return row_constraint_fn + + +def _build_sum_upper_strict(specification, design: "Design") -> RowConstraint: + """``lhs < sum(rhs)``, strictly. + + Written for ``anti_time < time_1 + time_2``: the antisolvent has to be dropped + while the substrate is still spinning, so equality is already too late rather + than just in time. Strictness is the whole point of the constraint and is why + this is not the existing bounds check with a different argument. + """ + if not isinstance(specification, dict): + raise KeyError( + "sum_upper_strict takes a mapping with 'lhs' and 'rhs'; " + f"got {specification!r}." + ) + left = specification.get("lhs") + if not isinstance(left, str) or not left.strip(): + raise KeyError("sum_upper_strict needs 'lhs' to name one column.") + left_index = _idx_for(left.strip(), design) + right_indices = [ + _idx_for(name, design) + for name in _column_names(specification.get("rhs"), field="rhs") + ] + + def row_constraint_fn(X: np.ndarray, _design: "Design") -> np.ndarray: + values = np.asarray(X, dtype=float) + return values[:, left_index] < values[:, right_indices].sum(axis=1) + + return row_constraint_fn + + +def _build_nonzero_minimum(specification, design: "Design") -> RowConstraint: + """A column is either exactly zero or at least ``minimum``. + + This exists because the design grid is arithmetic -- ``start``/``stop``/``step`` + with uniform spacing, which ``lhs`` asserts -- so a grid of ``{0} U {10, 15, + ... 60}`` cannot be declared directly. Reaching 0 with ``step: 5`` also reaches + 5, and a 5 s second spin stage was not in the first campaign's design and has + never been run. + + Declaring the hole here keeps it visible in config and enforced everywhere the + other constraints are, rather than widening the design space in silence. + """ + if not isinstance(specification, dict): + raise KeyError( + "nonzero_minimum takes a mapping with 'column' and 'minimum'; " + f"got {specification!r}." + ) + column = specification.get("column") + if not isinstance(column, str) or not column.strip(): + raise KeyError("nonzero_minimum needs 'column' to name one column.") + index = _idx_for(column.strip(), design) + minimum = specification.get("minimum") + if isinstance(minimum, bool) or not isinstance(minimum, (int, float)): + raise KeyError("nonzero_minimum needs a numeric 'minimum'.") + threshold = float(minimum) + if not np.isfinite(threshold) or threshold <= 0: + raise ValueError("nonzero_minimum 'minimum' must be finite and positive.") + + def row_constraint_fn(X: np.ndarray, _design: "Design") -> np.ndarray: + values = np.asarray(X, dtype=float)[:, index] + return np.isclose(values, 0.0, rtol=0.0, atol=1e-12) | (values >= threshold) return row_constraint_fn -# Map of **boolean keys** in YAML → builder _SUPPORTED_BOOL_KEYS = { - "clausius_clapeyron": _build_cc, - # add more: "my_constraint_key": _build_my_constraint + "clausius_clapeyron": _build_clausius_clapeyron, } +#: Types whose entry key carries the constraint's parameters rather than a bool. +#: ``clausius_clapeyron: true`` predates these and keeps its flag spelling. +_SUPPORTED_VALUE_KEYS = { + "zero_coupled": _build_zero_coupled, + "sum_upper_strict": _build_sum_upper_strict, + "nonzero_minimum": _build_nonzero_minimum, +} -# ========================= -# Config parsing & application -# ========================= def constraints_from_config(cfg: Dict, design: "Design") -> List[RowConstraint]: - """ - Parse a YAML layout like: + """Build only explicitly configured campaign constraints. + + An entry names exactly one type. ``clausius_clapeyron`` takes a boolean flag + and its parameters as siblings; the rest carry their parameters on the type + key itself:: + + constraints: + - clausius_clapeyron: true + ah_col: absolute_humidity + temp_c_col: temperature_c - constraints: - - clausius_clapeyron: true - absolute_humidity_col: "absolute_humidity" - temperature_col: "temperature_c" + - zero_coupled: [speed_2, time_2] + - sum_upper_strict: {lhs: anti_time, rhs: [time_1, time_2]} + - nonzero_minimum: {column: time_2, minimum: 10} - - clausius_clapeyron: false # ignored - ... + An optional ``name:`` overrides the label a violation is reported under. - Returns a list of row-constraint callables (possibly empty). + Missing ``constraints`` and an empty list both mean no constraints. Invalid + explicit entries fail instead of being silently ignored. """ + + if not isinstance(cfg, dict): + raise TypeError("Constraint configuration must be a mapping.") + items = cfg.get("constraints", []) if not items: return [] + if not isinstance(items, list): + raise TypeError("Config 'constraints' must be a list of mappings.") - fns: List[RowConstraint] = [] - for raw in items: + known_types = sorted({*_SUPPORTED_BOOL_KEYS, *_SUPPORTED_VALUE_KEYS}) + constraints: List[RowConstraint] = [] + for index, raw in enumerate(items): if not isinstance(raw, dict): - continue + raise TypeError( + f"Constraint entry at index {index} must be a mapping; " + f"got {type(raw).__name__}." + ) + + known_keys = [key for key in known_types if key in raw] + if not known_keys: + raise KeyError( + f"Constraint entry at index {index} has no supported type; " + f"expected one of {known_types}." + ) + if len(known_keys) > 1: + raise ValueError( + f"Constraint entry at index {index} enables multiple types: " + f"{known_keys}. Use one constraint type per entry." + ) - # find which supported boolean key (if any) is enabled - chosen_key = None - for key, builder in _SUPPORTED_BOOL_KEYS.items(): - val = raw.get(key, None) - if isinstance(val, bool) and val: - chosen_key = key - break + chosen_key = known_keys[0] + if chosen_key in _SUPPORTED_BOOL_KEYS: + enabled = raw[chosen_key] + if not isinstance(enabled, bool): + raise TypeError( + f"Constraint flag '{chosen_key}' must be true or false; " + f"got {enabled!r}." + ) + if not enabled: + continue + builder = _SUPPORTED_BOOL_KEYS[chosen_key] + # the flag spelling keeps its parameters as siblings of the flag + parameters = raw + else: + builder = _SUPPORTED_VALUE_KEYS[chosen_key] + # the value spelling carries its parameters on the type key itself + parameters = raw[chosen_key] + + label = raw.get("name") + constraints.append( + NamedConstraint( + name=str(label) if label else chosen_key, + description=_describe(chosen_key, parameters), + check=builder(parameters, design), + ) + ) - if chosen_key is None: - # no supported boolean flag set to true -> skip this entry - continue + return constraints + + +def _describe(kind: str, parameters) -> str: + """A one-line statement of the rule, for a reviewer rather than a log.""" + if kind == "zero_coupled": + names = [parameters] if isinstance(parameters, str) else list(parameters or ()) + return f"{' and '.join(str(c) for c in names)} are all zero or all nonzero" + if kind == "sum_upper_strict" and isinstance(parameters, dict): + rhs = parameters.get("rhs") + names = [rhs] if isinstance(rhs, str) else list(rhs or ()) + return f"{parameters.get('lhs')} < {' + '.join(str(c) for c in names)}" + if kind == "nonzero_minimum" and isinstance(parameters, dict): + return ( + f"{parameters.get('column')} is 0 or at least " + f"{parameters.get('minimum')}" + ) + return kind - # build constraint from the same dict (which also holds column names, etc.) - builder = _SUPPORTED_BOOL_KEYS[chosen_key] - fns.append(builder(raw, design)) - return fns +def constraint_violations( + X_phys: np.ndarray, + design: "Design", + constraints: Optional[Sequence[RowConstraint]], +) -> List[List[str]]: + """Per row, the names of the constraints it breaks. + + :func:`apply_row_constraints` answers "may this row be used"; this answers + "and if not, which rule". A batch review needs the second, because "condition + 3 is invalid" is not something anyone can act on. + """ + X_phys = np.asarray(X_phys, dtype=float) + if X_phys.ndim != 2: + raise ValueError("Physical input array must be two-dimensional.") + per_row: List[List[str]] = [[] for _ in range(X_phys.shape[0])] + if not constraints or X_phys.shape[0] == 0: + return per_row + for index, constraint in enumerate(constraints): + mask = apply_row_constraints(X_phys, design, [constraint]) + label = getattr(constraint, "name", f"constraint #{index}") + for row in np.flatnonzero(~mask): + per_row[int(row)].append(label) + return per_row def apply_row_constraints( @@ -126,21 +322,46 @@ def apply_row_constraints( design: "Design", constraints: Optional[Sequence[RowConstraint]], ) -> np.ndarray: - """ - Apply zero or more row-wise constraints; returns a boolean mask AND-ing all. - If constraints is None or empty, returns all True. - """ - n = X_phys.shape[0] - if not constraints: - return np.ones(n, dtype=bool) + """AND zero or more row constraints over physical-space input rows.""" + + X_phys = np.asarray(X_phys, dtype=float) + if X_phys.ndim != 2 or X_phys.shape[1] != len(design.names): + raise ValueError( + "Physical input array must have shape (n_rows, n_design_inputs); " + f"got {X_phys.shape} for {len(design.names)} design inputs." + ) - mask = np.ones(n, dtype=bool) - for k, fn in enumerate(constraints): - m = fn(X_phys, design) - if m is None or m.dtype != bool or m.shape != (n,): + row_count = X_phys.shape[0] + if not constraints: + return np.ones(row_count, dtype=bool) + + mask = np.ones(row_count, dtype=bool) + for index, constraint in enumerate(constraints): + result = constraint(X_phys, design) + if ( + result is None + or not isinstance(result, np.ndarray) + or result.dtype != bool + or result.shape != (row_count,) + ): + actual = ( + None + if result is None + else (getattr(result, "dtype", None), getattr(result, "shape", None)) + ) raise ValueError( - f"Constraint #{k} must return a boolean mask of shape ({n},); " - f"got {None if m is None else (m.dtype, m.shape)}" + f"Constraint #{index} must return a boolean mask of shape " + f"({row_count},); got {actual}." ) - mask &= m + mask &= result return mask + + +__all__ = [ + "NamedConstraint", + "RowConstraint", + "apply_row_constraints", + "check_clausius_clapeyron_np", + "constraint_violations", + "constraints_from_config", +] diff --git a/src/mobo_kit/design.py b/src/mobo_kit/design.py index bb8b7d4..6274cb6 100644 --- a/src/mobo_kit/design.py +++ b/src/mobo_kit/design.py @@ -4,14 +4,107 @@ from typing import List, Optional, Dict, Any import numpy as np -def make_linspace(start: float, stop: float, step: float, decimals: int = 6) -> np.ndarray: - """Create a grid of values from start to stop with given step size.""" - num_points = int(round((stop - start) / step)) + 1 - return np.round(np.linspace(start, stop, num_points), decimals) + +def _finite_float(value: Any, *, field: str, input_name: str) -> float: + """Return ``value`` as a finite float with a campaign-friendly error.""" + if isinstance(value, (bool, np.bool_)): + raise ValueError( + f"Input '{input_name}' field '{field}' must be a finite number, not bool." + ) + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError( + f"Input '{input_name}' field '{field}' must be a finite number; " + f"got {value!r}." + ) from exc + if not np.isfinite(number): + raise ValueError( + f"Input '{input_name}' field '{field}' must be finite; got {value!r}." + ) + return number + + +def _validate_decimals(value: Any, *, input_name: str) -> int: + if isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer)): + raise ValueError( + f"Input '{input_name}' field 'decimals' must be a non-negative integer; " + f"got {value!r}." + ) + decimals = int(value) + if decimals < 0: + raise ValueError( + f"Input '{input_name}' field 'decimals' must be a non-negative integer; " + f"got {decimals}." + ) + return decimals + + +def make_linspace( + start: float, + stop: float, + step: float, + decimals: int = 6, +) -> np.ndarray: + """Create an endpoint-aligned grid with the requested step. + + ``numpy.linspace`` can silently change the requested spacing when the endpoint + is not aligned. Campaign inputs must instead satisfy + ``stop == start + k * step`` (within floating-point tolerance). + """ + input_name = "" + start_f = _finite_float(start, field="start", input_name=input_name) + stop_f = _finite_float(stop, field="stop", input_name=input_name) + step_f = _finite_float(step, field="step", input_name=input_name) + decimals_i = _validate_decimals(decimals, input_name=input_name) + + if step_f <= 0: + raise ValueError(f"Grid step must be > 0; got {step_f}.") + if stop_f < start_f: + raise ValueError( + f"Grid stop must be greater than or equal to start; " + f"got start={start_f}, stop={stop_f}." + ) + + span = stop_f - start_f + if span == 0: + return np.asarray([round(start_f, decimals_i)], dtype=float) + + interval_count = int(round(span / step_f)) + if interval_count < 1: + raise ValueError( + "Grid endpoint is not aligned with step: " + f"start={start_f}, stop={stop_f}, step={step_f}. " + "Require stop = start + k * step for an integer k." + ) + aligned_span = interval_count * step_f + alignment_atol = max(1e-12, abs(step_f) * 1e-9) + if not np.isclose(span, aligned_span, rtol=0.0, atol=alignment_atol): + raise ValueError( + "Grid endpoint is not aligned with step: " + f"start={start_f}, stop={stop_f}, step={step_f}. " + "Require stop = start + k * step for an integer k." + ) + + grid = np.round( + start_f + step_f * np.arange(interval_count + 1, dtype=float), + decimals_i, + ) + grid[0] = round(start_f, decimals_i) + grid[-1] = round(stop_f, decimals_i) + + if np.unique(grid).size != grid.size: + raise ValueError( + f"Rounding the grid to {decimals_i} decimals creates duplicate values. " + "Increase 'decimals' or use a larger step." + ) + return grid + @dataclass class InputSpec: """Specification for an input parameter with grid-based discretization.""" + name: str start: float stop: float @@ -19,53 +112,120 @@ class InputSpec: unit: Optional[str] = None decimals: int = 6 + def __post_init__(self) -> None: + if not isinstance(self.name, str) or not self.name.strip(): + raise ValueError("Input name must be a non-empty string.") + self.name = self.name.strip() + self.start = _finite_float(self.start, field="start", input_name=self.name) + self.stop = _finite_float(self.stop, field="stop", input_name=self.name) + self.step = _finite_float(self.step, field="step", input_name=self.name) + self.decimals = _validate_decimals(self.decimals, input_name=self.name) + if self.step <= 0: + raise ValueError( + f"Input '{self.name}' field 'step' must be > 0; got {self.step}." + ) + if self.stop < self.start: + raise ValueError( + f"Input '{self.name}' requires stop >= start; " + f"got start={self.start}, stop={self.stop}." + ) + # Constructing the grid here validates endpoint alignment and precision. + try: + make_linspace(self.start, self.stop, self.step, self.decimals) + except ValueError as exc: + raise ValueError(f"Invalid grid for input '{self.name}': {exc}") from exc + + def build_input_spec_list(cfg_inputs: List[Dict[str, Any]]) -> List[InputSpec]: """Build InputSpec objects from config dictionary.""" + if not isinstance(cfg_inputs, list) or not cfg_inputs: + raise ValueError("Config 'inputs' must be a non-empty list.") + specs = [] - for item in cfg_inputs: - # Handle both old format (lower/upper) and new format (start/stop/step) - if 'start' in item and 'stop' in item and 'step' in item: - # New format: start/stop/step - spec = InputSpec( - name=item['name'], - unit=item.get('unit'), - start=float(item['start']), - stop=float(item['stop']), - step=float(item['step']), - decimals=item.get('decimals', 6) + seen_names = set() + for index, item in enumerate(cfg_inputs): + if not isinstance(item, dict): + raise ValueError( + f"Config input at index {index} must be a mapping; " + f"got {type(item).__name__}." + ) + missing = [key for key in ("name", "start", "stop", "step") if key not in item] + if missing: + display_name = item.get("name", f"index {index}") + raise ValueError( + f"Input '{display_name}' is missing required field(s): " + f"{', '.join(missing)}." + ) + + spec = InputSpec( + name=item["name"], + unit=item.get("unit"), + start=item["start"], + stop=item["stop"], + step=item["step"], + decimals=item.get("decimals", 6), + ) + if spec.name in seen_names: + raise ValueError( + f"Input names must be unique; duplicate name '{spec.name}'." ) - else: - raise ValueError(f"Input '{item.get('name', 'unknown')}' must have either (start, stop, step) or (lower, upper).") - + seen_names.add(spec.name) specs.append(spec) - + return specs + @dataclass class Design: """Design space specification with grid-based discretization.""" + names: List[str] units: List[Optional[str]] - lowers: np.ndarray # (D,) minimum values - uppers: np.ndarray # (D,) maximum values - steps: np.ndarray # (D,) step sizes - var_array: List[np.ndarray] # grid values for each feature - var_list: List[np.ndarray] # grid values for each feature (same as var_array for consistency) + lowers: np.ndarray # (D,) minimum values + uppers: np.ndarray # (D,) maximum values + steps: np.ndarray # (D,) step sizes + var_array: List[np.ndarray] # grid values for each feature + # Grid values for each feature (same as var_array for compatibility). + var_list: List[np.ndarray] + def build_design(specs: List[InputSpec]) -> Design: """Build a Design object from InputSpec objects.""" + if not isinstance(specs, list) or not specs: + raise ValueError("At least one InputSpec is required to build a design.") + names, units = [], [] lowers, uppers, steps = [], [], [] var_array: List[np.ndarray] = [] + seen_names = set() - for spec in specs: + for index, raw_spec in enumerate(specs): + if not isinstance(raw_spec, InputSpec): + raise TypeError( + f"Design item at index {index} must be an InputSpec; " + f"got {type(raw_spec).__name__}." + ) + # InputSpec is mutable, so revalidate a fresh copy at the boundary. + spec = InputSpec( + name=raw_spec.name, + start=raw_spec.start, + stop=raw_spec.stop, + step=raw_spec.step, + unit=raw_spec.unit, + decimals=raw_spec.decimals, + ) + if spec.name in seen_names: + raise ValueError( + f"Input names must be unique; duplicate name '{spec.name}'." + ) + seen_names.add(spec.name) names.append(spec.name) units.append(spec.unit) - + # Create grid for this parameter grid = make_linspace(spec.start, spec.stop, spec.step, spec.decimals) var_array.append(grid) - + # Store bounds and step lowers.append(float(grid.min())) uppers.append(float(grid.max())) @@ -81,33 +241,44 @@ def build_design(specs: List[InputSpec]) -> Design: var_list=var_array, # For backward compatibility ) + def build_design_from_config(config: Dict[str, Any]) -> Design: """Build a Design object directly from a config dictionary.""" - if 'inputs' not in config: + if not isinstance(config, dict): + raise ValueError( + "Config must be a mapping containing a non-empty 'inputs' list." + ) + if "inputs" not in config: raise ValueError("Config must contain 'inputs' key.") - - specs = build_input_spec_list(config['inputs']) + + specs = build_input_spec_list(config["inputs"]) return build_design(specs) + def get_variable_space() -> List[np.ndarray]: """Get the variable space as a list of arrays (for backward compatibility).""" # This function would need a config to work with the new system # For now, return empty list - users should use build_design_from_config instead return [] + def get_parameter_space(): """Get the parameter space (for backward compatibility).""" # This function would need a config to work with the new system # For now, return None - users should use build_design_from_config instead return None -def generate_initial_design(n_samples: int, config: Optional[Dict[str, Any]] = None) -> np.ndarray: + +def generate_initial_design( + n_samples: int, + config: Optional[Dict[str, Any]] = None, +) -> np.ndarray: """Generate initial design using Latin Hypercube Sampling. - + Args: n_samples: Number of samples to generate config: Optional config dictionary. If provided, uses the new design system. - + Returns: Array of shape (n_samples, n_features) """ @@ -117,9 +288,7 @@ def generate_initial_design(n_samples: int, config: Optional[Dict[str, Any]] = N # This would integrate with the LHS module # For now, return random samples in the bounds samples = np.random.uniform( - low=design.lowers, - high=design.uppers, - size=(n_samples, len(design.names)) + low=design.lowers, high=design.uppers, size=(n_samples, len(design.names)) ) return samples else: diff --git a/src/mobo_kit/discrete_refinement.py b/src/mobo_kit/discrete_refinement.py new file mode 100644 index 0000000..8ac82e6 --- /dev/null +++ b/src/mobo_kit/discrete_refinement.py @@ -0,0 +1,730 @@ +"""Deterministic coordinate refinement on an exact finite design grid. + +**NOT constraint-aware, and currently not wired into a round.** This moves a +selected point one coordinate at a time across the declared grid, which is +exactly the operation that can walk a valid recipe into an invalid one -- lowering +``time_2`` to 0 while ``speed_2`` stays at 3500, say. Campaign constraints are +enforced by filtering the candidate pool before any acquisition sees it +(``campaign.run_r1_ucb``), and a local search that leaves the pool escapes that +filter entirely. If this is ever wired into a round, it must take the same +``row_constraints`` the pool sampler takes and reject off-constraint neighbours; +``campaign.validate_batch`` would catch the result, but only after the search had +already spent its budget walking somewhere it was never allowed to go. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from numbers import Real +from typing import Callable, Sequence + +import numpy as np + +from .batch_selection import soft_local_penalty +from .candidate_pool import CandidatePool +from .design import Design + + +GridScoreFunction = Callable[[np.ndarray], np.ndarray] + + +@dataclass(frozen=True) +class RefinementConfig: + anchors_per_selection_step: int = 64 + max_sweeps: int = 10 + improvement_tolerance: float = 1.0e-10 + radius: float | None = 0.25 + min_batch_distance: float = 0.15 + min_observed_distance: float = 0.0 + dimension_weights: np.ndarray | None = None + epsilon: float = 1.0e-12 + + def __post_init__(self) -> None: + for name in ("anchors_per_selection_step", "max_sweeps"): + value = getattr(self, name) + if ( + isinstance(value, (bool, np.bool_)) + or not isinstance(value, (int, np.integer)) + or int(value) <= 0 + ): + raise ValueError(f"{name} must be a positive integer.") + object.__setattr__(self, name, int(value)) + for name in ( + "improvement_tolerance", + "min_batch_distance", + "min_observed_distance", + "epsilon", + ): + value = getattr(self, name) + if isinstance(value, (bool, np.bool_)) or not isinstance(value, Real): + raise ValueError(f"{name} must be a real non-boolean number.") + number = float(value) + if not np.isfinite(number) or number < 0: + raise ValueError(f"{name} must be finite and non-negative.") + object.__setattr__(self, name, number) + if self.epsilon <= 0 or self.epsilon >= 1: + raise ValueError("epsilon must be strictly between zero and one.") + if self.radius is not None: + if isinstance(self.radius, (bool, np.bool_)) or not isinstance( + self.radius, Real + ): + raise ValueError("radius must be null or a real number.") + radius = float(self.radius) + if not np.isfinite(radius) or radius <= 0: + raise ValueError("radius must be null or finite and positive.") + object.__setattr__(self, "radius", radius) + if self.dimension_weights is not None: + weights = np.asarray(self.dimension_weights, dtype=float).copy() + if weights.ndim != 1 or weights.size == 0: + raise ValueError("dimension_weights must be a non-empty vector.") + if not np.all(np.isfinite(weights)) or np.any(weights <= 0): + raise ValueError("dimension_weights must be finite and positive.") + weights.setflags(write=False) + object.__setattr__(self, "dimension_weights", weights) + + +@dataclass(frozen=True) +class RefinementTraceRow: + selection_step: int + anchor_rank: int + anchor_pool_index: int | None + sweep: int + coordinate: int | None + coordinate_name: str | None + start_grid_index: tuple[int, ...] + chosen_grid_index: tuple[int, ...] + score_before: float + score_after: float + base_score_before: float + base_score_after: float + penalized_log_score_before: float + penalized_log_score_after: float + accepted_move: bool + termination_reason: str | None + + +@dataclass(frozen=True) +class RefinedAnchor: + selection_step: int + anchor_rank: int + anchor_pool_index: int | None + anchor_grid_index: tuple[int, ...] + refined_grid_index: tuple[int, ...] + start_base_score: float + start_penalized_score: float + start_penalized_log_score: float + base_score: float + penalized_score: float + end_penalized_log_score: float + sweeps: int + accepted_move_count: int + changed_dimensions: tuple[str, ...] + termination_reason: str + + +@dataclass(frozen=True) +class RefinedBatchResult: + grid_indices: np.ndarray + X_phys: np.ndarray + X_norm: np.ndarray + base_scores: np.ndarray + penalized_scores_at_selection: np.ndarray + anchors: tuple[RefinedAnchor, ...] + trace: tuple[RefinementTraceRow, ...] + distinct_converged_optima: int + + +class CachedGridScorer: + """Memoize a vectorized exact-grid score function by integer tuple.""" + + def __init__(self, score_function: GridScoreFunction, dimension: int) -> None: + if not callable(score_function): + raise TypeError("score_function must be callable.") + if isinstance(dimension, bool) or int(dimension) <= 0: + raise ValueError("dimension must be a positive integer.") + self._score_function = score_function + self._dimension = int(dimension) + self._cache: dict[tuple[int, ...], float] = {} + + @property + def cache_size(self) -> int: + return len(self._cache) + + def seed(self, grid_indices: np.ndarray, scores: np.ndarray) -> None: + rows = _grid_matrix(grid_indices, self._dimension, name="grid_indices") + values = _score_vector(scores, rows.shape[0]) + for row, value in zip(rows, values): + key = tuple(int(item) for item in row) + previous = self._cache.get(key) + if previous is not None and previous != float(value): + raise ValueError("Conflicting score supplied for a cached grid tuple.") + self._cache[key] = float(value) + + def __call__(self, grid_indices: np.ndarray) -> np.ndarray: + rows = _grid_matrix(grid_indices, self._dimension, name="grid_indices") + keys = [tuple(int(item) for item in row) for row in rows] + missing_keys = list( + dict.fromkeys(key for key in keys if key not in self._cache) + ) + if missing_keys: + missing = np.asarray(missing_keys, dtype=np.int64) + values = _score_vector(self._score_function(missing), missing.shape[0]) + for key, value in zip(missing_keys, values): + self._cache[key] = float(value) + return np.asarray([self._cache[key] for key in keys], dtype=float) + + +def _grid_matrix(value: np.ndarray, dimension: int, *, name: str) -> np.ndarray: + raw = np.asarray(value) + if raw.ndim != 2 or raw.shape[1] != dimension: + raise ValueError(f"{name} must have shape (N, {dimension}); got {raw.shape}.") + if not np.issubdtype(raw.dtype, np.integer): + if not np.all(np.isfinite(raw)) or not np.all(raw == np.floor(raw)): + raise ValueError(f"{name} must contain integer grid indices.") + return raw.astype(np.int64, copy=False) + + +def _score_vector(value: np.ndarray, size: int) -> np.ndarray: + scores = np.asarray(value, dtype=float) + if scores.shape != (size,) or not np.all(np.isfinite(scores)): + raise ValueError(f"score_function must return {size} finite scores.") + if np.any(scores < 0): + raise ValueError("Acquisition scores must be non-negative.") + return scores + + +def _validate_design(design: Design) -> tuple[np.ndarray, ...]: + if not isinstance(design, Design): + raise TypeError("design must be a Design.") + grids = tuple(np.asarray(grid, dtype=float) for grid in design.var_array) + if len(grids) != len(design.names) or not grids: + raise ValueError("design must contain one grid per named dimension.") + if any(grid.ndim != 1 or grid.size == 0 for grid in grids): + raise ValueError("Every design grid must be a non-empty vector.") + return grids + + +def grid_indices_to_physical_and_normalized( + grid_indices: np.ndarray, design: Design +) -> tuple[np.ndarray, np.ndarray]: + """Resolve exact integer tuples without allocating the Cartesian product.""" + grids = _validate_design(design) + rows = _grid_matrix(grid_indices, len(grids), name="grid_indices") + physical = np.empty(rows.shape, dtype=float) + for column, grid in enumerate(grids): + if np.any(rows[:, column] < 0) or np.any(rows[:, column] >= grid.size): + raise ValueError( + f"grid_indices contains an out-of-range value for {design.names[column]!r}." + ) + physical[:, column] = grid[rows[:, column]] + + # Canonicalize exactly as the Sobol candidate-pool path. Index fractions are + # mathematically equivalent on an evenly spaced grid, but decimal-valued axes + # can differ by one floating-point bit. Using physical bounds here keeps pool + # anchors, refined candidates, distance checks, and exact-overlap diagnostics + # on one representation. + lower = np.asarray(design.lowers, dtype=float) + upper = np.asarray(design.uppers, dtype=float) + spans = upper - lower + normalized = np.zeros_like(physical, dtype=float) + changing = spans > 0.0 + normalized[:, changing] = (physical[:, changing] - lower[changing]) / spans[ + changing + ] + return physical, normalized + + +def _weights(config: RefinementConfig, dimension: int) -> np.ndarray: + if config.dimension_weights is None: + return np.ones(dimension, dtype=float) + if config.dimension_weights.shape != (dimension,): + raise ValueError( + f"dimension_weights must have shape ({dimension},); got " + f"{config.dimension_weights.shape}." + ) + return config.dimension_weights + + +def _distance_to_references( + X_norm: np.ndarray, references: np.ndarray, weights: np.ndarray +) -> np.ndarray: + if references.shape[0] == 0: + return np.full(X_norm.shape[0], np.inf, dtype=float) + difference = X_norm[:, None, :] - references[None, :, :] + return np.sqrt(np.sum(weights * difference**2, axis=-1)).min(axis=1) + + +def _penalized_scores( + base_scores: np.ndarray, + X_norm: np.ndarray, + *, + selected_norm: np.ndarray, + observed_norm: np.ndarray, + grid_indices: np.ndarray, + forbidden_grid_keys: set[tuple[int, ...]], + config: RefinementConfig, + positive_score_threshold: float, +) -> np.ndarray: + weights = _weights(config, X_norm.shape[1]) + penalized = np.asarray(base_scores, dtype=float).copy() + valid = penalized > positive_score_threshold + if forbidden_grid_keys: + valid &= np.asarray( + [ + tuple(int(value) for value in row) not in forbidden_grid_keys + for row in grid_indices + ], + dtype=bool, + ) + nearest_selected = _distance_to_references(X_norm, selected_norm, weights) + nearest_observed = _distance_to_references(X_norm, observed_norm, weights) + if selected_norm.shape[0]: + valid &= nearest_selected >= config.min_batch_distance + if observed_norm.shape[0] and config.min_observed_distance > 0: + valid &= nearest_observed >= config.min_observed_distance + if selected_norm.shape[0] and config.radius is not None: + difference = X_norm[:, None, :] - selected_norm[None, :, :] + distances = np.sqrt(np.sum(weights * difference**2, axis=-1)) + factors, _ = soft_local_penalty( + distances, radius=config.radius, epsilon=config.epsilon + ) + penalized *= np.prod(factors, axis=1) + penalized[~valid] = -np.inf + return penalized + + +def _lexicographic_best( + grid_indices: np.ndarray, scores: np.ndarray, *, tie_tolerance: float = 1.0e-15 +) -> int: + valid = np.flatnonzero(np.isfinite(scores)) + if valid.size == 0: + raise RuntimeError("No eligible finite acquisition score remains.") + maximum = float(np.max(scores[valid])) + tied = valid[np.abs(scores[valid] - maximum) <= tie_tolerance] + if tied.size == 1: + return int(tied[0]) + keys = tuple( + grid_indices[tied, column] for column in reversed(range(grid_indices.shape[1])) + ) + return int(tied[np.lexsort(keys)[0]]) + + +def refine_discrete_acquisition_anchors( + design: Design, + anchor_grid_indices: np.ndarray, + score_function: GridScoreFunction, + *, + config: RefinementConfig, + selection_step: int, + anchor_pool_indices: Sequence[int | None] | None = None, + selected_grid_indices: np.ndarray | None = None, + selected_norm: np.ndarray | None = None, + observed_grid_indices: np.ndarray | None = None, + observed_norm: np.ndarray | None = None, + avoid_grid_indices: np.ndarray | None = None, + positive_score_threshold: float = 0.0, +) -> tuple[tuple[RefinedAnchor, ...], tuple[RefinementTraceRow, ...]]: + """Coordinate-ascent every anchor using every allowed value per dimension.""" + grids = _validate_design(design) + dimension = len(grids) + anchors = _grid_matrix(anchor_grid_indices, dimension, name="anchor_grid_indices") + if anchors.shape[0] == 0: + raise ValueError("At least one refinement anchor is required.") + if isinstance(selection_step, bool) or int(selection_step) <= 0: + raise ValueError("selection_step must be a positive integer.") + if not isinstance(config, RefinementConfig): + raise TypeError("config must be a RefinementConfig.") + if anchor_pool_indices is None: + pool_indices: tuple[int | None, ...] = (None,) * anchors.shape[0] + else: + if len(anchor_pool_indices) != anchors.shape[0]: + raise ValueError("anchor_pool_indices must align with anchor rows.") + parsed_indices: list[int | None] = [] + for value in anchor_pool_indices: + if value is None: + parsed_indices.append(None) + elif isinstance(value, (bool, np.bool_)) or int(value) < 0: + raise ValueError( + "anchor_pool_indices values must be non-negative integers or None." + ) + else: + parsed_indices.append(int(value)) + pool_indices = tuple(parsed_indices) + threshold = float(positive_score_threshold) + if not np.isfinite(threshold) or threshold < 0: + raise ValueError("positive_score_threshold must be finite and non-negative.") + scorer = ( + score_function + if isinstance(score_function, CachedGridScorer) + else CachedGridScorer(score_function, dimension) + ) + + empty_grid = np.empty((0, dimension), dtype=np.int64) + selected_grid = _grid_matrix( + empty_grid if selected_grid_indices is None else selected_grid_indices, + dimension, + name="selected_grid_indices", + ) + observed_grid = _grid_matrix( + empty_grid if observed_grid_indices is None else observed_grid_indices, + dimension, + name="observed_grid_indices", + ) + avoid_grid = _grid_matrix( + empty_grid if avoid_grid_indices is None else avoid_grid_indices, + dimension, + name="avoid_grid_indices", + ) + empty_norm = np.empty((0, dimension), dtype=float) + selected_X = ( + empty_norm if selected_norm is None else np.asarray(selected_norm, dtype=float) + ) + observed_X = ( + empty_norm if observed_norm is None else np.asarray(observed_norm, dtype=float) + ) + for name, value in (("selected_norm", selected_X), ("observed_norm", observed_X)): + if ( + value.ndim != 2 + or value.shape[1] != dimension + or not np.all(np.isfinite(value)) + ): + raise ValueError(f"{name} must be a finite (N, {dimension}) matrix.") + forbidden = { + tuple(int(value) for value in row) + for row in np.vstack([selected_grid, observed_grid, avoid_grid]) + } + + refined: list[RefinedAnchor] = [] + trace: list[RefinementTraceRow] = [] + for anchor_rank, (anchor, anchor_pool_index) in enumerate( + zip(anchors, pool_indices), start=1 + ): + current = anchor.copy() + current_phys, current_norm = grid_indices_to_physical_and_normalized( + current[None, :], design + ) + del current_phys + current_base = scorer(current[None, :])[0] + current_penalized = _penalized_scores( + np.asarray([current_base]), + current_norm, + selected_norm=selected_X, + observed_norm=observed_X, + grid_indices=current[None, :], + forbidden_grid_keys=forbidden, + config=config, + positive_score_threshold=threshold, + )[0] + if not np.isfinite(current_penalized): + raise RuntimeError("A refinement anchor violates an eligibility rule.") + start_base = float(current_base) + start_penalized = float(current_penalized) + accepted_move_count = 0 + changed_dimensions: set[str] = set() + termination = "max_sweeps" + sweeps_completed = 0 + for sweep in range(1, config.max_sweeps + 1): + sweep_improved = False + sweeps_completed = sweep + for coordinate, (name, grid) in enumerate(zip(design.names, grids)): + candidates = np.repeat(current[None, :], grid.size, axis=0) + candidates[:, coordinate] = np.arange(grid.size, dtype=np.int64) + _, candidates_norm = grid_indices_to_physical_and_normalized( + candidates, design + ) + base = scorer(candidates) + penalized = _penalized_scores( + base, + candidates_norm, + selected_norm=selected_X, + observed_norm=observed_X, + grid_indices=candidates, + forbidden_grid_keys=forbidden, + config=config, + positive_score_threshold=threshold, + ) + chosen = _lexicographic_best(candidates, penalized) + proposed = candidates[chosen] + proposed_score = float(penalized[chosen]) + accepted = proposed_score > ( + current_penalized + config.improvement_tolerance + ) + start_key = tuple(int(value) for value in current) + chosen_key = tuple(int(value) for value in proposed) + before = float(current_penalized) + base_before = float(current_base) + if accepted: + current = proposed.copy() + current_base = float(base[chosen]) + current_penalized = proposed_score + sweep_improved = True + accepted_move_count += 1 + changed_dimensions.add(name) + trace.append( + RefinementTraceRow( + selection_step=int(selection_step), + anchor_rank=anchor_rank, + anchor_pool_index=anchor_pool_index, + sweep=sweep, + coordinate=coordinate, + coordinate_name=name, + start_grid_index=start_key, + chosen_grid_index=chosen_key, + score_before=before, + score_after=float(current_penalized), + base_score_before=base_before, + base_score_after=float(current_base), + penalized_log_score_before=float(np.log(before)), + penalized_log_score_after=float(np.log(current_penalized)), + accepted_move=accepted, + termination_reason=None, + ) + ) + if not sweep_improved: + termination = "no_improvement" + break + trace.append( + RefinementTraceRow( + selection_step=int(selection_step), + anchor_rank=anchor_rank, + anchor_pool_index=anchor_pool_index, + sweep=sweeps_completed, + coordinate=None, + coordinate_name=None, + start_grid_index=tuple(int(value) for value in current), + chosen_grid_index=tuple(int(value) for value in current), + score_before=float(current_penalized), + score_after=float(current_penalized), + base_score_before=float(current_base), + base_score_after=float(current_base), + penalized_log_score_before=float(np.log(current_penalized)), + penalized_log_score_after=float(np.log(current_penalized)), + accepted_move=False, + termination_reason=termination, + ) + ) + refined.append( + RefinedAnchor( + selection_step=int(selection_step), + anchor_rank=anchor_rank, + anchor_pool_index=anchor_pool_index, + anchor_grid_index=tuple(int(value) for value in anchor), + refined_grid_index=tuple(int(value) for value in current), + start_base_score=start_base, + start_penalized_score=start_penalized, + start_penalized_log_score=float(np.log(start_penalized)), + base_score=float(current_base), + penalized_score=float(current_penalized), + end_penalized_log_score=float(np.log(current_penalized)), + sweeps=sweeps_completed, + accepted_move_count=accepted_move_count, + changed_dimensions=tuple(sorted(changed_dimensions)), + termination_reason=termination, + ) + ) + return tuple(refined), tuple(trace) + + +def propose_refined_discrete_batch( + master_pool: CandidatePool, + design: Design, + score_function: GridScoreFunction, + *, + q: int, + config: RefinementConfig, + master_base_scores: np.ndarray | None = None, + observed_grid_indices: np.ndarray | None = None, + observed_norm: np.ndarray | None = None, + avoid_grid_indices: np.ndarray | None = None, + positive_score_threshold: float = 0.0, +) -> RefinedBatchResult: + """Sequentially refine top pool anchors and select an exact-grid batch.""" + if not isinstance(master_pool, CandidatePool): + raise TypeError("master_pool must be a CandidatePool.") + grids = _validate_design(design) + dimension = len(grids) + pool_grid = _grid_matrix( + master_pool.grid_indices, dimension, name="pool.grid_indices" + ) + if isinstance(q, bool) or not isinstance(q, (int, np.integer)) or int(q) <= 0: + raise ValueError("q must be a positive integer.") + requested = int(q) + scorer = CachedGridScorer(score_function, dimension) + if master_base_scores is None: + pool_base = scorer(pool_grid) + else: + pool_base = _score_vector(master_base_scores, pool_grid.shape[0]) + scorer.seed(pool_grid, pool_base) + empty_grid = np.empty((0, dimension), dtype=np.int64) + observed_grid = _grid_matrix( + empty_grid if observed_grid_indices is None else observed_grid_indices, + dimension, + name="observed_grid_indices", + ) + avoid_grid = _grid_matrix( + empty_grid if avoid_grid_indices is None else avoid_grid_indices, + dimension, + name="avoid_grid_indices", + ) + observed_X = ( + np.empty((0, dimension), dtype=float) + if observed_norm is None + else np.asarray(observed_norm, dtype=float) + ) + if ( + observed_X.ndim != 2 + or observed_X.shape[1] != dimension + or not np.all(np.isfinite(observed_X)) + ): + raise ValueError(f"observed_norm must be a finite (N, {dimension}) matrix.") + + selected_grid: list[np.ndarray] = [] + selected_norm: list[np.ndarray] = [] + selected_base: list[float] = [] + selected_penalized: list[float] = [] + all_anchors: list[RefinedAnchor] = [] + all_trace: list[RefinementTraceRow] = [] + discovered_optima: set[tuple[int, ...]] = set() + pool_position_by_key = { + tuple(int(value) for value in row): index for index, row in enumerate(pool_grid) + } + for selection_step in range(1, requested + 1): + selected_grid_array = ( + np.asarray(selected_grid, dtype=np.int64) + if selected_grid + else empty_grid.copy() + ) + selected_norm_array = ( + np.asarray(selected_norm, dtype=float) + if selected_norm + else np.empty((0, dimension), dtype=float) + ) + forbidden = { + tuple(int(value) for value in row) + for row in np.vstack([observed_grid, avoid_grid, selected_grid_array]) + } + pool_penalized = _penalized_scores( + pool_base, + np.asarray(master_pool.X_norm, dtype=float), + selected_norm=selected_norm_array, + observed_norm=observed_X, + grid_indices=pool_grid, + forbidden_grid_keys=forbidden, + config=config, + positive_score_threshold=positive_score_threshold, + ) + valid = np.flatnonzero(np.isfinite(pool_penalized)) + if valid.size == 0: + raise RuntimeError( + "No master-pool anchors remain without relaxing an eligibility rule." + ) + lex_order = np.lexsort( + tuple(pool_grid[valid, column] for column in reversed(range(dimension))) + ) + lex_valid = valid[lex_order] + score_order = np.argsort(-pool_penalized[lex_valid], kind="stable") + anchor_positions = lex_valid[score_order][ + : min(config.anchors_per_selection_step, valid.size) + ] + anchor_rows = [pool_grid[position].copy() for position in anchor_positions] + anchor_pool_indices: list[int | None] = [ + int(position) for position in anchor_positions + ] + seen_anchor_keys = {tuple(int(value) for value in row) for row in anchor_rows} + for previous_key in sorted(discovered_optima): + if previous_key in seen_anchor_keys or previous_key in forbidden: + continue + anchor_rows.append(np.asarray(previous_key, dtype=np.int64)) + anchor_pool_indices.append(pool_position_by_key.get(previous_key)) + seen_anchor_keys.add(previous_key) + combined_anchors = np.asarray(anchor_rows, dtype=np.int64) + _, combined_norm = grid_indices_to_physical_and_normalized( + combined_anchors, design + ) + combined_penalized = _penalized_scores( + scorer(combined_anchors), + combined_norm, + selected_norm=selected_norm_array, + observed_norm=observed_X, + grid_indices=combined_anchors, + forbidden_grid_keys=forbidden, + config=config, + positive_score_threshold=positive_score_threshold, + ) + eligible_anchor_mask = np.isfinite(combined_penalized) + combined_anchors = combined_anchors[eligible_anchor_mask] + eligible_pool_indices = tuple( + value + for value, eligible in zip(anchor_pool_indices, eligible_anchor_mask) + if eligible + ) + if combined_anchors.shape[0] == 0: + raise RuntimeError("No eligible local-refinement anchor remains.") + refined, trace = refine_discrete_acquisition_anchors( + design, + combined_anchors, + scorer, + config=config, + selection_step=selection_step, + anchor_pool_indices=eligible_pool_indices, + selected_grid_indices=selected_grid_array, + selected_norm=selected_norm_array, + observed_grid_indices=observed_grid, + observed_norm=observed_X, + avoid_grid_indices=avoid_grid, + positive_score_threshold=positive_score_threshold, + ) + all_anchors.extend(refined) + all_trace.extend(trace) + discovered_optima.update(item.refined_grid_index for item in refined) + unique_optima = np.asarray( + sorted({item.refined_grid_index for item in refined}), dtype=np.int64 + ) + _, optima_norm = grid_indices_to_physical_and_normalized(unique_optima, design) + optima_base = scorer(unique_optima) + optima_penalized = _penalized_scores( + optima_base, + optima_norm, + selected_norm=selected_norm_array, + observed_norm=observed_X, + grid_indices=unique_optima, + forbidden_grid_keys=forbidden, + config=config, + positive_score_threshold=positive_score_threshold, + ) + chosen = _lexicographic_best(unique_optima, optima_penalized) + selected_grid.append(unique_optima[chosen].copy()) + selected_norm.append(optima_norm[chosen].copy()) + selected_base.append(float(optima_base[chosen])) + selected_penalized.append(float(optima_penalized[chosen])) + + selected_grid_array = np.asarray(selected_grid, dtype=np.int64) + physical, normalized = grid_indices_to_physical_and_normalized( + selected_grid_array, design + ) + if np.unique(selected_grid_array, axis=0).shape[0] != requested: + raise RuntimeError("Refinement produced duplicate selected grid tuples.") + return RefinedBatchResult( + grid_indices=selected_grid_array, + X_phys=physical, + X_norm=normalized, + base_scores=np.asarray(selected_base, dtype=float), + penalized_scores_at_selection=np.asarray(selected_penalized, dtype=float), + anchors=tuple(all_anchors), + trace=tuple(all_trace), + distinct_converged_optima=len( + {anchor.refined_grid_index for anchor in all_anchors} + ), + ) + + +__all__ = [ + "CachedGridScorer", + "RefinedAnchor", + "RefinedBatchResult", + "RefinementConfig", + "RefinementTraceRow", + "grid_indices_to_physical_and_normalized", + "propose_refined_discrete_batch", + "refine_discrete_acquisition_anchors", +] diff --git a/src/mobo_kit/launcher.py b/src/mobo_kit/launcher.py new file mode 100644 index 0000000..6757ebe --- /dev/null +++ b/src/mobo_kit/launcher.py @@ -0,0 +1,865 @@ +"""The one-button loop for the experimentalist. + +Double-click ``launch_mobo_kit.bat`` (Windows) or ``launch_mobo_kit.command`` +(macOS), point it at the campaign workbook, press the button. It works out which +round is due, reads what has been measured, proposes the next batch and writes it +to a sheet beside the workbook. + +Everything above the UI lives in plain functions -- :func:`inspect_campaign`, +:func:`gather_observations`, :func:`generate_next_round` -- so the decisions can +be tested without a display, and so the same steps are available from a script +when someone would rather not click. + +Three rules the UI keeps, all of them inherited rather than invented: + +* **The source workbook is never opened for writing.** Candidates go to a + sibling file, because openpyxl discards cached formula values on save. +* **Fail closed.** A half-filled sheet, a film with no usable measurement, a + workbook missing a column: each stops the round with a plain sentence rather + than being guessed at. +* **Nothing here approves a batch.** Fifteen films is a real cost; the window + shows what was proposed and why, and a human decides. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import traceback +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +import numpy as np + +from .batch_review import BatchReview, build_batch_review, write_review_sheet +from .campaign import ( + RoundResult, + load_campaign_config, + objective_names, + replicate_aggregates, + run_r1_ucb, + run_r2_qlognehvi, +) +from .replicate_variance import yvar_for_campaign +from .scores import ScoreFinding, ScoreSeverity, describe_findings +from .workbook_io import ( + CandidateSheetError, + candidate_workbook_path, + detect_round, + read_campaign_workbook, + read_candidate_results, + source_sheet, + write_candidate_sheet, +) + +__all__ = [ + "CampaignStatus", + "DEFAULT_CONFIG", + "Generated", + "LauncherError", + "gather_observations", + "generate_next_round", + "inspect_campaign", + "main", +] + +#: The ACTIVE campaign. This is the one path an experimentalist reaches by +#: double-clicking, so it must never point at an archived contract: the launcher +#: would then ask the new workbook for the previous campaign's columns and report +#: it as a missing column, which reads as a broken workbook rather than as the +#: config mismatch it is. That happened once, on 2026-08-18, between archiving +#: campaign_d2d_perovskite.yaml and updating this line. +DEFAULT_CONFIG = "configs/campaign_d2d_perovskite_final.yaml" + +#: Remembered between runs so the experimentalist browses to the workbook once. +#: Kept in the user's home rather than the repo, so moving the checkout does not +#: lose it. Every read and write here is best-effort: a launcher that cannot +#: start because of its own preferences file would be worse than one that forgets. +SETTINGS_PATH = Path.home() / ".mobo_kit" / "launcher.json" + + +class LauncherError(RuntimeError): + """Something the user needs to fix, phrased for the user.""" + + +# --------------------------------------------------------------------------- # +# remembering the workbook +# --------------------------------------------------------------------------- # + + +def load_settings() -> dict[str, Any]: + try: + with open(SETTINGS_PATH, encoding="utf-8") as handle: + settings = json.load(handle) + return settings if isinstance(settings, dict) else {} + except (OSError, ValueError): + return {} + + +def save_settings(settings: Mapping[str, Any]) -> None: + try: + SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(SETTINGS_PATH, "w", encoding="utf-8") as handle: + json.dump(dict(settings), handle, indent=2) + except OSError: + pass + + +def remembered_workbook() -> Path | None: + raw = load_settings().get("workbook") + if not raw: + return None + path = Path(str(raw)) + return path if path.exists() else None + + +def remember_workbook(path: str | Path) -> None: + settings = load_settings() + settings["workbook"] = str(Path(path).resolve()) + save_settings(settings) + + +# --------------------------------------------------------------------------- # +# status +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class CampaignStatus: + """What the campaign looks like right now, in the user's terms.""" + + workbook: Path + next_round: str | None + reason: str + scored_rows: int + total_rows: int + observed_conditions: int + findings: tuple[ScoreFinding, ...] = () + #: Which sheet the rows came from; "Sheet1" on the older contracts, "R0" + #: on v4. Shown rather than assumed, because a user looking at the wrong + #: sheet is exactly the confusion this line exists to prevent. + source_sheet: str = "Sheet1" + + @property + def can_generate(self) -> bool: + return self.next_round is not None + + @property + def headline(self) -> str: + if self.next_round: + return f"Ready to propose {self.next_round}." + return "Nothing to propose yet." + + @property + def errors(self) -> tuple[ScoreFinding, ...]: + return tuple(f for f in self.findings if f.severity is ScoreSeverity.ERROR) + + @property + def warnings(self) -> tuple[ScoreFinding, ...]: + return tuple(f for f in self.findings if f.severity is ScoreSeverity.WARNING) + + def detail(self) -> str: + """The body text of the window: what is known, then what was noticed.""" + lines = [ + f"Workbook: {self.workbook}", + f"Measured: {self.observed_conditions} conditions on {self.source_sheet}", + f"Status: {self.reason}", + ] + if self.total_rows: + lines.append( + f"Candidates: {self.scored_rows} of {self.total_rows} rows measured" + ) + if self.errors: + lines += ["", "These must be fixed before a round can run:"] + lines += [f" {finding}" for finding in self.errors] + if self.warnings: + lines += ["", "Worth a look, but not blocking:"] + lines += [f" {finding}" for finding in self.warnings] + notes = [f for f in self.findings if f.severity is ScoreSeverity.NOTE] + if notes: + lines += ["", "For the record:"] + lines += [f" {finding}" for finding in notes] + return "\n".join(lines) + + +def inspect_campaign( + workbook: str | Path, config: Mapping[str, Any] +) -> CampaignStatus: + """Read the workbook and decide what is due, without proposing anything.""" + path = Path(workbook) + if not path.exists(): + raise LauncherError(f"{path} does not exist.") + contents = read_campaign_workbook(path, config) + state = detect_round(path, config) + return CampaignStatus( + workbook=path.resolve(), + next_round=state.next_round, + reason=state.reason, + scored_rows=state.scored_rows, + total_rows=state.total_rows, + observed_conditions=contents.n_rows, + findings=contents.findings, + source_sheet=source_sheet(config), + ) + + +# --------------------------------------------------------------------------- # +# observations +# --------------------------------------------------------------------------- # + + +def gather_observations( + workbook: str | Path, config: Mapping[str, Any], *, for_round: str +) -> tuple[np.ndarray, np.ndarray, np.ndarray | None, list[str]]: + """Every measured design point the next round should learn from. + + R1 trains on Sheet1 alone. R2 trains on Sheet1 plus the aggregated R1 + conditions -- three films become one observation, which is why + :func:`read_candidate_results` exists. + + Returns ``(X, Y, Yvar, provenance)``. ``Yvar`` is ``None`` unless the config + asks for measured replicate variance *and* replicated conditions exist to pool + from; see :mod:`replicate_variance`. + + Raises rather than dropping rows: a NaN objective reaching the GP is how a + round gets proposed from data nobody checked. + """ + path = Path(workbook) + names = list(objective_names(config)) + input_names = [item["name"] for item in config["inputs"]] + + contents = read_campaign_workbook(path, config) + if contents.errors: + raise LauncherError( + "Sheet1 has rows that cannot be turned into objective values:\n" + + describe_findings(contents.errors) + ) + X = [contents.inputs.to_numpy(dtype=float)] + Y = [contents.model_values.to_numpy(dtype=float)] + provenance = [f"{source_sheet(config)}: {contents.n_rows} conditions"] + Yvar: np.ndarray | None = None + + if for_round.upper() == "R2": + results = read_candidate_results(path, config, "R1") + if results.errors: + raise LauncherError( + "The R1 sheet has conditions that cannot be turned into objective " + "values:\n" + describe_findings(results.errors) + ) + X.append(results.conditions[input_names].to_numpy(dtype=float)) + Y.append(results.model_values[names].to_numpy(dtype=float)) + provenance.append( + f"R1 sheet: {results.n_conditions} conditions from " + f"{len(results.replicates)} films" + ) + Yvar, floor_findings = yvar_for_campaign( + config, + results, + n_rows_without_replicates=contents.n_rows, + objective_names=names, + aggregates=replicate_aggregates(config), + ) + if Yvar is not None: + provenance.append( + "observation noise: pooled between-film variance from the R1 " + "triplicates, not fitted" + ) + for message in floor_findings: + provenance.append(f"WARNING {message}") + + X_all = np.vstack(X) + Y_all = np.vstack(Y) + if not np.all(np.isfinite(Y_all)): + bad = int((~np.isfinite(Y_all)).any(axis=1).sum()) + raise LauncherError( + f"{bad} observation(s) still hold a non-finite objective value after " + "aggregation. Fix the measurements before proposing a round." + ) + return X_all, Y_all, Yvar, provenance + + +# --------------------------------------------------------------------------- # +# generating +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class Generated: + """What a successful press of the button produced.""" + + round_name: str + sheet_path: Path + result: RoundResult + provenance: list[str] = field(default_factory=list) + review: BatchReview | None = None + report: Any = None + """The round report's manifest, or ``None`` if it was skipped or failed.""" + report_error: str | None = None + """Why the report is missing. A batch is never rolled back over a figure.""" + + @property + def n_films(self) -> int: + return len(self.result.replicates) + + def summary(self) -> str: + """The body text after a successful run: what, from what, and how spread.""" + diagnostics = self.result.diagnostics + validity = diagnostics.get("validity", {}) + distance = validity.get("min_pairwise_distance") + lines = [ + f"Wrote {self.result.n_conditions} {self.round_name} conditions " + f"({self.n_films} films) to:", + f" {self.sheet_path}", + "", + "Trained on:", + *(f" {item}" for item in self.provenance), + "", + f"Method: {diagnostics.get('method')}", + f"Seed: {diagnostics.get('seed')}", + f"Candidate pool: {diagnostics.get('pool_size')}", + f"Objective contract: {diagnostics.get('objective_contract')}", + "Min pairwise distance: " + + (f"{distance:.4f}" if isinstance(distance, float) else str(distance)), + f"Boundary coords/row: {validity.get('boundary_coords_per_condition')}", + "", + "Proposed conditions, physical units:", + self.result.conditions.to_string( + index=False, float_format=lambda value: f"{value:g}" + ), + ] + if self.review is not None: + # the review is the artifact; the lines above are its provenance + lines += ["", self.review.to_text()] + else: + lines += [ + "", + "Nothing here is approved. Read the conditions, then run each in " + "triplicate and fill in the highlighted columns.", + ] + if self.report is not None: + lines += ["", self.report.summary()] + elif self.report_error is not None: + lines += [ + "", + "FIGURES NOT PRODUCED", + "-" * 78, + self.report_error, + ] + return "\n".join(lines) + + +def generate_next_round( + workbook: str | Path, + config: Mapping[str, Any], + *, + seed: int | None = None, + progress: Callable[[str], None] | None = None, + with_report: bool = True, +) -> Generated: + """Propose and write whichever round is due. Refuses if none is. + + ``with_report`` renders the round's figures beside the workbook afterwards. + **A failure there never costs the batch.** The worklist and the Review sheet + are already written and correct at that point; discarding them because a + figure could not be drawn would throw away the expensive, careful part of the + run over the cheap, decorative one. The failure is reported loudly instead. + """ + + def say(message: str) -> None: + if progress is not None: + progress(message) + + path = Path(workbook) + say("Reading the workbook...") + status = inspect_campaign(path, config) + if not status.can_generate: + raise LauncherError(status.reason) + + round_name = str(status.next_round) + destination = candidate_workbook_path(path, round_name) + if destination.exists(): + raise LauncherError( + f"{destination.name} already exists. Rename or delete it first; this " + "tool never overwrites a file that may hold measurements." + ) + + say(f"Collecting observations for {round_name}...") + X, Y, Yvar, provenance = gather_observations(path, config, for_round=round_name) + + say(f"Fitting the model and scoring candidates for {round_name}. About 10 seconds.") + runner = run_r1_ucb if round_name == "R1" else run_r2_qlognehvi + result = runner(config, X, Y, seed=seed, observed_Yvar=Yvar) + + say(f"Writing {destination.name}...") + replicates = int( + (config.get("rounds", {}).get(round_name.lower(), {}) or {}).get( + "replicates_per_condition", 3 + ) + ) + sheet_path = write_candidate_sheet( + path, + config, + result.conditions, + round_name=round_name, + replicates=replicates, + ) + say("Building the review...") + contents = read_campaign_workbook(path, config) + # The report directory is decided BEFORE the review is written, so the Review + # sheet can point at the figures. The report is rendered into exactly this + # directory afterwards; if it fails, the sheet points at a directory holding + # the error trace, which is more useful than pointing at nothing. + from .round_report import report_directory + + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + figures_dir = report_directory(path, round_name, when=stamp) + review = build_batch_review( + config, + X, + Y, + result.conditions, + round_name=round_name, + seed=result.diagnostics.get("seed"), + findings=contents.findings, + context={ + "Round": round_name, + "Worklist": sheet_path.name, + "Figures": str(figures_dir), + "Trained on": "; ".join(provenance), + "Observations": len(X), + "Method": result.diagnostics.get("method"), + "Seed": result.diagnostics.get("seed"), + "Candidate pool": result.diagnostics.get("pool_size"), + "Objective contract": result.diagnostics.get("objective_contract"), + "Films to run": len(result.replicates), + }, + ) + write_review_sheet(sheet_path, review) + + report = None + report_error = None + if with_report: + from .round_report import generate_round_report + + try: + report = generate_round_report( + path, + config, + proposal=result, + review=review, + outdir=figures_dir, + when=stamp, + seed=result.diagnostics.get("seed"), + progress=progress, + ) + except Exception as exc: # noqa: BLE001 - a figure must never cost a batch + report_error = ( + f"{type(exc).__name__}: {exc}. The worklist and the Review sheet " + "were written and are unaffected." + ) + + say("Done.") + return Generated( + round_name=round_name, + sheet_path=sheet_path, + result=result, + provenance=provenance, + review=review, + report=report, + report_error=report_error, + ) + + +def generate_data_report( + workbook: str | Path, + config: Mapping[str, Any], + *, + seed: int | None = None, + progress: Callable[[str], None] | None = None, +) -> Any: + """Figures from the measurements alone, with no batch proposed. + + What the "Figures from current data" button runs. Useful the moment a round's + measurements are entered and before anyone decides whether to propose: the + parity, attribution, hypervolume and objective-space figures are all about + what has been measured, and none of them needs a candidate batch. + """ + from .round_report import generate_round_report + + return generate_round_report(workbook, config, seed=seed, progress=progress) + + +def reveal(path: str | Path) -> None: + """Show a file in the platform's file manager. Never raises.""" + target = Path(path) + try: + if sys.platform.startswith("win"): + subprocess.run(["explorer", "/select,", str(target)], check=False) + elif sys.platform == "darwin": + subprocess.run(["open", "-R", str(target)], check=False) + else: + subprocess.run(["xdg-open", str(target.parent)], check=False) + except OSError: + pass + + +# --------------------------------------------------------------------------- # +# the window +# --------------------------------------------------------------------------- # + + +class LauncherWindow: + """A small tkinter window over the functions above. + + tkinter is imported here rather than at module scope so that the logic can be + imported and tested on a machine with no display. + + **Results are matched to the request that asked for them.** Work runs off the + main thread and reports back through a queue, so without that matching two + things can paint the pane with an answer to a question the user has moved on + from: the auto-check scheduled 200 ms after startup, and any second press while + the first is still running. Each dispatch takes a request id; a reply carrying + a stale id is dropped. A status reply also names the workbook it examined and + is dropped if the selection has changed since -- reporting "Ready to propose R1" + over a workbook the user has navigated away from is worse than reporting + nothing. A dropped reply still clears the busy state, or the window would + disable its own buttons forever. + """ + + def __init__(self, config_path: str | Path = DEFAULT_CONFIG) -> None: + import queue + import tkinter as tk + from tkinter import ttk + + self._tk = tk + self._ttk = ttk + self._queue: queue.Queue[tuple[int, str, Any]] = queue.Queue() + self._config_path = Path(config_path) + self._config: dict[str, Any] | None = None + self._status: CampaignStatus | None = None + self._generated: Generated | None = None + self._report: Any = None + self._busy = False + self._request_id = 0 + self._auto_check_id: Any = None + + self.root = tk.Tk() + self.root.title("MOBO-Kit - propose the next round") + self.root.minsize(760, 520) + + outer = ttk.Frame(self.root, padding=12) + outer.pack(fill="both", expand=True) + + chooser = ttk.Frame(outer) + chooser.pack(fill="x") + ttk.Label(chooser, text="Campaign workbook:").pack(side="left") + self.path_var = tk.StringVar() + remembered = remembered_workbook() + if remembered is not None: + self.path_var.set(str(remembered)) + ttk.Entry(chooser, textvariable=self.path_var).pack( + side="left", fill="x", expand=True, padx=6 + ) + ttk.Button(chooser, text="Browse...", command=self.browse).pack(side="left") + + self.headline = ttk.Label(outer, text="Choose a workbook, then check it.") + self.headline.pack(anchor="w", pady=(12, 4)) + + # a text pane with both scrollbars, laid out on a grid so neither one + # overlaps the text -- proposed conditions are ten columns wide + pane = ttk.Frame(outer) + pane.pack(fill="both", expand=True) + pane.rowconfigure(0, weight=1) + pane.columnconfigure(0, weight=1) + self.text = tk.Text(pane, wrap="none", height=20, state="disabled") + scroll_y = ttk.Scrollbar(pane, orient="vertical", command=self.text.yview) + scroll_x = ttk.Scrollbar(pane, orient="horizontal", command=self.text.xview) + self.text.configure(yscrollcommand=scroll_y.set, xscrollcommand=scroll_x.set) + self.text.grid(row=0, column=0, sticky="nsew") + scroll_y.grid(row=0, column=1, sticky="ns") + scroll_x.grid(row=1, column=0, sticky="ew") + + self.progress = ttk.Progressbar(outer, mode="indeterminate") + + buttons = ttk.Frame(outer) + buttons.pack(fill="x", pady=(10, 0)) + self.check_button = ttk.Button(buttons, text="Check workbook", command=self.check) + self.check_button.pack(side="left") + self.generate_button = ttk.Button( + buttons, text="Propose next round", command=self.generate, state="disabled" + ) + self.generate_button.pack(side="left", padx=6) + self.reveal_button = ttk.Button( + buttons, text="Show the new sheet", command=self.reveal, state="disabled" + ) + self.reveal_button.pack(side="left") + # Enabled from the start: it needs measurements, not a proposal, and the + # question "is the model learning anything yet" is worth asking before + # deciding whether to spend fifteen films on a batch. + self.figures_button = ttk.Button( + buttons, text="Figures from current data", command=self.figures + ) + self.figures_button.pack(side="left", padx=6) + ttk.Button(buttons, text="Close", command=self.root.destroy).pack(side="right") + + self.root.after(120, self._drain) + if remembered is not None: + self._auto_check_id = self.root.after(200, self.check) + + # -- helpers ----------------------------------------------------------- # + + def _write(self, body: str) -> None: + self.text.configure(state="normal") + self.text.delete("1.0", "end") + self.text.insert("1.0", body) + self.text.configure(state="disabled") + + def _config_or_load(self) -> dict[str, Any]: + if self._config is None: + if not self._config_path.exists(): + raise LauncherError( + f"Cannot find the campaign configuration at {self._config_path}. " + "Run the launcher from the MOBO-Kit folder, or pass the path as " + "an argument." + ) + self._config = load_campaign_config(self._config_path) + return self._config + + def _start(self, message: str) -> None: + self._busy = True + self.check_button.configure(state="disabled") + self.generate_button.configure(state="disabled") + self.headline.configure(text=message) + self.progress.pack(fill="x", pady=(8, 0)) + self.progress.start(12) + + def _finish(self) -> None: + self._busy = False + self.progress.stop() + self.progress.pack_forget() + self.check_button.configure(state="normal") + can = self._status is not None and self._status.can_generate + self.generate_button.configure(state="normal" if can else "disabled") + + def _cancel_auto_check(self) -> None: + """Drop the startup auto-check the moment the user does anything. + + Without this it fires 200 ms in and answers a question about whichever + workbook was remembered, which may no longer be the one on screen. + """ + if self._auto_check_id is not None: + try: + self.root.after_cancel(self._auto_check_id) + except Exception: + pass + self._auto_check_id = None + + def _selection(self) -> str: + raw = self.path_var.get().strip() + try: + return str(Path(raw).resolve()) if raw else "" + except OSError: + return raw + + def _in_thread( + self, work: Callable[[Callable[[str, Any], None]], tuple[str, Any]] + ) -> None: + import threading + + self._request_id += 1 + request = self._request_id + + def post(kind: str, payload: Any) -> None: + self._queue.put((request, kind, payload)) + + def target() -> None: + try: + kind, payload = work(post) + post(kind, payload) + except Exception as exc: # surfaced in the window, never a traceback box + post("error", exc) + + threading.Thread(target=target, daemon=True).start() + + def drain_once(self) -> None: + """Apply whatever the worker threads have reported, dropping stale replies. + + Separate from the polling loop so the drop rules can be tested by putting a + message on the queue, rather than by racing two real threads and hoping the + timing lands -- which is a flaky test of a race-condition fix. + """ + import queue + + try: + while True: + request, kind, payload = self._queue.get_nowait() + if request != self._request_id: + # superseded by a newer press; that request's own reply follows + self._finish() + continue + if kind == "status" and str(payload.workbook) != self._selection(): + # answers a workbook the user has navigated away from + self._finish() + continue + self._handle(kind, payload) + except queue.Empty: + pass + + def _drain(self) -> None: + self.drain_once() + self.root.after(120, self._drain) + + def _handle(self, kind: str, payload: Any) -> None: + if kind == "progress": + self.headline.configure(text=str(payload)) + return + if kind == "status": + self._status = payload + self.headline.configure(text=payload.headline) + self._write(payload.detail()) + if payload.can_generate: + self.generate_button.configure( + text=f"Propose {payload.next_round}" + ) + self._finish() + return + if kind == "generated": + self._generated = payload + self.headline.configure( + text=f"{payload.round_name} written. Nothing is approved -- read it first." + ) + self._write(payload.summary()) + self.reveal_button.configure(state="normal") + self._status = None + self._finish() + return + if kind == "report": + self._report = payload + self.headline.configure( + text=f"{len(payload.figures)} figures written. Nothing is approved." + ) + self._write(payload.summary()) + self._finish() + return + if kind == "error": + error = payload + if isinstance(error, (LauncherError, CandidateSheetError, ValueError)): + body = str(error) + self.headline.configure(text="Cannot continue.") + else: + body = ( + "Something unexpected went wrong. The details below are for a " + "developer; the workbook was not modified.\n\n" + + "".join( + traceback.format_exception( + type(error), error, error.__traceback__ + ) + ) + ) + self.headline.configure(text="Unexpected error.") + self._write(body) + self._finish() + + # -- actions ----------------------------------------------------------- # + + def browse(self) -> None: + from tkinter import filedialog + + self._cancel_auto_check() + chosen = filedialog.askopenfilename( + title="Choose the campaign workbook", + filetypes=[("Excel workbook", "*.xlsx"), ("All files", "*.*")], + ) + if chosen: + self.path_var.set(chosen) + self.check() + + def check(self) -> None: + self._auto_check_id = None # this call IS the auto-check when scheduled + if self._busy: + return + workbook = self.path_var.get().strip() + if not workbook: + self.headline.configure(text="Choose a workbook first.") + return + self._status = None + self._start("Reading the workbook...") + + def work(post: Callable[[str, Any], None]) -> tuple[str, Any]: + config = self._config_or_load() + status = inspect_campaign(workbook, config) + remember_workbook(workbook) + return "status", status + + self._in_thread(work) + + def generate(self) -> None: + self._cancel_auto_check() + if self._busy or self._status is None or not self._status.can_generate: + return + workbook = self.path_var.get().strip() + self._start(f"Proposing {self._status.next_round}...") + + def work(post: Callable[[str, Any], None]) -> tuple[str, Any]: + config = self._config_or_load() + generated = generate_next_round( + workbook, + config, + progress=lambda message: post("progress", message), + ) + return "generated", generated + + self._in_thread(work) + + def figures(self) -> None: + """Render the data-only report. Needs measurements, not a proposal.""" + self._cancel_auto_check() + if self._busy: + return + workbook = self.path_var.get().strip() + if not workbook: + self.headline.configure(text="Choose a workbook first.") + return + self._start("Rendering figures from the measured data...") + + def work(post: Callable[[str, Any], None]) -> tuple[str, Any]: + config = self._config_or_load() + manifest = generate_data_report( + workbook, + config, + progress=lambda message: post("progress", message), + ) + return "report", manifest + + self._in_thread(work) + + def reveal(self) -> None: + if self._generated is not None: + reveal(self._generated.sheet_path) + + def run(self) -> None: + self.root.mainloop() + + +def main(argv: Sequence[str] | None = None) -> int: + """Entry point for the double-click launchers and ``python -m``.""" + args = list(sys.argv[1:] if argv is None else argv) + config_path = args[0] if args else DEFAULT_CONFIG + try: + LauncherWindow(config_path).run() + except ImportError as exc: # tkinter absent from a stripped Python + print( + "This launcher needs tkinter, which this Python does not have " + f"({exc}). Install a python.org build, or use the API directly:\n" + " from mobo_kit.launcher import generate_next_round", + file=sys.stderr, + ) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/mobo_kit/lhs.py b/src/mobo_kit/lhs.py index 98b7905..d57838c 100644 --- a/src/mobo_kit/lhs.py +++ b/src/mobo_kit/lhs.py @@ -1,52 +1,35 @@ -# src/lhs.py +"""Deterministic, grid-safe Latin hypercube campaign design.""" + from __future__ import annotations + +from math import prod +from typing import Optional, Sequence, Tuple, Union + import numpy as np import pandas as pd -from typing import Optional, Callable, Tuple, Sequence, Union -from .constraints import apply_row_constraints, RowConstraint - -from emukit.core import ParameterSpace, ContinuousParameter -from emukit.core.initial_designs.latin_design import LatinDesign +from .constraints import RowConstraint, apply_row_constraints from .design import Design -from .data import snap_to_grid_np # snaps only dims with grids (design.var_list) -import seaborn as sns -import matplotlib.pyplot as plt -from sklearn.decomposition import PCA -from sklearn.preprocessing import StandardScaler - -# ---------------------------- -# Emukit space & core sampling -# ---------------------------- - -def _space_from_design(design: Design) -> ParameterSpace: - """ - Build an Emukit ParameterSpace using only ContinuousParameter. - For grid dims, we use [min(grid), max(grid)] and snap after sampling. - """ - params = [] - for j, name in enumerate(design.names): - lo = float(design.lowers[j]) - hi = float(design.uppers[j]) - params.append(ContinuousParameter(name, lo, hi)) - return ParameterSpace(params) +def _max_abs_corr(X: np.ndarray) -> float: + """Return the largest absolute Pearson correlation between varying columns.""" + values = np.asarray(X, dtype=float) + if values.ndim != 2: + raise ValueError(f"X must be two-dimensional; got shape {values.shape}.") + if values.shape[0] < 2 or values.shape[1] < 2: + return 0.0 -# ---------------------------- -# Dataset-level corr utilities -# ---------------------------- + varying = np.ptp(values, axis=0) > 0 + varying_values = values[:, varying] + if varying_values.shape[1] < 2: + return 0.0 -def _max_abs_corr(X: np.ndarray) -> float: - """ - Maximum absolute Pearson correlation across columns of X. - NaNs from zero-variance columns are treated as 0 correlation. - """ - C = np.corrcoef(X, rowvar=False) # DxD - A = np.abs(C) - np.fill_diagonal(A, 0.0) - A = np.nan_to_num(A, nan=0.0, posinf=1.0, neginf=1.0) - return float(np.max(A)) + corr = np.corrcoef(varying_values, rowvar=False) + absolute = np.abs(corr) + np.fill_diagonal(absolute, 0.0) + absolute = np.nan_to_num(absolute, nan=0.0, posinf=1.0, neginf=1.0) + return float(np.max(absolute)) def _pick_subset_with_corr( @@ -56,154 +39,329 @@ def _pick_subset_with_corr( tries: int, rng: np.random.Generator, ) -> Tuple[np.ndarray, float]: - """ - Try random subsets of size n from X to find one with max|corr| <= threshold. - Returns (best_subset, best_max_corr). If none meet threshold, returns best found. - """ - m = X.shape[0] - if m == n: - return X, _max_abs_corr(X) - - best_X = None - best_val = float("inf") - for _ in range(max(1, tries)): - idx = rng.choice(m, size=n, replace=False) - X_sub = X[idx] - val = _max_abs_corr(X_sub) - if val < best_val: - best_val, best_X = val, X_sub - if val <= threshold: - break - return best_X, best_val + """Search deterministically (for a seeded RNG) for a qualifying subset.""" + if X.shape[0] < n: + raise ValueError( + f"Need at least {n} rows for subset selection; got {X.shape[0]}." + ) + + best = X[:n].copy() + best_corr = _max_abs_corr(best) + if best_corr <= threshold or X.shape[0] == n: + return best, best_corr + + for _ in range(tries): + indices = rng.choice(X.shape[0], size=n, replace=False) + candidate = X[indices] + correlation = _max_abs_corr(candidate) + if correlation < best_corr: + best = candidate.copy() + best_corr = correlation + if correlation <= threshold: + return candidate.copy(), correlation + + return best, best_corr + def _lhs_select_numeric(df: pd.DataFrame, design: Design) -> pd.DataFrame: - cols = [c for c in design.names if c in df.columns] - return df[cols].select_dtypes(include=[np.number]).copy() + columns = [name for name in design.names if name in df.columns] + return df[columns].select_dtypes(include=[np.number]).copy() -def _lhs_labels(design: Design, cols) -> list: - name_to_label = {n: l for n, l in zip(design.names, design.labels)} - return [name_to_label.get(c, c) for c in cols] +def _lhs_labels(design: Design, columns: Sequence[str]) -> list[str]: + """Build labels from the fields Design actually owns (names and units).""" + labels = {} + for name, unit in zip(design.names, design.units): + labels[name] = f"{name} [{unit}]" if unit else name + return [labels.get(column, column) for column in columns] -# ---------------------------- -# OPTIMIZED LHS IMPLEMENTATION -# ---------------------------- -def _batch_generate_lhs_samples( - design: Design, - total_samples: int, - batch_size: int = 100, - seed: Optional[int] = None +def _validate_positive_int(value: object, *, name: str) -> int: + if isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer)): + raise ValueError(f"{name} must be a positive integer; got {value!r}.") + integer = int(value) + if integer <= 0: + raise ValueError(f"{name} must be a positive integer; got {integer}.") + return integer + + +def _validate_design(design: Design) -> None: + if not isinstance(design, Design): + raise TypeError( + f"design must be a Design instance; got {type(design).__name__}." + ) + dimension = len(design.names) + if dimension == 0: + raise ValueError("Design must contain at least one input.") + if len(set(design.names)) != dimension: + raise ValueError("Design input names must be unique.") + if len(design.units) != dimension: + raise ValueError( + f"Design has {dimension} names but {len(design.units)} unit entries." + ) + if len(design.var_list) != dimension: + raise ValueError( + f"Design has {dimension} names but {len(design.var_list)} variable grids." + ) + for field_name in ("lowers", "uppers", "steps"): + values = np.asarray(getattr(design, field_name), dtype=float) + if values.shape != (dimension,) or not np.all(np.isfinite(values)): + raise ValueError( + f"Design field '{field_name}' must contain {dimension} finite values." + ) + if np.any(np.asarray(design.steps, dtype=float) <= 0): + raise ValueError("Design steps must all be > 0.") + + for index, (name, raw_grid) in enumerate(zip(design.names, design.var_list)): + grid = np.asarray(raw_grid, dtype=float) + if grid.ndim != 1 or grid.size == 0: + raise ValueError( + f"Design grid for input '{name}' must be a non-empty 1D array." + ) + if not np.all(np.isfinite(grid)): + raise ValueError( + f"Design grid for input '{name}' contains non-finite values." + ) + if np.unique(grid).size != grid.size or np.any(np.diff(grid) <= 0): + raise ValueError( + f"Design grid for input '{name}' must be strictly increasing " + "and unique." + ) + if grid.size > 1: + step = float(design.steps[index]) + if not np.allclose( + np.diff(grid), + step, + rtol=0.0, + atol=max(1e-12, abs(step) * 1e-9), + ): + raise ValueError( + f"Design grid for input '{name}' is not aligned to step {step}." + ) + if not np.isclose(grid[0], design.lowers[index], rtol=0.0, atol=1e-12): + raise ValueError( + f"Design lower bound for input '{name}' does not match its grid." + ) + if not np.isclose(grid[-1], design.uppers[index], rtol=0.0, atol=1e-12): + raise ValueError( + f"Design upper bound for input '{name}' does not match its grid." + ) + + +def _normalize_constraints( + row_constraints: Optional[Union[RowConstraint, Sequence[RowConstraint]]], +) -> list[RowConstraint]: + if row_constraints is None: + return [] + if callable(row_constraints): + return [row_constraints] + constraints = list(row_constraints) + if not all(callable(constraint) for constraint in constraints): + raise ValueError("Every row constraint must be callable.") + return constraints + + +def _latin_hypercube_unit( + sample_count: int, + dimension: int, + rng: np.random.Generator, ) -> np.ndarray: - """ - Generate LHS samples in batches for better memory management. - """ - if seed is not None: - np.random.seed(seed) - - space = _space_from_design(design) - all_samples = [] - - for i in range(0, total_samples, batch_size): - current_batch = min(batch_size, total_samples - i) - batch_samples = LatinDesign(space).get_samples(current_batch) - all_samples.append(batch_samples) - - return np.vstack(all_samples) + """Generate one randomized Latin hypercube using a persistent RNG.""" + jitter = rng.random((sample_count, dimension)) + unit = (np.arange(sample_count, dtype=float)[:, None] + jitter) / sample_count + for column in range(dimension): + rng.shuffle(unit[:, column]) + return unit + + +def _snap_to_design_grid(X: np.ndarray, design: Design) -> np.ndarray: + """Snap physical values to exact configured grid values.""" + values = np.asarray(X, dtype=float) + snapped = values.copy() + for column, raw_grid in enumerate(design.var_list): + grid = np.asarray(raw_grid, dtype=float) + nearest = np.argmin( + np.abs(snapped[:, column, None] - grid[None, :]), + axis=1, + ) + snapped[:, column] = grid[nearest] + return snapped + + +def _generate_lhs_samples( + design: Design, + total_samples: int, + rng: np.random.Generator, +) -> np.ndarray: + """Generate one physical-space LHS without resetting RNG state.""" + unit = _latin_hypercube_unit(total_samples, len(design.names), rng) + return design.lowers + unit * (design.uppers - design.lowers) def _batch_apply_constraints( - X_samples: np.ndarray, - design: Design, + X_samples: np.ndarray, + design: Design, constraints: Sequence[RowConstraint], - batch_size: int = 1000 -) -> Tuple[np.ndarray, np.ndarray]: - """ - Apply constraints in batches to avoid memory issues with large sample sets. - Returns (valid_samples, valid_indices). - """ + batch_size: int, +) -> np.ndarray: + """Apply constraints to already-snapped rows in bounded-size chunks.""" if not constraints: - return X_samples, np.arange(len(X_samples)) - - valid_samples = [] - valid_indices = [] - - for i in range(0, len(X_samples), batch_size): - batch_end = min(i + batch_size, len(X_samples)) - X_batch = X_samples[i:batch_end] - - # Apply constraints to this batch - mask = apply_row_constraints(X_batch, design, constraints) - valid_batch = X_batch[mask] - valid_indices.extend(np.arange(i, batch_end)[mask]) - - if len(valid_batch) > 0: - valid_samples.append(valid_batch) - - if valid_samples: - return np.vstack(valid_samples), np.array(valid_indices) - else: - return np.empty((0, X_samples.shape[1])), np.array([], dtype=int) - + return X_samples + + valid_batches = [] + for start in range(0, X_samples.shape[0], batch_size): + batch = X_samples[start : start + batch_size] + mask = apply_row_constraints(batch, design, constraints) + if np.any(mask): + valid_batches.append(batch[mask]) + if not valid_batches: + return np.empty((0, X_samples.shape[1]), dtype=float) + return np.vstack(valid_batches) + + +def _append_unique_rows( + pool: list[np.ndarray], + seen: set[tuple[float, ...]], + rows: np.ndarray, +) -> None: + """Append rows in encounter order, deduplicating exact snapped grid values.""" + for row in rows: + key = tuple(float(value) for value in row) + if key not in seen: + seen.add(key) + pool.append(np.asarray(row, dtype=float).copy()) + + +def _validate_grid_membership(X: np.ndarray, design: Design) -> None: + """Guard the campaign invariant that every emitted value is on its grid.""" + for column, (name, raw_grid) in enumerate(zip(design.names, design.var_list)): + grid = np.asarray(raw_grid, dtype=float) + if not np.all(np.isin(X[:, column], grid)): + raise RuntimeError( + "Internal LHS error: generated value outside configured grid " + f"for '{name}'." + ) + + +def _strict_lhs_dataframe( + *, + design: Design, + n: int, + seed: Optional[int], + snap_to_grids: bool, + row_constraints: Optional[Union[RowConstraint, Sequence[RowConstraint]]], + max_abs_corr: Optional[float], + max_attempts: int, + oversample: int, + batch_size: int, + subset_tries: int, + samples_per_attempt: Optional[int], + verbose: bool, +) -> pd.DataFrame: + _validate_design(design) + n = _validate_positive_int(n, name="n") + max_attempts = _validate_positive_int(max_attempts, name="max_attempts") + oversample = _validate_positive_int(oversample, name="oversample") + batch_size = _validate_positive_int(batch_size, name="batch_size") + subset_tries = _validate_positive_int(subset_tries, name="subset_tries") + + if not snap_to_grids: + raise ValueError( + "Campaign LHS requires snap_to_grids=True so every condition is exactly " + "on the configured processing grid." + ) -def _smart_subset_selection( - X_valid: np.ndarray, - n_target: int, - max_abs_corr: float, - max_tries: int = 1000, - early_stop_threshold: float = 0.1, - seed: Optional[int] = None -) -> Tuple[np.ndarray, float, int]: - """ - Smart subset selection with early stopping and adaptive search. - Returns (best_subset, best_correlation, tries_used). - """ if seed is not None: - rng = np.random.default_rng(seed) + if isinstance(seed, (bool, np.bool_)) or not isinstance( + seed, (int, np.integer) + ): + raise ValueError(f"seed must be an integer or None; got {seed!r}.") + seed = int(seed) + if seed < 0: + raise ValueError(f"seed must be non-negative; got {seed}.") + + if max_abs_corr is not None: + try: + max_abs_corr = float(max_abs_corr) + except (TypeError, ValueError) as exc: + raise ValueError("max_abs_corr must be a finite number in [0, 1].") from exc + if not np.isfinite(max_abs_corr) or not 0.0 <= max_abs_corr <= 1.0: + raise ValueError("max_abs_corr must be a finite number in [0, 1].") + + if samples_per_attempt is None: + samples_per_attempt = max(n, n * oversample) else: - rng = np.random.default_rng() - - m = X_valid.shape[0] - if m <= n_target: - return X_valid, _max_abs_corr(X_valid), 0 - - best_X = None + samples_per_attempt = _validate_positive_int( + samples_per_attempt, name="samples_per_attempt" + ) + + total_grid_points = prod(len(grid) for grid in design.var_list) + if n > total_grid_points: + raise ValueError( + f"Requested n={n} unique conditions, but the design contains only " + f"{total_grid_points} unique grid combinations." + ) + + constraints = _normalize_constraints(row_constraints) + rng = np.random.default_rng(seed) + unique_pool: list[np.ndarray] = [] + seen: set[tuple[float, ...]] = set() best_corr = float("inf") - tries_used = 0 - - # Phase 1: Quick random sampling - quick_tries = min(max_tries // 2, 100) - for _ in range(quick_tries): - idx = rng.choice(m, size=n_target, replace=False) - X_sub = X_valid[idx] - corr = _max_abs_corr(X_sub) - - if corr < best_corr: - best_corr = corr - best_X = X_sub.copy() - tries_used += 1 - - # Early stopping if we're close to target - if corr <= max_abs_corr + early_stop_threshold: - break - - # Phase 2: Targeted improvement if needed - if best_corr > max_abs_corr: - remaining_tries = max_tries - tries_used - for _ in range(remaining_tries): - idx = rng.choice(m, size=n_target, replace=False) - X_sub = X_valid[idx] - corr = _max_abs_corr(X_sub) - - if corr < best_corr: - best_corr = corr - best_X = X_sub.copy() - tries_used += 1 - - if corr <= max_abs_corr: - break - - return best_X, best_corr, tries_used + + for attempt in range(1, max_attempts + 1): + raw = _generate_lhs_samples( + design=design, + total_samples=samples_per_attempt, + rng=rng, + ) + # Constraints deliberately see executable, snapped processing conditions. + snapped = _snap_to_design_grid(raw, design) + _validate_grid_membership(snapped, design) + valid = _batch_apply_constraints(snapped, design, constraints, batch_size) + _append_unique_rows(unique_pool, seen, valid) + + if verbose: + print( + f"[LHS] attempt {attempt}/{max_attempts}: " + f"{len(unique_pool)} unique valid grid points" + ) + + if len(unique_pool) < n: + continue + + pool_array = np.vstack(unique_pool) + if max_abs_corr is None: + selected = pool_array[:n].copy() + _validate_grid_membership(selected, design) + return pd.DataFrame(selected, columns=design.names) + + selected, correlation = _pick_subset_with_corr( + pool_array, + n=n, + threshold=max_abs_corr, + tries=subset_tries, + rng=rng, + ) + best_corr = min(best_corr, correlation) + if verbose: + print( + f"[LHS] attempt {attempt}: best max|corr|={correlation:.6f}; " + f"required <= {max_abs_corr:.6f}" + ) + if correlation <= max_abs_corr: + _validate_grid_membership(selected, design) + return pd.DataFrame(selected, columns=design.names) + + details = ( + f"Generated {len(unique_pool)} unique constraint-valid grid points after " + f"{max_attempts} attempt(s), using {samples_per_attempt} samples per attempt." + ) + if max_abs_corr is not None and len(unique_pool) >= n: + details += f" Best max|corr|={best_corr:.6f}, required <= {max_abs_corr:.6f}." + raise RuntimeError( + f"Unable to generate exactly n={n} campaign conditions. {details} " + "Increase samples_per_attempt/max_attempts or revise the design constraints." + ) def lhs_dataframe_optimized( @@ -213,214 +371,73 @@ def lhs_dataframe_optimized( snap_to_grids: bool = True, row_constraints: Optional[Union[RowConstraint, Sequence[RowConstraint]]] = None, max_abs_corr: Optional[float] = None, - # Performance tuning: max_attempts: int = 100, - oversample: int = 5, # Increased for better constraint satisfaction + oversample: int = 5, batch_size: int = 100, subset_tries: int = 1000, early_stop_threshold: float = 0.1, verbose: bool = False, + samples_per_attempt: Optional[int] = None, ) -> pd.DataFrame: + """Generate exactly ``n`` deterministic, unique, constraint-valid grid rows. + + ``samples_per_attempt`` controls the candidate count directly. When omitted, + it is ``max(n, n * oversample)``. A configured correlation limit is hard: the + function raises instead of returning a noncompliant or unconstrained fallback. + + ``early_stop_threshold`` remains accepted for API compatibility but is not + used; stopping occurs only when the hard ``max_abs_corr`` limit is satisfied. """ - OPTIMIZED LHS generation with batch processing, smart subset selection, and early stopping. - - Key optimizations: - 1. Batch LHS generation to manage memory - 2. Batch constraint application - 3. Smart subset selection with early stopping - 4. Adaptive oversampling based on constraint strictness - """ - if seed is not None: - np.random.seed(int(seed)) - - # Normalize constraints - if row_constraints is None: - constraints_list = [] - elif callable(row_constraints): - constraints_list = [row_constraints] - else: - constraints_list = list(row_constraints) - - # Adjust oversampling based on constraint complexity - if constraints_list: - effective_oversample = max(oversample, len(constraints_list) * 2) - else: - effective_oversample = 1 - - total_samples_needed = n * effective_oversample - - if verbose: - print(f"[OPTIMIZED LHS] Target: {n} samples, Generating: {total_samples_needed} samples") - - best_candidate = None - best_corr = float("inf") - - for attempt in range(1, max_attempts + 1): - if verbose and attempt % 10 == 0: - print(f"[OPTIMIZED LHS] Attempt {attempt}/{max_attempts}") - - # Generate LHS samples in batches - X_raw = _batch_generate_lhs_samples( - design, total_samples_needed, batch_size, seed - ) - - # Snap to grids if requested - if snap_to_grids: - X_raw = snap_to_grid_np(X_raw, design) - - # Apply constraints in batches - X_valid, valid_indices = _batch_apply_constraints( - X_raw, design, constraints_list, batch_size - ) - - if len(X_valid) < n: - if verbose: - print(f"[OPTIMIZED LHS] Attempt {attempt}: Only {len(X_valid)} valid samples") - continue - - # Smart subset selection - X_best, corr_val, tries_used = _smart_subset_selection( - X_valid, n, max_abs_corr or 1.0, subset_tries, early_stop_threshold - ) - - if verbose: - print(f"[OPTIMIZED LHS] Attempt {attempt}: max|corr|={corr_val:.3f} (tries: {tries_used})") - - # Check if we met the correlation target - if max_abs_corr is None or corr_val <= max_abs_corr: - if verbose: - print(f"[OPTIMIZED LHS] Success! max|corr|={corr_val:.3f} <= {max_abs_corr}") - return pd.DataFrame(X_best, columns=design.names) - - # Keep track of best candidate - if corr_val < best_corr: - best_corr = corr_val - best_candidate = X_best.copy() - - # Return best candidate if we didn't meet the target - if best_candidate is not None: - if verbose: - print(f"[OPTIMIZED LHS] Best achieved: max|corr|={best_corr:.3f} (> target)") - return pd.DataFrame(best_candidate, columns=design.names) - - # Fallback: return whatever we have - if verbose: - print(f"[OPTIMIZED LHS] Failed to generate {n} samples after {max_attempts} attempts") - - # Try one more time with minimal constraints - X_fallback = _batch_generate_lhs_samples(design, n, batch_size, seed) - if snap_to_grids: - X_fallback = snap_to_grid_np(X_fallback, design) - - return pd.DataFrame(X_fallback, columns=design.names) - - -# ---------------------------- -# Original LHS (kept for backward compatibility) -# ---------------------------- + try: + compatibility_threshold = float(early_stop_threshold) + except (TypeError, ValueError) as exc: + raise ValueError( + "early_stop_threshold must be a finite non-negative number." + ) from exc + if not np.isfinite(compatibility_threshold) or compatibility_threshold < 0: + raise ValueError("early_stop_threshold must be a finite non-negative number.") + return _strict_lhs_dataframe( + design=design, + n=n, + seed=seed, + snap_to_grids=snap_to_grids, + row_constraints=row_constraints, + max_abs_corr=max_abs_corr, + max_attempts=max_attempts, + oversample=oversample, + batch_size=batch_size, + subset_tries=subset_tries, + samples_per_attempt=samples_per_attempt, + verbose=verbose, + ) + def lhs_dataframe( design: Design, n: int, seed: Optional[int] = None, snap_to_grids: bool = True, - # Constraints: - #row_constraint_fn: Optional[Callable[[np.ndarray, Design], np.ndarray]] = None, row_constraints: Optional[Union[RowConstraint, Sequence[RowConstraint]]] = None, max_abs_corr: Optional[float] = None, - # Sampling controls: max_attempts: int = 100, oversample: int = 3, subset_tries: int = 800, verbose: bool = False, + samples_per_attempt: Optional[int] = None, + batch_size: int = 100, ) -> pd.DataFrame: - """ - Generate an LHS using Emukit, then enforce: - - pointwise constraints via `row_constraints -> bool mask` - - dataset-level Pearson correlation bound via `max_abs_corr` - - Strategy: - 1) Sample in continuous box via Emukit. - 2) Snap grid features (if requested). - 3) Apply row-wise mask (e.g., Clausius–Clapeyron); accumulate valid rows. - 4) If we have >= n rows, optionally pick a subset with max|corr| <= threshold. - - Returns DataFrame with columns == design.names (physical units). - """ - # Seed Emukit's internal RNG - if seed is not None: - np.random.seed(int(seed)) - rng = np.random.default_rng(seed) - - space = _space_from_design(design) - - # Normalize row constraints to a list (None -> empty list) - if row_constraints is None: - row_constraints_list: Sequence[RowConstraint] = [] - elif callable(row_constraints): - row_constraints_list = [row_constraints] # backward-compatible single fn - else: - row_constraints_list = list(row_constraints) - - collected = [] - best_candidate = None - best_corr_val = float("inf") - - for attempt in range(1, max_attempts + 1): - # Heuristic: oversample to survive row constraints - #batch = n if row_constraint_fn is None else max(n, oversample * n) - batch = n if not row_constraints_list else max(n, oversample * n) - - X = LatinDesign(space).get_samples(batch) # (batch, D) - if snap_to_grids: - X = snap_to_grid_np(X, design) - - # if row_constraint_fn is not None: - # mask = row_constraint_fn(X, design) - # if mask is None or mask.dtype != bool or mask.shape[0] != X.shape[0]: - # raise ValueError("row_constraint_fn must return a boolean mask of shape (batch,).") - # X = X[mask] - - if row_constraints_list: - mask = apply_row_constraints(X, design, row_constraints_list) - X = X[mask] - - if X.size == 0: - if verbose: - print(f"[LHS] attempt {attempt}: 0 valid after row constraints; retrying.") - continue - - collected.append(X) - X_all = np.vstack(collected) - - if X_all.shape[0] >= n: - # If no correlation constraint, take first n (shuffled) - idx = rng.permutation(X_all.shape[0]) - X_all = X_all[idx] - - if max_abs_corr is None: - return pd.DataFrame(X_all[:n], columns=design.names) - - # Try to find subset meeting correlation threshold - X_best, corr_val = _pick_subset_with_corr(X_all, n, max_abs_corr, subset_tries, rng) - if verbose: - print(f"[LHS] attempt {attempt}: candidate max|corr|={corr_val:.3f}") - if corr_val <= max_abs_corr: - return pd.DataFrame(X_best, columns=design.names) - - # Keep best-so-far - if corr_val < best_corr_val: - best_corr_val = corr_val - best_candidate = X_best - - # If we got here, we didn't meet correlation threshold; return best we found - if best_candidate is None: - # Maybe we never amassed n rows; return whatever we have (possibly < n) - X_all = np.vstack(collected) if collected else np.empty((0, len(design.names))) - X_all = X_all[:n] - return pd.DataFrame(X_all, columns=design.names) - - if verbose: - print(f"[LHS] giving best candidate with max|corr|={best_corr_val:.3f} (> target).") - return pd.DataFrame(best_candidate, columns=design.names) - + """Compatibility wrapper around the strict campaign LHS implementation.""" + return _strict_lhs_dataframe( + design=design, + n=n, + seed=seed, + snap_to_grids=snap_to_grids, + row_constraints=row_constraints, + max_abs_corr=max_abs_corr, + max_attempts=max_attempts, + oversample=oversample, + batch_size=batch_size, + subset_tries=subset_tries, + samples_per_attempt=samples_per_attempt, + verbose=verbose, + ) diff --git a/src/mobo_kit/loocv.py b/src/mobo_kit/loocv.py new file mode 100644 index 0000000..5e212d2 --- /dev/null +++ b/src/mobo_kit/loocv.py @@ -0,0 +1,267 @@ +"""Exact leave-one-out for one objective, with its declared structured mean. + +There is one fold loop in this project and this is it. ``scripts/intake_new_data.py`` +is canonical for LOO numbers, the round report plots them, and +``scripts/permutation_rank_test.py`` builds a null out of them -- so they must be +*the same* numbers rather than three implementations that agree today. This module +exists because they briefly were three implementations. + +Two rules the fold loop keeps, both of which flatter the result if broken: + +* **the trend is refit inside every fold**, on the training rows only. Fitting it + once on everything and holding it fixed leaks the held-out value into the mean + function. +* **the model is refit from scratch per fold** under the campaign's own variant and + seeding, so this reproduces the round's model rather than a similar one. + +:func:`model_validation.run_exact_loocv` is the multi-output validation harness and +is a different tool: it fits one N-1 model for all objectives at once and does not +take a per-objective mean module. Objectives here carry different structured means, +so they are fitted one at a time. +""" + +from __future__ import annotations + +import contextlib +import math +import warnings +from dataclasses import dataclass +from typing import Any, Mapping, Sequence + +import numpy as np +import torch + +from .model_validation import DIM_SCALED_PRIOR, SIGNAL_COLLAPSE_STAGE, fit_model_variant +from .structured_mean import build_structured_mean, mean_spec_from_config + +__all__ = ["LooResult", "loo_predictions", "null_loo_r2", "resolution_sd"] + +#: Bootstrap sd of LOO R2 measured at N=15 on the first campaign, 4000 resamples. +#: Scaled by ``sqrt(15/N)`` elsewhere, which is an approximation -- re-run the +#: bootstrap if a decision turns on the third decimal. +RESOLUTION_SD_AT_15 = 0.236 +RESOLUTION_REFERENCE_N = 15 + + +def null_loo_r2(n: int) -> float: + """What predicting the leave-one-out mean scores, independent of the data. + + ``1 - (N/(N-1))^2``: -0.148 at 15, -0.105 at 21, -0.069 at 31. It moves with + N, so recompute rather than reuse. + + **THIS IS NOT A SIGNIFICANCE THRESHOLD, and this project read it as one for a + year.** It is the score of ONE SPECIFIC PREDICTOR -- predict every held-out + row with the mean of the others -- and a fitted GP does not behave like that + predictor. Measured on the v4 campaign, 2026-09-04, 300 permutations of a + real objective with the campaign's own model variant: + + fitted GP under permuted y median -0.4210 95th percentile +0.2890 + fraction of pure-noise draws scoring above -0.1480: 28.7% + + So "beats the null" happens better than one time in four when there is no + signal at all. The GP makes real predictions with about six times the spread + of the constant predictor; they are noise, and they land FURTHER from y, which + is why the empirical null sits well below this number while its upper tail + sits well above it. + + Below this value a model has certainly learned nothing. ABOVE it means + nothing on its own. The honest single-candidate bar is the 95th percentile of + that candidate's own permutation null -- ``scripts/raw_component_screen.py + --calibrate`` measures it -- and the project's standing adjudicator for a + real verdict is the rank permutation test in + ``scripts/permutation_rank_test.py``, which was always the right instrument + and is now the only one. + + A declared mean function LOWERS this empirical null rather than raising it: + an OLS trend fitted on N-1 rows of shuffled y is a noise fit, and + extrapolating it to the held-out row adds error. Median goes -0.4075 with no + mean, -0.4368 with one feature, -0.5384 with two. + """ + if n < 2: + raise ValueError("The leave-one-out null needs at least two rows.") + return 1.0 - (n / (n - 1)) ** 2 + + +def resolution_sd(n: int) -> float: + """Sampling sd of LOO R2 at this N, scaled from the N=15 bootstrap.""" + if n < 1: + raise ValueError("resolution_sd needs at least one row.") + return RESOLUTION_SD_AT_15 * math.sqrt(RESOLUTION_REFERENCE_N / n) + + +@contextlib.contextmanager +def _single_threaded_torch(): + """Run the fold loop on one thread, and put the setting back afterwards. + + Every fold here fits a GP on an N-1 x D design -- 14 x 10 on this campaign. + At that size intra-op threading costs more than it buys. Measured on an IDLE + machine, 45 fits: **9.8 s at 1, 2 or 4 threads against 15.1 s at this box's + default of 12**, for bit-identical results (LOO R2 -0.6447 / -0.5842 / +0.6630 + at every setting). + + The idleness matters and is not a footnote. The first version of this comment + claimed 51 s against 117 s, which was measured while sixteen permutation + workers were saturating the CPU -- a real effect, but of the load and not of + the thread count. A timing taken under contention is a wrong number in the + same way any other unreproduced number is, so it was re-measured before being + written down. + + Scoped rather than set globally, because the same setting would SLOW the + round itself down: scoring a 32768-point candidate pool is exactly the large + matrix work threads are for. Not safe to call while another thread in this + process is computing with torch. + """ + previous = torch.get_num_threads() + torch.set_num_threads(1) + try: + yield + finally: + torch.set_num_threads(previous) + + +@dataclass(frozen=True) +class LooResult: + """One objective's exact leave-one-out predictions, in both spaces.""" + + objective: str + #: What the GP emits: ``log(y)`` when the objective declares a log-response + #: mean function, ``y`` otherwise. This is what a utility transform consumes. + mean_model_space: np.ndarray + variance_model_space: np.ndarray + #: The measurement's own units -- nanometres for thickness. This is what a + #: parity plot must show, because nobody reads log(nm). + predicted: np.ndarray + predictive_sd: np.ndarray + observed: np.ndarray + r2: float + spearman: float + has_mean_function: bool + model_link: str + collapse_warnings: tuple[str, ...] + + @property + def n(self) -> int: + return len(self.observed) + + +def loo_predictions( + config: Mapping[str, Any], + entry: Mapping[str, Any], + X_phys: np.ndarray, + y: np.ndarray, + *, + seed: int = 73, + use_mean_function: bool = True, +) -> LooResult: + """Exact leave-one-out for one objective under the campaign's own contract. + + ``y`` is in MEASUREMENT space, as :func:`workbook_io.read_campaign_workbook` + returns it. Set ``use_mean_function=False`` for the plain-GP comparison the + intake verdict rests on. + """ + from .campaign import normalise_inputs + from scipy.stats import spearmanr + + X_phys = np.asarray(X_phys, dtype=float) + y = np.asarray(y, dtype=float) + n = len(y) + if n < 3: + raise ValueError(f"Leave-one-out needs at least three rows; got {n}.") + + mean_spec = mean_spec_from_config(entry) if use_mean_function else None + log_response = mean_spec is not None and mean_spec.response == "log" + design_names = [item["name"] for item in config["inputs"]] + lowers = np.array([float(item["start"]) for item in config["inputs"]]) + uppers = np.array([float(item["stop"]) for item in config["inputs"]]) + X_norm = normalise_inputs(config, X_phys) + + mu = np.empty(n) + var = np.empty(n) + collapse: list[str] = [] + with _single_threaded_torch(): + for held in range(n): + keep = [i for i in range(n) if i != held] + target = y[keep] + module = None + if mean_spec is not None: + module, target = build_structured_mean( + X_phys[keep], y[keep], mean_spec, design_names, lowers, uppers + ) + torch.manual_seed(seed) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + record = fit_model_variant( + torch.tensor(X_norm[keep], dtype=torch.double), + torch.tensor(target, dtype=torch.double).unsqueeze(-1), + sample_ids=tuple(range(len(keep))), + objective_names=("y",), + variant=DIM_SCALED_PRIOR, + seed=seed, + mean_module=module, + ) + collapse.extend( + w.message for w in record.warnings if w.stage == SIGNAL_COLLAPSE_STAGE + ) + gp = record.model.models[0] + gp.eval() + with torch.no_grad(): + posterior = gp.posterior( + torch.tensor(X_norm[held : held + 1], dtype=torch.double) + ) + mu[held] = float(posterior.mean.reshape(-1)[0]) + var[held] = float(max(0.0, float(posterior.variance.reshape(-1)[0]))) + + if log_response: + # The GP emits log(y), so the measurement-space POINT prediction is the + # median exp(mu), matching the convention the round simulation and the + # SHAP oracle both use. The sd is the lognormal one; it is asymmetric in + # nanometres, which is why the parity plot labels it as an interval rather + # than pretending to a symmetric error bar. + predicted = np.exp(mu) + predictive_sd = np.sqrt(np.clip(np.exp(var) - 1.0, 0.0, None)) * np.exp( + mu + var / 2.0 + ) + else: + predicted = mu + predictive_sd = np.sqrt(var) + + ss_res = float(np.sum((y - predicted) ** 2)) + ss_tot = float(np.sum((y - np.mean(y)) ** 2)) + r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else float("nan") + rank = ( + float(spearmanr(y, predicted).statistic) + if len(np.unique(predicted)) > 1 + else float("nan") + ) + + return LooResult( + objective=str(entry.get("name", "")), + mean_model_space=mu, + variance_model_space=var, + predicted=predicted, + predictive_sd=predictive_sd, + observed=y, + r2=r2, + spearman=rank, + has_mean_function=mean_spec is not None, + model_link="log" if log_response else "identity", + collapse_warnings=tuple(dict.fromkeys(collapse)), + ) + + +def loo_for_objectives( + config: Mapping[str, Any], + X_phys: np.ndarray, + Y_measured: np.ndarray, + *, + names: Sequence[str], + seed: int = 73, +) -> dict[str, LooResult]: + """One :class:`LooResult` per objective, each with its own declared mean.""" + entries = config["objectives"]["specs"] + out: dict[str, LooResult] = {} + for index, name in enumerate(names): + out[name] = loo_predictions( + config, entries[index], X_phys, np.asarray(Y_measured)[:, index], seed=seed + ) + return out diff --git a/src/mobo_kit/main.py b/src/mobo_kit/main.py index c222b44..18a9afb 100644 --- a/src/mobo_kit/main.py +++ b/src/mobo_kit/main.py @@ -7,17 +7,21 @@ import os import sys -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, Sequence +import numpy as np import pandas as pd import torch import matplotlib.pyplot as plt import yaml from .utils import ( - load_csv, split_XY, csv_to_config, set_seeds, select_device, - get_objective_names + parse_campaign_csv, + split_XY, + set_seeds, + select_device, + get_objective_names, ) -from .design import build_input_spec_list, build_design +from .design import Design, build_design_from_config from .data import x_normalizer_np from .models import fit_gp_models, posterior_report from .plotting import plot_parity_np @@ -27,6 +31,22 @@ from .lhs import lhs_dataframe_optimized +class CampaignProposalRedirect(RuntimeError): + """Raised when the legacy runner is asked to propose candidates.""" + + +_PROPOSE_REDIRECT = ( + "This runner fits models and reports diagnostics; it does not propose " + "candidates.\n" + "Use mobo_kit.campaign instead:\n" + " from mobo_kit.campaign import load_campaign_config, run_r1_ucb\n" + " config = load_campaign_config('configs/.yaml')\n" + " batch = run_r1_ucb(config, X_phys, Y_model, n=5)\n" + "campaign.py carries the objective contract, the fixed scales, and the " + "batch validity checks that this path never had." +) + + def generate_initial_experiments( config_path: str, n_samples: int, @@ -34,14 +54,14 @@ def generate_initial_experiments( seed: int = 42, verbose: bool = True, max_abs_corr: Optional[float] = None, - max_attempts: int = 100 + max_attempts: int = 100, ) -> Dict[str, Any]: """ Generate initial experiments using Latin Hypercube Sampling. - + This function is useful when you have a design space but no existing data. It generates a CSV file with initial experiments to run. - + Args: config_path: Path to YAML configuration file n_samples: Number of initial experiments to generate @@ -50,7 +70,7 @@ def generate_initial_experiments( verbose: Whether to print progress information max_abs_corr: Maximum absolute correlation between variables (optional) max_attempts: Maximum attempts for LHS generation - + Returns: Dictionary with generation results and metadata """ @@ -60,28 +80,28 @@ def generate_initial_experiments( print(f"Generating {n_samples} initial experiments...") print(f"Config: {config_path}") print(f"Output: {save_path}") - + # Set random seed set_seeds(seed) - + # Load configuration - with open(config_path, 'r') as f: + with open(config_path, "r", encoding="utf-8") as f: config = yaml.safe_load(f) - - # Build design space - space = build_design(config) - + + # Build design space through the validated config-to-design path. + space = build_design_from_config(config) + if verbose: print(f"Design space: {len(space.names)} variables") print(f"Variables: {', '.join(space.names)}") - + # Get constraints row_constraints = constraints_from_config(config, space) - + # Generate LHS samples if verbose: print("Generating Latin Hypercube samples...") - + lhs_df = lhs_dataframe_optimized( design=space, n=n_samples, @@ -90,17 +110,19 @@ def generate_initial_experiments( row_constraints=row_constraints, max_abs_corr=max_abs_corr, max_attempts=max_attempts, - verbose=verbose + verbose=verbose, ) - + # Save to CSV + output_parent = os.path.dirname(os.path.abspath(save_path)) + os.makedirs(output_parent, exist_ok=True) lhs_df.to_csv(save_path, index=False) - + if verbose: print(f"Generated {len(lhs_df)} initial experiments") print(f"Saved to: {save_path}") print("=" * 50) - + return { "status": "success", "n_samples": len(lhs_df), @@ -108,23 +130,90 @@ def generate_initial_experiments( "save_path": save_path, "seed": seed, "variables": space.names, - "constraints_applied": len(row_constraints) > 0 if row_constraints else False + "constraints_applied": len(row_constraints) > 0 if row_constraints else False, } +def _validate_candidate_batch( + batch_result: Dict[str, Any], + design: Design, + observed_inputs: pd.DataFrame, + batch_size: int, +) -> np.ndarray: + """Fail closed on incomplete, duplicate, observed, or off-grid batches.""" + + if not isinstance(batch_result, dict) or "X_phys" not in batch_result: + raise ValueError("Candidate proposal did not return an 'X_phys' array.") + try: + candidates = np.asarray(batch_result["X_phys"], dtype=float) + except (TypeError, ValueError) as exc: + raise ValueError("Candidate physical inputs must be numeric.") from exc + + expected_shape = (batch_size, len(design.names)) + if candidates.shape != expected_shape: + raise ValueError( + "Candidate proposal returned an incomplete or malformed batch: " + f"expected shape {expected_shape}, got {candidates.shape}." + ) + if not np.isfinite(candidates).all(): + raise ValueError("Candidate proposal contains non-finite physical inputs.") + + tolerances = np.maximum(np.abs(design.steps) * 1e-9, 1e-10) + grid_indices = np.empty(expected_shape, dtype=np.int64) + for column_index, (name, grid) in enumerate(zip(design.names, design.var_list)): + distances = np.abs(candidates[:, column_index, None] - grid[None, :]) + nearest_indices = np.argmin(distances, axis=1) + nearest_distances = distances[ + np.arange(batch_size, dtype=np.int64), nearest_indices + ] + if np.any(nearest_distances > tolerances[column_index]): + bad_rows = np.flatnonzero( + nearest_distances > tolerances[column_index] + ).tolist() + raise ValueError( + f"Candidate input '{name}' is off-grid at batch rows {bad_rows}." + ) + grid_indices[:, column_index] = nearest_indices + + if np.unique(grid_indices, axis=0).shape[0] != batch_size: + raise ValueError("Candidate proposal contains duplicate snapped recipes.") + + observed = observed_inputs.loc[:, design.names].to_numpy(dtype=float) + if observed.size: + matches_observed = np.all( + np.isclose( + candidates[:, None, :], + observed[None, :, :], + rtol=0.0, + atol=tolerances, + ), + axis=2, + ) + repeated_rows = np.flatnonzero(matches_observed.any(axis=1)).tolist() + if repeated_rows: + raise ValueError( + "Candidate proposal repeats observed recipes at batch rows " + f"{repeated_rows}." + ) + + return candidates + + def run_mobo_experiment( csv_path: str, - save_dir: str = "results/experiment", + save_dir: str = "local_outputs/experiment", config_path: Optional[str] = None, seed: int = 42, device: str = "auto", verbose: bool = True, batch_size: int = 5, - propose_candidates: bool = True + propose_candidates: bool = False, + reference_point: Optional[Sequence[float]] = None, + num_restarts: int = 20, ) -> Dict[str, Any]: """ Run a complete MOBO experiment from CSV data. - + Args: csv_path: Path to CSV file with experimental data save_dir: Directory to save results @@ -133,83 +222,108 @@ def run_mobo_experiment( device: Device to use ("auto", "cpu", "cuda") verbose: Whether to print progress information batch_size: Number of candidates to propose for next batch - propose_candidates: Whether to propose new candidates for next experiments - + propose_candidates: Request campaign candidates. Step 2A always blocks + this legacy path; a reviewed Step 2B campaign adapter is required. + reference_point: Explicit hypervolume reference point in the exact same + transformed objective space as ``Y``. Required when proposing. + num_restarts: Number of acquisition-optimization restarts. + Returns: Dictionary with experiment results and metadata """ if verbose: print("MOBO-Kit: Multi-objective Bayesian Optimization Toolkit") print("=" * 60) - + # Set random seeds set_seeds(seed) - + # Select device if device == "auto": device_obj = select_device("cuda") else: device_obj = select_device(device) - + if verbose: print(f"Using device: {device_obj}") print(f"Loading data from: {csv_path}") - - # Load and process data - df = load_csv(csv_path) - - # Load or generate configuration - if config_path and os.path.exists(config_path): - import yaml - with open(config_path, 'r') as f: + + # Load the explicit configuration first when supplied, then parse the CSV + # exactly once against the expected objective names. + if config_path is not None: + if not os.path.isfile(config_path): + raise FileNotFoundError(f"Configuration file not found: {config_path}") + with open(config_path, "r", encoding="utf-8") as f: config = yaml.safe_load(f) + if propose_candidates: + raise CampaignProposalRedirect(_PROPOSE_REDIRECT) + parsed_csv = parse_campaign_csv( + csv_path, expected_objectives=get_objective_names(config) + ) if verbose: print(f"Loaded config from: {config_path}") else: - config = csv_to_config(csv_path) + if propose_candidates: + raise CampaignProposalRedirect(_PROPOSE_REDIRECT) + parsed_csv = parse_campaign_csv(csv_path) + config = parsed_csv.config if verbose: print("Auto-generated config from CSV metadata") - - # Build design space - specs = build_input_spec_list(config["inputs"]) - space = build_design(specs) - + + df = parsed_csv.data + + # Build the design through the same validated path used by LHS generation. + space = build_design_from_config(config) + # Split data into inputs and objectives X, Y = split_XY(df, space, config) - + + if propose_candidates and reference_point is None: + raise ValueError( + "Candidate proposal requires an explicit reference_point in the " + "same transformed objective space as the model outputs. MOBO-Kit " + "will not invent a campaign reference point." + ) + if verbose: - print(f"Loaded {len(X)} samples with {X.shape[1]} inputs and {Y.shape[1]} objectives") + print( + f"Loaded {len(X)} samples with {X.shape[1]} inputs and {Y.shape[1]} objectives" + ) print(f"Objective names: {get_objective_names(config)}") - + # Normalize inputs to [0,1] range X_norm = x_normalizer_np(X, space) - + # Convert to torch tensors (Y is used directly, not standardized) X_t = torch.tensor(X_norm, dtype=torch.float64, device=device_obj) Y_t = torch.tensor(Y.values, dtype=torch.float64, device=device_obj) - + if verbose: print("Fitting Gaussian Process models...") - + # Fit GP models model = fit_gp_models(X_t, Y_t) - + if verbose: print("Generating predictions...") - + # Generate predictions (already in original units due to internal standardization) pred_mean, pred_std = posterior_report(model, X_t) - + # Create output directory os.makedirs(save_dir, exist_ok=True) - + # Predictions are generated but not saved to separate CSV - + # Generate plots try: if verbose: print("Generating plots...") - + + # The runner writes diagnostics to disk and must also work on lab PCs, + # CI workers, and managed Python installs without a Tcl/Tk GUI runtime. + plt.switch_backend("Agg") + # Create parity plots parity_path = os.path.join(save_dir, "parity_plots.png") fig, metrics_df = plot_parity_np( @@ -218,35 +332,43 @@ def run_mobo_experiment( pred_std=pred_std, objective_names=get_objective_names(config), save=parity_path, - show_plot=False + show_plot=False, ) plt.close(fig) # Close the figure to free memory - + if verbose: print(f"Parity plots saved to: {parity_path}") - + except Exception as e: if verbose: print(f"Warning: Could not generate plots: {e}") - + # Propose new candidates for next batch candidates = None if propose_candidates: try: if verbose: print("Proposing new candidates for next batch...") - + + ref_point_np = np.asarray(reference_point, dtype=float) + if ref_point_np.shape != (Y.shape[1],): + raise ValueError( + "reference_point must contain exactly one value per objective " + f"({Y.shape[1]} expected, got shape {ref_point_np.shape})." + ) + # Compute reference point and hypervolume - _, pareto_Y_t, hv_val = compute_ref_pareto_hv(Y_t) - ref_point_t = torch.tensor([-0.01] * Y.shape[1], dtype=X_t.dtype, device=device_obj) - + ref_point_t, pareto_Y_t, hv_val = compute_ref_pareto_hv( + Y_t, ref_point_np=ref_point_np + ) + if verbose: print(f"Current hypervolume: {hv_val:.4f}") print(f"Pareto points: {pareto_Y_t.shape[0]}") - + # Get constraints row_constraints = constraints_from_config(config, space) - + # Propose batch batch_result = propose_batch( design=space, @@ -255,41 +377,49 @@ def run_mobo_experiment( ref_point_t=ref_point_t, batch_size=batch_size, row_constraints=row_constraints, - verbose=verbose + num_restarts=num_restarts, + verbose=verbose, ) - + + candidate_array = _validate_candidate_batch( + batch_result=batch_result, + design=space, + observed_inputs=X, + batch_size=batch_size, + ) + batch_result = dict(batch_result) + batch_result["X_phys"] = candidate_array + candidates = batch_result candidates_path = os.path.join(save_dir, "next_batch.csv") - - # Create next_batch.csv in the same format as input CSV with metadata - # Load the original CSV to get metadata structure - original_df = load_csv(csv_path) - - # Create new candidates DataFrame with same structure - new_candidates_df = pd.DataFrame( - batch_result['X_phys'], - columns=space.names - ) - + + # Retain the legacy flat-table export for the explicit qNEHVI path. + # It is not a metadata-style campaign CSV and therefore is not a + # supported round-trip input to parse_campaign_csv. + new_candidates_df = pd.DataFrame(candidate_array, columns=space.names) + # Add empty objective columns to match original format objective_names = get_objective_names(config) for obj_name in objective_names: new_candidates_df[obj_name] = "" - + # Combine original data with new candidates - combined_df = pd.concat([original_df, new_candidates_df], ignore_index=True) - + combined_df = pd.concat([df, new_candidates_df], ignore_index=True) + # Save the combined CSV combined_df.to_csv(candidates_path, index=False) - + if verbose: print(f"Proposed {batch_size} candidates for next batch") print(f"Candidates saved to: {candidates_path}") - + except Exception as e: if verbose: - print(f"Warning: Could not propose candidates: {e}") - + print(f"Candidate proposal failed: {e}") + raise RuntimeError( + "Candidate proposal failed; no batch was accepted." + ) from e + # Prepare results summary results = { "status": "success", @@ -301,40 +431,38 @@ def run_mobo_experiment( "seed": seed, "save_dir": save_dir, "config": config, - "candidates": candidates + "candidates": candidates, } - + if verbose: print(f"Experiment completed successfully!") print(f"Results saved to: {save_dir}") - + return results def main(): """ Main entry point for running MOBO-Kit with default settings. - + This function runs a complete MOBO experiment using the default CSV file and saves results to the default output directory. """ # Default paths csv_path = "data/processed/configCSV_example.csv" - save_dir = "results/demo" - + save_dir = "local_outputs/demo" + # Check if default CSV exists if not os.path.exists(csv_path): print(f"Error: Default data file not found: {csv_path}") print("Please provide a valid CSV file path or ensure the default file exists.") sys.exit(1) - + try: results = run_mobo_experiment( - csv_path=csv_path, - save_dir=save_dir, - verbose=True + csv_path=csv_path, save_dir=save_dir, verbose=True ) - + print("\n" + "=" * 60) print("EXPERIMENT SUMMARY:") print(f" Samples: {results['n_samples']}") @@ -343,11 +471,12 @@ def main(): print(f" Objectives: {', '.join(results['objective_names'])}") print(f" Results: {results['save_dir']}") print("=" * 60) - + except Exception as e: print(f"Error running MOBO experiment: {e}") if "--verbose" in sys.argv: import traceback + traceback.print_exc() sys.exit(1) diff --git a/src/mobo_kit/metrics.py b/src/mobo_kit/metrics.py index 42fd266..e531eaf 100644 --- a/src/mobo_kit/metrics.py +++ b/src/mobo_kit/metrics.py @@ -14,16 +14,14 @@ def compute_ref_pareto_hv( Y: torch.Tensor, ref_point_np: Optional[np.ndarray] = None, - eps: float = 1e-8 ) -> Tuple[torch.Tensor, torch.Tensor, float]: """ Compute the Pareto front and hypervolume of the training data. Args: Y: (N, M) array of objectives. - ref_point: (M,) array reference point for hypervolume calculation. - eps: small margin to ensure auto reference point is strictly dominated. - + ref_point_np: (M,) reference point. REQUIRED, and fixed for the campaign. + Returns: ref_point: Reference point used for hypervolume. pareto_Y: Pareto front points. @@ -32,24 +30,68 @@ def compute_ref_pareto_hv( Notes: * All objectives are to be MAXIMIZED. If any are minimized, flip sign before calling. * Returns tensors on the same device/dtype as `Y`. + + The reference point is required rather than inferred, for two reasons that + both produced wrong numbers here. + + A previous version defaulted to ``Y.min(dim=0) - 1e-8``, essentially the nadir + of whatever data it was handed. Every hypervolume slab is then 1e-8 thick: on + the campaign's R0 utilities that gave **6e-8 against 1.448** from BoTorch's + ``infer_reference_point`` on the same data. It also re-derived the reference + from the current data on every call, so two rounds' hypervolumes were measured + against two different reference points and were never comparable -- which is + the whole purpose of tracking hypervolume across rounds. + + Pass the campaign's declared ``reference_point_utility`` from config, in + utility space, after the objective transforms. """ + if ref_point_np is None: + raise ValueError( + "compute_ref_pareto_hv requires an explicit ref_point_np. Pass the " + "campaign's fixed reference: " + "np.asarray(config['reference_point_utility'], dtype=float), in utility " + "space after the objective transforms. A reference inferred from the " + "current data moves between rounds, which makes hypervolumes " + "incomparable across them." + ) + device, dtype = Y.device, Y.dtype N, M = Y.shape + del N pareto_mask = is_non_dominated(Y) pareto_Y = Y[pareto_mask] - if ref_point_np is None: - mins = torch.min(Y, dim=0).values - ref_point_t = mins - eps - else: - if not isinstance(ref_point_np, np.ndarray): - raise TypeError("ref_point_np must be a numpy.ndarray") - if ref_point_np.ndim != 1: - raise ValueError(f"ref_point must be 1D of length {M}, got shape {ref_point_np.shape}") - if ref_point_np.size != M: - raise ValueError(f"ref_point length {ref_point_np.size} does not match number of objectives M={M}") - ref_point_t = torch.as_tensor(ref_point_np, device=device, dtype=dtype) + if not isinstance(ref_point_np, (np.ndarray, torch.Tensor)): + raise TypeError("ref_point_np must be a numpy.ndarray or torch.Tensor") + reference = np.asarray( + ref_point_np.detach().cpu().numpy() + if isinstance(ref_point_np, torch.Tensor) + else ref_point_np, + dtype=float, + ) + if reference.ndim != 1: + raise ValueError(f"ref_point must be 1D of length {M}, got shape {reference.shape}") + if reference.size != M: + raise ValueError(f"ref_point length {reference.size} does not match number of objectives M={M}") + if not np.all(np.isfinite(reference)): + raise ValueError("ref_point must be finite.") + ref_point_t = torch.as_tensor(reference, device=device, dtype=dtype) + + # BoTorch's Hypervolume assumes maximisation and SILENTLY DROPS points that do + # not dominate the reference -- no warning, no exception, just a smaller number + # or 0.0. If nothing dominates, the answer would be 0.0 and indistinguishable + # from a wrongly signed or badly placed reference, so say so instead. + dominating = bool((pareto_Y > ref_point_t).all(dim=-1).any()) + if not dominating: + raise ValueError( + "No observation dominates the reference point, so the hypervolume " + "would be 0.0 for a reason the number cannot express. Check the sign " + "convention (every objective must be maximised here) and that the " + "reference sits below the achievable region. Reference: " + f"{reference.tolist()}; per-objective observed maxima: " + f"{Y.max(dim=0).values.detach().cpu().numpy().tolist()}." + ) hv = Hypervolume(ref_point=ref_point_t) volume = float(hv.compute(pareto_Y)) diff --git a/src/mobo_kit/model_validation.py b/src/mobo_kit/model_validation.py new file mode 100644 index 0000000..5026c86 --- /dev/null +++ b/src/mobo_kit/model_validation.py @@ -0,0 +1,1317 @@ +"""Strict GP fitting and exact leave-one-out validation for robustness studies. + +This module intentionally does not use the historical model-selection helper in +``models.py``. That helper prints fitting failures and continues with fallback +hyperparameters, which is appropriate for its exploratory notebook use but not +for an auditable robustness study. Every fit here either returns a structured +record or raises :class:`ModelFitError` without silently changing its contract. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +import hashlib +import json +from math import log, pi +from numbers import Real +from time import perf_counter +from typing import Any, Hashable, Iterable, Mapping, Sequence +import warnings + +import gpytorch +import numpy as np +import pandas as pd +from botorch.fit import fit_gpytorch_mll +from botorch.models import SingleTaskGP +from botorch.models.model_list_gp_regression import ModelListGP +from botorch.models.transforms.outcome import Standardize +from botorch.models.utils.gpytorch_modules import ( + get_covar_module_with_dim_scaled_prior, + get_gaussian_likelihood_with_lognormal_prior, +) +from gpytorch.constraints import GreaterThan +from gpytorch.kernels import MaternKernel, ScaleKernel +from gpytorch.likelihoods import GaussianLikelihood +from gpytorch.mlls import ExactMarginalLogLikelihood +from scipy.stats import spearmanr +import torch + + +DIM_SCALED_PRIOR_NAME = "dim_scaled_prior" +LEGACY_NO_PRIOR_NAME = "legacy_matern_no_prior" +CONSERVATIVE_NAME = "conservative" +#: BoTorch's MIN_INFERRED_NOISE_LEVEL, the floor that ships with its LogNormal +#: noise prior. The prior, not the floor, is what stops the variance collapse. +DIM_SCALED_PRIOR_MIN_NOISE = 1.0e-4 +LEGACY_NO_PRIOR_MIN_NOISE = 1.0e-3 +CONSERVATIVE_MIN_NOISE = 0.01 +CONSERVATIVE_MIN_LENGTHSCALE = 0.05 +GAUSSIAN_95_Z = 1.959963984540054 +VERY_SMALL_NORMALIZED_LENGTHSCALE = 0.05 +EXTREMELY_LARGE_NORMALIZED_LENGTHSCALE = 10.0 + + +def _finite_positive(value: Any, *, field_name: str) -> float: + if isinstance(value, (bool, np.bool_)) or not isinstance(value, Real): + raise ValueError(f"{field_name} must be a real non-boolean number.") + result = float(value) + if not np.isfinite(result) or result <= 0.0: + raise ValueError(f"{field_name} must be finite and strictly positive.") + return result + + +def _seed(value: Any) -> int: + if ( + isinstance(value, (bool, np.bool_)) + or not isinstance(value, (int, np.integer)) + or int(value) < 0 + ): + raise ValueError("seed must be a non-negative integer.") + return int(value) + + +@dataclass(frozen=True) +class ModelVariantSpec: + """One explicit GP model contract. + + ``use_dim_scaled_prior`` selects BoTorch's dimension-scaled LogNormal + lengthscale prior. Without it, an unregularised ARD kernel fitted to 15 + observations in 10 dimensions drives lengthscales to bimodal extremes + (measured: 0.13 to 3.8e4) and pins the likelihood noise at its floor, which + is interpolation rather than learning. + + ``use_lognormal_noise_prior`` selects BoTorch's ``LogNormal(-4, 1)`` noise + prior. It is required alongside the lengthscale prior, not optional: with a + bare noise floor the fit has a second degenerate mode in which the + outputscale collapses to zero and the model declares the data pure noise. + That mode was observed on 10 of 15 leave-one-out folds of the thickness + score, producing a latent predictive sd of 1e-4 against a fitted noise of + 0.93, 68% interval coverage of 0.133, and a mean NLPD of 3.1e6. + + See docs/GP_MODEL_DECISION.md. + """ + + name: str + min_noise: float + min_lengthscale: float | None + kernel_name: str = "matern_2.5_ard" + use_dim_scaled_prior: bool = False + use_lognormal_noise_prior: bool = False + + def __post_init__(self) -> None: + if self.name not in { + DIM_SCALED_PRIOR_NAME, + LEGACY_NO_PRIOR_NAME, + CONSERVATIVE_NAME, + }: + raise ValueError( + "Model variant name must be 'dim_scaled_prior', " + "'legacy_matern_no_prior' or 'conservative'." + ) + noise = _finite_positive(self.min_noise, field_name="min_noise") + lengthscale = ( + None + if self.min_lengthscale is None + else _finite_positive(self.min_lengthscale, field_name="min_lengthscale") + ) + if self.kernel_name != "matern_2.5_ard": + raise ValueError("Only the audited matern_2.5_ard kernel is supported.") + if self.name == DIM_SCALED_PRIOR_NAME and ( + noise != DIM_SCALED_PRIOR_MIN_NOISE + or lengthscale is not None + or not self.use_dim_scaled_prior + or not self.use_lognormal_noise_prior + ): + raise ValueError( + "dim_scaled_prior must use min_noise=1e-4, no lengthscale floor, " + "and BOTH the dimension-scaled lengthscale prior and the " + "LogNormal noise prior." + ) + if self.name == LEGACY_NO_PRIOR_NAME and ( + noise != LEGACY_NO_PRIOR_MIN_NOISE + or lengthscale is not None + or self.use_dim_scaled_prior + or self.use_lognormal_noise_prior + ): + raise ValueError( + "legacy_matern_no_prior must preserve the retired Step 2B contract: " + "min_noise=1e-3, no lengthscale floor, and no priors." + ) + if self.name == CONSERVATIVE_NAME and ( + noise != CONSERVATIVE_MIN_NOISE + or lengthscale != CONSERVATIVE_MIN_LENGTHSCALE + or self.use_dim_scaled_prior + or self.use_lognormal_noise_prior + ): + raise ValueError( + "conservative must use min_noise=0.01, min_lengthscale=0.05 and " + "no priors." + ) + object.__setattr__(self, "min_noise", noise) + object.__setattr__(self, "min_lengthscale", lengthscale) + + +#: The campaign default. Matern 2.5 ARD with BoTorch's dimension-scaled +#: LogNormal lengthscale prior and its LogNormal(-4, 1) noise prior. Both are +#: required; see ModelVariantSpec for what happens with only the former. +DIM_SCALED_PRIOR = ModelVariantSpec( + DIM_SCALED_PRIOR_NAME, + min_noise=DIM_SCALED_PRIOR_MIN_NOISE, + min_lengthscale=None, + use_dim_scaled_prior=True, + use_lognormal_noise_prior=True, +) +#: Retired. The prior-free contract used through Step 2C, kept only so archived +#: runs remain reproducible and interpretable. Do not select for new work. +LEGACY_NO_PRIOR = ModelVariantSpec( + LEGACY_NO_PRIOR_NAME, + min_noise=LEGACY_NO_PRIOR_MIN_NOISE, + min_lengthscale=None, +) +CONSERVATIVE = ModelVariantSpec( + CONSERVATIVE_NAME, + min_noise=CONSERVATIVE_MIN_NOISE, + min_lengthscale=CONSERVATIVE_MIN_LENGTHSCALE, +) + +#: What new runs get unless a caller deliberately asks for something else. +PRIMARY_VARIANT = DIM_SCALED_PRIOR + + +def model_variant_spec(name: str) -> ModelVariantSpec: + """Return one of the fixed model contracts by name.""" + if name == DIM_SCALED_PRIOR_NAME: + return DIM_SCALED_PRIOR + if name == LEGACY_NO_PRIOR_NAME: + return LEGACY_NO_PRIOR + if name == CONSERVATIVE_NAME: + return CONSERVATIVE + raise ValueError(f"Unsupported model variant {name!r}.") + + +@dataclass(frozen=True) +class ModelFitWarning: + variant_name: str + fit_key: str + omitted_sample_id: Hashable | None + objective_index: int + objective_name: str + stage: str + warning_category: str + message: str + + +class ModelFitError(RuntimeError): + """A strict model-fit failure with all warnings observed before failure.""" + + def __init__( + self, + *, + variant_name: str, + fit_key: str, + omitted_sample_id: Hashable | None, + objective_index: int, + objective_name: str, + stage: str, + cause: BaseException, + fit_warnings: Sequence[ModelFitWarning], + ) -> None: + self.variant_name = variant_name + self.fit_key = fit_key + self.omitted_sample_id = omitted_sample_id + self.objective_index = objective_index + self.objective_name = objective_name + self.stage = stage + self.cause = cause + self.fit_warnings = tuple(fit_warnings) + super().__init__( + "Strict GP fit failed: " + f"variant={variant_name!r}, fit_key={fit_key!r}, " + f"objective={objective_name!r}, stage={stage!r}, " + f"cause={type(cause).__name__}: {cause}" + ) + + +@dataclass(frozen=True) +class ModelFitCacheKey: + variant_name: str + cohort_fingerprint: str + omitted_sample_key: str + objective_names: tuple[str, ...] + seed: int + + +@dataclass(frozen=True) +class FittedModelRecord: + variant: ModelVariantSpec + model: ModelListGP + train_X: torch.Tensor + train_Y: torch.Tensor + sample_ids: tuple[Hashable, ...] + objective_names: tuple[str, ...] + fit_key: str + omitted_sample_id: Hashable | None + seed: int + cohort_fingerprint: str + training_fingerprint: str + fit_runtime_seconds: float + warnings: tuple[ModelFitWarning, ...] + + +@dataclass +class ModelFitCache: + """In-memory fit cache shared by LOOCV and observation influence.""" + + records: dict[ModelFitCacheKey, FittedModelRecord] = field(default_factory=dict) + hits: int = 0 + misses: int = 0 + + def get(self, key: ModelFitCacheKey) -> FittedModelRecord | None: + record = self.records.get(key) + if record is None: + self.misses += 1 + else: + self.hits += 1 + return record + + def store(self, key: ModelFitCacheKey, record: FittedModelRecord) -> None: + existing = self.records.get(key) + if existing is not None and existing is not record: + raise ValueError(f"A different fit already exists for cache key {key}.") + self.records[key] = record + + +@dataclass(frozen=True) +class LOOCVPrediction: + variant_name: str + omitted_sample_id: Hashable + row_role: str + is_control: bool + objective_index: int + objective_name: str + observed: float + predicted_mean: float + latent_std: float + predictive_std: float + prediction_error: float + residual: float + standardized_residual: float + within_68_percent_interval: bool + within_95_percent_interval: bool + gaussian_nlpd: float + fold_fit_key: str + fold_fit_warning_count: int + + +@dataclass(frozen=True) +class PredictionMetricRecord: + variant_name: str + objective_index: int + objective_name: str + prediction_count: int + mae: float + rmse: float + r_squared: float + r_squared_warning: str + spearman_rank_correlation: float + mean_signed_error: float + median_absolute_error: float + coverage_68_percent: float + coverage_95_percent: float + mean_standardized_residual: float + maximum_absolute_standardized_residual: float + mean_gaussian_nlpd: float + + +@dataclass(frozen=True) +class HyperparameterRecord: + variant_name: str + fit_key: str + omitted_sample_id: Hashable | None + objective_index: int + objective_name: str + kernel_type: str + likelihood_noise: float + outputscale: float + ard_lengthscales: tuple[float, ...] + input_names: tuple[str, ...] + configured_min_noise: float + configured_min_lengthscale: float | None + noise_constraint_lower_bound: float + lengthscale_constraint_lower_bound: float + noise_near_floor: bool + lengthscales_near_floor: tuple[bool, ...] + lengthscales_very_small_normalized_domain: tuple[bool, ...] + lengthscales_extremely_large_flat: tuple[bool, ...] + very_small_lengthscale_threshold: float = VERY_SMALL_NORMALIZED_LENGTHSCALE + extremely_large_lengthscale_threshold: float = ( + EXTREMELY_LARGE_NORMALIZED_LENGTHSCALE + ) + input_parameter_space: str = "normalized_0_1" + outcome_parameter_space: str = "standardized_internal" + + def as_flat_dict(self) -> dict[str, Any]: + result = asdict(self) + result.pop("ard_lengthscales") + result.pop("input_names") + result.pop("lengthscales_near_floor") + result.pop("lengthscales_very_small_normalized_domain") + result.pop("lengthscales_extremely_large_flat") + result["any_lengthscale_very_small_normalized_domain"] = any( + self.lengthscales_very_small_normalized_domain + ) + result["any_lengthscale_extremely_large_flat"] = any( + self.lengthscales_extremely_large_flat + ) + for input_name, value, near_floor, very_small, extremely_large in zip( + self.input_names, + self.ard_lengthscales, + self.lengthscales_near_floor, + self.lengthscales_very_small_normalized_domain, + self.lengthscales_extremely_large_flat, + ): + result[f"ard_lengthscale_{input_name}"] = value + result[f"ard_lengthscale_{input_name}_near_floor"] = near_floor + result[f"ard_lengthscale_{input_name}_very_small_normalized_domain"] = ( + very_small + ) + result[f"ard_lengthscale_{input_name}_extremely_large_flat"] = ( + extremely_large + ) + return result + + +@dataclass(frozen=True) +class ExactLOOCVResult: + variant: ModelVariantSpec + predictions: tuple[LOOCVPrediction, ...] + metrics: tuple[PredictionMetricRecord, ...] + fold_records: Mapping[Hashable, FittedModelRecord] + cohort_fingerprint: str + cache: ModelFitCache + + def predictions_frame(self) -> pd.DataFrame: + return pd.DataFrame(asdict(row) for row in self.predictions) + + def metrics_frame(self) -> pd.DataFrame: + return pd.DataFrame(asdict(row) for row in self.metrics) + + +@dataclass(frozen=True) +class ModelValidationResult: + variant: ModelVariantSpec + full_fit: FittedModelRecord + loocv: ExactLOOCVResult + hyperparameters: tuple[HyperparameterRecord, ...] + + def hyperparameters_frame(self) -> pd.DataFrame: + return pd.DataFrame(row.as_flat_dict() for row in self.hyperparameters) + + def warnings_frame(self) -> pd.DataFrame: + records = [self.full_fit, *self.loocv.fold_records.values()] + return fit_warnings_frame(records) + + +def _sample_key(value: Hashable | None) -> str: + if value is None: + return "" + return f"{type(value).__name__}:{value!r}" + + +def _tensor_bytes(value: torch.Tensor) -> bytes: + tensor = value.detach().cpu().contiguous() + return tensor.numpy().tobytes() + + +def dataset_fingerprint( + X: torch.Tensor, + Y: torch.Tensor, + sample_ids: Sequence[Hashable], +) -> str: + """Return a deterministic fingerprint for cache and provenance checks.""" + digest = hashlib.sha256() + for tensor in (X, Y): + digest.update(str(tensor.dtype).encode("utf-8")) + digest.update(json.dumps(tuple(tensor.shape)).encode("utf-8")) + digest.update(_tensor_bytes(tensor)) + serialized_ids = [f"{type(value).__name__}:{value!r}" for value in sample_ids] + digest.update(json.dumps(serialized_ids, separators=(",", ":")).encode("utf-8")) + return digest.hexdigest() + + +def _validate_dataset( + X: torch.Tensor, + Y: torch.Tensor, + sample_ids: Sequence[Hashable], + objective_names: Sequence[str], + *, + minimum_rows: int, +) -> tuple[tuple[Hashable, ...], tuple[str, ...]]: + if not isinstance(X, torch.Tensor) or X.ndim != 2: + raise ValueError("X must be a torch tensor with shape (N, D).") + if not isinstance(Y, torch.Tensor) or Y.ndim != 2: + raise ValueError("Y must be a torch tensor with shape (N, M).") + if not X.is_floating_point() or not Y.is_floating_point(): + raise TypeError("X and Y must use floating dtypes.") + if X.device != Y.device or X.dtype != Y.dtype: + raise ValueError("X and Y must share dtype and device.") + if X.shape[0] != Y.shape[0] or X.shape[0] < minimum_rows: + raise ValueError( + f"X and Y must share at least {minimum_rows} rows; " + f"got {X.shape[0]} and {Y.shape[0]}." + ) + if X.shape[1] == 0 or Y.shape[1] == 0: + raise ValueError("X and Y must each contain at least one column.") + if not torch.isfinite(X).all() or not torch.isfinite(Y).all(): + raise ValueError("X and Y must contain only finite values.") + if torch.any(X < 0.0) or torch.any(X > 1.0): + raise ValueError("X must be normalized to [0, 1].") + ids = tuple(sample_ids) + if len(ids) != X.shape[0] or len(set(ids)) != len(ids): + raise ValueError("sample_ids must be unique and aligned with X/Y rows.") + names = tuple(objective_names) + if ( + len(names) != Y.shape[1] + or any(not isinstance(name, str) or not name.strip() for name in names) + or len(set(names)) != len(names) + ): + raise ValueError( + "objective_names must be unique non-empty strings aligned with Y columns." + ) + return ids, tuple(name.strip() for name in names) + + +def _warning_rows( + caught: Sequence[warnings.WarningMessage], + *, + variant: ModelVariantSpec, + fit_key: str, + omitted_sample_id: Hashable | None, + objective_index: int, + objective_name: str, + stage: str, +) -> list[ModelFitWarning]: + return [ + ModelFitWarning( + variant_name=variant.name, + fit_key=fit_key, + omitted_sample_id=omitted_sample_id, + objective_index=objective_index, + objective_name=objective_name, + stage=stage, + warning_category=warning.category.__name__, + message=str(warning.message), + ) + for warning in caught + ] + + +class SignalCollapseError(RuntimeError): + """The fitted outputscale went to zero; the model has no signal component.""" + + +#: A fit whose latent (signal) sd falls below this multiple of the fitted noise +#: sd has no signal component left in its GP. Acquisition reads the latent +#: posterior, so the exploration term degenerates, even though predictive +#: intervals look fine because the inflated noise hides it. +MINIMUM_LATENT_TO_NOISE_SD_RATIO = 1.0e-2 + +#: How much the posterior MEAN must vary across the training inputs, as a fraction +#: of how much the OBSERVATIONS vary, for the fit to be able to rank candidates. +#: +#: This is the second half of the diagnosis, and what separates two very different +#: situations that share one numeric signature. +#: +#: The observed spread is the yardstick rather than the fitted noise sd, which was +#: the first attempt and is wrong: the noise is inflated precisely in the +#: degenerate case, so a noise-relative test co-varies with the thing it is trying +#: to detect. Measured instance -- a linear mean on `anneal_temp` against a forced +#: noise of 0.9 gave a mean/noise ratio of 0.38 and would have been called +#: "effectively constant" while it was in fact tracking the data. +#: +#: Against the observed spread the question is scale-free and stable: does the +#: model's mean move with the measurements, or not at all? +#: +#: This is a DIAGNOSTIC threshold and it never touches utility space, so it does +#: not violate the campaign-fixed-scaling rule in `assert_scaling_is_campaign_fixed`. +#: That rule governs the objective scales that feed hypervolume, where a +#: data-derived scale would make rounds incomparable. Nothing here reaches a +#: utility, a reference point or a hypervolume; it only asks whether one fit's +#: mean moved. Do not "correct" it to a fixed constant. +MINIMUM_MEAN_SPREAD_TO_TARGET_RATIO = 0.05 + +#: Fit stage name for the guard, so warnings and errors are filterable. +SIGNAL_COLLAPSE_STAGE = "signal_collapse_guard" + +#: Warning category raised when the GP's signal component has collapsed but the +#: mean function still carries a usable trend. +EXPLORATION_DEGENERATE_CATEGORY = "ExplorationTermDegenerate" + + +def _assert_signal_not_collapsed( + gp: SingleTaskGP, + X: torch.Tensor, + target: torch.Tensor, + *, + variant: ModelVariantSpec, + objective_index: int, + objective_name: str, + fit_key: str, + omitted_sample_id: Hashable | None, + fit_warnings: list[ModelFitWarning] | Sequence[ModelFitWarning], +) -> None: + """Judge a fit whose GP signal component has gone to zero. + + This is a numerical guard, not a configuration check. Naming a variant + correctly cannot prevent a degenerate optimum: the same contract refitted on + different data -- more observations, replicate-derived ``train_Yvar``, a new + round -- can land there again. So it runs on every fit. + + **Two situations share the collapsed-latent-sd signature, and they need + different answers.** + + *True collapse.* A zero-mean GP whose outputscale went to zero: the + posterior mean is flat, nothing can be ranked, acquisition is meaningless. + Observed instance: 10 of 15 leave-one-out folds of the thickness score fitted + a noise of 0.93 against a latent sd of 1e-4. This must fail. + + *The mean function did its job.* With a ``StructuredMean`` carrying the + trend, the residual GP can legitimately have nothing left to model. The + posterior *mean* still varies -- the mean module is not part of the covariance + and so never enters ``posterior().variance`` -- so candidates still rank and + the round is still worth proposing. Refusing here would dead-end the campaign + at the moment the physics model started working, with no remedy available: + better data cannot be collected without first proposing conditions. So this + warns instead, and the human review artifact is the gate. + + The warning is not a formality. Two things are genuinely wrong with such a + fit and both are named in its message: UCB's exploration term has degenerated, + and the mean module's coefficients are frozen buffers carrying no uncertainty + of their own, so the narrow intervals the model reports are **understated + rather than earned**. + """ + with torch.no_grad(): + posterior = gp.posterior(X) + latent_sd = float(posterior.variance.clamp_min(0.0).sqrt().min()) + mean_values = posterior.mean.detach().reshape(-1) + mean_spread = float(mean_values.std()) if mean_values.numel() > 1 else 0.0 + # a FixedNoiseGaussianLikelihood (measured train_Yvar) carries one noise per + # observation rather than one for the model, so take the average rather than + # whichever row happens to be first + noise_values = gp.likelihood.noise.detach().reshape(-1) + noise_sd = float(noise_values.mean() ** 0.5) + observed = target.detach().reshape(-1) + target_spread = float(observed.std()) if observed.numel() > 1 else 0.0 + if noise_sd <= 0.0: + return + latent_ratio = latent_sd / noise_sd + if latent_ratio >= MINIMUM_LATENT_TO_NOISE_SD_RATIO: + return + + # a constant objective has nothing to rank by and nothing to diagnose + mean_ratio = mean_spread / target_spread if target_spread > 0.0 else 0.0 + measured = ( + f"minimum latent sd {latent_sd:.3e} is {latent_ratio:.3e} of the fitted " + f"noise sd {noise_sd:.3e}, below the " + f"{MINIMUM_LATENT_TO_NOISE_SD_RATIO:g} floor" + ) + + if mean_ratio < MINIMUM_MEAN_SPREAD_TO_TARGET_RATIO: + raise ModelFitError( + variant_name=variant.name, + fit_key=fit_key, + omitted_sample_id=omitted_sample_id, + objective_index=objective_index, + objective_name=objective_name, + stage=SIGNAL_COLLAPSE_STAGE, + cause=SignalCollapseError( + f"{measured}, and the posterior mean varies by only " + f"{mean_spread:.3e} across the training inputs " + f"({mean_ratio:.3e} of the observed spread {target_spread:.3e}, " + f"floor {MINIMUM_MEAN_SPREAD_TO_TARGET_RATIO:g}). The model has explained " + "the data as pure noise: its posterior mean is effectively " + "constant, so it cannot order two candidates and its acquisition " + "scores are meaningless. Predictive intervals do NOT reveal this, " + "because the inflated noise masks the collapse." + ), + fit_warnings=tuple(fit_warnings), + ) + + warning = ModelFitWarning( + variant_name=variant.name, + fit_key=fit_key, + omitted_sample_id=omitted_sample_id, + objective_index=objective_index, + objective_name=objective_name, + stage=SIGNAL_COLLAPSE_STAGE, + warning_category=EXPLORATION_DEGENERATE_CATEGORY, + message=( + f"{objective_name}: {measured}, but the posterior mean still varies by " + f"{mean_spread:.3e} across the training inputs, {mean_ratio:.2f} of the " + f"observed spread, so the mean function is carrying the signal and " + "candidates can still be ranked. " + "Two consequences to distrust: UCB's exploration term has degenerated, " + "because it reads the latent posterior that just collapsed; and the " + "mean module's coefficients are frozen buffers with no uncertainty of " + "their own, so the narrow intervals this model reports are UNDERSTATED " + "rather than earned. Treat its confidence, especially away from the " + "observed points, as unproven." + ), + ) + if isinstance(fit_warnings, list): + fit_warnings.append(warning) + + +def _build_single_task_gp( + X: torch.Tensor, + y: torch.Tensor, + variant: ModelVariantSpec, + mean_module: Any = None, + train_Yvar: torch.Tensor | None = None, +) -> SingleTaskGP: + if variant.use_dim_scaled_prior: + # BoTorch's dimension-scaled LogNormal lengthscale prior, the same one + # SingleTaskGP applies by default when no covar_module is supplied. We + # still pass an explicit module so the ScaleKernel wrapper (which the + # hyperparameter readout and plots depend on) stays in place. + base_kernel = get_covar_module_with_dim_scaled_prior( + ard_num_dims=X.shape[1], + use_rbf_kernel=False, + ) + else: + lengthscale_constraint = ( + None + if variant.min_lengthscale is None + else GreaterThan(variant.min_lengthscale) + ) + kernel_kwargs: dict[str, Any] = { + "nu": 2.5, + "ard_num_dims": X.shape[1], + } + if lengthscale_constraint is not None: + kernel_kwargs["lengthscale_constraint"] = lengthscale_constraint + base_kernel = MaternKernel(**kernel_kwargs) + covar_module = ScaleKernel(base_kernel) + if train_Yvar is not None: + # Measured observation noise replaces fitted noise, so no noise prior or + # constraint applies -- there is nothing left to fit. + # + # BoTorch will accept BOTH `train_Yvar` and an explicit `likelihood` and + # then SILENTLY IGNORE the variance: the likelihood wins, stays a + # single-element GaussianLikelihood, and the replicate information is + # dropped with no error. Verified on 0.15.1. Hence the either/or here. + # + # `train_Yvar` is in the ORIGINAL target units; `Standardize` rescales it + # along with the targets. Passing an already-standardized variance would be + # wrong by var(Y) and would also fail silently. + model = SingleTaskGP( + X, + y, + train_Yvar=train_Yvar, + covar_module=covar_module, + outcome_transform=Standardize(m=1), + ) + if mean_module is not None: + model.mean_module = mean_module + return model + if variant.use_lognormal_noise_prior: + # LogNormal(-4, 1) with a GreaterThan(1e-4) floor. Without the prior the + # marginal likelihood is free to drive the outputscale to zero and call + # the data pure noise, which yields a degenerate near-zero latent + # variance. The floor alone does not prevent that. + likelihood = get_gaussian_likelihood_with_lognormal_prior() + else: + likelihood = GaussianLikelihood(noise_constraint=GreaterThan(variant.min_noise)) + model = SingleTaskGP( + X, + y, + covar_module=covar_module, + likelihood=likelihood, + outcome_transform=Standardize(m=1), + ) + if mean_module is not None: + # a frozen structured mean: its coefficients are registered as buffers, + # so the marginal likelihood still fits only the GP hyperparameters + model.mean_module = mean_module + return model + + +def fit_model_variant( + X: torch.Tensor, + Y: torch.Tensor, + *, + sample_ids: Sequence[Hashable], + objective_names: Sequence[str], + variant: ModelVariantSpec, + seed: int = 73, + fit_key: str = "full", + omitted_sample_id: Hashable | None = None, + cohort_fingerprint: str | None = None, + cache: ModelFitCache | None = None, + mean_module: Any = None, + train_Yvar: torch.Tensor | None = None, +) -> FittedModelRecord: + """Fit one strict independent GP per objective and return an audit record. + + ``train_Yvar`` is measured observation variance, shaped like ``Y``, in the + ORIGINAL target units. Supplying it replaces the fitted noise entirely: there + is no noise hyperparameter left to optimise, so the variant's noise prior and + floor no longer apply to that fit. + """ + ids, names = _validate_dataset(X, Y, sample_ids, objective_names, minimum_rows=2) + if not isinstance(variant, ModelVariantSpec): + raise TypeError("variant must be a ModelVariantSpec.") + resolved_seed = _seed(seed) + if not isinstance(fit_key, str) or not fit_key.strip(): + raise ValueError("fit_key must be a non-empty string.") + fit_key = fit_key.strip() + training_hash = dataset_fingerprint(X, Y, ids) + cohort_hash = training_hash if cohort_fingerprint is None else cohort_fingerprint + if not isinstance(cohort_hash, str) or not cohort_hash.strip(): + raise ValueError("cohort_fingerprint must be None or a non-empty string.") + if mean_module is not None and cache is not None: + # the cache key is built from the data and the variant, not the mean + # module, so a cached fit could be returned for a different trend. + # Refuse rather than silently serve the wrong model. + raise ValueError( + "A structured mean_module cannot be combined with a ModelFitCache: " + "the cache key does not capture the mean, so a fold could be served " + "a fit built from a different trend." + ) + cache_key = ModelFitCacheKey( + variant_name=variant.name, + cohort_fingerprint=cohort_hash, + omitted_sample_key=_sample_key(omitted_sample_id), + objective_names=names, + seed=resolved_seed, + ) + if cache is not None: + cached = cache.get(cache_key) + if cached is not None: + if cached.training_fingerprint != training_hash: + raise ValueError( + "Cached model training fingerprint does not match supplied data." + ) + return cached + + np.random.seed(resolved_seed) + torch.manual_seed(resolved_seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(resolved_seed) + + started = perf_counter() + models: list[SingleTaskGP] = [] + fit_warning_rows: list[ModelFitWarning] = [] + for objective_index, objective_name in enumerate(names): + caught: list[warnings.WarningMessage] = [] + try: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + gp = _build_single_task_gp( + X, + Y[:, objective_index : objective_index + 1], + variant, + mean_module=mean_module, + train_Yvar=( + None + if train_Yvar is None + else train_Yvar[:, objective_index : objective_index + 1] + ), + ) + fit_warning_rows.extend( + _warning_rows( + caught, + variant=variant, + fit_key=fit_key, + omitted_sample_id=omitted_sample_id, + objective_index=objective_index, + objective_name=objective_name, + stage="construct", + ) + ) + except Exception as exc: + fit_warning_rows.extend( + _warning_rows( + caught, + variant=variant, + fit_key=fit_key, + omitted_sample_id=omitted_sample_id, + objective_index=objective_index, + objective_name=objective_name, + stage="construct", + ) + ) + raise ModelFitError( + variant_name=variant.name, + fit_key=fit_key, + omitted_sample_id=omitted_sample_id, + objective_index=objective_index, + objective_name=objective_name, + stage="construct", + cause=exc, + fit_warnings=fit_warning_rows, + ) from exc + + mll = ExactMarginalLogLikelihood(gp.likelihood, gp) + try: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + fit_gpytorch_mll(mll) + fit_warning_rows.extend( + _warning_rows( + caught, + variant=variant, + fit_key=fit_key, + omitted_sample_id=omitted_sample_id, + objective_index=objective_index, + objective_name=objective_name, + stage="optimize", + ) + ) + except Exception as exc: + fit_warning_rows.extend( + _warning_rows( + caught, + variant=variant, + fit_key=fit_key, + omitted_sample_id=omitted_sample_id, + objective_index=objective_index, + objective_name=objective_name, + stage="optimize", + ) + ) + raise ModelFitError( + variant_name=variant.name, + fit_key=fit_key, + omitted_sample_id=omitted_sample_id, + objective_index=objective_index, + objective_name=objective_name, + stage="optimize", + cause=exc, + fit_warnings=fit_warning_rows, + ) from exc + gp.eval() + gp.likelihood.eval() + _assert_signal_not_collapsed( + gp, + X, + Y[:, objective_index], + variant=variant, + objective_index=objective_index, + objective_name=objective_name, + fit_key=fit_key, + omitted_sample_id=omitted_sample_id, + fit_warnings=fit_warning_rows, + ) + models.append(gp) + + record = FittedModelRecord( + variant=variant, + model=ModelListGP(*models), + train_X=X.detach().clone(), + train_Y=Y.detach().clone(), + sample_ids=ids, + objective_names=names, + fit_key=fit_key, + omitted_sample_id=omitted_sample_id, + seed=resolved_seed, + cohort_fingerprint=cohort_hash, + training_fingerprint=training_hash, + fit_runtime_seconds=perf_counter() - started, + warnings=tuple(fit_warning_rows), + ) + record.model.eval() + if cache is not None: + cache.store(cache_key, record) + return record + + +def _posterior_prediction( + record: FittedModelRecord, + X: torch.Tensor, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + with torch.no_grad(): + latent = record.model.posterior(X, observation_noise=False) + predictive = record.model.posterior(X, observation_noise=True) + mean = latent.mean.detach().cpu().double().numpy() + latent_std = latent.variance.clamp_min(0.0).sqrt().detach().cpu().double().numpy() + predictive_std = ( + predictive.variance.clamp_min(0.0).sqrt().detach().cpu().double().numpy() + ) + if mean.shape != latent_std.shape or mean.shape != predictive_std.shape: + raise RuntimeError("Posterior mean and uncertainty shapes do not match.") + if not ( + np.all(np.isfinite(mean)) + and np.all(np.isfinite(latent_std)) + and np.all(np.isfinite(predictive_std)) + and np.all(predictive_std > 0.0) + ): + raise RuntimeError("Posterior predictions must be finite with positive noise.") + if np.any(predictive_std + 1.0e-12 < latent_std): + raise RuntimeError("Predictive uncertainty cannot be below latent uncertainty.") + return mean, latent_std, predictive_std + + +def compute_prediction_metrics( + observed: Sequence[float] | np.ndarray, + predicted_mean: Sequence[float] | np.ndarray, + predictive_std: Sequence[float] | np.ndarray, + *, + variant_name: str = "unspecified", + objective_index: int = 0, + objective_name: str = "objective", + small_n_warning_threshold: int = 20, +) -> PredictionMetricRecord: + """Compute declared predictive metrics from original-unit predictions.""" + actual = np.asarray(observed, dtype=float) + predicted = np.asarray(predicted_mean, dtype=float) + uncertainty = np.asarray(predictive_std, dtype=float) + if ( + actual.ndim != 1 + or predicted.shape != actual.shape + or uncertainty.shape != actual.shape + ): + raise ValueError("observed, predicted_mean, and predictive_std must align.") + if actual.size == 0: + raise ValueError("At least one prediction is required.") + if not ( + np.all(np.isfinite(actual)) + and np.all(np.isfinite(predicted)) + and np.all(np.isfinite(uncertainty)) + ): + raise ValueError("Prediction metrics require finite inputs.") + if np.any(uncertainty <= 0.0): + raise ValueError("predictive_std must be strictly positive.") + if ( + isinstance(small_n_warning_threshold, bool) + or not isinstance(small_n_warning_threshold, (int, np.integer)) + or int(small_n_warning_threshold) < 2 + ): + raise ValueError("small_n_warning_threshold must be an integer >= 2.") + + errors = predicted - actual + residuals = -errors + standardized = residuals / uncertainty + squared_error = errors**2 + centered = actual - actual.mean() + denominator = float(np.sum(centered**2)) + if actual.size < 2 or denominator <= 0.0: + r_squared = np.nan + r_squared_warning = ( + "R² is undefined for fewer than two or constant observations." + ) + else: + r_squared = 1.0 - float(np.sum(squared_error)) / denominator + r_squared_warning = ( + f"R² is unstable with small N={actual.size}." + if actual.size < int(small_n_warning_threshold) + else "" + ) + if actual.size < 2 or np.unique(actual).size < 2 or np.unique(predicted).size < 2: + spearman = np.nan + else: + spearman = float(spearmanr(actual, predicted).statistic) + nlpd = 0.5 * np.log(2.0 * pi * uncertainty**2) + 0.5 * standardized**2 + return PredictionMetricRecord( + variant_name=str(variant_name), + objective_index=int(objective_index), + objective_name=str(objective_name), + prediction_count=int(actual.size), + mae=float(np.mean(np.abs(errors))), + rmse=float(np.sqrt(np.mean(squared_error))), + r_squared=float(r_squared), + r_squared_warning=r_squared_warning, + spearman_rank_correlation=float(spearman), + mean_signed_error=float(np.mean(errors)), + median_absolute_error=float(np.median(np.abs(errors))), + coverage_68_percent=float(np.mean(np.abs(standardized) <= 1.0)), + coverage_95_percent=float(np.mean(np.abs(standardized) <= GAUSSIAN_95_Z)), + mean_standardized_residual=float(np.mean(standardized)), + maximum_absolute_standardized_residual=float(np.max(np.abs(standardized))), + mean_gaussian_nlpd=float(np.mean(nlpd)), + ) + + +def _summarize_predictions( + predictions: Sequence[LOOCVPrediction], + variant: ModelVariantSpec, + objective_names: Sequence[str], +) -> tuple[PredictionMetricRecord, ...]: + rows: list[PredictionMetricRecord] = [] + for objective_index, objective_name in enumerate(objective_names): + selected = [ + row for row in predictions if row.objective_index == objective_index + ] + rows.append( + compute_prediction_metrics( + [row.observed for row in selected], + [row.predicted_mean for row in selected], + [row.predictive_std for row in selected], + variant_name=variant.name, + objective_index=objective_index, + objective_name=objective_name, + ) + ) + return tuple(rows) + + +def run_exact_loocv( + X: torch.Tensor, + Y: torch.Tensor, + *, + sample_ids: Sequence[Hashable], + objective_names: Sequence[str], + variant: ModelVariantSpec, + seed: int = 73, + row_roles: Sequence[str] | None = None, + control_sample_ids: Iterable[Hashable] = (), + cache: ModelFitCache | None = None, +) -> ExactLOOCVResult: + """Fit exactly one N-1 model per row and predict every held-out objective.""" + ids, names = _validate_dataset(X, Y, sample_ids, objective_names, minimum_rows=3) + roles = tuple("observation" for _ in ids) if row_roles is None else tuple(row_roles) + if len(roles) != len(ids) or any( + not isinstance(role, str) or not role.strip() for role in roles + ): + raise ValueError("row_roles must contain one non-empty string per row.") + control_ids = set(control_sample_ids) + resolved_cache = ModelFitCache() if cache is None else cache + cohort_hash = dataset_fingerprint(X, Y, ids) + fold_records: dict[Hashable, FittedModelRecord] = {} + prediction_rows: list[LOOCVPrediction] = [] + for omitted_index, omitted_id in enumerate(ids): + mask = torch.ones(X.shape[0], dtype=torch.bool, device=X.device) + mask[omitted_index] = False + fold_ids = tuple( + sample_id for index, sample_id in enumerate(ids) if index != omitted_index + ) + fit_key = f"omit:{_sample_key(omitted_id)}" + record = fit_model_variant( + X[mask], + Y[mask], + sample_ids=fold_ids, + objective_names=names, + variant=variant, + seed=seed, + fit_key=fit_key, + omitted_sample_id=omitted_id, + cohort_fingerprint=cohort_hash, + cache=resolved_cache, + ) + fold_records[omitted_id] = record + mean, latent_std, predictive_std = _posterior_prediction( + record, X[omitted_index : omitted_index + 1] + ) + for objective_index, objective_name in enumerate(names): + observed = float(Y[omitted_index, objective_index].item()) + predicted = float(mean[0, objective_index]) + latent_uncertainty = float(latent_std[0, objective_index]) + predictive_uncertainty = float(predictive_std[0, objective_index]) + error = predicted - observed + residual = -error + standardized = residual / predictive_uncertainty + nlpd = 0.5 * log(2.0 * pi * predictive_uncertainty**2) + 0.5 * ( + standardized**2 + ) + prediction_rows.append( + LOOCVPrediction( + variant_name=variant.name, + omitted_sample_id=omitted_id, + row_role=roles[omitted_index].strip(), + is_control=omitted_id in control_ids, + objective_index=objective_index, + objective_name=objective_name, + observed=observed, + predicted_mean=predicted, + latent_std=latent_uncertainty, + predictive_std=predictive_uncertainty, + prediction_error=error, + residual=residual, + standardized_residual=standardized, + within_68_percent_interval=abs(standardized) <= 1.0, + within_95_percent_interval=(abs(standardized) <= GAUSSIAN_95_Z), + gaussian_nlpd=nlpd, + fold_fit_key=record.fit_key, + fold_fit_warning_count=len(record.warnings), + ) + ) + expected_count = X.shape[0] * Y.shape[1] + if len(prediction_rows) != expected_count: + raise RuntimeError( + f"LOOCV produced {len(prediction_rows)} rows; expected {expected_count}." + ) + metrics = _summarize_predictions(prediction_rows, variant, names) + return ExactLOOCVResult( + variant=variant, + predictions=tuple(prediction_rows), + metrics=metrics, + fold_records=fold_records, + cohort_fingerprint=cohort_hash, + cache=resolved_cache, + ) + + +def _constraint_lower_bound(constraint: Any) -> float: + value = constraint.lower_bound.detach().cpu().double().reshape(-1) + return float(value[0].item()) + + +def extract_model_hyperparameters( + record: FittedModelRecord, + *, + input_names: Sequence[str], +) -> tuple[HyperparameterRecord, ...]: + """Extract comparable full/fold hyperparameters from a fitted model list.""" + names = tuple(input_names) + if len(names) != record.train_X.shape[1] or any( + not isinstance(name, str) or not name.strip() for name in names + ): + raise ValueError("input_names must align with the fitted input dimension.") + rows: list[HyperparameterRecord] = [] + for objective_index, (objective_name, gp) in enumerate( + zip(record.objective_names, record.model.models) + ): + base_kernel = gp.covar_module.base_kernel + lengthscales = tuple( + float(value) + for value in base_kernel.lengthscale.detach() + .cpu() + .double() + .reshape(-1) + .tolist() + ) + if len(lengthscales) != len(names): + raise RuntimeError("ARD lengthscales do not align with input names.") + noise = float(gp.likelihood.noise.detach().cpu().double().reshape(-1)[0].item()) + outputscale = float( + gp.covar_module.outputscale.detach().cpu().double().reshape(-1)[0].item() + ) + noise_floor = _constraint_lower_bound( + gp.likelihood.noise_covar.raw_noise_constraint + ) + lengthscale_floor = _constraint_lower_bound( + base_kernel.raw_lengthscale_constraint + ) + noise_near = noise <= noise_floor * 1.05 + 1.0e-12 + lengthscale_near = tuple( + value <= lengthscale_floor * 1.05 + 1.0e-12 for value in lengthscales + ) + lengthscale_very_small = tuple( + value <= VERY_SMALL_NORMALIZED_LENGTHSCALE for value in lengthscales + ) + lengthscale_extremely_large = tuple( + value >= EXTREMELY_LARGE_NORMALIZED_LENGTHSCALE for value in lengthscales + ) + rows.append( + HyperparameterRecord( + variant_name=record.variant.name, + fit_key=record.fit_key, + omitted_sample_id=record.omitted_sample_id, + objective_index=objective_index, + objective_name=objective_name, + kernel_type=type(base_kernel).__name__, + likelihood_noise=noise, + outputscale=outputscale, + ard_lengthscales=lengthscales, + input_names=tuple(name.strip() for name in names), + configured_min_noise=record.variant.min_noise, + configured_min_lengthscale=record.variant.min_lengthscale, + noise_constraint_lower_bound=noise_floor, + lengthscale_constraint_lower_bound=lengthscale_floor, + noise_near_floor=noise_near, + lengthscales_near_floor=lengthscale_near, + lengthscales_very_small_normalized_domain=lengthscale_very_small, + lengthscales_extremely_large_flat=lengthscale_extremely_large, + ) + ) + return tuple(rows) + + +def fit_warnings_frame(records: Iterable[FittedModelRecord]) -> pd.DataFrame: + columns = [field.name for field in ModelFitWarning.__dataclass_fields__.values()] + rows = [asdict(warning) for record in records for warning in record.warnings] + return pd.DataFrame(rows, columns=columns) + + +def validate_model_variant( + X: torch.Tensor, + Y: torch.Tensor, + *, + sample_ids: Sequence[Hashable], + input_names: Sequence[str], + objective_names: Sequence[str], + variant: ModelVariantSpec, + seed: int = 73, + row_roles: Sequence[str] | None = None, + control_sample_ids: Iterable[Hashable] = (), + cache: ModelFitCache | None = None, +) -> ModelValidationResult: + """Fit the full model, run exact LOOCV, and extract every hyperparameter.""" + ids, names = _validate_dataset(X, Y, sample_ids, objective_names, minimum_rows=3) + resolved_cache = ModelFitCache() if cache is None else cache + cohort_hash = dataset_fingerprint(X, Y, ids) + full = fit_model_variant( + X, + Y, + sample_ids=ids, + objective_names=names, + variant=variant, + seed=seed, + fit_key="full", + omitted_sample_id=None, + cohort_fingerprint=cohort_hash, + cache=resolved_cache, + ) + loocv = run_exact_loocv( + X, + Y, + sample_ids=ids, + objective_names=names, + variant=variant, + seed=seed, + row_roles=row_roles, + control_sample_ids=control_sample_ids, + cache=resolved_cache, + ) + all_records = [full, *loocv.fold_records.values()] + hyperparameters = tuple( + hyperparameter + for record in all_records + for hyperparameter in extract_model_hyperparameters( + record, input_names=input_names + ) + ) + return ModelValidationResult( + variant=variant, + full_fit=full, + loocv=loocv, + hyperparameters=hyperparameters, + ) + + +__all__ = [ + "CONSERVATIVE", + "MINIMUM_LATENT_TO_NOISE_SD_RATIO", + "SignalCollapseError", + "DIM_SCALED_PRIOR", + "LEGACY_NO_PRIOR", + "PRIMARY_VARIANT", + "ExactLOOCVResult", + "FittedModelRecord", + "HyperparameterRecord", + "LOOCVPrediction", + "ModelFitCache", + "ModelFitError", + "ModelFitWarning", + "ModelValidationResult", + "ModelVariantSpec", + "PredictionMetricRecord", + "compute_prediction_metrics", + "dataset_fingerprint", + "extract_model_hyperparameters", + "fit_model_variant", + "fit_warnings_frame", + "model_variant_spec", + "run_exact_loocv", + "validate_model_variant", +] diff --git a/src/mobo_kit/objectives.py b/src/mobo_kit/objectives.py new file mode 100644 index 0000000..2dcf474 --- /dev/null +++ b/src/mobo_kit/objectives.py @@ -0,0 +1,578 @@ +"""Versioned, all-maximize objective transformations for MOBO acquisition.""" + +from __future__ import annotations + +from dataclasses import dataclass +from numbers import Real +from typing import Literal, Sequence + +import numpy as np +import torch +from botorch.acquisition.multi_objective.objective import MCMultiOutputObjective + + +Goal = Literal["maximize", "minimize", "target"] +TransformName = Literal[ + "identity", "affine", "gaussian_target", "negative_absolute_target" +] +UtilityBound = tuple[float | None, float | None] + + +def _finite_optional(value: float | None, *, field: str, name: str) -> float | None: + if value is None: + return None + if isinstance(value, (bool, np.bool_)) or not isinstance(value, Real): + raise ValueError( + f"Objective {name!r} field {field!r} must be a real non-boolean number." + ) + number = float(value) + if not np.isfinite(number): + raise ValueError(f"Objective {name!r} field {field!r} must be finite.") + return number + + +@dataclass(frozen=True) +class ObjectiveSpec: + """Immutable definition of one raw-output-to-utility transformation.""" + + name: str + goal: Goal + transform: TransformName + #: What the MODEL emits, relative to the physical quantity the utility is + #: defined on. ``log`` means the GP was fitted in log space (see + #: ``structured_mean``), so a model output must be exponentiated before the + #: utility applies, and a model *posterior* is lognormal rather than normal. + model_link: Literal["identity", "log"] = "identity" + source_column: str | None = None + lower_anchor: float | None = None + upper_anchor: float | None = None + target: float | None = None + sigma: float | None = None + scale: float | None = None + clip: bool = False + + def __post_init__(self) -> None: + if not isinstance(self.name, str) or not self.name.strip(): + raise ValueError("Objective name must be a non-empty string.") + object.__setattr__(self, "name", self.name.strip()) + if self.goal not in {"maximize", "minimize", "target"}: + raise ValueError( + f"Objective {self.name!r} has unsupported goal {self.goal!r}." + ) + supported = { + "identity", + "affine", + "gaussian_target", + "negative_absolute_target", + } + if self.transform not in supported: + raise ValueError( + f"Objective {self.name!r} has unsupported transform " + f"{self.transform!r}." + ) + if self.source_column is not None and ( + not isinstance(self.source_column, str) or not self.source_column.strip() + ): + raise ValueError("source_column must be None or a non-empty string.") + if self.source_column is not None: + object.__setattr__(self, "source_column", self.source_column.strip()) + if not isinstance(self.clip, bool): + raise ValueError("clip must be a boolean.") + + numeric = { + field: _finite_optional(getattr(self, field), field=field, name=self.name) + for field in ( + "lower_anchor", + "upper_anchor", + "target", + "sigma", + "scale", + ) + } + for field, value in numeric.items(): + object.__setattr__(self, field, value) + + if self.transform == "identity": + if self.goal != "maximize": + raise ValueError("identity is supported only for maximize utilities.") + self._require_unused( + "lower_anchor", "upper_anchor", "target", "sigma", "scale" + ) + if self.clip: + raise ValueError( + "identity utilities cannot enable clip; approve their scale " + "upstream or use an explicit affine transform." + ) + elif self.transform == "affine": + if self.goal not in {"maximize", "minimize"}: + raise ValueError("affine requires goal 'maximize' or 'minimize'.") + if self.lower_anchor is None or self.upper_anchor is None: + raise ValueError("affine requires lower_anchor and upper_anchor.") + if self.lower_anchor >= self.upper_anchor: + raise ValueError("affine requires lower_anchor < upper_anchor.") + self._require_unused("target", "sigma", "scale") + elif self.transform == "gaussian_target": + if self.goal != "target": + raise ValueError("gaussian_target requires goal 'target'.") + if self.target is None or self.sigma is None: + raise ValueError("gaussian_target requires target and sigma.") + if self.sigma <= 0: + raise ValueError("gaussian_target sigma must be strictly positive.") + self._require_unused("lower_anchor", "upper_anchor", "scale") + if self.clip: + raise ValueError( + "gaussian_target is naturally bounded; clip is invalid." + ) + else: + if self.goal != "target": + raise ValueError("negative_absolute_target requires goal 'target'.") + if self.target is None or self.scale is None: + raise ValueError( + "negative_absolute_target requires target and explicit scale." + ) + if self.scale <= 0: + raise ValueError( + "negative_absolute_target scale must be strictly positive." + ) + self._require_unused("lower_anchor", "upper_anchor", "sigma") + if self.clip: + raise ValueError("clip is not supported for negative_absolute_target.") + + def _require_unused(self, *fields: str) -> None: + used = [field for field in fields if getattr(self, field) is not None] + if used: + raise ValueError( + f"Objective {self.name!r} transform {self.transform!r} does not " + f"accept parameter(s): {', '.join(used)}." + ) + + +class ObjectiveTransform: + """Apply an ordered objective contract to floating tensors ``[..., M]``.""" + + def __init__(self, specs: Sequence[ObjectiveSpec], *, version: str) -> None: + if not isinstance(version, str) or not version.strip(): + raise ValueError("Objective contract version must be a non-empty string.") + if not specs: + raise ValueError("At least one ObjectiveSpec is required.") + validated = tuple(specs) + if not all(isinstance(spec, ObjectiveSpec) for spec in validated): + raise TypeError("Every objective specification must be an ObjectiveSpec.") + names = [spec.name for spec in validated] + duplicates = sorted({name for name in names if names.count(name) > 1}) + if duplicates: + raise ValueError(f"Objective names must be unique; got {duplicates}.") + self.specs = validated + self.version = version.strip() + + @property + def objective_count(self) -> int: + return len(self.specs) + + @property + def names(self) -> tuple[str, ...]: + return tuple(spec.name for spec in self.specs) + + def transform(self, Y: torch.Tensor) -> torch.Tensor: + """Transform raw/model outputs while preserving shape, dtype, and device.""" + if not isinstance(Y, torch.Tensor): + raise TypeError("Y must be a torch.Tensor.") + if not Y.is_floating_point(): + raise TypeError("Y must use a floating dtype.") + if Y.ndim < 1 or Y.shape[-1] != self.objective_count: + raise ValueError( + f"Y final dimension must be {self.objective_count}; " + f"got shape {tuple(Y.shape)}." + ) + if not torch.isfinite(Y).all(): + raise ValueError("Y must contain only finite values.") + outputs: list[torch.Tensor] = [] + for index, spec in enumerate(self.specs): + outputs.append(self._utility(Y[..., index], spec)) + transformed = torch.stack(outputs, dim=-1) + if transformed.shape != Y.shape: + raise RuntimeError("Internal objective transform shape error.") + return transformed + + def _utility(self, model_output: torch.Tensor, spec: ObjectiveSpec): + """Utility for one objective, given that objective's MODEL output. + + The link decode happens exactly here and nowhere else. Quadrature and + Monte-Carlo paths must both route through this, or one of them will + exponentiate twice. + """ + raw = torch.exp(model_output) if spec.model_link == "log" else model_output + return self._utility_from_physical(raw, spec) + + @staticmethod + def _utility_from_physical(raw: torch.Tensor, spec: ObjectiveSpec): + """Utility for one objective from its PHYSICAL value (link already undone).""" + if True: + if spec.transform == "identity": + utility = raw + elif spec.transform == "affine": + lower = raw.new_tensor(spec.lower_anchor) + upper = raw.new_tensor(spec.upper_anchor) + if spec.goal == "maximize": + utility = (raw - lower) / (upper - lower) + else: + utility = (upper - raw) / (upper - lower) + if spec.clip: + utility = utility.clamp(0.0, 1.0) + elif spec.transform == "gaussian_target": + target = raw.new_tensor(spec.target) + sigma = raw.new_tensor(spec.sigma) + utility = torch.exp(-0.5 * ((raw - target) / sigma).square()) + else: + target = raw.new_tensor(spec.target) + scale = raw.new_tensor(spec.scale) + utility = -(raw - target).abs() / scale + return utility + + __call__ = transform + + def encode_measurements(self, Y_measured: torch.Tensor) -> torch.Tensor: + """MEASUREMENT-space values into the MODEL space :meth:`transform` expects. + + :meth:`transform` is a *model-output* decoder: its first act is to undo the + link, so a ``log`` objective is exponentiated before the utility is + computed. Handing it a raw measurement therefore exponentiates a value + that was never a logarithm. + + **This fails silently and it has.** ``run_r1_ucb`` passed thickness in + nanometres here until 2026-07-31; ``exp(360…1303)`` saturates the 650 nm + Gaussian to exactly ``0.0``, which is finite, so neither the transform's + own finiteness check nor the caller's noticed. Every observation's + thickness utility was zero, and the UCB-HVI baseline hypervolume came out + 0.004659 where the correct value is 0.436442 -- a factor of 94, against + which every candidate looked like a large improvement. + + Use this, or :meth:`transform_measurements`, wherever the values in hand + are what the workbook reports rather than what the GP emits. + """ + if not isinstance(Y_measured, torch.Tensor): + raise TypeError("Y_measured must be a torch.Tensor.") + if not Y_measured.is_floating_point(): + raise TypeError("Y_measured must use a floating dtype.") + if Y_measured.ndim < 1 or Y_measured.shape[-1] != self.objective_count: + raise ValueError( + f"Y_measured final dimension must be {self.objective_count}; " + f"got shape {tuple(Y_measured.shape)}." + ) + if not torch.isfinite(Y_measured).all(): + raise ValueError("Y_measured must contain only finite values.") + columns: list[torch.Tensor] = [] + for index, spec in enumerate(self.specs): + column = Y_measured[..., index] + if spec.model_link == "log": + if not bool((column > 0).all()): + raise ValueError( + f"Objective {spec.name!r} has a log link, so its measured " + "values must be strictly positive." + ) + column = torch.log(column) + columns.append(column) + return torch.stack(columns, dim=-1) + + def transform_measurements(self, Y_measured: torch.Tensor) -> torch.Tensor: + """Utility straight from MEASUREMENT-space values. + + The one-call safe route: :meth:`encode_measurements` then + :meth:`transform`. Prefer it at any call site holding workbook values, so + the encoding step cannot be forgotten. + """ + return self.transform(self.encode_measurements(Y_measured)) + + def expected_transform( + self, mean: torch.Tensor, variance: torch.Tensor + ) -> torch.Tensor: + """Expected utility ``E[transform(Y)]`` for ``Y ~ N(mean, variance)``. + + Use this when the GP is trained on a *raw* measurement and the utility is + a nonlinear function of it. Applying :meth:`transform` to the posterior + mean is wrong in that case: it is biased by Jensen's inequality and it + discards the posterior variance entirely, which for a target-seeking + utility is precisely the information that matters. + + ``identity`` and ``affine`` are linear, so their expectation is just the + transform of the mean. The two target transforms are nonlinear and have + exact closed forms: + + * ``gaussian_target`` with target ``c`` and width ``s``:: + + E = s / sqrt(s^2 + v) * exp(-0.5 * (mu - c)^2 / (s^2 + v)) + + At ``mu == c`` this decays from 1 as the posterior widens, so a + confidently on-target candidate outranks an uncertain one. + + * ``negative_absolute_target`` uses the folded-normal mean. + + Both reduce to :meth:`transform` as ``variance -> 0``. + """ + if not isinstance(mean, torch.Tensor) or not isinstance(variance, torch.Tensor): + raise TypeError("mean and variance must be torch.Tensors.") + if not mean.is_floating_point() or not variance.is_floating_point(): + raise TypeError("mean and variance must use a floating dtype.") + if mean.shape != variance.shape: + raise ValueError( + f"mean and variance must share a shape; got {tuple(mean.shape)} " + f"and {tuple(variance.shape)}." + ) + if mean.ndim < 1 or mean.shape[-1] != self.objective_count: + raise ValueError( + f"mean final dimension must be {self.objective_count}; " + f"got shape {tuple(mean.shape)}." + ) + if not torch.isfinite(mean).all() or not torch.isfinite(variance).all(): + raise ValueError("mean and variance must contain only finite values.") + if (variance < 0).any(): + raise ValueError("variance must be non-negative.") + + outputs: list[torch.Tensor] = [] + for index, spec in enumerate(self.specs): + mu = mean[..., index] + var = variance[..., index] + if spec.model_link == "log": + # the posterior is lognormal, so no Gaussian closed form applies; + # integrate in log space by quadrature + utility = self._quadrature_expectation(mu, var, spec, nodes=20) + elif spec.transform in {"identity", "affine"}: + # linear in the physical value, and the link is identity in this + # branch, so E[f(Y)] = f(E[Y]) + utility = self._utility_from_physical(mu, spec) + elif spec.transform == "gaussian_target": + target = mu.new_tensor(spec.target) + s2 = mu.new_tensor(spec.sigma) ** 2 + denom = s2 + var + utility = torch.sqrt(s2 / denom) * torch.exp( + -0.5 * (mu - target).square() / denom + ) + else: + target = mu.new_tensor(spec.target) + scale = mu.new_tensor(spec.scale) + sd = var.clamp_min(0.0).sqrt() + delta = mu - target + # folded-normal mean; the sd == 0 branch degenerates to |delta| + safe_sd = torch.where(sd > 0, sd, torch.ones_like(sd)) + folded = safe_sd * np.sqrt(2.0 / np.pi) * torch.exp( + -0.5 * (delta / safe_sd).square() + ) + delta * torch.erf(delta / (safe_sd * np.sqrt(2.0))) + folded = torch.where(sd > 0, folded, delta.abs()) + utility = -folded / scale + outputs.append(utility) + expected = torch.stack(outputs, dim=-1) + if expected.shape != mean.shape: + raise RuntimeError("Internal expected-objective shape error.") + return expected + + def _quadrature_expectation( + self, + log_mean: torch.Tensor, + log_variance: torch.Tensor, + spec: ObjectiveSpec, + *, + nodes: int, + ) -> torch.Tensor: + """E[utility] for one log-link objective, by Gauss-Hermite in log space. + + E[g(Y)] = int g(exp(z)) N(z; m, s^2) dz + ~ (1/sqrt(pi)) sum_i w_i g(exp(m + sqrt(2) s x_i)) + + Exact for the Gaussian weight, deterministic, differentiable, and + cheaper than sampling. Moment-matching the lognormal to a Gaussian and + reusing the closed form is ~500x less accurate here, and its bias + changes sign across the range, which reorders candidates rather than + merely shifting them. + """ + raw_nodes, raw_weights = np.polynomial.hermite.hermgauss(nodes) + abscissa = log_mean.new_tensor(raw_nodes) + weights = log_mean.new_tensor(raw_weights) / float(np.sqrt(np.pi)) + sd = log_variance.clamp_min(0.0).sqrt() + shape = (-1, *([1] * log_mean.ndim)) + shifted = log_mean.unsqueeze(0) + np.sqrt(2.0) * sd.unsqueeze( + 0 + ) * abscissa.view(shape) + utilities = self._utility_from_physical(torch.exp(shifted), spec) + return (utilities * weights.view(shape)).sum(dim=0) + + def expected_transform_lognormal( + self, + log_mean: torch.Tensor, + log_variance: torch.Tensor, + *, + nodes: int = 20, + ) -> torch.Tensor: + """Expected utility treating EVERY objective as log-link. + + Prefer :meth:`expected_transform`, which dispatches per objective from + each spec's ``model_link``. This method is kept for the single-objective + case where the caller knows the posterior is lognormal. + """ + if not isinstance(log_mean, torch.Tensor) or not isinstance( + log_variance, torch.Tensor + ): + raise TypeError("log_mean and log_variance must be torch.Tensors.") + if not log_mean.is_floating_point() or not log_variance.is_floating_point(): + raise TypeError("log_mean and log_variance must use a floating dtype.") + if log_mean.shape != log_variance.shape: + raise ValueError( + f"log_mean and log_variance must share a shape; got " + f"{tuple(log_mean.shape)} and {tuple(log_variance.shape)}." + ) + if log_mean.ndim < 1 or log_mean.shape[-1] != self.objective_count: + raise ValueError( + f"log_mean final dimension must be {self.objective_count}; " + f"got shape {tuple(log_mean.shape)}." + ) + if not torch.isfinite(log_mean).all() or not torch.isfinite(log_variance).all(): + raise ValueError("log_mean and log_variance must be finite.") + if (log_variance < 0).any(): + raise ValueError("log_variance must be non-negative.") + if isinstance(nodes, bool) or not isinstance(nodes, int) or nodes < 2: + raise ValueError("nodes must be an integer of at least 2.") + columns = [ + self._quadrature_expectation( + log_mean[..., index], log_variance[..., index], spec, nodes=nodes + ) + for index, spec in enumerate(self.specs) + ] + return torch.stack(columns, dim=-1) + + +class ConfiguredMCMultiOutputObjective(MCMultiOutputObjective): + """BoTorch MC objective backed by the exact same `ObjectiveTransform`.""" + + def __init__(self, objective_transform: ObjectiveTransform) -> None: + super().__init__() + if not isinstance(objective_transform, ObjectiveTransform): + raise TypeError("objective_transform must be an ObjectiveTransform.") + self.objective_transform = objective_transform + + def forward( + self, samples: torch.Tensor, X: torch.Tensor | None = None + ) -> torch.Tensor: + del X + return self.objective_transform.transform(samples) + + +def _validate_posterior_sample_bounds( + bounds: Sequence[UtilityBound], objective_transform: ObjectiveTransform +) -> tuple[UtilityBound, ...]: + if isinstance(bounds, (str, bytes)): + raise TypeError("bounds must be an ordered sequence of (lower, upper) pairs.") + try: + raw_bounds = tuple(bounds) + except TypeError as exc: + raise TypeError( + "bounds must be an ordered sequence of (lower, upper) pairs." + ) from exc + if len(raw_bounds) != objective_transform.objective_count: + raise ValueError( + "bounds must contain one (lower, upper) pair per objective; " + f"expected {objective_transform.objective_count}, got {len(raw_bounds)}." + ) + + validated: list[UtilityBound] = [] + bounded_count = 0 + for index, (raw_bound, spec) in enumerate( + zip(raw_bounds, objective_transform.specs) + ): + if isinstance(raw_bound, (str, bytes)): + raise TypeError(f"bounds[{index}] must be a (lower, upper) pair.") + try: + pair = tuple(raw_bound) + except TypeError as exc: + raise TypeError(f"bounds[{index}] must be a (lower, upper) pair.") from exc + if len(pair) != 2: + raise ValueError(f"bounds[{index}] must contain exactly two values.") + lower = _finite_optional( + pair[0], field="posterior_sample_lower_bound", name=spec.name + ) + upper = _finite_optional( + pair[1], field="posterior_sample_upper_bound", name=spec.name + ) + if lower is not None and upper is not None and lower > upper: + raise ValueError( + f"Objective {spec.name!r} posterior-sample lower bound must not " + "exceed its upper bound." + ) + if lower is not None or upper is not None: + bounded_count += 1 + if spec.transform != "identity" or spec.goal != "maximize": + raise ValueError( + "Posterior-sample bounds are supported only for explicit " + f"identity/maximize utilities; objective {spec.name!r} uses " + f"{spec.transform!r}/{spec.goal!r}." + ) + validated.append((lower, upper)) + if bounded_count == 0: + raise ValueError("At least one posterior-sample utility bound is required.") + return tuple(validated) + + +class BoundedPosteriorSampleTransform: + """Clamp declared identity utilities only after transforming MC samples. + + This acquisition-only wrapper never changes observed training targets or the + underlying versioned objective contract. It is intended for explicit + synthetic qLogNEHVI policy comparisons. + """ + + def __init__( + self, + objective_transform: ObjectiveTransform, + bounds: Sequence[UtilityBound], + ) -> None: + if not isinstance(objective_transform, ObjectiveTransform): + raise TypeError("objective_transform must be an ObjectiveTransform.") + self.objective_transform = objective_transform + self.bounds = _validate_posterior_sample_bounds(bounds, objective_transform) + self.version = f"{objective_transform.version}+posterior-sample-bounds-v1" + + def transform(self, samples: torch.Tensor) -> torch.Tensor: + utilities = self.objective_transform.transform(samples) + bounded_columns: list[torch.Tensor] = [] + for index, (lower, upper) in enumerate(self.bounds): + utility = utilities[..., index] + if lower is not None or upper is not None: + utility = torch.clamp(utility, min=lower, max=upper) + bounded_columns.append(utility) + bounded = torch.stack(bounded_columns, dim=-1) + if bounded.shape != utilities.shape: + raise RuntimeError("Internal posterior-sample bounds shape error.") + return bounded + + __call__ = transform + + +class BoundedMCMultiOutputObjective(MCMultiOutputObjective): + """BoTorch objective applying explicit bounds to posterior utility samples.""" + + def __init__( + self, + objective_transform: ObjectiveTransform, + bounds: Sequence[UtilityBound], + ) -> None: + super().__init__() + self.posterior_sample_transform = BoundedPosteriorSampleTransform( + objective_transform, bounds + ) + self.objective_transform = objective_transform + self.bounds = self.posterior_sample_transform.bounds + self.version = self.posterior_sample_transform.version + + def forward( + self, samples: torch.Tensor, X: torch.Tensor | None = None + ) -> torch.Tensor: + del X + return self.posterior_sample_transform.transform(samples) + + +__all__ = [ + "BoundedMCMultiOutputObjective", + "BoundedPosteriorSampleTransform", + "ConfiguredMCMultiOutputObjective", + "ObjectiveSpec", + "ObjectiveTransform", + "UtilityBound", +] diff --git a/src/mobo_kit/plotting.py b/src/mobo_kit/plotting.py index 38c1a89..947264e 100644 --- a/src/mobo_kit/plotting.py +++ b/src/mobo_kit/plotting.py @@ -732,6 +732,27 @@ def plot_shap( save: Optional[str] = None, show_plot: bool = True, ): + """Mean |SHAP| bars over each GP's RAW posterior mean. Demo path only. + + Kept because ``notebooks/MOBO_demo_annotated.ipynb`` calls it. For the campaign + use ``scripts/plot_shap_attribution.py`` instead, which differs in three ways + that change what the bars mean: + + * **It explains the raw model output, not utility.** For a log-link objective + that is ``log(nm)``, so the campaign's 650 nm Gaussian target is never + applied and features are ranked by their effect on log thickness rather than + on how good the film is. The campaign script explains + ``E[utility]`` through ``ObjectiveTransform.expected_transform``. + * **``nsamples=300`` is below the 1024 coalitions that ten inputs need**, so + these values are a sampled approximation, and an unseeded one. At ten + features the campaign script's explainer enumerates exhaustively, which makes + its attributions exact and reproducible. + * **It explains only the training points.** The campaign script attributes over + a sampled on-grid pool, so the picture covers the design space rather than + the handful of recipes already run. + + None of that is wrong for a demo. It is wrong for deciding anything. + """ X_eval = np.asarray(train_X, dtype=float) feature_names = design.names M = getattr(model, "num_outputs", len(model.models)) diff --git a/src/mobo_kit/qlognehvi_batch.py b/src/mobo_kit/qlognehvi_batch.py new file mode 100644 index 0000000..e6ca39b --- /dev/null +++ b/src/mobo_kit/qlognehvi_batch.py @@ -0,0 +1,389 @@ +"""Discrete singleton-pool qLogNEHVI scoring and sequential batch proposals.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Sequence + +import numpy as np +import torch +from botorch.acquisition.multi_objective.logei import ( + qLogNoisyExpectedHypervolumeImprovement, +) +from botorch.sampling.normal import SobolQMCNormalSampler + + +@dataclass(frozen=True) +class QLogNEHVIPoolScoreResult: + """Singleton qLogNEHVI values and the pending context used to compute them.""" + + base_log_score: np.ndarray + evaluated_shape: tuple[int, int, int] + pending_count: int + mc_samples: int + seed: int + reference_point_utility: np.ndarray + objective_contract_version: str + method: str = "qlognehvi" + method_version: str = "step2a-v1" + + +@dataclass(frozen=True) +class QLogNEHVIBatchProposal: + """Sequentially selected qLogNEHVI batch and per-step score histories.""" + + selection: Any + score_history: tuple[QLogNEHVIPoolScoreResult, ...] + preexisting_pending_count: int + metadata: dict[str, Any] + + +def _positive_integer(value: int, *, name: str) -> int: + if isinstance(value, bool) or not isinstance(value, (int, np.integer)): + raise ValueError(f"{name} must be a positive integer; got {value!r}.") + result = int(value) + if result <= 0: + raise ValueError(f"{name} must be a positive integer; got {value!r}.") + return result + + +def _input_matrix(value: torch.Tensor, *, name: str) -> torch.Tensor: + if not isinstance(value, torch.Tensor) or value.ndim != 2: + shape = getattr(value, "shape", None) + raise ValueError(f"{name} must be a tensor with shape (N, D); got {shape}.") + if not value.is_floating_point(): + raise TypeError(f"{name} must use a floating dtype.") + if not torch.isfinite(value).all(): + raise ValueError(f"{name} must contain only finite values.") + return value + + +def _reference( + value: np.ndarray | torch.Tensor | None, + *, + dtype: torch.dtype, + device: torch.device, +) -> torch.Tensor: + if value is None: + raise ValueError( + "reference_point_utility is required and is never derived from data." + ) + reference = torch.as_tensor(value, dtype=dtype, device=device) + if reference.ndim != 1 or reference.numel() == 0: + raise ValueError( + "reference_point_utility must have shape (M,) with at least one objective." + ) + if not torch.isfinite(reference).all(): + raise ValueError("reference_point_utility must contain only finite values.") + return reference + + +def _build_qlognehvi( + *, + model: Any, + train_X: torch.Tensor, + reference_point: torch.Tensor, + objective: Any, + mc_samples: int, + seed: int, + X_pending: torch.Tensor | None, + constraints: Sequence[Callable[[torch.Tensor], torch.Tensor]] | None, + eta: float | torch.Tensor, + prune_baseline: bool, +) -> qLogNoisyExpectedHypervolumeImprovement: + sampler = SobolQMCNormalSampler( + sample_shape=torch.Size([mc_samples]), seed=int(seed) + ) + return qLogNoisyExpectedHypervolumeImprovement( + model=model, + ref_point=reference_point, + X_baseline=train_X, + sampler=sampler, + objective=objective, + constraints=None if constraints is None else list(constraints), + eta=eta, + X_pending=X_pending, + prune_baseline=prune_baseline, + ) + + +def score_qlognehvi_singletons( + model: Any, + train_X_norm: torch.Tensor, + X_pool_norm: torch.Tensor, + objective: Any, + reference_point_utility: np.ndarray | torch.Tensor | None, + *, + mc_samples: int = 128, + seed: int = 0, + chunk_size: int = 512, + X_pending_norm: torch.Tensor | None = None, + constraints: Sequence[Callable[[torch.Tensor], torch.Tensor]] | None = None, + eta: float | torch.Tensor = 0.001, + prune_baseline: bool = False, +) -> QLogNEHVIPoolScoreResult: + """Evaluate qLogNEHVI on a normalized pool with explicit ``N x 1 x D`` shape.""" + from .objectives import ( + BoundedMCMultiOutputObjective, + ConfiguredMCMultiOutputObjective, + ) + + train_X = _input_matrix(train_X_norm, name="train_X_norm") + pool = _input_matrix(X_pool_norm, name="X_pool_norm") + if pool.shape[0] == 0: + raise ValueError("X_pool_norm must contain at least one candidate.") + if pool.shape[1] != train_X.shape[1]: + raise ValueError("train_X_norm and X_pool_norm dimensions must match.") + sample_count = _positive_integer(mc_samples, name="mc_samples") + chunk = _positive_integer(chunk_size, name="chunk_size") + if ( + isinstance(seed, (bool, np.bool_)) + or not isinstance(seed, (int, np.integer)) + or int(seed) < 0 + ): + raise ValueError("seed must be a non-negative integer.") + approved_objective_types = ( + ConfiguredMCMultiOutputObjective, + BoundedMCMultiOutputObjective, + ) + if not isinstance(objective, approved_objective_types): + raise TypeError( + "objective must be a configured or bounded configured multi-output " + "objective so raw outcomes cannot silently bypass the approved utility " + "transform." + ) + pending: torch.Tensor | None = None + if X_pending_norm is not None: + pending = _input_matrix(X_pending_norm, name="X_pending_norm") + if pending.shape[1] != train_X.shape[1]: + raise ValueError("X_pending_norm and train_X_norm dimensions must match.") + pending = pending.to(dtype=train_X.dtype, device=train_X.device) + reference = _reference( + reference_point_utility, dtype=train_X.dtype, device=train_X.device + ) + objective_count = objective.objective_transform.objective_count + if reference.numel() != objective_count: + raise ValueError( + "reference_point_utility dimension must match the configured objective " + f"count ({objective_count}); got {reference.numel()}." + ) + model_output_count = getattr(model, "num_outputs", None) + if model_output_count is not None and int(model_output_count) != objective_count: + raise ValueError( + f"Model has {int(model_output_count)} outputs but objective contract " + f"has {objective_count}." + ) + objective_version = getattr( + objective, "version", objective.objective_transform.version + ) + acquisition = _build_qlognehvi( + model=model, + train_X=train_X, + reference_point=reference, + objective=objective, + mc_samples=sample_count, + seed=int(seed), + X_pending=pending, + constraints=constraints, + eta=eta, + prune_baseline=bool(prune_baseline), + ) + + values: list[torch.Tensor] = [] + with torch.no_grad(): + for start in range(0, pool.shape[0], chunk): + singleton_batch = ( + pool[start : start + chunk] + .to(dtype=train_X.dtype, device=train_X.device) + .unsqueeze(-2) + ) + chunk_values = acquisition(singleton_batch) + if chunk_values.shape != (singleton_batch.shape[0],): + raise RuntimeError( + "qLogNEHVI singleton evaluation returned unexpected shape " + f"{tuple(chunk_values.shape)} for input " + f"{tuple(singleton_batch.shape)}." + ) + values.append(chunk_values.detach().cpu().double()) + scores = torch.cat(values).numpy() + if np.any(np.isnan(scores)) or np.any(np.isposinf(scores)): + raise RuntimeError("qLogNEHVI returned NaN or positive-infinite scores.") + return QLogNEHVIPoolScoreResult( + base_log_score=scores, + evaluated_shape=(int(pool.shape[0]), 1, int(pool.shape[1])), + pending_count=0 if pending is None else int(pending.shape[0]), + mc_samples=sample_count, + seed=int(seed), + reference_point_utility=reference.detach().cpu().double().numpy(), + objective_contract_version=objective_version, + ) + + +def _assert_no_reference_overlap( + pool_norm: np.ndarray, + reference_norm: np.ndarray | None, + *, + name: str, + atol: float = 1e-12, +) -> None: + if reference_norm is None: + return + reference = np.asarray(reference_norm, dtype=float) + if reference.ndim != 2 or reference.shape[1] != pool_norm.shape[1]: + raise ValueError( + f"{name} must have shape (N, {pool_norm.shape[1]}); got {reference.shape}." + ) + if not np.all(np.isfinite(reference)): + raise ValueError(f"{name} must contain only finite values.") + if reference.shape[0] == 0: + return + overlap = np.all( + np.isclose( + pool_norm[:, None, :], + reference[None, :, :], + rtol=0.0, + atol=atol, + ), + axis=-1, + ) + if np.any(overlap): + pool_indices = np.flatnonzero(np.any(overlap, axis=1)).tolist() + raise ValueError( + f"Candidate pool overlaps {name} at pool indices {pool_indices}; " + "resample with those recipes in the avoid set." + ) + + +def propose_qlognehvi_penalized_batch( + candidate_pool: Any, + model: Any, + train_X_norm: torch.Tensor, + objective: Any, + reference_point_utility: np.ndarray | torch.Tensor | None, + *, + q: int, + local_penalization_config: Any, + X_pending_norm: torch.Tensor | None = None, + mc_samples: int = 128, + seed: int = 0, + chunk_size: int = 512, + constraints: Sequence[Callable[[torch.Tensor], torch.Tensor]] | None = None, + eta: float | torch.Tensor = 0.001, + prune_baseline: bool = False, +) -> QLogNEHVIBatchProposal: + """Select a discrete batch, rebuilding qLogNEHVI pending state each step.""" + from .batch_selection import BaseScoreResult, select_local_penalized_batch + + train_X = _input_matrix(train_X_norm, name="train_X_norm") + pool_norm = np.asarray(candidate_pool.X_norm, dtype=float) + if pool_norm.ndim != 2 or pool_norm.shape[1] != train_X.shape[1]: + raise ValueError( + "candidate_pool.X_norm and train_X_norm must share input dimension." + ) + train_numpy = train_X.detach().cpu().double().numpy() + _assert_no_reference_overlap(pool_norm, train_numpy, name="observed train_X_norm") + + pending_tensor: torch.Tensor | None = None + pending_numpy: np.ndarray | None = None + if X_pending_norm is not None: + pending_tensor = _input_matrix(X_pending_norm, name="X_pending_norm").to( + dtype=train_X.dtype, device=train_X.device + ) + pending_numpy = pending_tensor.detach().cpu().double().numpy() + _assert_no_reference_overlap( + pool_norm, pending_numpy, name="pre-existing X_pending_norm" + ) + + histories: list[QLogNEHVIPoolScoreResult] = [] + + def score_remaining( + remaining_indices: np.ndarray, selected_indices: np.ndarray + ) -> Any: + selected_tensor = torch.as_tensor( + pool_norm[selected_indices], dtype=train_X.dtype, device=train_X.device + ) + if pending_tensor is None: + current_pending = selected_tensor if selected_indices.size else None + elif selected_indices.size: + current_pending = torch.cat([pending_tensor, selected_tensor], dim=0) + else: + current_pending = pending_tensor + result = score_qlognehvi_singletons( + model, + train_X, + torch.as_tensor( + pool_norm[remaining_indices], + dtype=train_X.dtype, + device=train_X.device, + ), + objective, + reference_point_utility, + mc_samples=mc_samples, + seed=seed, + chunk_size=chunk_size, + X_pending_norm=current_pending, + constraints=constraints, + eta=eta, + prune_baseline=prune_baseline, + ) + histories.append(result) + raw_base_score = np.exp(np.clip(result.base_log_score, -745.0, 709.0)) + raw_base_score[np.isneginf(result.base_log_score)] = 0.0 + return BaseScoreResult( + base_log_score=result.base_log_score, + base_score=raw_base_score, + diagnostics={ + "pending_count": result.pending_count, + "evaluated_shape": result.evaluated_shape, + "remaining_pool_indices": remaining_indices.copy(), + }, + ) + + observed_pending = ( + train_numpy + if pending_numpy is None + else np.vstack([train_numpy, pending_numpy]) + ) + selection = select_local_penalized_batch( + candidate_pool, + q, + score_remaining, + local_penalization_config, + observed_pending_norm=observed_pending, + ) + return QLogNEHVIBatchProposal( + selection=selection, + score_history=tuple(histories), + preexisting_pending_count=( + 0 if pending_tensor is None else int(pending_tensor.shape[0]) + ), + metadata={ + "method": "qlognehvi", + "method_version": "step2a-v1", + "objective_contract_version": getattr( + objective, "version", objective.objective_transform.version + ), + "reference_point_utility": np.asarray( + reference_point_utility, dtype=float + ).copy(), + "pool_seed": candidate_pool.seed, + "pool_size": candidate_pool.size, + "pool_draws": candidate_pool.draws, + "pool_rejected_duplicate": candidate_pool.rejected_duplicate, + "pool_rejected_avoid": candidate_pool.rejected_avoid, + "pool_rejected_constraint": candidate_pool.rejected_constraint, + "mc_seed": int(seed), + "mc_samples": int(mc_samples), + "preexisting_pending_count": ( + 0 if pending_tensor is None else int(pending_tensor.shape[0]) + ), + "local_penalization": { + "radius": local_penalization_config.radius, + "min_batch_distance": local_penalization_config.min_batch_distance, + "min_observed_distance": local_penalization_config.min_observed_distance, + "dimension_weights": local_penalization_config.dimension_weights, + "epsilon": local_penalization_config.epsilon, + }, + "selected_pool_indices": selection.selected_pool_indices.copy(), + }, + ) diff --git a/src/mobo_kit/replicate_variance.py b/src/mobo_kit/replicate_variance.py new file mode 100644 index 0000000..188b2c1 --- /dev/null +++ b/src/mobo_kit/replicate_variance.py @@ -0,0 +1,289 @@ +"""Turn replicate films into the observation variance the GP is told about. + +Each proposed condition is run in triplicate. Those three films are one design +point, so they are aggregated to a single observation -- and the scatter that +aggregation discards is the only direct measurement this campaign has of how +reproducible its own process is. Handing it to the GP as ``train_Yvar`` stops the +marginal likelihood from having to guess the noise from 15 points in 10 dimensions. + +**Two variances live in this campaign and they are not interchangeable.** + +*Between-film* variance is the quantity ``train_Yvar`` needs: two films made from +the same recipe differ by everything that varies run to run -- ambient conditions, +the operator, the substrate, the anneal. It only becomes measurable when the R1 +triplicates land. + +*Within-film* variance is the scatter of the 2-4 thickness points measured across +one film. It is measurement plus spatial nonuniformity, and it is available today: +pooled over the R0 rows it is 0.0593 on ``log T``, 24 dof. It is **not** a +substitute. It excludes run-to-run variation entirely, so it is a *floor* -- if the +pooled between-film variance ever comes out below it, something is wrong with the +measurement or the pooling, because films cannot be more reproducible than points +on a single film. :func:`sanity_floor_findings` says so rather than assuming it. + +**Space matters.** Variance must be in the space the GP trains in. Thickness +trains on ``log T``, so its variance is of ``log T``; passing a variance in nm^2 +would be wrong by a factor of T^2, which over the observed range is 1.3e5 to 1.7e6 +-- not even a constant rescaling. :class:`workbook_io.CandidateResults` already +reports ``replicate_spread`` in each objective's aggregation space for this reason, +and that is the same space as the model's target by construction. + +**What the GP is told is the variance of the MEAN**, not of a single film. The +observation handed to the model is an average of ``n`` films, so its variance is +``pooled / n``. Passing the single-film variance instead would hand the model a +number three times too large on a triplicate -- overstating its uncertainty, so it +would trust a well-replicated condition less than it has earned -- and BoTorch will +not complain. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, Mapping, Sequence + +import numpy as np +import pandas as pd + +__all__ = [ + "PooledVariance", + "REPLICATE_POOLED", + "WITHIN_FILM_LOG_THICKNESS_VARIANCE", + "pool_between_film_variance", + "sanity_floor_findings", + "train_yvar_for_rows", + "variance_config", + "yvar_for_campaign", +] + +#: Pooled within-row variance of ``log T`` across the 15 R0 rows, 24 dof, from the +#: 2-4 thickness points each row carries. A FLOOR for the between-film variance, +#: never a replacement: it contains no run-to-run variation at all. +WITHIN_FILM_LOG_THICKNESS_VARIANCE = 0.0593 + + +@dataclass(frozen=True) +class PooledVariance: + """One objective's between-film variance, pooled across conditions.""" + + objective: str + variance: float + dof: int + n_conditions: int + space: str + """``value`` or ``log`` -- the space the aggregation and the GP both work in.""" + + @property + def sd(self) -> float: + return math.sqrt(self.variance) + + def variance_of_mean(self, n_films: int) -> float: + """Variance of an observation that is the mean of ``n_films`` films.""" + if n_films < 1: + raise ValueError("n_films must be at least 1.") + return self.variance / float(n_films) + + +def pool_between_film_variance( + replicate_spread: pd.DataFrame, + films_used: pd.DataFrame, + *, + aggregates: Mapping[str, str] | Sequence[str] | None = None, +) -> dict[str, PooledVariance]: + """Pool per-condition replicate scatter into one variance per objective. + + ``replicate_spread`` holds the per-condition sample sd in each objective's + aggregation space, and ``films_used`` how many films each came from -- both + straight off :func:`workbook_io.read_candidate_results`. + + Pooling is the usual dof-weighted estimate, ``sum((n_i - 1) * s_i^2) / + sum(n_i - 1)``. Conditions with one usable film contribute no dof and are + skipped rather than counted as zero variance: one film measures no + reproducibility, and treating that as perfect reproducibility is how a model + ends up certain about a process nobody has measured twice. + """ + if not isinstance(replicate_spread, pd.DataFrame): + raise TypeError("replicate_spread must be a pandas DataFrame.") + if not isinstance(films_used, pd.DataFrame): + raise TypeError("films_used must be a pandas DataFrame.") + if list(replicate_spread.columns) != list(films_used.columns): + raise ValueError( + "replicate_spread and films_used must describe the same objectives; got " + f"{list(replicate_spread.columns)} and {list(films_used.columns)}." + ) + if isinstance(aggregates, Mapping): + spaces = dict(aggregates) + elif aggregates is None: + spaces = {} + else: + spaces = dict(zip(replicate_spread.columns, aggregates)) + + pooled: dict[str, PooledVariance] = {} + for name in replicate_spread.columns: + weighted = 0.0 + dof = 0 + used = 0 + for spread, films in zip(replicate_spread[name], films_used[name]): + count = int(films) + if count < 2 or not np.isfinite(spread): + continue + weighted += (count - 1) * float(spread) ** 2 + dof += count - 1 + used += 1 + if dof == 0: + raise ValueError( + f"Objective {name!r} has no condition with two or more usable films, " + "so between-film variance cannot be estimated. Replicates are what " + "make it measurable; a single film per condition measures no " + "reproducibility at all." + ) + rule = str(spaces.get(name, "mean")) + pooled[name] = PooledVariance( + objective=name, + variance=weighted / dof, + dof=dof, + n_conditions=used, + space="log" if rule == "mean_of_log" else "value", + ) + return pooled + + +def sanity_floor_findings( + pooled: Mapping[str, PooledVariance], floors: Mapping[str, float] +) -> tuple[str, ...]: + """Report any objective whose between-film variance falls below its floor. + + A floor comes from within-film scatter, which contains no run-to-run variation. + Between-film variance below it means films are apparently more reproducible than + points on one film, which is not a thing -- so it indicates a measurement or + pooling mistake, not a very good process. + """ + messages: list[str] = [] + for name, floor in floors.items(): + estimate = pooled.get(name) + if estimate is None: + continue + if estimate.variance < float(floor): + messages.append( + f"{name}: pooled between-film variance {estimate.variance:.4g} " + f"({estimate.dof} dof) is BELOW the within-film floor {float(floor):.4g}. " + "Films cannot be more reproducible than points measured on a single " + "film, so this points at the measurements or the pooling, not at a " + "very reproducible process. Check before trusting train_Yvar." + ) + return tuple(messages) + + +def train_yvar_for_rows( + pooled: Mapping[str, PooledVariance], + films_per_row: pd.DataFrame | np.ndarray, + objective_names: Sequence[str], + *, + rows_without_replicates: int = 1, +) -> np.ndarray: + """The ``(n_rows, n_objectives)`` variance array to hand the model. + + Each entry is the variance of that row's observation, which is a mean of + ``n`` films, so ``pooled / n``. + + ``rows_without_replicates`` is the film count assumed for rows that have none -- + the R0 rows, which predate the triplicate policy. It defaults to 1: their + observation is a single film, so it carries the full between-film variance + rather than a third of it. That assumes the measurement process is unchanged + between rounds, which is an assumption and is recorded in the config as one. + """ + names = list(objective_names) + counts = ( + films_per_row[names].to_numpy(dtype=float) + if isinstance(films_per_row, pd.DataFrame) + else np.asarray(films_per_row, dtype=float) + ) + if counts.ndim == 1: + counts = np.repeat(counts[:, None], len(names), axis=1) + if counts.shape[1] != len(names): + raise ValueError( + f"films_per_row must have one column per objective ({len(names)}); " + f"got {counts.shape[1]}." + ) + missing = [name for name in names if name not in pooled] + if missing: + raise ValueError(f"No pooled variance for objective(s) {missing}.") + + # A pooled variance of zero says every replicate of every condition agreed to + # the last digit. For a physical measurement that means the values were copied + # rather than measured -- and handing zero to the model asserts the observation + # is exact, which makes it interpolate through a number nobody verified. + degenerate = [name for name in names if pooled[name].variance <= 0.0] + if degenerate: + raise ValueError( + f"Pooled between-film variance is zero for {degenerate}. Replicate films " + "that agree exactly are a transcription, not a measurement, and a zero " + "train_Yvar tells the model the observation is exact. Check the entries " + "before enabling measured observation noise." + ) + + counts = np.where(counts >= 1, counts, float(rows_without_replicates)) + variances = np.empty_like(counts, dtype=float) + for index, name in enumerate(names): + variances[:, index] = pooled[name].variance / counts[:, index] + return variances + + +#: ``model.observation_noise`` value that switches measured replicate variance on. +REPLICATE_POOLED = "replicate_pooled" + + +def yvar_for_campaign( + config: Mapping[str, Any], + results: Any, + *, + n_rows_without_replicates: int, + objective_names: Sequence[str], + aggregates: Sequence[str] | Mapping[str, str] | None = None, +) -> tuple[np.ndarray | None, tuple[str, ...]]: + """Build ``train_Yvar`` for the whole observed set, or return ``None``. + + ``None`` unless ``model.observation_noise`` is ``replicate_pooled``: until the + triplicates land there is nothing to pool, and the marginal likelihood keeps + fitting the noise as it does today. Once they do land, turning this on is a + config edit, which is the point of wiring it before the data exists. + + Rows without replicates -- the R0 block, which predates the triplicate policy -- + come first and take the declared film count. The replicated conditions follow + in the order :func:`workbook_io.read_candidate_results` returned them, which is + the order they are appended to the observation matrix. + """ + if str((config.get("model") or {}).get("observation_noise")) != REPLICATE_POOLED: + return None, () + + settings = variance_config(config) + names = list(objective_names) + pooled = pool_between_film_variance( + results.replicate_spread, results.films_used, aggregates=aggregates + ) + findings = sanity_floor_findings(pooled, settings["sanity_floor"]) + + prior = np.full((int(n_rows_without_replicates), len(names)), 0.0) + replicated = results.films_used[names].to_numpy(dtype=float) + films = np.vstack([prior, replicated]) + yvar = train_yvar_for_rows( + pooled, + films, + names, + rows_without_replicates=settings["rows_without_replicates"], + ) + return yvar, findings + + +def variance_config(config: Mapping[str, Any]) -> dict[str, Any]: + """The ``model.replicate_variance`` block, with its defaults filled in.""" + block = (config.get("model") or {}).get("replicate_variance") or {} + if not isinstance(block, Mapping): + raise ValueError("model.replicate_variance must be a mapping.") + floors = block.get("sanity_floor") or {} + if not isinstance(floors, Mapping): + raise ValueError("model.replicate_variance.sanity_floor must be a mapping.") + return { + "rows_without_replicates": int(block.get("rows_without_replicates", 1)), + "sanity_floor": {str(k): float(v) for k, v in floors.items()}, + } diff --git a/src/mobo_kit/research_qnehvi.py b/src/mobo_kit/research_qnehvi.py new file mode 100644 index 0000000..8051212 --- /dev/null +++ b/src/mobo_kit/research_qnehvi.py @@ -0,0 +1,286 @@ +"""qNEHVI as a research-only R2 variant, for comparison against qLogNEHVI. + +**This is not part of the campaign.** ``campaign.py`` proposes R2 with qLogNEHVI +and nothing here changes that; the frozen acquisition modules +(``qlognehvi_batch.py``, ``ucb_hvi.py``, ``batch_selection.py``) are untouched. +This module exists so the two acquisitions can be compared on identical inputs, +which is a question about the tooling rather than about the chemistry. + +**BoTorch itself recommends against qNEHVI.** Constructing one emits a +``NumericsWarning`` saying it "has known numerical issues that lead to suboptimal +optimization performance" and to use qLogNEHVI instead (arXiv:2310.20708). That +warning is deliberately not silenced here -- if this module is used, the caller +should see it. + +Measured on this campaign at the default cell (radius 0.25, beta 4.0, seed 73), +the two acquisitions propose the **identical batch**. That is the useful finding, +and it is what makes the comparison worth having run once rather than repeatedly. + +Derived from the structure of ``qlognehvi_batch.propose_qlognehvi_penalized_batch`` +and from the ``qnehvi_batch`` module on Annie Xu's ``ax_plots_simulation`` branch, +which is where the idea of carrying a second acquisition came from. Two things are +reviewed against current conventions rather than copied: the observed baseline +reaches the objective transform in MODEL space, and the hypervolume reference is +explicit rather than inferred. + +THE ONE REAL DIFFERENCE, and why it is handled the way it is. qLogNEHVI returns +the *logarithm* of the expected improvement; qNEHVI returns the improvement +itself. ``select_local_penalized_batch`` applies its soft penalty in log space, so +a raw qNEHVI value must be logged before it enters the selector or the two +variants would be penalised on different scales and would not be comparable. That +conversion is exactly the numerically fragile step qLogNEHVI exists to avoid: for +a small Monte-Carlo estimate, ``log(mean(...))`` loses precision where qLogNEHVI +computes the log directly. +""" + +from __future__ import annotations + +from typing import Any, Callable, Sequence + +import numpy as np +import pandas as pd +import torch +from botorch.acquisition.multi_objective import ( + qNoisyExpectedHypervolumeImprovement, +) +from botorch.sampling.normal import SobolQMCNormalSampler + +from .batch_selection import LocalPenalizationConfig +from .campaign import ( + RoundResult, + build_objective_transform, + expand_replicates, + validate_batch, +) +from .candidate_pool import sample_discrete_candidate_pool +from .constraints import constraints_from_config +from .design import build_design_from_config + +__all__ = [ + "propose_qnehvi_penalized_batch", + "run_r2_qnehvi_research", + "R2_ACQUISITIONS", +] + +#: Selectable R2 acquisitions. ``qlognehvi`` is the campaign's own. +R2_ACQUISITIONS = ("qlognehvi", "qnehvi") + +#: Below this the log of a Monte-Carlo improvement estimate is meaningless. +_LOG_FLOOR = 1e-12 + + +def _score_qnehvi_singletons( + model: Any, + train_X: torch.Tensor, + pool_norm: np.ndarray, + objective: Any, + reference: torch.Tensor, + *, + mc_samples: int, + seed: int, + chunk_size: int, + pending: torch.Tensor | None, + constraints: Sequence[Callable[[torch.Tensor], torch.Tensor]] | None, + eta: float | torch.Tensor, + prune_baseline: bool, +) -> np.ndarray: + """Evaluate qNEHVI on each pool row as its own ``1 x D`` batch.""" + sampler = SobolQMCNormalSampler( + sample_shape=torch.Size([int(mc_samples)]), seed=int(seed) + ) + acquisition = qNoisyExpectedHypervolumeImprovement( + model=model, + ref_point=reference, + X_baseline=train_X, + sampler=sampler, + objective=objective, + constraints=None if constraints is None else list(constraints), + eta=eta, + X_pending=pending, + prune_baseline=bool(prune_baseline), + ) + values: list[torch.Tensor] = [] + with torch.no_grad(): + for start in range(0, pool_norm.shape[0], chunk_size): + batch = torch.as_tensor( + pool_norm[start : start + chunk_size], + dtype=train_X.dtype, + device=train_X.device, + ).unsqueeze(-2) + chunk_values = acquisition(batch) + if chunk_values.shape != (batch.shape[0],): + raise RuntimeError( + "qNEHVI singleton evaluation returned unexpected shape " + f"{tuple(chunk_values.shape)} for input {tuple(batch.shape)}." + ) + values.append(chunk_values.detach().cpu().double()) + scores = torch.cat(values).numpy() + if np.any(np.isnan(scores)) or np.any(np.isposinf(scores)): + raise RuntimeError("qNEHVI returned NaN or positive-infinite scores.") + return scores + + +def propose_qnehvi_penalized_batch( + candidate_pool: Any, + model: Any, + train_X_norm: torch.Tensor, + objective: Any, + reference_point_utility: np.ndarray | torch.Tensor, + *, + q: int, + local_penalization_config: LocalPenalizationConfig, + mc_samples: int = 128, + seed: int = 0, + chunk_size: int = 512, + constraints: Sequence[Callable[[torch.Tensor], torch.Tensor]] | None = None, + eta: float | torch.Tensor = 0.001, + prune_baseline: bool = False, +) -> Any: + """Select ``q`` locally penalised pool candidates by qNEHVI. + + Mirrors ``propose_qlognehvi_penalized_batch`` step for step -- same pool, same + selector, same pending-state rebuild after each pick -- so any difference in + the batch is attributable to the acquisition and nothing else. + """ + from .batch_selection import BaseScoreResult, select_local_penalized_batch + + train_X = torch.as_tensor(train_X_norm, dtype=torch.double) + if train_X.ndim != 2: + raise ValueError("train_X_norm must be a 2-D (N, D) tensor.") + pool_norm = np.asarray(candidate_pool.X_norm, dtype=float) + if pool_norm.ndim != 2 or pool_norm.shape[1] != train_X.shape[1]: + raise ValueError( + "candidate_pool.X_norm and train_X_norm must share input dimension." + ) + reference = torch.as_tensor( + np.asarray(reference_point_utility, dtype=float), dtype=torch.double + ) + + def score_remaining( + remaining_indices: np.ndarray, selected_indices: np.ndarray + ) -> Any: + pending = ( + torch.as_tensor(pool_norm[selected_indices], dtype=torch.double) + if selected_indices.size + else None + ) + raw = _score_qnehvi_singletons( + model, + train_X, + pool_norm[remaining_indices], + objective, + reference, + mc_samples=mc_samples, + seed=seed, + chunk_size=chunk_size, + pending=pending, + constraints=constraints, + eta=eta, + prune_baseline=prune_baseline, + ) + # qNEHVI returns the improvement; the selector penalises in log space. + base_score = np.clip(raw, 0.0, None) + with np.errstate(divide="ignore"): + base_log_score = np.where( + base_score > _LOG_FLOOR, np.log(np.maximum(base_score, _LOG_FLOOR)), + -np.inf, + ) + return BaseScoreResult( + base_log_score=base_log_score, + base_score=base_score, + diagnostics={"remaining_pool_indices": remaining_indices.copy()}, + ) + + return select_local_penalized_batch( + candidate_pool, + q, + score_remaining, + local_penalization_config, + observed_pending_norm=train_X.detach().cpu().double().numpy(), + ) + + +def run_r2_qnehvi_research( + config: Any, + observed_X_phys: np.ndarray, + observed_Y_raw: np.ndarray, + *, + n: int | None = None, + seed: int | None = None, +) -> RoundResult: + """An R2 round proposed by qNEHVI, for comparison only. + + Same contract as ``campaign.run_r2_qlognehvi``: ``observed_Y_raw`` holds the + MODEL SOURCE values in objective order, so thickness arrives in nanometres. + """ + from .campaign import _fit_models, _normalise, _penalization, _reference_point + from .campaign import _on_grid_mask, _round_settings + from .objectives import ConfiguredMCMultiOutputObjective + + design = build_design_from_config(dict(config)) + settings = _round_settings(config, "r2") + q = int(settings["batch_size"]) if n is None else int(n) + resolved_seed = ( + int(config.get("reproducibility", {}).get("seed", 0)) if seed is None else seed + ) + transform = build_objective_transform(config) + reference = _reference_point(config, transform.objective_count) + penalization = _penalization(config) + + observed_norm = _normalise(design, observed_X_phys) + model, fit_warnings, raw_fit_warnings = _fit_models( + config, + np.asarray(observed_X_phys, dtype=float), + observed_norm, + observed_Y_raw, + resolved_seed, + ) + + on_grid = _on_grid_mask(design, observed_X_phys) + pool = sample_discrete_candidate_pool( + design, + int(settings.get("candidate_pool_size", 32768)), + seed=resolved_seed, + observed_phys=np.asarray(observed_X_phys, dtype=float)[on_grid], + row_constraints=constraints_from_config(dict(config), design) or None, + ) + + selection = propose_qnehvi_penalized_batch( + pool, + model, + torch.tensor(observed_norm, dtype=torch.double), + ConfiguredMCMultiOutputObjective(transform), + reference, + q=q, + local_penalization_config=penalization, + mc_samples=int(settings.get("mc_samples", 128)), + seed=resolved_seed, + ) + + conditions = pd.DataFrame( + np.asarray(selection.X_phys, dtype=float), columns=list(design.names) + ) + report = validate_batch( + conditions, + design, + expected_count=q, + min_pairwise_distance=penalization.min_batch_distance, + ) + replicates_per = int(settings.get("replicates_per_condition", 1)) + return RoundResult( + round_name="R2", + conditions=conditions, + replicates=expand_replicates( + conditions, replicates=replicates_per, round_name="R2" + ), + diagnostics={ + "method": "qnehvi", + "research_only": True, + "seed": resolved_seed, + "pool_size": pool.size, + "objective_contract": transform.version, + "model_fit_warnings": list(fit_warnings), + "fit_warnings_raw": list(raw_fit_warnings), + "validity": report, + }, + ) diff --git a/src/mobo_kit/round_report.py b/src/mobo_kit/round_report.py new file mode 100644 index 0000000..d20a93f --- /dev/null +++ b/src/mobo_kit/round_report.py @@ -0,0 +1,1416 @@ +"""Figures an experimentalist sees when a round is proposed. + +One orchestrator, :func:`generate_round_report`, pure and headless. The launcher +calls it after a successful propose; ``scripts/generate_round_report.py`` calls it +from a terminal; neither owns any of the logic. + +**Every figure writes the numbers behind it.** A PNG whose data cannot be +re-derived is the next plausible-finite-number bug waiting to happen -- this +project has had three, and all three were quantities nothing recomputed. So each +figure emits at least one CSV, ``manifest.json`` records what was produced, and +determinism is checked against the CSVs rather than against PNG bytes. + +**Nothing here decides anything, and several figures exist to say so.** Two of +the three objectives on the current campaign carry no learnable signal; their +panels look exactly as convincing as thickness's and mean nothing. Each such panel +is labelled on its face rather than in a caption somewhere else, because a figure +travels without its documentation. + +**Output goes beside the workbook, never into it.** ``openpyxl`` discards cached +formula values on save, so the source workbook is opened read-only for the life of +this module. + +Three notebook conventions are deliberately NOT ported; see +``docs/CAMPAIGN_STATUS.md``: + +* in-sample parity -- a model is being asked about points it was fitted on, which + measures memorisation. Parity here is leave-one-out. +* ad-hoc sign flips at plot time -- objective polarity is a config contract + (``goal:``), and flipping it in a figure makes the figure disagree with the + optimiser. +* auto-referenced hypervolume -- the reference point is required and campaign-fixed, + because a reference re-derived per call makes rounds incomparable. +""" + +from __future__ import annotations + +import json +import platform +import subprocess +import time +import traceback +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 +import pandas as pd # noqa: E402 +import torch # noqa: E402 + +from .campaign import ( # noqa: E402 + build_design_from_config, + build_objective_transform, + fit_campaign_models, + normalise_inputs, + objective_names, +) +from .candidate_diagnostics import ( # noqa: E402 + nearest_reference_distances, + pairwise_normalized_distances, +) +from .loocv import loo_predictions, null_loo_r2 # noqa: E402 +from .metrics import compute_ref_pareto_hv # noqa: E402 +from .scores import ScoreSeverity # noqa: E402 +from .workbook_io import ( # noqa: E402 + candidate_workbook_path, + read_campaign_workbook, + read_candidate_results, +) + +__all__ = [ + "FigureRecord", + "ReportManifest", + "generate_round_report", + "report_directory", +] + +#: The palette every figure in this project shares, so they read as one set. +R0_COLOR, R1_COLOR, R2_COLOR = "#2a78d6", "#eb6834", "#1baf7a" +PROPOSED_COLOR = "#7b3fbf" +REFERENCE_COLOR = "#c0392b" +GRID_COLOR = "#e6e5e1" +SPINE = "#d8d7d2" +OBSERVED_GREY = "#9a9894" + +ROUND_COLORS = {"R0": R0_COLOR, "R1": R1_COLOR, "R2": R2_COLOR} + +#: Posterior draws for the batch hypervolume diagnostic. Fixed, and recorded in +#: the manifest: a distribution whose sample count moves between runs is not a +#: distribution anyone can compare. +HV_POSTERIOR_SAMPLES = 512 + + +# --------------------------------------------------------------------------- # +# records +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class FigureRecord: + """One rendered figure and the data files that reproduce it.""" + + key: str + title: str + caption: str + png: str + data: tuple[str, ...] = () + caveats: tuple[str, ...] = () + + def as_dict(self) -> dict[str, Any]: + return { + "key": self.key, + "title": self.title, + "caption": self.caption, + "png": self.png, + "data": list(self.data), + "caveats": list(self.caveats), + } + + +@dataclass +class ReportManifest: + """What one report run produced, and what a reader must know about it.""" + + directory: Path + round_name: str + mode: str + """``proposal`` when a batch was supplied, ``data_only`` otherwise.""" + figures: tuple[FigureRecord, ...] = () + skipped: tuple[tuple[str, str], ...] = () + notices: tuple[str, ...] = () + context: dict[str, Any] = field(default_factory=dict) + runtime_seconds: float = 0.0 + + def as_dict(self) -> dict[str, Any]: + return { + "round": self.round_name, + "mode": self.mode, + "generated_utc": self.context.get("generated_utc"), + "runtime_seconds": round(self.runtime_seconds, 2), + "context": self.context, + "figures": [figure.as_dict() for figure in self.figures], + "skipped": [{"key": key, "why": why} for key, why in self.skipped], + "notices": list(self.notices), + } + + def summary(self) -> str: + """One line per figure, for the launcher pane.""" + lines = [f"Round report ({self.mode}) -> {self.directory}"] + for figure in self.figures: + lines.append(f" {figure.png:<34} {figure.title}") + for key, why in self.skipped: + lines.append(f" {key:<34} SKIPPED: {why}") + if self.notices: + lines.append("") + lines.append(" Read with the figures:") + for notice in self.notices: + lines.append(f" - {notice}") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- # +# plumbing +# --------------------------------------------------------------------------- # + + +def report_directory(workbook: str | Path, round_name: str, *, when: str) -> Path: + """``_reports/_/``, beside the workbook.""" + source = Path(workbook) + return source.with_name(f"{source.stem}_reports") / f"{round_name}_{when}" + + +def _git_describe() -> str: + try: + out = subprocess.run( + ["git", "describe", "--always", "--dirty"], + capture_output=True, + text=True, + timeout=5, + cwd=Path(__file__).resolve().parent, + ) + return out.stdout.strip() or "unknown" + except Exception: # pragma: no cover - git absent or not a checkout + return "unknown" + + +def _style(ax: plt.Axes) -> None: + ax.set_facecolor("white") + ax.grid(True, color=GRID_COLOR, linewidth=0.8, zorder=0) + ax.set_axisbelow(True) + for side in ("top", "right"): + ax.spines[side].set_visible(False) + for side in ("left", "bottom"): + ax.spines[side].set_color(SPINE) + + +def _wrapped_caveats(fig: plt.Figure, caveats: Sequence[str]) -> list[str]: + """Hard-wrap to the figure width; matplotlib's own ``wrap=True`` is unreliable. + + The footer is 7.2 pt monospace, so a character is about 0.06 in and an inch + holds ~16.5 of them. The estimate used to be 17 per inch with no right margin, + which overflowed the canvas on a three-panel figure: the last words of a long + caveat rendered past the edge and were cropped by ``savefig``. Nothing warns + when that happens -- the text is simply not in the PNG. + """ + import textwrap + + columns = max(60, int((fig.get_size_inches()[0] - 0.25) * 16)) + lines: list[str] = [] + for caveat in caveats: + wrapped = textwrap.wrap(caveat, width=columns) or [""] + lines.append(f"* {wrapped[0]}") + lines.extend(f" {piece}" for piece in wrapped[1:]) + return lines + + +def _save( + fig: plt.Figure, path: Path, caveats: Sequence[str], *, tight: bool = True +) -> None: + """Reserve the footer's space BEFORE laying out, so nothing lands on an axis label. + + The caveats are part of the figure rather than a caption in a document, because + a PNG gets pasted into a slide and the caption does not travel with it. That + only helps if they are legible, hence the explicit reservation rather than + trusting a default margin. + + ``tight=False`` for any figure holding a 3-D axes: ``tight_layout`` does not + support them and warns that its result may be wrong, which on this project's + rules means not using it rather than ignoring the warning. + """ + lines = _wrapped_caveats(fig, caveats) + reserved = float(min(0.42, (len(lines) * 0.155 + 0.30) / fig.get_size_inches()[1])) + if tight: + fig.tight_layout(rect=(0.0, reserved, 1.0, 0.99)) + else: + fig.subplots_adjust(bottom=reserved + 0.09, top=0.9, left=0.055, right=0.985) + if lines: + fig.text( + 0.008, + 0.008, + "\n".join(lines), + ha="left", + va="bottom", + fontsize=7.2, + color="#5a5854", + family="monospace", + linespacing=1.35, + ) + fig.savefig(path, dpi=150, facecolor="white") + plt.close(fig) + + +def _write_csv(frame: pd.DataFrame, path: Path) -> str: + frame.to_csv(path, index=False) + return path.name + + +# --------------------------------------------------------------------------- # +# data gathering +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class _RoundBlock: + """One round's observations, in the order they entered the campaign.""" + + name: str + X_phys: np.ndarray + Y_measured: np.ndarray + labels: tuple[str, ...] + + +def _observations_by_round( + workbook: Path, config: Mapping[str, Any] +) -> list[_RoundBlock]: + """R0 from Sheet1, then whichever candidate sheets are filled in. + + A round whose sheet exists but is not fully measured is skipped rather than + partially included: a hypervolume computed on half a round is not that round's + hypervolume, and it would silently make the trajectory wrong rather than short. + """ + contents = read_campaign_workbook(workbook, config) + blocks = [ + _RoundBlock( + "R0", + contents.inputs.to_numpy(float), + contents.model_values.to_numpy(float), + tuple(str(value) for value in contents.sample_ids), + ) + ] + for round_name in ("R1", "R2"): + path = candidate_workbook_path(workbook, round_name) + if not path.exists(): + break + try: + results = read_candidate_results(workbook, config, round_name) + except Exception: + break + values = results.model_values.to_numpy(float) + if values.size == 0 or not np.all(np.isfinite(values)): + break + blocks.append( + _RoundBlock( + round_name, + results.conditions.to_numpy(float), + values, + tuple(results.candidate_ids), + ) + ) + return blocks + + +def _utility(transform: Any, Y_measured: np.ndarray) -> np.ndarray: + """Measurement space -> utility, by the one call that cannot forget the link.""" + block = torch.tensor(np.asarray(Y_measured, dtype=float), dtype=torch.double) + return transform.transform_measurements(block).detach().cpu().numpy() + + +def _pareto_mask(utility: np.ndarray) -> np.ndarray: + """Non-dominated rows, maximisation. Small N, so the O(n^2) form is fine.""" + n = len(utility) + mask = np.ones(n, dtype=bool) + for i in range(n): + if not mask[i]: + continue + dominated = np.all(utility >= utility[i], axis=1) & np.any( + utility > utility[i], axis=1 + ) + if dominated.any(): + mask[i] = False + return mask + + +# --------------------------------------------------------------------------- # +# figures +# --------------------------------------------------------------------------- # + + +def _figure_batch_placement( + directory: Path, + config: Mapping[str, Any], + observed_X: np.ndarray, + proposed_X: np.ndarray, + round_name: str, +) -> FigureRecord: + """Where in recipe space the algorithm is asking to go. + + The figure a coater operator actually reads. Parallel coordinates because ten + inputs will not fit on two axes and a projection would invent structure; the + distance panel because "is this batch spread out" is the question local + penalisation exists to answer and it is not visible in the lines. + """ + design = build_design_from_config(dict(config)) + names = list(design.names) + observed_norm = normalise_inputs(config, observed_X) + proposed_norm = normalise_inputs(config, proposed_X) + + fig = plt.figure(figsize=(13.5, 5.4)) + grid = fig.add_gridspec(1, 2, width_ratios=[1.85, 1.0], wspace=0.28) + + ax = fig.add_subplot(grid[0, 0]) + _style(ax) + xs = np.arange(len(names)) + for row in observed_norm: + ax.plot(xs, row, color=OBSERVED_GREY, linewidth=1.0, alpha=0.55, zorder=2) + colour = ROUND_COLORS.get(round_name, PROPOSED_COLOR) + for index, row in enumerate(proposed_norm, start=1): + ax.plot( + xs, + row, + color=colour, + linewidth=2.2, + marker="o", + markersize=4.5, + zorder=3, + label=f"{round_name}_C{index:02d}", + ) + ax.set_xticks(xs) + ax.set_xticklabels(names, rotation=35, ha="right", fontsize=8) + ax.set_ylim(-0.05, 1.05) + ax.set_ylabel("normalised to the declared grid") + ax.set_title( + f"{round_name} proposal against {len(observed_norm)} measured recipes", + fontsize=11, + ) + ax.legend(fontsize=7.5, ncol=2, framealpha=0.9) + + ax2 = fig.add_subplot(grid[0, 1]) + within = pairwise_normalized_distances(proposed_norm) + image = ax2.imshow(within, cmap="magma_r", vmin=0.0) + ax2.set_xticks(range(len(proposed_norm))) + ax2.set_yticks(range(len(proposed_norm))) + labels = [f"C{i:02d}" for i in range(1, len(proposed_norm) + 1)] + ax2.set_xticklabels(labels, fontsize=8) + ax2.set_yticklabels(labels, fontsize=8) + for i in range(len(proposed_norm)): + for j in range(len(proposed_norm)): + ax2.text( + j, + i, + f"{within[i, j]:.2f}", + ha="center", + va="center", + fontsize=7.5, + color="white" if within[i, j] > within.max() * 0.55 else "#333333", + ) + ax2.set_title("pairwise distance within the batch", fontsize=11) + fig.colorbar(image, ax=ax2, fraction=0.046, pad=0.04) + + nearest = nearest_reference_distances(proposed_norm, observed_norm) + frame = pd.DataFrame(proposed_norm, columns=[f"{name}_norm" for name in names]) + frame.insert(0, "candidate", labels) + frame["distance_to_nearest_observed"] = nearest + frame["min_distance_within_batch"] = [ + np.min(np.delete(within[i], i)) if len(within) > 1 else np.nan + for i in range(len(within)) + ] + data = _write_csv(frame, directory / "00_batch_placement.csv") + + penalization = (config.get("local_penalization") or {}) + caveats = [ + "Normalised against the DECLARED GRID, not the observed range: 0 and 1 are " + "the range edges the config allows, so a line touching them is at a bound.", + f"Local penalisation radius {penalization.get('radius')}, minimum batch " + f"spacing {penalization.get('min_batch_distance')}; achieved minimum " + f"{np.min(within[within > 0]) if (within > 0).any() else float('nan'):.3f}.", + ] + _save(fig, directory / "00_batch_placement.png", caveats, tight=False) + return FigureRecord( + key="00_batch_placement", + title="Where the proposed batch sits in recipe space", + caption=( + "Each line is one recipe across the ten inputs, normalised to the " + "declared grid. Grey lines are what has been measured; coloured lines " + "are what is proposed. The heatmap is the batch's internal spacing." + ), + png="00_batch_placement.png", + data=(data,), + caveats=tuple(caveats), + ) + + +def _figure_loo_parity( + directory: Path, + config: Mapping[str, Any], + X_phys: np.ndarray, + Y_measured: np.ndarray, + names: Sequence[str], + labels: Sequence[str], + seed: int, +) -> FigureRecord: + """Predicted against measured, leave-one-out, in the measurement's own units. + + **Leave-one-out and not in-sample.** An in-sample parity plot asks the model + about points it was fitted on and therefore measures memorisation; at N=15 in + 10 dimensions it is close to a straight line no matter what the model knows. + That is one of the three notebook conventions deliberately not carried over. + + The numbers come from :mod:`mobo_kit.loocv`, which is the same fold loop + ``scripts/intake_new_data.py`` uses -- not a reimplementation that agrees today. + """ + entries = config["objectives"]["specs"] + results = { + name: loo_predictions(config, entries[index], X_phys, Y_measured[:, index], seed=seed) + for index, name in enumerate(names) + } + null = null_loo_r2(len(Y_measured)) + + fig, axes = plt.subplots(1, len(names), figsize=(5.4 * len(names), 5.6)) + axes = np.atleast_1d(axes) + rows: list[dict[str, Any]] = [] + for ax, name in zip(axes, names): + result = results[name] + _style(ax) + entry = entries[names.index(name)] + learnable = str(entry.get("signal_status", "")) == "learnable" + colour = R0_COLOR if learnable else OBSERVED_GREY + ax.errorbar( + result.observed, + result.predicted, + yerr=result.predictive_sd, + fmt="o", + markersize=6, + color=colour, + ecolor=colour, + elinewidth=1.0, + capsize=2.5, + alpha=0.9, + zorder=3, + ) + lo = float(min(result.observed.min(), result.predicted.min())) + hi = float(max(result.observed.max(), result.predicted.max())) + pad = 0.07 * (hi - lo if hi > lo else 1.0) + line = np.array([lo - pad, hi + pad]) + ax.plot(line, line, color="#666666", linewidth=1.0, linestyle="--", zorder=2) + ax.set_xlim(*line) + ax.set_ylim(*line) + ax.set_xlabel(f"measured {name}") + ax.set_ylabel("leave-one-out prediction") + ax.set_title( + f"{name}\nLOO R2 {result.r2:+.4f} null {null:+.4f}", + fontsize=11, + color="#222222" if learnable else "#8a3b2f", + ) + if not learnable: + # inside the axes, not in the title: this is the single most important + # thing about the panel and it must not be croppable + ax.text( + 0.5, + 0.955, + "NO LEARNABLE SIGNAL", + transform=ax.transAxes, + ha="center", + va="top", + fontsize=10, + color="#8a3b2f", + bbox=dict(boxstyle="round,pad=0.35", fc="#fdeeea", ec="#e0b4a8"), + ) + for position, label in enumerate(labels): + ax.annotate( + label, + (result.observed[position], result.predicted[position]), + fontsize=6.5, + color="#555555", + xytext=(3, 3), + textcoords="offset points", + ) + for position, label in enumerate(labels): + rows.append( + { + "objective": name, + "sample": label, + "observed": result.observed[position], + "loo_predicted": result.predicted[position], + "loo_predictive_sd": result.predictive_sd[position], + "model_link": result.model_link, + "loo_r2": result.r2, + "loo_spearman": result.spearman, + "null_loo_r2": null, + "has_mean_function": result.has_mean_function, + } + ) + + data = _write_csv(pd.DataFrame(rows), directory / "01_loo_parity.csv") + caveats = [ + "Leave-one-out, not in-sample: every point is predicted by a model that " + "never saw it. An in-sample version of this plot looks far better and " + "measures memorisation.", + f"The bar to clear is the NULL, {null:+.4f}, not zero. Predicting the " + "leave-one-out mean scores exactly that, whatever the data.", + "An axis marked NO LEARNABLE SIGNAL has a model that does not beat the " + "null. Its scatter is not a weak trend; it is nothing.", + ] + if any(results[name].model_link == "log" for name in names): + caveats.append( + "Where the model emits log(y), the point shown is the median exp(mu) " + "and the bar is the lognormal sd, which is asymmetric in the original " + "units." + ) + _save(fig, directory / "01_loo_parity.png", caveats) + return FigureRecord( + key="01_loo_parity", + title="How well the model predicts a film it has not seen", + caption=( + "Leave-one-out prediction against measurement, one panel per objective, " + "in the measurement's own units. Points on the dashed line are perfect." + ), + png="01_loo_parity.png", + data=(data,), + caveats=tuple(caveats), + ) + + +def _figure_attribution( + directory: Path, + config: Mapping[str, Any], + model: Any, + transform: Any, + X_phys: np.ndarray, + names: Sequence[str], + seed: int, + max_instances: int, +) -> FigureRecord: + """Mean |SHAP| per input per objective, from the campaign's own fitted model.""" + from .attribution import mean_absolute_shap, shap_values_for + + design = build_design_from_config(dict(config)) + feature_names = list(design.names) + instances = X_phys[: max(1, min(max_instances, len(X_phys)))] + + entries = config["objectives"]["specs"] + rows: list[dict[str, Any]] = [] + magnitudes: dict[str, np.ndarray] = {} + for index, name in enumerate(names): + values = shap_values_for( + model, config, transform, index, X_phys, instances, seed=seed + ) + magnitude = mean_absolute_shap(values) + magnitudes[name] = magnitude + declared = { + str(feature["column"]) + for feature in (entries[index].get("mean_function") or {}).get( + "features", [] + ) + } + order = np.argsort(magnitude)[::-1] + for rank, position in enumerate(order, start=1): + rows.append( + { + "objective": name, + "feature": feature_names[position], + "mean_abs_shap": float(magnitude[position]), + "mean_shap": float(values[:, position].mean()), + "rank": rank, + "in_mean_function": feature_names[position] in declared, + "signal_status": str(entries[index].get("signal_status", "")), + } + ) + + fig, axes = plt.subplots(1, len(names), figsize=(5.4 * len(names), 5.6)) + axes = np.atleast_1d(axes) + for ax, name in zip(axes, names): + _style(ax) + index = names.index(name) + magnitude = magnitudes[name] + order = np.argsort(magnitude) + declared = { + str(feature["column"]) + for feature in (entries[index].get("mean_function") or {}).get( + "features", [] + ) + } + learnable = str(entries[index].get("signal_status", "")) == "learnable" + colours = [ + R1_COLOR if feature_names[position] in declared else ( + R0_COLOR if learnable else OBSERVED_GREY + ) + for position in order + ] + ax.barh( + range(len(order)), + magnitude[order], + color=colours, + edgecolor="white", + zorder=3, + ) + ax.set_yticks(range(len(order))) + ax.set_yticklabels([feature_names[position] for position in order], fontsize=8) + ax.set_xlabel("mean |SHAP| in utility units") + verdict = "" if learnable else " [fitted noise]" + ax.set_title(f"{name}{verdict}", fontsize=10.5, + color="#222222" if learnable else "#8a3b2f") + + data = _write_csv(pd.DataFrame(rows), directory / "02_attribution.csv") + caveats = [ + "Attributions explain the MODEL, not the world. Orange bars are features " + "the config TOLD the model about through a mean function, so recovering " + "them is a consistency check rather than a discovery.", + "On an axis with no learnable signal the bars are structure fitted to " + "noise. They have real magnitude and orderly ranking and mean nothing.", + "Explains E[utility] through the campaign transform, so thickness is " + "attributed on its 650 nm target and not on nanometres.", + f"Exact Shapley values: all 2^{len(feature_names)} coalitions are " + f"enumerated over {len(instances)} instances, so these do not depend on " + "the seed.", + ] + _save(fig, directory / "02_attribution.png", caveats) + return FigureRecord( + key="02_attribution", + title="Which process inputs move each objective, in the model", + caption=( + "Mean absolute SHAP value per input, per objective, computed on the " + "campaign's own fitted model in utility units." + ), + png="02_attribution.png", + data=(data,), + caveats=tuple(caveats), + ) + + +def _figure_batch_predictions( + directory: Path, + config: Mapping[str, Any], + review: Any, + names: Sequence[str], + hv_frame: pd.DataFrame, + round_name: str, +) -> FigureRecord: + """What the model expects from each proposed condition, physical and utility. + + **The numbers are read from the batch-review artifact, not recomputed.** The + Review sheet and this figure must not be able to disagree; one of them is the + source and it is the one already attached to the worklist. + """ + candidates = review.candidates + labels = [f"C{i:02d}" for i in range(1, len(candidates) + 1)] + colour = ROUND_COLORS.get(round_name, PROPOSED_COLOR) + + fig, axes = plt.subplots(2, len(names), figsize=(4.7 * len(names), 8.2)) + axes = np.atleast_2d(axes) + rows: list[dict[str, Any]] = [] + positions = np.arange(len(candidates)) + for column, name in enumerate(names): + physical = candidates[f"{name}_predicted"].to_numpy(float) + lo = candidates[f"{name}_lo68"].to_numpy(float) + hi = candidates[f"{name}_hi68"].to_numpy(float) + utility = candidates[f"{name}_utility"].to_numpy(float) + utility_sd = candidates[f"{name}_sd"].to_numpy(float) + + ax = axes[0, column] + _style(ax) + ax.bar(positions, physical, color=colour, edgecolor="white", zorder=3) + ax.errorbar( + positions, + physical, + yerr=[physical - lo, hi - physical], + fmt="none", + ecolor="#333333", + elinewidth=1.1, + capsize=3.5, + zorder=4, + ) + ax.set_xticks(positions) + ax.set_xticklabels(labels, fontsize=8) + ax.set_title(f"{name} - predicted measurement", fontsize=10.5) + ax.set_ylabel("measurement units") + + ax = axes[1, column] + _style(ax) + ax.bar(positions, utility, color=colour, edgecolor="white", zorder=3) + ax.errorbar( + positions, + utility, + yerr=utility_sd, + fmt="none", + ecolor="#333333", + elinewidth=1.1, + capsize=3.5, + zorder=4, + ) + ax.set_xticks(positions) + ax.set_xticklabels(labels, fontsize=8) + ax.set_ylim(0.0, 1.05) + ax.set_title(f"{name} - utility (higher is better)", fontsize=10.5) + ax.set_ylabel("utility") + + for position, label in enumerate(labels): + rows.append( + { + "candidate": label, + "objective": name, + "predicted_measurement": physical[position], + "lo68": lo[position], + "hi68": hi[position], + "utility_mean": utility[position], + "utility_sd": utility_sd[position], + } + ) + + data = _write_csv(pd.DataFrame(rows), directory / "03_batch_predictions.csv") + hv_data = _write_csv(hv_frame, directory / "03_batch_hypervolume.csv") + overall = hv_frame[hv_frame["candidate"] == "BATCH"] + caveats = [ + "Predictions, not measurements. The bars are what the model expects before " + "anything is fabricated, and the whiskers are its own uncertainty.", + "The top row is in each measurement's units; the bottom row is utility, " + "which is what the optimiser maximises. Thickness utility peaks at the " + "650 nm target, so a thicker film is not a better one.", + "Numbers are read from the Review sheet's artifact, not recomputed here, " + "so the two cannot disagree.", + ] + if not overall.empty: + row = overall.iloc[0] + caveats.append( + f"Expected hypervolume gain {row['delta_hv_p50']:+.4f} " + f"(p05 {row['delta_hv_p05']:+.4f}, p95 {row['delta_hv_p95']:+.4f}), " + f"P(gain > 0) = {row['p_gain_positive']:.2f}, over " + f"{HV_POSTERIOR_SAMPLES} posterior draws." + ) + _save(fig, directory / "03_batch_predictions.png", caveats) + return FigureRecord( + key="03_batch_predictions", + title="What the model expects from each proposed condition", + caption=( + "Per condition and objective: predicted measurement with a 68% interval " + "above, utility with its posterior sd below. The companion CSV carries " + "the batch's hypervolume-gain distribution." + ), + png="03_batch_predictions.png", + data=(data, hv_data), + caveats=tuple(caveats), + ) + + +def _batch_hypervolume_diagnostic( + config: Mapping[str, Any], + model: Any, + transform: Any, + observed_utility: np.ndarray, + proposed_X: np.ndarray, + reference: np.ndarray, + seed: int, +) -> pd.DataFrame: + """How much this batch could add, as a distribution rather than a point. + + A single expected utility per candidate cannot answer "is this batch worth + fabricating": hypervolume gain is a joint, nonlinear function of all of them. + So draw from the posterior at the proposed points, transform each draw to + utility, and recompute the hypervolume of ``observed + batch`` per draw. + + ``P(non-dominated)`` per candidate is the share of draws in which that + condition is not dominated by anything already measured -- the question "is + this one pulling its weight" for a specific row. + """ + X_norm = normalise_inputs(config, np.asarray(proposed_X, dtype=float)) + torch.manual_seed(int(seed)) + model.eval() + with torch.no_grad(): + posterior = model.posterior(torch.tensor(X_norm, dtype=torch.double)) + draws = posterior.rsample( + torch.Size([HV_POSTERIOR_SAMPLES]) + ) # (S, q, m) in MODEL space + utility_draws = transform.transform(draws).detach().cpu().numpy() + + base_ref = np.asarray(reference, dtype=float) + _, _, base_hv = compute_ref_pareto_hv( + torch.tensor(observed_utility, dtype=torch.double), base_ref + ) + base_hv = float(base_hv) + + gains = np.empty(HV_POSTERIOR_SAMPLES) + non_dominated = np.zeros(utility_draws.shape[1]) + for s in range(HV_POSTERIOR_SAMPLES): + combined = np.vstack([observed_utility, utility_draws[s]]) + _, _, volume = compute_ref_pareto_hv( + torch.tensor(combined, dtype=torch.double), base_ref + ) + gains[s] = float(volume) - base_hv + for q in range(utility_draws.shape[1]): + point = utility_draws[s, q] + dominated = np.all(observed_utility >= point, axis=1) & np.any( + observed_utility > point, axis=1 + ) + if not dominated.any(): + non_dominated[q] += 1.0 + non_dominated /= HV_POSTERIOR_SAMPLES + + rows = [ + { + "candidate": f"C{q + 1:02d}", + "p_non_dominated": float(non_dominated[q]), + "delta_hv_p05": float("nan"), + "delta_hv_p50": float("nan"), + "delta_hv_p95": float("nan"), + "p_gain_positive": float("nan"), + "baseline_hv": base_hv, + "posterior_draws": HV_POSTERIOR_SAMPLES, + } + for q in range(utility_draws.shape[1]) + ] + rows.append( + { + "candidate": "BATCH", + "p_non_dominated": float("nan"), + "delta_hv_p05": float(np.percentile(gains, 5)), + "delta_hv_p50": float(np.percentile(gains, 50)), + "delta_hv_p95": float(np.percentile(gains, 95)), + "p_gain_positive": float(np.mean(gains > 0.0)), + "baseline_hv": base_hv, + "posterior_draws": HV_POSTERIOR_SAMPLES, + } + ) + return pd.DataFrame(rows) + + +def _figure_hv_trajectory( + directory: Path, + blocks: Sequence[_RoundBlock], + transform: Any, + reference: np.ndarray, +) -> FigureRecord: + """Cumulative observed hypervolume, one point per completed round.""" + rows: list[dict[str, Any]] = [] + cumulative_X: list[np.ndarray] = [] + previous = 0.0 + for block in blocks: + cumulative_X.append(block.Y_measured) + utility = _utility(transform, np.vstack(cumulative_X)) + _, pareto, volume = compute_ref_pareto_hv( + torch.tensor(utility, dtype=torch.double), np.asarray(reference, float) + ) + rows.append( + { + "round": block.name, + "cumulative_points": len(utility), + "points_added": len(block.Y_measured), + "hypervolume": float(volume), + "gain": float(volume) - previous, + "pareto_size": int(pareto.shape[0]), + } + ) + previous = float(volume) + + frame = pd.DataFrame(rows) + fig, ax = plt.subplots(figsize=(7.6, 5.0)) + _style(ax) + xs = np.arange(len(frame)) + ax.plot(xs, frame["hypervolume"], color="#444444", linewidth=1.4, zorder=2) + ax.scatter( + xs, + frame["hypervolume"], + s=110, + c=[ROUND_COLORS.get(name, PROPOSED_COLOR) for name in frame["round"]], + edgecolor="white", + linewidth=1.5, + zorder=4, + ) + for position, row in frame.iterrows(): + ax.annotate( + f"{row['hypervolume']:.4f}" + + ("" if position == 0 else f"\n(+{row['gain']:.4f})"), + (position, row["hypervolume"]), + fontsize=8.5, + ha="center", + va="bottom", + xytext=(0, 9), + textcoords="offset points", + ) + ax.set_xticks(xs) + ax.set_xticklabels( + [f"{row['round']}\nn={row['cumulative_points']}" for _, row in frame.iterrows()] + ) + ax.set_ylabel("cumulative hypervolume, utility space") + ax.set_title("Learning progress across measured rounds", fontsize=11.5) + if len(frame) == 1: + ax.set_xlim(-0.6, 0.6) + ax.text( + 0, + frame["hypervolume"].iloc[0], + " only R0 is measured, so there is\n no trajectory yet", + fontsize=9, + va="center", + ha="left", + color="#8a3b2f", + ) + + data = _write_csv(frame, directory / "04_hv_trajectory.csv") + caveats = [ + "Cumulative hypervolume rises monotonically BY CONSTRUCTION -- adding " + "points can only grow a Pareto front. Random sampling produces a rising " + "line too, so this shows progress and is not evidence of optimisation.", + "OBSERVED outcomes only. No predicted point appears on this line.", + # `list(np.round(...))` yields np.float64 objects whose repr leaks the + # type into the caption. A figure that prints "np.float64(-0.01)" at an + # experimentalist is telling them about numpy, not about the campaign. + "Fixed campaign reference point " + + str([round(float(value), 4) for value in np.asarray(reference, float)]) + + " in utility space. Re-deriving it per round would make these numbers " + "incomparable with each other.", + ] + _save(fig, directory / "04_hv_trajectory.png", caveats) + return FigureRecord( + key="04_hv_trajectory", + title="Hypervolume after each measured round", + caption=( + "Cumulative hypervolume of everything measured up to and including each " + "round, in utility space, against the campaign's fixed reference point." + ), + png="04_hv_trajectory.png", + data=(data,), + caveats=tuple(caveats), + ) + + +def _figure_objective_space( + directory: Path, + blocks: Sequence[_RoundBlock], + transform: Any, + reference: np.ndarray, + names: Sequence[str], + proposed_utility: np.ndarray | None, + proposed_sd: np.ndarray | None, +) -> FigureRecord: + """The trade-off itself: pairwise panels, plus one 3D view for orientation. + + Pairwise 2D is primary because a static 3D scatter cannot be read for + position -- depth is ambiguous without rotation, and "which point dominates + which" is exactly a position question. The 3D panel is kept for the shape of + the front, which the pairs do not convey. + """ + all_utility = np.vstack([_utility(transform, block.Y_measured) for block in blocks]) + round_of = [name for block in blocks for name in [block.name] * len(block.Y_measured)] + labels = [label for block in blocks for label in block.labels] + pareto = _pareto_mask(all_utility) + + rows = [ + { + "point": labels[i], + "round": round_of[i], + "kind": "observed", + "on_pareto": bool(pareto[i]), + **{f"utility_{name}": float(all_utility[i, j]) for j, name in enumerate(names)}, + } + for i in range(len(all_utility)) + ] + if proposed_utility is not None: + for i, point in enumerate(proposed_utility): + rows.append( + { + "point": f"C{i + 1:02d}", + "round": "proposed", + "kind": "proposed", + "on_pareto": False, + **{f"utility_{name}": float(point[j]) for j, name in enumerate(names)}, + } + ) + + pairs = [(0, 1), (0, 2), (1, 2)][: max(1, len(names) * (len(names) - 1) // 2)] + fig = plt.figure(figsize=(5.2 * len(pairs) + 6.0, 5.8)) + grid = fig.add_gridspec(1, len(pairs) + 1, wspace=0.34, width_ratios=[1] * len(pairs) + [1.25]) + + for position, (i, j) in enumerate(pairs): + ax = fig.add_subplot(grid[0, position]) + _style(ax) + for block_name in dict.fromkeys(round_of): + mask = np.array([name == block_name for name in round_of]) + ax.scatter( + all_utility[mask, i], + all_utility[mask, j], + s=52, + color=ROUND_COLORS.get(block_name, OBSERVED_GREY), + edgecolor="white", + linewidth=1.1, + label=block_name, + zorder=3, + ) + # The 2-D front for THIS PAIR, computed on this pair alone. Sorting the + # 3-D Pareto set by one axis and joining it produces a zigzag that is not + # a front in any space -- a point can be non-dominated in 3-D while sitting + # well inside the 2-D trade-off, and the line then crosses itself and + # invites exactly the wrong reading. + pair_mask = _pareto_mask(all_utility[:, [i, j]]) + pair_front = all_utility[pair_mask][np.argsort(all_utility[pair_mask][:, i])] + ax.step( + pair_front[:, i], + pair_front[:, j], + where="post", + color="#1baf7a", + linewidth=1.6, + alpha=0.85, + zorder=2, + label="front for this pair" if position == 0 else None, + ) + # Points on the FULL 3-objective front, ringed rather than joined: they are + # what the optimiser is trading off, and several of them are interior here. + ax.scatter( + all_utility[pareto, i], + all_utility[pareto, j], + s=150, + facecolor="none", + edgecolor="#1baf7a", + linewidth=1.6, + zorder=3, + label="on the 3-objective front" if position == 0 else None, + ) + if proposed_utility is not None: + ax.errorbar( + proposed_utility[:, i], + proposed_utility[:, j], + xerr=None if proposed_sd is None else proposed_sd[:, i], + yerr=None if proposed_sd is None else proposed_sd[:, j], + fmt="o", + markersize=9, + markerfacecolor="none", + markeredgecolor=PROPOSED_COLOR, + markeredgewidth=1.8, + ecolor=PROPOSED_COLOR, + elinewidth=1.0, + zorder=4, + label="proposed" if position == 0 else None, + ) + # The reference point is at (-0.01, -0.01) while every observation lives + # above 0.35, so plotting it in scale spends about 40% of the panel on + # empty space and squeezes the region anyone needs to read. It is marked + # at the corner instead, labelled with its real coordinates, because + # hypervolume is measured from it and hiding it entirely would be worse. + drawn = [all_utility[:, [i, j]]] + if proposed_utility is not None: + drawn.append(proposed_utility[:, [i, j]]) + visible = np.vstack(drawn) + lo = visible.min(axis=0) + hi = visible.max(axis=0) + pad = np.where(hi > lo, (hi - lo) * 0.09, 0.05) + ax.set_xlim(lo[0] - pad[0], hi[0] + pad[0]) + ax.set_ylim(lo[1] - pad[1], hi[1] + pad[1]) + # A star drawn at the corner sits at real data coordinates and reads as an + # observation. Text only, below the axes, where nothing can be mistaken + # for a measurement. + ax.annotate( + f"* reference ({reference[i]:g}, {reference[j]:g}) is off-scale, " + "down and to the left", + xy=(0.0, -0.155), + xycoords="axes fraction", + fontsize=7.4, + color=REFERENCE_COLOR, + ha="left", + va="top", + annotation_clip=False, + ) + ax.set_xlabel(f"{names[i]} utility") + ax.set_ylabel(f"{names[j]} utility") + if position == 0: + ax.legend(fontsize=7.0, loc="upper left", framealpha=0.92) + + ax3d = fig.add_subplot(grid[0, len(pairs)], projection="3d") + ax3d.scatter( + all_utility[~pareto, 0], + all_utility[~pareto, 1], + all_utility[~pareto, 2], + s=26, + color=OBSERVED_GREY, + alpha=0.75, + ) + ax3d.scatter( + all_utility[pareto, 0], + all_utility[pareto, 1], + all_utility[pareto, 2], + s=62, + color="#1baf7a", + edgecolor="white", + label="Pareto set", + ) + if proposed_utility is not None: + ax3d.scatter( + proposed_utility[:, 0], + proposed_utility[:, 1], + proposed_utility[:, 2], + s=62, + color=PROPOSED_COLOR, + marker="^", + label="proposed", + ) + ax3d.scatter( + [reference[0]], [reference[1]], [reference[2]], + marker="*", s=200, color=REFERENCE_COLOR, label="reference", + ) + ax3d.set_xlabel(f"{names[0]}", fontsize=8) + ax3d.set_ylabel(f"{names[1]}", fontsize=8) + ax3d.set_zlabel(f"{names[2]}", fontsize=8) + ax3d.view_init(elev=25, azim=-45) + ax3d.set_title("utility space, one fixed view", fontsize=10) + ax3d.legend(fontsize=7, loc="upper left") + + data = _write_csv(pd.DataFrame(rows), directory / "05_objective_space.csv") + caveats = [ + "Axes are cropped to the data. The reference point sits below every " + "observation, so drawing it in scale would spend most of the panel on " + "empty space; it is marked at the corner instead.", + "The green step line is the front FOR THAT PAIR. Rings mark points on the " + "full three-objective front -- several of those sit inside the pairwise " + "trade-off, which is what a three-way trade-off looks like in projection.", + "UTILITY space, not measurement units. Thickness utility peaks at the " + "650 nm target, so a point high on that axis is near the target rather " + "than thick.", + "The 3D panel is a single fixed view and cannot be read for position -- " + "depth is ambiguous without rotation. Read the pairwise panels for which " + "point dominates which.", + "A point on the Pareto set is non-dominated among what has been MEASURED. " + "It is not a claim about the whole design space.", + ] + _save(fig, directory / "05_objective_space.png", caveats, tight=False) + return FigureRecord( + key="05_objective_space", + title="The trade-off between objectives", + caption=( + "Every measured film in utility space: pairwise panels with the Pareto " + "set traced, plus one 3D view. Open markers are the proposed batch; the " + "red star is the campaign's reference point." + ), + png="05_objective_space.png", + data=(data,), + caveats=tuple(caveats), + ) + + +# --------------------------------------------------------------------------- # +# orchestrator +# --------------------------------------------------------------------------- # + + +def generate_round_report( + workbook: str | Path, + config: Mapping[str, Any], + *, + proposal: Any = None, + review: Any = None, + outdir: str | Path | None = None, + seed: int | None = None, + shap_max_instances: int = 15, + progress: Callable[[str], None] | None = None, + when: str | None = None, +) -> ReportManifest: + """Render the round's figures beside the workbook and return the manifest. + + ``proposal`` is a :class:`campaign.RoundResult`; supplying it turns on the two + batch figures. Without one this runs in ``data_only`` mode, which is what the + "Figures from current data" button uses once measurements are entered and + before anything is proposed. + + A figure that fails is recorded in ``skipped`` and in ``notices`` and the rest + of the report still renders. That is deliberate: losing the attribution panel + should not cost the parity plot, and a silent absence is prevented by the + manifest naming what went wrong. + """ + + def say(message: str) -> None: + if progress is not None: + progress(message) + + started = time.perf_counter() + workbook = Path(workbook) + names = list(objective_names(config)) + transform = build_objective_transform(config) + reference = np.asarray(config["reference_point_utility"], dtype=float) + resolved_seed = ( + int(config.get("reproducibility", {}).get("seed", 0)) if seed is None else seed + ) + stamp = when or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + mode = "proposal" if proposal is not None else "data_only" + round_name = str(getattr(proposal, "round_name", None) or "current") + directory = ( + Path(outdir) + if outdir is not None + else report_directory(workbook, round_name, when=stamp) + ) + directory.mkdir(parents=True, exist_ok=True) + + say("Reading the workbook...") + contents = read_campaign_workbook(workbook, config) + X_phys = contents.inputs.to_numpy(float) + Y_measured = contents.model_values.to_numpy(float) + labels = [str(value) for value in contents.sample_ids] + blocks = _observations_by_round(workbook, config) + + say("Fitting the model the round used...") + model, model_warnings = fit_campaign_models( + config, X_phys, Y_measured, seed=resolved_seed + ) + + figures: list[FigureRecord] = [] + skipped: list[tuple[str, str]] = [] + notices: list[str] = [] + + def attempt(key: str, build: Callable[[], FigureRecord]) -> None: + say(f"Rendering {key}...") + try: + figures.append(build()) + except Exception as exc: # noqa: BLE001 - one bad figure must not cost the rest + detail = f"{type(exc).__name__}: {exc}" + skipped.append((key, detail)) + notices.append(f"{key} could not be rendered -- {detail}") + (directory / f"{key}.error.txt").write_text( + traceback.format_exc(), encoding="utf-8" + ) + + proposed_X = None + if proposal is not None: + proposed_X = proposal.conditions.to_numpy(float) + attempt( + "00_batch_placement", + lambda: _figure_batch_placement( + directory, config, X_phys, proposed_X, round_name + ), + ) + else: + skipped.append( + ("00_batch_placement", "no proposal supplied (data-only mode)") + ) + + attempt( + "01_loo_parity", + lambda: _figure_loo_parity( + directory, config, X_phys, Y_measured, names, labels, resolved_seed + ), + ) + attempt( + "02_attribution", + lambda: _figure_attribution( + directory, + config, + model, + transform, + X_phys, + names, + resolved_seed, + shap_max_instances, + ), + ) + + if proposal is not None and review is not None: + observed_utility = _utility(transform, Y_measured) + + def build_batch_figure() -> FigureRecord: + hv_frame = _batch_hypervolume_diagnostic( + config, + model, + transform, + observed_utility, + proposed_X, + reference, + resolved_seed, + ) + return _figure_batch_predictions( + directory, config, review, names, hv_frame, round_name + ) + + attempt("03_batch_predictions", build_batch_figure) + else: + skipped.append( + ( + "03_batch_predictions", + "no proposal supplied (data-only mode)" + if proposal is None + else "no batch review supplied", + ) + ) + + attempt( + "04_hv_trajectory", + lambda: _figure_hv_trajectory(directory, blocks, transform, reference), + ) + + proposed_utility = None + proposed_sd = None + if review is not None: + proposed_utility = np.column_stack( + [review.candidates[f"{name}_utility"].to_numpy(float) for name in names] + ) + proposed_sd = np.column_stack( + [review.candidates[f"{name}_sd"].to_numpy(float) for name in names] + ) + attempt( + "05_objective_space", + lambda: _figure_objective_space( + directory, + blocks, + transform, + reference, + names, + proposed_utility, + proposed_sd, + ), + ) + + # ---------------------------------------------------------- the notices -- + entries = config["objectives"]["specs"] + frozen = [ + str(entry.get("name")) + for entry in entries + if str((entry.get("measurement") or {}).get("recipe")) == "stored" + ] + if frozen: + notices.append( + f"{' and '.join(frozen)} are taken from the workbook as stored; no " + "independent recomputation exists under this contract, so a stale " + "value in those columns would not be caught here." + ) + for index, name in enumerate(names): + status = str(entries[index].get("signal_status", "")) + if status and status != "learnable": + notices.append( + f"{name}: {status.replace('_', ' ')} -- its model does not beat the " + "leave-one-out null, so its predictions carry no signal." + ) + for warning in model_warnings: + notices.append(f"model fit warning: {warning}") + for finding in contents.findings: + if finding.severity is ScoreSeverity.WARNING: + notices.append(f"data: {finding}") + + runtime = time.perf_counter() - started + manifest = ReportManifest( + directory=directory, + round_name=round_name, + mode=mode, + figures=tuple(figures), + skipped=tuple(skipped), + notices=tuple(dict.fromkeys(notices)), + context={ + "workbook": workbook.name, + "generated_utc": stamp, + "objective_contract": config["objectives"]["contract_version"], + "campaign": config.get("campaign", {}).get("name"), + "seed": resolved_seed, + "reference_point_utility": [float(value) for value in reference], + "observed_rows": int(len(X_phys)), + "rounds_measured": [block.name for block in blocks], + "git": _git_describe(), + "python": platform.python_version(), + "posterior_draws_for_hv": HV_POSTERIOR_SAMPLES, + "shap_instances": int(min(shap_max_instances, len(X_phys))), + }, + runtime_seconds=runtime, + ) + (directory / "manifest.json").write_text( + json.dumps(manifest.as_dict(), indent=2), encoding="utf-8" + ) + (directory / "README.txt").write_text(manifest.summary() + "\n", encoding="utf-8") + say("Report written.") + return manifest diff --git a/src/mobo_kit/scores.py b/src/mobo_kit/scores.py new file mode 100644 index 0000000..4abbac8 --- /dev/null +++ b/src/mobo_kit/scores.py @@ -0,0 +1,1231 @@ +"""Turn the workbook's columns into each objective's model input. + +**THE LIVE POLICY, and it is not uniform across objectives.** Under +``d2d-objectives-v4-final-nomean``: + +* **uniformity and optoelectronic are READ AS STORED.** The workbook's score + column *is* the objective value. Python computes nothing and this module + deliberately does NOT record how those numbers are arrived at. + + **THE SCORE VALUE IS THE INTERFACE; THE FORMULA BEHIND IT IS NOT.** How a + composite score is defined is a decision each group makes for itself -- which + terms, what weighting, which normalisation -- and MOBO-Kit is not the right + place to encode one group's convention. Two groups running this tool on the + same chemistry may disagree entirely on how uniformity is scored and both be + right. What they share is the shape of the contract: a number per objective per + film, on a declared scale. Encoding one group's arithmetic here would make the + tool quietly specific to them, and in practice it also gave the definition a + second place to live and go stale, which happened three times. + + The `stored` recipe is the whole implementation. +* **thickness is COMPUTED**, from ``T1..T4`` with the operator's ``T anom`` + readings excluded and reported, and cross-checked against the workbook's own + average. It earns the exception because the recomputation is what lets an + anomalous reading be excluded *and named*, and because the model trains on + nanometres rather than on the stored score -- see the objective transform, not + this module, for why that matters. + +**What this costs, stated plainly:** there is no independent recomputation of the +two frozen objectives, so a stale pasted literal in either column cannot be caught +by comparing it against anything here. That is the price of the freeze and it is +paid deliberately. + +THE RECIPES. Named in config, implemented here, because a recipe is the shape of +a measurement rather than a property of one dataset: + +=================== ========================================================= +``stored`` the column IS the value; no arithmetic v4 +``mean_of_present`` mean of whichever of ``T1..T4`` were measured all +``mean`` the mean of every input, all of them required v3 +``product`` the product of every input v2 +``log10_product`` the sum of the inputs' base-10 logarithms v2 +=================== ========================================================= + +**The v2 and v3 recipes are retained and tested, not dead weight.** Their configs +are archived but must stay loadable, because a contract whose numbers cannot be +regenerated is a contract nobody can audit. ``log10_product`` sums logarithms +rather than logging a product, which is algebraically identical and cannot +overflow on the way there. + +``mean_of_present`` needs at least one reading; every other recipe needs all of +theirs. **Blank means not measured, never zero.** Thickness rows carry three or +four readings depending on the film, so a recipe demanding all four would reject +the campaign; a blank ``Coverage`` is a missing measurement and ``mean`` refuses +it. + +Two input transforms carry a threshold, ``clamped_complement`` and +``capped_ratio``. Both exist for archived contracts only. Nothing live uses them, +and a live contract that needs one should think hard first: a clamp that binds is +information being discarded, and on the v3 data it bound on two of fifteen rows. + +CROSS-CHECKS AND FINGERPRINTS are two different instruments and the difference +matters. A ``cross_check`` compares a computed value against a stored one and so +only exists where Python computes -- thickness. A ``formula_fingerprint`` reads a +column's FORMULA TEXT, never evaluates it, and reports when that text changes; it +is what a frozen objective has instead of a cross-check. It notices a changed +DEFINITION, not a stale VALUE, and that gap is inherent to reading a number +somebody else computes. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from enum import Enum +from numbers import Real +from typing import Any, Callable, Iterable, Mapping, Sequence + +import numpy as np +import pandas as pd + +__all__ = [ + "AgreementCheck", + "FormulaFingerprint", + "CrossCheck", + "MeasurementInput", + "MeasurementResult", + "MeasurementSpec", + "RECIPES", + "ScoreFinding", + "ScoreSeverity", + "ScoreValidationError", + "compute_measurements", + "entry_columns", + "measurement_spec_from_config", + "row_completeness", +] + + +#: A cell holding this is empty as far as the campaign is concerned. The +#: workbook's ``T anom`` column is full of non-breaking spaces, which are not +#: ``None`` and are not whitespace to ``str.strip`` unless normalised first. +_BLANK_TEXT = frozenset({"", "-", "--", "n/a", "na"}) + +#: Excel leaves non-breaking spaces in cells that look empty -- the +#: workbook's ``T anom`` column is full of them -- and ``str.strip`` does not +#: remove one, so it has to be normalised before any blank test. +_NBSP = "\u00a0" + + +class _NotNumeric(ValueError): + """A cell holds something that is present but not a number.""" + + +def _is_missing(value: Any) -> bool: + if value is None: + return True + if isinstance(value, str): + return value.replace(_NBSP, " ").strip().lower() in _BLANK_TEXT + if isinstance(value, (bool, np.bool_)): + return False + try: + missing = pd.isna(value) + except (TypeError, ValueError): + return False + return isinstance(missing, (bool, np.bool_)) and bool(missing) + + +def _number(value: Any, *, column: str) -> float | None: + """Return ``value`` as a float, ``None`` if the cell is empty.""" + if _is_missing(value): + return None + if isinstance(value, (bool, np.bool_)): + raise _NotNumeric(f"{column!r} holds a boolean, not a measurement.") + if isinstance(value, Real): + number = float(value) + elif isinstance(value, str): + try: + number = float(value.replace(_NBSP, " ").strip()) + except ValueError as exc: + raise _NotNumeric(f"{column!r} holds {value!r}, which is not a number.") from exc + else: + raise _NotNumeric(f"{column!r} holds {type(value).__name__}, not a number.") + if not math.isfinite(number): + raise _NotNumeric(f"{column!r} holds {value!r}, which is not finite.") + return number + + +# --------------------------------------------------------------------------- # +# recipes +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class _Recipe: + """One way of turning measured columns into a single model input.""" + + name: str + combine: Callable[[Sequence[float]], float] + #: ``True`` when every declared input must be present, ``False`` when the + #: recipe averages over whichever ones were measured. + requires_all: bool + description: str + + def apply(self, values: Sequence[float]) -> float: + result = float(self.combine(values)) + if not math.isfinite(result): + raise _NotNumeric(f"recipe {self.name!r} produced a non-finite result.") + return result + + +def _product(values: Sequence[float]) -> float: + result = 1.0 + for value in values: + result *= value + return result + + +def _log10_product(values: Sequence[float]) -> float: + for value in values: + if value <= 0: + raise _NotNumeric( + f"log10 needs strictly positive inputs; got {value!r}. A failed " + "film must be recorded as blank, not as zero." + ) + return math.fsum(math.log10(value) for value in values) + + +def _mean_of_present(values: Sequence[float]) -> float: + return math.fsum(values) / len(values) + + +def _single(values: Sequence[float]) -> float: + """The one value, unchanged. Guarded because 'stored' means exactly one column.""" + if len(values) != 1: + raise _NotNumeric( + f"the 'stored' recipe takes exactly one column; got {len(values)}." + ) + return float(values[0]) + + +RECIPES: Mapping[str, _Recipe] = { + recipe.name: recipe + for recipe in ( + _Recipe("product", _product, True, "the product of every input"), + _Recipe( + "log10_product", + _log10_product, + True, + "the sum of the base-10 logarithms, i.e. log10 of the product", + ), + # Same arithmetic as `mean_of_present`, opposite policy on a blank cell. + # `mean` is for terms that were all supposed to be measured -- a missing + # Coverage is a hole in the row, and averaging the other two would quietly + # answer a different question. `mean_of_present` is for repeated readings + # of one quantity, where three instead of four is a normal film. + _Recipe("mean", _mean_of_present, True, "the mean of every input"), + # The FROZEN objective. `stored` takes the workbook's own score column as + # the objective value and computes nothing, which is a deliberate reversal + # of this module's usual polarity -- normally Python computes and the + # stored cell is demoted to a cross-check. + # + # It exists because the group is still revising how uniformity and + # optoelectronic are defined. Reimplementing a formula that is about to + # change means the code and the sheet disagree at exactly the moment + # someone edits the sheet, and the disagreement would look like a bug in + # whichever one was checked second. Reading the value instead makes the + # workbook the single source of truth while the definition moves. + # + # What is LOST by freezing, and is worth saying out loud: there is no + # independent recomputation of these two objectives under this contract, + # so a stale pasted literal in the score column cannot be detected by + # comparing it against anything. `formula_fingerprint` is the partial + # replacement -- it notices when the DEFINITION moves, not when a value + # goes stale. + _Recipe( + "stored", + _single, + True, + "the workbook's own score column, taken as computed and not recomputed", + ), + _Recipe( + "mean_of_present", + _mean_of_present, + False, + "the mean of whichever inputs were measured", + ), + ) +} + + +# --------------------------------------------------------------------------- # +# configuration +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class MeasurementInput: + """One measured column feeding a recipe. + + ``complement`` exists because the workbook records ``Uniformity`` and the + score wants ``1 - Uniformity``. The workbook also stores that complement in + its own column, but as a pasted literal -- so it is computed here and the + stored column is only ever a cross-check. The same is true of the v3 + workbook's clamped copy of the reading. + + The two parameterised transforms take their thresholds from config rather + than hard-coding them, because a clamp at 0.99 and a cap at 1.4 V are + campaign decisions the group can revise, not physics. + """ + + column: str + transform: str = "identity" + #: ``clamped_complement``: readings STRICTLY above this are replaced. + clamp_above: float | None = None + #: ``clamped_complement``: what they are replaced with. + clamp_to: float | None = None + #: ``capped_ratio``: the ceiling, which is also the divisor. + cap: float | None = None + + def __post_init__(self) -> None: + if not isinstance(self.column, str) or not self.column.strip(): + raise ValueError("A measurement input needs a non-empty column name.") + object.__setattr__(self, "column", self.column.strip()) + if self.transform not in _TRANSFORM_PARAMETERS: + raise ValueError( + f"Unsupported measurement transform {self.transform!r}; " + f"expected one of {sorted(_TRANSFORM_PARAMETERS)}." + ) + + def _required(field: str, *, positive: bool = False) -> float: + raw = getattr(self, field) + if raw is None: + raise ValueError( + f"Transform {self.transform!r} on column {self.column!r} needs " + f"{field!r}." + ) + if isinstance(raw, (bool, np.bool_)) or not isinstance(raw, Real): + raise ValueError( + f"{field!r} on column {self.column!r} must be a number; " + f"got {raw!r}." + ) + number = float(raw) + if not math.isfinite(number): + raise ValueError(f"{field!r} on column {self.column!r} must be finite.") + if positive and number <= 0: + raise ValueError( + f"{field!r} on column {self.column!r} must be positive; " + f"got {number!r}." + ) + object.__setattr__(self, field, number) + return number + + unused = [ + field + for field in ("clamp_above", "clamp_to", "cap") + if getattr(self, field) is not None + and field not in _TRANSFORM_PARAMETERS[self.transform] + ] + if unused: + # A threshold that silently does nothing is how a clamp gets believed + # to be active when it is not. + raise ValueError( + f"Transform {self.transform!r} on column {self.column!r} ignores " + f"{unused}; remove them or change the transform." + ) + for field in _TRANSFORM_PARAMETERS[self.transform]: + _required(field, positive=field == "cap") + + def evaluate(self, value: float) -> float: + if self.transform == "identity": + return value + if self.transform == "complement": + return 1.0 - value + if self.transform == "clamped_complement": + # strictly above, so an exact clamp_above keeps its own value + clamped = self.clamp_to if value > self.clamp_above else value + return 1.0 - float(clamped) + return min(value, self.cap) / self.cap + + +#: Which thresholds each transform consumes. Declared once so an unused threshold +#: is an error rather than a silent no-op. +_TRANSFORM_PARAMETERS: Mapping[str, tuple[str, ...]] = { + "identity": (), + "complement": (), + "clamped_complement": ("clamp_above", "clamp_to"), + "capped_ratio": ("cap",), +} + + +@dataclass(frozen=True) +class CrossCheck: + """A stored column to compare the computed value against. + + ``atol`` is per column on purpose: a live formula and a deliberately rounded + literal do not deserve the same tolerance. + """ + + column: str + atol: float = 0.005 + + def __post_init__(self) -> None: + if not isinstance(self.column, str) or not self.column.strip(): + raise ValueError("A cross-check needs a non-empty column name.") + object.__setattr__(self, "column", self.column.strip()) + if isinstance(self.atol, (bool, np.bool_)) or not isinstance(self.atol, Real): + raise ValueError(f"Cross-check {self.column!r} atol must be a number.") + atol = float(self.atol) + if not math.isfinite(atol) or atol < 0: + raise ValueError( + f"Cross-check {self.column!r} atol must be finite and non-negative." + ) + object.__setattr__(self, "atol", atol) + + +@dataclass(frozen=True) +class AgreementCheck: + """Does a supplied normalised column still rank like the raw one it summarises? + + Some columns arrive already normalised, with the derivation living outside the + workbook -- ``Normalized photoconductance`` is one, and nothing in the sheet + computes it. A recipe can only take such a column on trust, which means a + normalisation that has come loose from its raw measurement is invisible: every + value is in range, every row computes, and the objective is simply about + something else than it says. + + Rank agreement is the check that needs no formula. Whatever the mapping is, + a normalisation of a raw quantity must at least preserve its order, so + Spearman between the two is expected to be strongly positive. This reports it + and warns below ``min_spearman``. + + It is a FINDING, never a gate: which column the model trains on is a decision + for the group, and a diagnostic that blocks a round would make that decision + by refusing to run. + """ + + raw: str + normalized: str + min_spearman: float = 0.0 + + def __post_init__(self) -> None: + for field in ("raw", "normalized"): + value = getattr(self, field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"An agreement check needs a non-empty {field!r}.") + object.__setattr__(self, field, value.strip()) + if self.raw == self.normalized: + raise ValueError( + f"An agreement check compares two different columns; got " + f"{self.raw!r} twice." + ) + threshold = self.min_spearman + if isinstance(threshold, (bool, np.bool_)) or not isinstance(threshold, Real): + raise ValueError("min_spearman must be a number.") + threshold = float(threshold) + if not math.isfinite(threshold) or not -1.0 <= threshold <= 1.0: + raise ValueError("min_spearman must be finite and within [-1, 1].") + object.__setattr__(self, "min_spearman", threshold) + + +@dataclass(frozen=True) +class FormulaFingerprint: + """The formula text a frozen score column is expected to carry. + + A ``stored`` objective is read rather than recomputed, so nothing in Python + knows what it means. That is the point -- the group is still revising the + definition -- but it removes the cross-check that would otherwise catch a + redefinition. This is the partial replacement: record the formula as it + stands, and say so when it changes. + + **It notices a changed definition, not a stale value.** A pasted literal that + has stopped tracking its inputs looks identical to a correct one from here. + That gap is inherent to freezing and is recorded rather than papered over; it + closes when the group settles the formulas and the recipes are unfrozen. + + Compared after collapsing whitespace and upper-casing, because Excel rewrites + those freely, and with the row number stripped so one fingerprint covers every + row rather than fifteen near-copies. + """ + + column: str + formula: str + + def __post_init__(self) -> None: + for field in ("column", "formula"): + value = getattr(self, field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"A formula fingerprint needs a non-empty {field!r}.") + object.__setattr__(self, field, value.strip()) + + @staticmethod + def canonical(formula: Any) -> str: + """Row-independent, whitespace-independent form of a formula string.""" + import re + + if not isinstance(formula, str): + return "" + text = formula.strip().lstrip("=").upper() + text = re.sub(r"\s+", "", text) + # A2 -> A, AJ17 -> AJ: the same formula copied down a column differs only + # in the row, and fingerprinting per row would report fifteen changes for + # one edit. + return re.sub(r"(\$?[A-Z]{1,3})\$?\d+", r"\1", text) + + def matches(self, formula: Any) -> bool: + return self.canonical(formula) == self.canonical(self.formula) + + +@dataclass(frozen=True) +class MeasurementSpec: + """How one objective's model input is computed and checked.""" + + name: str + recipe: str + inputs: tuple[MeasurementInput, ...] + cross_checks: tuple[CrossCheck, ...] = () + #: Rank agreement between a supplied normalised column and its raw source. + agreement_check: "AgreementCheck | None" = None + #: Expected formula text for a frozen score column; checked, never evaluated. + formula_fingerprint: "FormulaFingerprint | None" = None + #: Columns holding readings the operator judged anomalous. They never enter + #: the recipe; their presence is recorded so an exclusion is visible rather + #: than silent. + excluded: tuple[str, ...] = () + #: Warn when ``(max - min) / mean`` over the used inputs exceeds this. Set + #: for thickness, where three R0 rows hold readings that split into two + #: clusters rather than scattering around one value. + spread_warning_ratio: float | None = None + + def __post_init__(self) -> None: + if not isinstance(self.name, str) or not self.name.strip(): + raise ValueError("A measurement spec needs a non-empty name.") + object.__setattr__(self, "name", self.name.strip()) + if self.recipe not in RECIPES: + raise ValueError( + f"Objective {self.name!r} requests unknown recipe {self.recipe!r}; " + f"known recipes are {sorted(RECIPES)}." + ) + if not self.inputs: + raise ValueError(f"Objective {self.name!r} declares no measurement inputs.") + if self.recipe == "stored" and len(self.inputs) != 1: + raise ValueError( + f"Objective {self.name!r} uses the 'stored' recipe, which reads one " + f"score column; it declares {len(self.inputs)} inputs." + ) + columns = [item.column for item in self.inputs] + duplicates = sorted({c for c in columns if columns.count(c) > 1}) + if duplicates: + raise ValueError( + f"Objective {self.name!r} repeats measurement input(s) {duplicates}." + ) + if self.spread_warning_ratio is not None: + ratio = float(self.spread_warning_ratio) + if not math.isfinite(ratio) or ratio <= 0: + raise ValueError( + f"Objective {self.name!r} spread_warning_ratio must be a " + "positive finite number." + ) + object.__setattr__(self, "spread_warning_ratio", ratio) + + @property + def recipe_impl(self) -> _Recipe: + return RECIPES[self.recipe] + + @property + def required_columns(self) -> tuple[str, ...]: + """Columns without which this objective cannot be computed at all.""" + if self.recipe_impl.requires_all: + return tuple(item.column for item in self.inputs) + return () + + @property + def optional_columns(self) -> tuple[str, ...]: + # BOTH of the agreement check's columns are offered but never required. + # It used to list only `raw`, on the assumption that `normalized` was an + # input to the recipe -- true while optoelectronic was computed, false the + # moment it was frozen and its only input became the score column. The + # check then silently reported "column absent" on a sheet that had it. + agreement = ( + (self.agreement_check.raw, self.agreement_check.normalized) + if self.agreement_check + else () + ) + if self.recipe_impl.requires_all: + return tuple(self.excluded) + agreement + return ( + tuple(item.column for item in self.inputs) + + tuple(self.excluded) + + agreement + ) + + +def measurement_spec_from_config(entry: Mapping[str, Any]) -> MeasurementSpec | None: + """Build a spec from one objective's ``measurement`` block, if present. + + Returning ``None`` for an objective without the block is deliberate: an + objective that still reads its stored column keeps working unchanged. + """ + block = entry.get("measurement") + if block is None: + return None + if not isinstance(block, Mapping): + raise ValueError("measurement must be a mapping.") + + raw_inputs = block.get("inputs") + if ( + not isinstance(raw_inputs, Sequence) + or isinstance(raw_inputs, (str, bytes)) + or not raw_inputs + ): + raise ValueError("measurement.inputs must be a non-empty list.") + inputs = [] + _INPUT_KEYS = {"column", "transform", "clamp_above", "clamp_to", "cap"} + for item in raw_inputs: + if isinstance(item, str): + inputs.append(MeasurementInput(item)) + elif isinstance(item, Mapping): + # A misspelt threshold would otherwise be dropped in silence, leaving + # a clamp everyone believes is configured and nothing applying it. + unknown = sorted(set(item) - _INPUT_KEYS) + if unknown: + raise ValueError( + f"Measurement input {item.get('column')!r} has unknown key(s) " + f"{unknown}; expected {sorted(_INPUT_KEYS)}." + ) + inputs.append( + MeasurementInput( + str(item["column"]), + str(item.get("transform", "identity")), + clamp_above=item.get("clamp_above"), + clamp_to=item.get("clamp_to"), + cap=item.get("cap"), + ) + ) + else: + raise ValueError("Each measurement input must be a string or a mapping.") + + raw_checks = block.get("cross_check") or () + if isinstance(raw_checks, Mapping): + raw_checks = [raw_checks] + elif isinstance(raw_checks, (str, bytes)): + raw_checks = [{"column": raw_checks}] + checks = [] + for item in raw_checks: + if isinstance(item, str): + checks.append(CrossCheck(item)) + elif isinstance(item, Mapping): + atol = item.get("atol") + checks.append( + CrossCheck(str(item["column"]), 0.005 if atol is None else float(atol)) + ) + else: + raise ValueError("Each cross_check must be a string or a mapping.") + + raw_excluded = block.get("excluded") or () + if isinstance(raw_excluded, (str, bytes)): + raw_excluded = [raw_excluded] + excluded = tuple( + str(item["column"]) if isinstance(item, Mapping) else str(item) + for item in raw_excluded + ) + + raw_fingerprint = block.get("formula_fingerprint") + if raw_fingerprint is None: + fingerprint = None + elif isinstance(raw_fingerprint, Mapping): + fingerprint = FormulaFingerprint( + column=str(raw_fingerprint["column"]), + formula=str(raw_fingerprint["formula"]), + ) + else: + raise ValueError("measurement.formula_fingerprint must be a mapping.") + + raw_agreement = block.get("agreement_check") + if raw_agreement is None: + agreement = None + elif isinstance(raw_agreement, Mapping): + threshold = raw_agreement.get("min_spearman") + agreement = AgreementCheck( + raw=str(raw_agreement["raw"]), + normalized=str(raw_agreement["normalized"]), + min_spearman=0.0 if threshold is None else float(threshold), + ) + else: + raise ValueError("measurement.agreement_check must be a mapping.") + + ratio = block.get("spread_warning_ratio") + return MeasurementSpec( + name=str(entry.get("name", block.get("name", ""))), + recipe=str(block["recipe"]), + inputs=tuple(inputs), + cross_checks=tuple(checks), + agreement_check=agreement, + formula_fingerprint=fingerprint, + excluded=excluded, + spread_warning_ratio=None if ratio is None else float(ratio), + ) + + +def entry_columns( + specs: Sequence[MeasurementSpec], +) -> tuple[tuple[str, ...], tuple[str, ...]]: + """The columns a worklist sheet must offer, split required / optional. + + Order is declaration order, de-duplicated. A column that is required by one + objective and optional for another counts as required. + """ + required: list[str] = [] + optional: list[str] = [] + for spec in specs: + for column in spec.required_columns: + if column not in required: + required.append(column) + for column in spec.optional_columns: + if column not in optional: + optional.append(column) + optional = [column for column in optional if column not in required] + return tuple(required), tuple(optional) + + +# --------------------------------------------------------------------------- # +# findings +# --------------------------------------------------------------------------- # + + +class ScoreSeverity(str, Enum): + """How much a finding should stop you.""" + + NOTE = "note" + WARNING = "warning" + ERROR = "error" + + +@dataclass(frozen=True) +class ScoreFinding: + """One thing worth saying about one row.""" + + severity: ScoreSeverity + code: str + objective: str + row_position: int + sample_id: Any + message: str + column: str | None = None + + @property + def is_column_level(self) -> bool: + """True when the finding is about a COLUMN rather than about one row. + + ``row_position = -1`` is the marker. Rank agreement over fifteen rows and + an absent column are both statements about the sheet, not about a film. + """ + return self.row_position < 0 + + def __str__(self) -> str: + # A column-level finding used to render as "sample ?", which reads as a + # row whose identity got lost rather than as a finding that has no row. + where = ( + f"all rows, {self.objective}" + if self.is_column_level + else f"sample {self.sample_id}, {self.objective}" + ) + return f"[{self.severity.value}] {where}: {self.message}" + + +class ScoreValidationError(ValueError): + """At least one row could not be turned into a model input.""" + + def __init__(self, findings: Sequence[ScoreFinding]) -> None: + self.findings = tuple(findings) + detail = "\n- ".join(str(finding) for finding in self.findings) + super().__init__(f"Objective values could not be computed:\n- {detail}") + + +@dataclass(frozen=True) +class MeasurementResult: + """Computed model inputs, how many readings each used, and what to say.""" + + values: pd.DataFrame + """One column per objective, in declaration order. NaN where unusable.""" + inputs_used: pd.DataFrame + """How many measured inputs each value was computed from.""" + findings: tuple[ScoreFinding, ...] + + def _by(self, severity: ScoreSeverity) -> tuple[ScoreFinding, ...]: + return tuple(f for f in self.findings if f.severity is severity) + + @property + def notes(self) -> tuple[ScoreFinding, ...]: + return self._by(ScoreSeverity.NOTE) + + @property + def warnings(self) -> tuple[ScoreFinding, ...]: + return self._by(ScoreSeverity.WARNING) + + @property + def errors(self) -> tuple[ScoreFinding, ...]: + return self._by(ScoreSeverity.ERROR) + + @property + def has_errors(self) -> bool: + return bool(self.errors) + + def findings_frame(self) -> pd.DataFrame: + return pd.DataFrame( + { + "severity": [f.severity.value for f in self.findings], + "code": [f.code for f in self.findings], + "objective": [f.objective for f in self.findings], + "sample_id": [f.sample_id for f in self.findings], + "column": [f.column for f in self.findings], + "message": [f.message for f in self.findings], + } + ) + + def raise_for_errors(self) -> "MeasurementResult": + if self.errors: + raise ScoreValidationError(self.errors) + return self + + +# --------------------------------------------------------------------------- # +# computation +# --------------------------------------------------------------------------- # + + +def _cell(frame: pd.DataFrame, column: str, position: int) -> Any: + return frame[column].to_numpy(dtype=object)[position] + + +def _with_stripped_columns(frame: pd.DataFrame) -> pd.DataFrame: + """Compare column names with surrounding whitespace removed, on both sides. + + :class:`MeasurementInput` and :class:`CrossCheck` strip the names they are + given, so a config may quote a header verbatim -- and the v3 sheet has two + that end in a space, ``'PL - Implied Voc (Max) Raw '`` and ``'Normalized + photoconductance '``. ``workbook_io`` strips the sheet side when it builds + its header index, but a caller who reads the sheet with pandas directly does + not, and would then be told the column is missing. It fails closed rather + than silently, but "missing" is the wrong answer to give about a column that + is right there. + + Renaming is skipped entirely when it would merge two distinct labels, because + quietly dropping one of them would be worse than the confusion this avoids. + """ + labels = list(frame.columns) + stripped = [name.strip() if isinstance(name, str) else name for name in labels] + if stripped == labels: + return frame + if len(set(map(str, stripped))) != len(stripped): + return frame + renamed = frame.copy(deep=False) + renamed.columns = stripped + return renamed + + +def _absent_required_columns( + frame: pd.DataFrame, specs: Sequence[MeasurementSpec] +) -> tuple[str, ...]: + """Input columns whose absence makes an objective impossible, not merely thin. + + A recipe that needs all its inputs cannot proceed without any one of them. A + recipe that averages over whatever was measured can: a sheet with no ``T4`` + column is a sheet where nobody measured a fourth point, which is the same + situation as an empty ``T4`` cell and is handled the same way. + """ + absent: list[str] = [] + for spec in specs: + for column in spec.required_columns: + if column not in frame.columns and column not in absent: + absent.append(column) + return tuple(absent) + + +def _agreement_findings( + frame: pd.DataFrame, + spec: MeasurementSpec, + sample_ids: Sequence[Any], +) -> list[ScoreFinding]: + """Rank-compare a supplied normalised column against the raw one it summarises. + + One finding for the whole column, not one per row -- the question is about the + mapping, so ``row_position`` is -1 and ``sample_id`` is None. The message + names the highest-raw film explicitly, because "Spearman is negative" is a + statistic and "the strongest film scores lowest" is the thing a reviewer can + act on. + """ + check = spec.agreement_check + assert check is not None # caller checks; keeps the type narrow + + def finding(severity: ScoreSeverity, code: str, message: str) -> ScoreFinding: + return ScoreFinding( + severity=severity, + code=code, + objective=spec.name, + row_position=-1, + sample_id=None, + message=message, + column=check.normalized, + ) + + missing = [c for c in (check.raw, check.normalized) if c not in frame.columns] + if missing: + return [ + finding( + ScoreSeverity.NOTE, + "agreement_check_absent", + f"{missing} not in this sheet, so {check.normalized!r} cannot be " + f"checked against {check.raw!r}.", + ) + ] + + pairs: list[tuple[float, float, Any]] = [] + for position in range(len(frame)): + try: + raw = _number(_cell(frame, check.raw, position), column=check.raw) + normalized = _number( + _cell(frame, check.normalized, position), column=check.normalized + ) + except _NotNumeric: + continue + if raw is None or normalized is None: + continue + pairs.append((raw, normalized, sample_ids[position])) + + if len(pairs) < 3: + return [ + finding( + ScoreSeverity.NOTE, + "agreement_check_too_few_rows", + f"only {len(pairs)} row(s) have both {check.raw!r} and " + f"{check.normalized!r}; a rank comparison needs at least 3.", + ) + ] + + raw_values = [item[0] for item in pairs] + normalized_values = [item[1] for item in pairs] + # Checked before calling scipy rather than by testing the result for NaN: a + # constant column makes `spearmanr` emit a ConstantInputWarning, and this + # suite keeps its warning tail fixed so that a NEW warning means something. + constant = [ + name + for name, series in ( + (check.raw, raw_values), + (check.normalized, normalized_values), + ) + if len(set(series)) == 1 + ] + if constant: + return [ + finding( + ScoreSeverity.NOTE, + "agreement_check_undefined", + f"rank correlation is undefined over {len(pairs)} rows because " + f"{constant} is constant.", + ) + ] + + from scipy.stats import spearmanr + + result = spearmanr(raw_values, normalized_values) + rho = float(result.statistic) + p_value = float(result.pvalue) + if not math.isfinite(rho): # pragma: no cover - constant input is caught above + return [ + finding( + ScoreSeverity.NOTE, + "agreement_check_undefined", + f"rank correlation over {len(pairs)} rows is not a finite number.", + ) + ] + + strongest = max(pairs, key=lambda item: item[0]) + weakest = min(pairs, key=lambda item: item[0]) + detail = ( + f"Spearman({check.raw!r}, {check.normalized!r}) = {rho:+.4f} " + f"(p = {p_value:.4f}) over {len(pairs)} rows. The highest raw reading " + f"({strongest[0]:.4g}, sample {strongest[2]}) normalises to " + f"{strongest[1]:.4g}; the lowest ({weakest[0]:.4g}, sample {weakest[2]}) " + f"normalises to {weakest[1]:.4g}." + ) + if rho < check.min_spearman: + return [ + finding( + ScoreSeverity.WARNING, + "agreement_not_monotonic", + f"{check.normalized!r} does not rank like {check.raw!r}, so this " + f"objective is provisional until the group supplies the " + f"normalisation. {detail} Expected at least " + f"{check.min_spearman:+.4f}. The computed value still stands -- " + f"this is a finding, not a gate.", + ) + ] + return [ + finding( + ScoreSeverity.NOTE, + "agreement_monotonic", + f"{check.normalized!r} ranks like {check.raw!r}. {detail}", + ) + ] + + +def compute_measurements( + frame: pd.DataFrame, + specs: Sequence[MeasurementSpec], + *, + sample_ids: Sequence[Any] | None = None, +) -> MeasurementResult: + """Compute one model input per objective per row, and check the workbook. + + ``frame`` holds the raw cells as read -- blanks, non-breaking spaces and + strings are all handled here rather than by the caller, because the point of + this module is that nothing downstream has to know how the workbook spells + "not measured". + + A row that cannot be computed gets ``NaN`` and an ``error`` finding rather + than an exception, so one bad row does not hide the state of the other + fourteen. Call :meth:`MeasurementResult.raise_for_errors` to fail closed. + """ + if not isinstance(frame, pd.DataFrame): + raise TypeError("frame must be a pandas DataFrame.") + specs = tuple(specs) + if not specs: + raise ValueError("At least one MeasurementSpec is required.") + frame = _with_stripped_columns(frame) + absent = _absent_required_columns(frame, specs) + if absent: + raise ValueError( + f"Measurement column(s) missing from the sheet: {list(absent)}. The " + "objectives are computed from these, so they cannot be skipped." + ) + + n_rows = len(frame) + ids = list(sample_ids) if sample_ids is not None else [None] * n_rows + if len(ids) != n_rows: + raise ValueError("sample_ids must have one entry per row.") + + findings: list[ScoreFinding] = [] + for spec in specs: + for item in spec.inputs: + if item.column not in frame.columns: + findings.append( + ScoreFinding( + severity=ScoreSeverity.NOTE, + code="input_column_absent", + objective=spec.name, + row_position=-1, + sample_id=None, + message=( + f"there is no {item.column!r} column in this sheet, so " + "it counts as unmeasured for every row." + ), + column=item.column, + ) + ) + values: dict[str, list[float]] = {spec.name: [] for spec in specs} + counts: dict[str, list[int]] = {spec.name: [] for spec in specs} + + for spec in specs: + recipe = spec.recipe_impl + for position in range(n_rows): + sample_id = ids[position] + + def record( + severity: ScoreSeverity, + code: str, + message: str, + *, + column: str | None = None, + _position: int = position, + _sample_id: Any = sample_id, + _objective: str = spec.name, + ) -> None: + findings.append( + ScoreFinding( + severity=severity, + code=code, + objective=_objective, + row_position=_position, + sample_id=_sample_id, + message=message, + column=column, + ) + ) + + used: list[float] = [] + raw_used: list[float] = [] + failed = False + for item in spec.inputs: + if item.column not in frame.columns: + continue # already reported once, above + try: + number = _number(_cell(frame, item.column, position), column=item.column) + except _NotNumeric as exc: + record( + ScoreSeverity.ERROR, + "input_not_numeric", + str(exc), + column=item.column, + ) + failed = True + continue + if number is None: + if recipe.requires_all: + record( + ScoreSeverity.ERROR, + "input_missing", + f"{item.column!r} is empty, and {spec.recipe!r} needs " + "every input. Blank means not measured, not zero.", + column=item.column, + ) + failed = True + continue + raw_used.append(number) + used.append(item.evaluate(number)) + + for column in spec.excluded: + if column not in frame.columns: + continue + try: + excluded_value = _number(_cell(frame, column, position), column=column) + except _NotNumeric: + excluded_value = None + if excluded_value is not None: + record( + ScoreSeverity.NOTE, + "reading_excluded", + f"{column!r} holds {excluded_value:g}, a reading judged " + "anomalous by the operator. It is recorded, not averaged.", + column=column, + ) + + if failed or not used: + if not failed: + record( + ScoreSeverity.ERROR, + "no_inputs_measured", + f"none of {[i.column for i in spec.inputs]} was measured, so " + f"{spec.name!r} cannot be computed for this row.", + ) + values[spec.name].append(float("nan")) + counts[spec.name].append(len(used)) + continue + + try: + value = recipe.apply(used) + except _NotNumeric as exc: + record(ScoreSeverity.ERROR, "recipe_failed", str(exc)) + values[spec.name].append(float("nan")) + counts[spec.name].append(len(used)) + continue + + if spec.spread_warning_ratio is not None and len(raw_used) > 1: + spread = max(raw_used) - min(raw_used) + centre = math.fsum(raw_used) / len(raw_used) + if centre != 0 and spread / abs(centre) > spec.spread_warning_ratio: + record( + ScoreSeverity.WARNING, + "readings_disagree", + f"{len(raw_used)} readings span {spread:g} around a mean of " + f"{centre:g} ({100 * spread / abs(centre):.0f}% of it): " + f"{[f'{v:g}' for v in raw_used]}. The mean may not describe " + "this film.", + ) + + for check in spec.cross_checks: + if check.column not in frame.columns: + record( + ScoreSeverity.NOTE, + "cross_check_absent", + f"cross-check column {check.column!r} is not in the sheet; " + "the computed value stands unchecked.", + column=check.column, + ) + continue + try: + stored = _number(_cell(frame, check.column, position), column=check.column) + except _NotNumeric as exc: + record( + ScoreSeverity.WARNING, + "cross_check_not_numeric", + str(exc), + column=check.column, + ) + continue + if stored is None: + record( + ScoreSeverity.WARNING, + "cross_check_empty", + f"{check.column!r} is empty. If it is a formula column, a " + "non-Excel tool has saved this file and dropped the cached " + "value.", + column=check.column, + ) + continue + difference = abs(value - stored) + if difference > check.atol + 1e-12: + record( + ScoreSeverity.WARNING, + "cross_check_mismatch", + f"computed {value:.10g} against stored {stored:.10g} in " + f"{check.column!r}, a difference of {difference:.3g} above " + f"the {check.atol:g} tolerance. The computed value is what " + "the model uses.", + column=check.column, + ) + + values[spec.name].append(value) + counts[spec.name].append(len(used)) + + if spec.agreement_check is not None: + findings.extend(_agreement_findings(frame, spec, ids)) + + index = frame.index + return MeasurementResult( + values=pd.DataFrame( + {spec.name: values[spec.name] for spec in specs}, index=index, dtype=float + ), + inputs_used=pd.DataFrame( + {spec.name: counts[spec.name] for spec in specs}, index=index, dtype=int + ), + findings=tuple(findings), + ) + + +def row_completeness( + frame: pd.DataFrame, specs: Sequence[MeasurementSpec] +) -> pd.Series: + """Which rows have enough measurements for every objective. + + This is what "has this row been measured yet" means once objectives are + computed rather than read: ``product`` and ``log10_product`` need all their + inputs, ``mean_of_present`` needs one. Round detection uses it, so getting + it wrong either blocks a finished round or advances on a half-filled sheet. + """ + specs = tuple(specs) + frame = _with_stripped_columns(frame) + complete = pd.Series(True, index=frame.index) + for spec in specs: + requires_all = spec.recipe_impl.requires_all + for position in range(len(frame)): + present = 0 + for item in spec.inputs: + if item.column not in frame.columns: + continue + try: + number = _number(_cell(frame, item.column, position), column=item.column) + except _NotNumeric: + number = None + if number is not None: + present += 1 + enough = ( + present == len(spec.inputs) if requires_all else present >= 1 + ) + if not enough: + complete.iloc[position] = False + return complete + + +def describe_findings(findings: Iterable[ScoreFinding]) -> str: + """A short human-readable block, worst first. Empty string when clean.""" + ordered = sorted( + findings, + key=lambda f: ( + {ScoreSeverity.ERROR: 0, ScoreSeverity.WARNING: 1, ScoreSeverity.NOTE: 2}[ + f.severity + ], + f.row_position, + ), + ) + return "\n".join(str(finding) for finding in ordered) diff --git a/src/mobo_kit/sobol_pool.py b/src/mobo_kit/sobol_pool.py new file mode 100644 index 0000000..172dd2f --- /dev/null +++ b/src/mobo_kit/sobol_pool.py @@ -0,0 +1,399 @@ +"""Deterministic nested Sobol prefixes for finite discrete designs.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +from math import prod +from typing import Sequence +import warnings + +import numpy as np +import scipy +from scipy.stats import qmc + +from .candidate_pool import ( + CandidatePool, + CandidatePoolSamplingError, + physical_rows_to_grid_indices, +) +from .constraints import RowConstraint, apply_row_constraints +from .design import Design + + +@dataclass(frozen=True) +class NestedSobolPoolResult: + """Accepted-unique Sobol prefixes and their reproducibility metadata.""" + + pools: dict[int, CandidatePool] + prefix_hashes: dict[int, str] + accepted_sizes: tuple[int, ...] + scramble_seed: int + raw_sobol_draws: int + rejected_duplicate: int + rejected_avoid: int + rejected_constraint: int + ignored_off_grid_observed: int + scipy_version: str + + @property + def largest_pool(self) -> CandidatePool: + """Return the largest accepted prefix.""" + + return self.pools[self.accepted_sizes[-1]] + + @property + def accepted_count(self) -> int: + """Return the accepted count in the final master prefix.""" + + return self.largest_pool.size + + @property + def pools_by_size(self) -> dict[int, CandidatePool]: + """Alias spelling useful to report-building callers.""" + + return self.pools + + +@dataclass(frozen=True) +class _PrefixSnapshot: + draws: int + rejected_duplicate: int + rejected_avoid: int + rejected_constraint: int + + +def _validated_grids(design: Design) -> tuple[np.ndarray, ...]: + if not isinstance(design, Design): + raise TypeError("design must be a Design.") + grids = tuple(np.asarray(grid, dtype=float) for grid in design.var_array) + if not grids or len(grids) != len(design.names): + raise ValueError("design must contain one non-empty grid per input name.") + for name, grid in zip(design.names, grids): + if grid.ndim != 1 or grid.size == 0: + raise ValueError(f"Design grid {name!r} must be a non-empty vector.") + if not np.all(np.isfinite(grid)) or np.unique(grid).size != grid.size: + raise ValueError(f"Design grid {name!r} must be finite and unique.") + return grids + + +def _positive_sizes(values: Sequence[int]) -> tuple[int, ...]: + if isinstance(values, (str, bytes)): + raise TypeError("accepted_sizes must be a non-empty sequence of integers.") + try: + raw = tuple(values) + except TypeError as exc: + raise TypeError( + "accepted_sizes must be a non-empty sequence of integers." + ) from exc + if not raw: + raise ValueError("accepted_sizes must not be empty.") + if any( + isinstance(value, (bool, np.bool_)) + or not isinstance(value, (int, np.integer)) + or int(value) <= 0 + for value in raw + ): + raise ValueError("accepted_sizes must contain only positive integers.") + normalized = tuple(int(value) for value in raw) + if len(set(normalized)) != len(normalized): + raise ValueError("accepted_sizes must not contain duplicates.") + return tuple(sorted(normalized)) + + +def _non_negative_seed(value: int) -> int: + if ( + isinstance(value, (bool, np.bool_)) + or not isinstance(value, (int, np.integer)) + or int(value) < 0 + ): + raise ValueError("scramble_seed must be a non-negative integer.") + return int(value) + + +def _physical_rows( + value: np.ndarray | None, *, name: str, dimension: int +) -> np.ndarray: + if value is None: + return np.empty((0, dimension), dtype=float) + array = np.asarray(value, dtype=float) + if array.ndim != 2 or array.shape[1] != dimension: + raise ValueError(f"{name} must have shape (N, {dimension}); got {array.shape}.") + if not np.all(np.isfinite(array)): + raise ValueError(f"{name} must contain only finite values.") + return array + + +def map_unit_points_to_grid_indices( + unit_points: np.ndarray, + design: Design, +) -> np.ndarray: + """Map points in ``[0, 1)`` to exact discrete grid indices.""" + + grids = _validated_grids(design) + points = np.asarray(unit_points, dtype=float) + if points.ndim != 2 or points.shape[1] != len(grids): + raise ValueError( + "unit_points must have shape (N, n_design_inputs); " + f"got {points.shape} for {len(grids)} inputs." + ) + if not np.all(np.isfinite(points)): + raise ValueError("unit_points must contain only finite values.") + if np.any(points < 0.0) or np.any(points >= 1.0): + raise ValueError("unit_points must lie in the half-open interval [0, 1).") + axis_sizes = np.asarray([grid.size for grid in grids], dtype=np.int64) + indices = np.floor(points * axis_sizes[None, :]).astype(np.int64) + return np.minimum(indices, axis_sizes[None, :] - 1) + + +def _indices_to_physical( + grid_indices: np.ndarray, + grids: tuple[np.ndarray, ...], +) -> np.ndarray: + physical = np.empty(grid_indices.shape, dtype=float) + for column, grid in enumerate(grids): + physical[:, column] = grid[grid_indices[:, column]] + return physical + + +def _normalize_physical(X_phys: np.ndarray, design: Design) -> np.ndarray: + lower = np.asarray(design.lowers, dtype=float) + upper = np.asarray(design.uppers, dtype=float) + spans = upper - lower + normalized = np.zeros_like(X_phys, dtype=float) + changing = spans > 0.0 + normalized[:, changing] = (X_phys[:, changing] - lower[changing]) / spans[changing] + return normalized + + +def hash_grid_index_prefix(grid_indices: np.ndarray) -> str: + """Return a platform-stable SHA-256 for one integer-index prefix.""" + + indices = np.asarray(grid_indices) + if indices.ndim != 2: + raise ValueError("grid_indices must be a two-dimensional integer matrix.") + if not np.issubdtype(indices.dtype, np.integer): + raise TypeError("grid_indices must have an integer dtype.") + canonical = np.ascontiguousarray(indices, dtype=" np.ndarray: + if rows.shape[0] == 0: + return np.empty((0, len(design.names)), dtype=np.int64) + return physical_rows_to_grid_indices(rows, design) + + +def _observed_exclusion_indices( + rows: np.ndarray, + design: Design, +) -> tuple[np.ndarray, int]: + """Partition observed rows, deliberately skipping exact off-grid controls.""" + + accepted: list[np.ndarray] = [] + ignored = 0 + lower = np.asarray(design.lowers, dtype=float) + upper = np.asarray(design.uppers, dtype=float) + for row in rows: + if np.any(row < lower) or np.any(row > upper): + raise ValueError( + "observed_phys rows must remain within the design bounds even " + "when an observed recipe is off-grid." + ) + try: + accepted.append(physical_rows_to_grid_indices(row[None, :], design)[0]) + except ValueError as exc: + if "off-grid" not in str(exc): + raise + ignored += 1 + if not accepted: + return np.empty((0, len(design.names)), dtype=np.int64), ignored + return np.asarray(accepted, dtype=np.int64), ignored + + +def _sobol_prefix(dimension: int, draws: int, seed: int) -> np.ndarray: + engine = qmc.Sobol(d=dimension, scramble=True, seed=seed) + if draws > 0 and draws & (draws - 1) == 0: + return engine.random_base2(int(draws.bit_length() - 1)) + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="The balance properties of Sobol.*", + category=UserWarning, + ) + return engine.random(draws) + + +def build_nested_sobol_discrete_pool( + design: Design, + accepted_sizes: Sequence[int], + *, + scramble_seed: int, + observed_phys: np.ndarray | None = None, + pending_phys: np.ndarray | None = None, + avoid_phys: np.ndarray | None = None, + row_constraints: Sequence[RowConstraint] | None = None, + max_raw_draws: int | None = None, +) -> NestedSobolPoolResult: + """Build exact accepted-unique nested prefixes from one Sobol scramble. + + Observed rows that are not exact grid members are intentionally omitted from + index exclusion (the D2D control is such a row). Pending and explicit avoid + rows must be exact grid members and fail closed otherwise. + """ + + grids = _validated_grids(design) + sizes = _positive_sizes(accepted_sizes) + seed = _non_negative_seed(scramble_seed) + largest = sizes[-1] + if max_raw_draws is None: + draw_limit = max(1024, largest * 50) + elif ( + isinstance(max_raw_draws, (bool, np.bool_)) + or not isinstance(max_raw_draws, (int, np.integer)) + or int(max_raw_draws) <= 0 + ): + raise ValueError("max_raw_draws must be a positive integer.") + else: + draw_limit = int(max_raw_draws) + if draw_limit < largest: + raise CandidatePoolSamplingError( + requested=largest, + accepted=0, + draws=0, + max_draws=draw_limit, + rejected_duplicate=0, + rejected_avoid=0, + rejected_constraint=0, + reason="max_raw_draws is smaller than the requested accepted prefix.", + ) + + dimension = len(grids) + observed = _physical_rows(observed_phys, name="observed_phys", dimension=dimension) + pending = _physical_rows(pending_phys, name="pending_phys", dimension=dimension) + explicit_avoid = _physical_rows(avoid_phys, name="avoid_phys", dimension=dimension) + observed_indices, ignored_off_grid = _observed_exclusion_indices(observed, design) + pending_indices = _strict_exclusion_indices(pending, design) + avoid_indices = _strict_exclusion_indices(explicit_avoid, design) + exclusions = np.vstack([observed_indices, pending_indices, avoid_indices]) + avoid_set = {tuple(int(value) for value in row) for row in exclusions} + + total_grid_size = prod(int(grid.size) for grid in grids) + available = total_grid_size - len(avoid_set) + if largest > available: + raise CandidatePoolSamplingError( + requested=largest, + accepted=0, + draws=0, + max_draws=draw_limit, + rejected_duplicate=0, + rejected_avoid=0, + rejected_constraint=0, + reason=( + "The request exceeds the number of grid tuples remaining after " + f"exact exclusions ({available})." + ), + ) + + seen: set[tuple[int, ...]] = set() + accepted: list[tuple[int, ...]] = [] + snapshots: dict[int, _PrefixSnapshot] = {} + draws = rejected_duplicate = rejected_avoid = rejected_constraint = 0 + prefix_draws = 1 << max(0, (largest - 1).bit_length()) + + while len(accepted) < largest and draws < draw_limit: + target_draws = min(prefix_draws, draw_limit) + points = _sobol_prefix(dimension, target_draws, seed) + new_indices = map_unit_points_to_grid_indices( + points[draws:target_draws], design + ) + for row in new_indices: + draws += 1 + key = tuple(int(value) for value in row) + if key in seen: + rejected_duplicate += 1 + continue + seen.add(key) + if key in avoid_set: + rejected_avoid += 1 + continue + if row_constraints: + physical = _indices_to_physical(row[None, :], grids) + if not bool( + apply_row_constraints(physical, design, row_constraints)[0] + ): + rejected_constraint += 1 + continue + accepted.append(key) + accepted_count = len(accepted) + if accepted_count in sizes: + snapshots[accepted_count] = _PrefixSnapshot( + draws=draws, + rejected_duplicate=rejected_duplicate, + rejected_avoid=rejected_avoid, + rejected_constraint=rejected_constraint, + ) + if accepted_count == largest: + break + if target_draws == draw_limit: + break + prefix_draws *= 2 + + if len(accepted) != largest: + raise CandidatePoolSamplingError( + requested=largest, + accepted=len(accepted), + draws=draws, + max_draws=draw_limit, + rejected_duplicate=rejected_duplicate, + rejected_avoid=rejected_avoid, + rejected_constraint=rejected_constraint, + reason=( + "Maximum raw Sobol draws reached; exact exclusions and constraints " + "were not relaxed." + ), + ) + + all_indices = np.asarray(accepted, dtype=np.int64) + pools: dict[int, CandidatePool] = {} + hashes: dict[int, str] = {} + for size in sizes: + indices = all_indices[:size].copy() + physical = _indices_to_physical(indices, grids) + snapshot = snapshots[size] + pools[size] = CandidatePool( + grid_indices=indices, + X_phys=physical, + X_norm=_normalize_physical(physical, design), + seed=seed, + draws=snapshot.draws, + rejected_duplicate=snapshot.rejected_duplicate, + rejected_avoid=snapshot.rejected_avoid, + rejected_constraint=snapshot.rejected_constraint, + ) + hashes[size] = hash_grid_index_prefix(indices) + + return NestedSobolPoolResult( + pools=pools, + prefix_hashes=hashes, + accepted_sizes=sizes, + scramble_seed=seed, + raw_sobol_draws=draws, + rejected_duplicate=rejected_duplicate, + rejected_avoid=rejected_avoid, + rejected_constraint=rejected_constraint, + ignored_off_grid_observed=ignored_off_grid, + scipy_version=scipy.__version__, + ) + + +__all__ = [ + "NestedSobolPoolResult", + "build_nested_sobol_discrete_pool", + "hash_grid_index_prefix", + "map_unit_points_to_grid_indices", +] diff --git a/src/mobo_kit/structured_mean.py b/src/mobo_kit/structured_mean.py new file mode 100644 index 0000000..eca12e1 --- /dev/null +++ b/src/mobo_kit/structured_mean.py @@ -0,0 +1,303 @@ +"""Physics-informed mean functions for small-data GPs. + +With 15 observations in 10 dimensions a zero-mean GP spends most of its capacity +rediscovering a trend that is already known from process physics. Giving it that +trend as a mean function, and letting the GP model only the residual, roughly +doubles the leave-one-out fit on this campaign's data. + +EVERY NUMBER BELOW IS FROM THE FIRST CAMPAIGN, contract +``d2d-objectives-v2-nm-thickness``, on ``Summary Table.xlsx``. Objectives have +been redefined twice since. Read them as the record of how these shapes were +chosen, NOT as current fits -- and see the second bullet for one that has since +been measured false. + +* **thickness** -- ``log T ~ log(speed_1) + log(precur_conc)``. Spin-coating + theory gives ``T ~ omega^-0.5``; the measured exponent was -0.38 on v2 and is + -0.255 on the current workbook. Neither term alone is worth much (LOO R2 + +0.159 and +0.187); the *pair* carries the signal (+0.449 on v2, and the shape + still holds on v4). Modelled in log space, so the response is lognormal. + **WITHDRAWN FROM THE LIVE CONFIG 2026-09-06.** The shape transfers, but the + physics justification does not: the exponent's 95% interval on v4 is + [-0.385, -0.126], which EXCLUDES the textbook -0.5 by 4.1 standard errors, and + fixing the exponents at theory scores +0.5600 against +0.5823 for no trend at + all. What survives is that the VARIABLE choice beats matched-flexibility + controls (four unmotivated pairs scored +0.30 to +0.40, all below the plain GP) + -- the magnitudes were fitted, not predicted. This module stays wired and + tested for a prior that clears the bar; nothing currently does. +* **optoelectronic** -- a single linear term on ``anneal_temp`` and nothing else, + LOO R2 +0.244 ON V2. **IT DOES NOT TRANSFER AND IS NO LONGER DECLARED + ANYWHERE.** v2's optoelectronic was ``log10(Voc x Photoconductance)``; the + current contract's is a different quantity, and on it the same mean function + scores **-1.0721** (and -0.7452 on raw Voc), far below even a constant. The + key was removed from the live config on the v3 intake verdict and nothing since + has argued for reinstating it. Measured 2026-09-04; reproduce with + ``scripts/raw_component_screen.py --spec + '[{"name":"x","expr":"score_opto","mean_features":["anneal_temp"]}]'``. + +Do not assume the two-term shape generalises: these are opposite patterns, and +one of them turned out to be about an objective that no longer exists. + +The linear coefficients are refit on the training rows of every fold, so +cross-validation stays honest. Feature *choice* is fixed in configuration from +physics, before fitting -- it is not selected against the outcome. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal, Mapping, Sequence + +import gpytorch +import numpy as np +import torch + +__all__ = [ + "MeanFeature", + "StructuredMeanSpec", + "StructuredPosterior", + "StructuredMean", + "build_structured_mean", + "fit_structured_mean", + "mean_spec_from_config", +] + +Link = Literal["identity", "log"] + + +@dataclass(frozen=True) +class MeanFeature: + """One column of the linear mean's design matrix.""" + + column: str + transform: Link = "identity" + + def evaluate(self, values: np.ndarray) -> np.ndarray: + if self.transform == "identity": + return values + if self.transform == "log": + if np.any(values <= 0): + raise ValueError( + f"Mean feature {self.column!r} uses a log transform but the " + "column contains non-positive values." + ) + return np.log(values) + raise ValueError(f"Unsupported mean-feature transform {self.transform!r}.") + + +@dataclass(frozen=True) +class StructuredMeanSpec: + """A linear trend removed before GP fitting and added back after. + + ``response`` is the space the GP works in. ``log`` means the GP models + ``log y``, which makes ``y`` lognormal -- the utility expectation must then + use Gauss-Hermite quadrature, not the Gaussian closed form. + """ + + response: Link + features: tuple[MeanFeature, ...] + + def __post_init__(self) -> None: + if self.response not in ("identity", "log"): + raise ValueError(f"Unsupported response link {self.response!r}.") + if not self.features: + raise ValueError("A structured mean needs at least one feature.") + names = [feature.column for feature in self.features] + if len(set(names)) != len(names): + raise ValueError(f"Mean features must be unique; got {names}.") + + def design_matrix( + self, X_phys: np.ndarray, input_names: Sequence[str] + ) -> np.ndarray: + columns = [] + for feature in self.features: + try: + index = list(input_names).index(feature.column) + except ValueError as exc: + raise ValueError( + f"Mean feature {feature.column!r} is not a declared input." + ) from exc + columns.append(feature.evaluate(np.asarray(X_phys, float)[:, index])) + return np.column_stack(columns) + + +@dataclass(frozen=True) +class StructuredPosterior: + """Posterior in the response space, plus the link needed to interpret it. + + When ``link == "log"`` these are the mean and variance of ``log y``, so the + utility expectation must integrate a lognormal. + """ + + mean: np.ndarray + variance: np.ndarray + link: Link + + +def _ols(F: np.ndarray, target: np.ndarray) -> np.ndarray: + """Least squares with an intercept, returned as coefficients on [1, F].""" + design = np.column_stack([np.ones(len(F)), F]) + coefficients, *_ = np.linalg.lstsq(design, target, rcond=None) + return coefficients + + +def fit_structured_mean( + X_phys: np.ndarray, + y: np.ndarray, + spec: StructuredMeanSpec, + input_names: Sequence[str], +) -> tuple[np.ndarray, np.ndarray]: + """Return the linear-mean coefficients and the residuals to hand the GP. + + Call this with the TRAINING rows only. Refitting per fold is what keeps + cross-validation honest; fitting once on everything and holding it fixed + leaks the held-out value into the trend. + """ + values = np.asarray(y, dtype=float) + if spec.response == "log": + if np.any(values <= 0): + raise ValueError("A log response requires strictly positive observations.") + target = np.log(values) + else: + target = values + F = spec.design_matrix(X_phys, input_names) + coefficients = _ols(F, target) + fitted = np.column_stack([np.ones(len(F)), F]) @ coefficients + return coefficients, target - fitted + + +def apply_structured_mean( + coefficients: np.ndarray, + X_phys: np.ndarray, + spec: StructuredMeanSpec, + input_names: Sequence[str], + residual_mean: np.ndarray, + residual_variance: np.ndarray, +) -> StructuredPosterior: + """Add the linear trend back to a GP residual posterior.""" + F = spec.design_matrix(X_phys, input_names) + trend = np.column_stack([np.ones(len(F)), F]) @ coefficients + return StructuredPosterior( + mean=np.asarray(residual_mean, float) + trend, + variance=np.asarray(residual_variance, float), + link=spec.response, + ) + + +def mean_spec_from_config(entry: Mapping[str, Any]) -> StructuredMeanSpec | None: + """Build a spec from one objective's ``mean_function`` block, if present.""" + block = entry.get("mean_function") + if block is None: + return None + if not isinstance(block, Mapping): + raise ValueError("mean_function must be a mapping.") + raw_features = block.get("features") + if not isinstance(raw_features, Sequence) or not raw_features: + raise ValueError("mean_function.features must be a non-empty list.") + features = [] + for item in raw_features: + if isinstance(item, str): + features.append(MeanFeature(item)) + elif isinstance(item, Mapping): + features.append( + MeanFeature(str(item["column"]), str(item.get("transform", "identity"))) + ) + else: + raise ValueError("Each mean feature must be a string or a mapping.") + return StructuredMeanSpec( + response=str(block.get("response", "identity")), + features=tuple(features), + ) + + +# --------------------------------------------------------------------------- # +# as a GPyTorch mean module +# --------------------------------------------------------------------------- # + + +class StructuredMean(gpytorch.means.Mean): + """The linear trend as a GP mean module, with the coefficients frozen. + + This is the two-stage pipeline -- OLS detrend, zero-mean GP on the residual -- + expressed as a single model. ``posterior()`` is then correct by + construction: there is no trend to add back afterwards and therefore no code + path that can forget to. + + Coefficients are registered as **buffers, not parameters**, so the marginal + likelihood fits only the GP hyperparameters and leaves the OLS fit alone. + + The module operates in the model's *standardized* outcome space, because + ``SingleTaskGP`` is built with ``Standardize(m=1)``. Getting that wrong is + silent: the trend comes out shifted and scaled, and the model still looks + plausible. :func:`build_structured_mean` handles the conversion. + """ + + def __init__( + self, + lowers: torch.Tensor, + uppers: torch.Tensor, + feature_columns: Sequence[int], + feature_logs: Sequence[bool], + coefficients: torch.Tensor, + bias: torch.Tensor, + ) -> None: + super().__init__() + self.register_buffer("lowers", lowers) + self.register_buffer("uppers", uppers) + self.register_buffer("coefficients", coefficients) + self.register_buffer("bias", bias) + self.feature_columns = tuple(int(c) for c in feature_columns) + self.feature_logs = tuple(bool(f) for f in feature_logs) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + # the GP sees normalized inputs; the trend is defined on physical ones + physical = self.lowers + x * (self.uppers - self.lowers) + columns = [] + for position, use_log in zip(self.feature_columns, self.feature_logs): + value = physical[..., position] + columns.append(torch.log(value) if use_log else value) + features = torch.stack(columns, dim=-1) + return (features * self.coefficients).sum(dim=-1) + self.bias + + +def build_structured_mean( + X_phys: np.ndarray, + y: np.ndarray, + spec: StructuredMeanSpec, + input_names: Sequence[str], + lowers: np.ndarray, + uppers: np.ndarray, +) -> tuple["StructuredMean", np.ndarray]: + """Fit the trend on these rows and return it as a mean module. + + Returns the module plus the response-space target the GP should train on + (``log y`` for a log response, ``y`` otherwise). Pass only TRAINING rows; + refitting per fold is what keeps cross-validation honest. + """ + values = np.asarray(y, dtype=float) + if spec.response == "log": + if np.any(values <= 0): + raise ValueError("A log response requires strictly positive observations.") + target = np.log(values) + else: + target = values + + coefficients = _ols(spec.design_matrix(X_phys, input_names), target) + intercept, slopes = float(coefficients[0]), coefficients[1:] + + # SingleTaskGP standardizes the outcome, so express the trend in that space: + # m_std(x) = (m_raw(x) - mu) / sigma + mu = float(np.mean(target)) + sigma = float(np.std(target, ddof=1)) + if not np.isfinite(sigma) or sigma <= 0: + sigma = 1.0 + + names = list(input_names) + module = StructuredMean( + lowers=torch.tensor(np.asarray(lowers, float), dtype=torch.double), + uppers=torch.tensor(np.asarray(uppers, float), dtype=torch.double), + feature_columns=[names.index(f.column) for f in spec.features], + feature_logs=[f.transform == "log" for f in spec.features], + coefficients=torch.tensor(slopes / sigma, dtype=torch.double), + bias=torch.tensor((intercept - mu) / sigma, dtype=torch.double), + ) + return module, target diff --git a/src/mobo_kit/ucb_hvi.py b/src/mobo_kit/ucb_hvi.py new file mode 100644 index 0000000..be7ecf3 --- /dev/null +++ b/src/mobo_kit/ucb_hvi.py @@ -0,0 +1,860 @@ +"""Discrete multi-objective UCB-HVI scoring in transformed utility space. + +The module deliberately separates posterior sampling from deterministic +hypervolume arithmetic. Nonlinear objective transforms are applied to every +posterior Monte Carlo sample before utility moments are formed. All objective +dimensions are maximized after transformation, and the reference point is +always supplied explicitly by the caller. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import sqrt +from numbers import Real +from typing import Any, Callable, Literal, Sequence + +import numpy as np +import torch +from botorch.sampling.normal import SobolQMCNormalSampler +from botorch.utils.multi_objective.hypervolume import Hypervolume +from botorch.utils.multi_objective.pareto import is_non_dominated + + +TensorTransform = Callable[[torch.Tensor], torch.Tensor] +MomentMethod = Literal["monte_carlo", "analytic_identity"] +UCBBoundPolicy = Literal["none", "clip_ucb"] +UtilityBound = tuple[float | None, float | None] + + +@dataclass(frozen=True) +class PosteriorIdentityMoments: + """Exact posterior moments for an all-identity maximize contract.""" + + utility_mean: torch.Tensor + utility_std: torch.Tensor + observation_noise: bool + objective_contract_version: str + moment_method: str = "analytic_identity" + + +@dataclass(frozen=True) +class BoundedUCBResult: + """Raw and policy-effective UCB vectors plus non-negative clip amounts.""" + + utility_ucb_raw: np.ndarray + utility_ucb_effective: np.ndarray + utility_ucb_clip_amount: np.ndarray + policy: str + bounds: tuple[UtilityBound, ...] + + +@dataclass(frozen=True) +class PosteriorUtilityMoments: + """Monte Carlo moments in all-maximize transformed utility space.""" + + utility_mean: np.ndarray + utility_std: np.ndarray + mc_samples: int + seed: int + observation_noise: bool + standard_deviation_correction: int = 0 + moment_method: str = "monte_carlo" + + +@dataclass(frozen=True) +class UCBHVIScoreResult: + """Candidate-wise optimistic utilities and hypervolume improvements.""" + + base_score: np.ndarray + base_log_score: np.ndarray + utility_mean: np.ndarray + utility_std: np.ndarray + utility_ucb: np.ndarray + baseline_hypervolume: float + pareto_utility: np.ndarray + reference_point_utility: np.ndarray + beta: float + kappa: float + mc_samples: int | None + seed: int | None + observation_noise: bool + objective_contract_version: str + moment_method: str = "monte_carlo" + bound_policy: str = "none" + utility_bounds: tuple[UtilityBound, ...] | None = None + utility_ucb_raw: np.ndarray | None = None + utility_ucb_effective: np.ndarray | None = None + utility_ucb_clip_amount: np.ndarray | None = None + method: str = "ucb_hvi" + method_version: str = "step2a-v1" + + def __post_init__(self) -> None: + effective = ( + np.asarray(self.utility_ucb, dtype=float) + if self.utility_ucb_effective is None + else np.asarray(self.utility_ucb_effective, dtype=float) + ) + raw = ( + effective.copy() + if self.utility_ucb_raw is None + else np.asarray(self.utility_ucb_raw, dtype=float) + ) + clip_amount = ( + np.abs(raw - effective) + if self.utility_ucb_clip_amount is None + else np.asarray(self.utility_ucb_clip_amount, dtype=float) + ) + expected_shape = np.asarray(self.utility_mean).shape + if ( + effective.shape != expected_shape + or raw.shape != expected_shape + or clip_amount.shape != expected_shape + ): + raise ValueError( + "Raw/effective UCB and clip amounts must align with utility_mean." + ) + object.__setattr__(self, "utility_ucb", effective) + object.__setattr__(self, "utility_ucb_raw", raw) + object.__setattr__(self, "utility_ucb_effective", effective) + object.__setattr__(self, "utility_ucb_clip_amount", clip_amount) + + +@dataclass(frozen=True) +class UCBHVIBatchProposal: + """A locally penalized batch plus its complete static UCB-HVI score table.""" + + selection: Any + scoring: UCBHVIScoreResult + positive_score_tolerance: float + metadata: dict[str, Any] + + +def _finite_nonnegative(value: float, *, name: str) -> float: + if isinstance(value, (bool, np.bool_)) or not isinstance(value, Real): + raise ValueError(f"{name} must be a real non-boolean number; got {value!r}.") + number = float(value) + if not np.isfinite(number) or number < 0: + raise ValueError(f"{name} must be finite and non-negative; got {value!r}.") + return number + + +def _positive_integer(value: int, *, name: str) -> int: + if isinstance(value, bool) or not isinstance(value, (int, np.integer)): + raise ValueError(f"{name} must be a positive integer; got {value!r}.") + result = int(value) + if result <= 0: + raise ValueError(f"{name} must be a positive integer; got {value!r}.") + return result + + +def _apply_objective_transform(transform: Any, samples: torch.Tensor) -> torch.Tensor: + if hasattr(transform, "transform"): + utility = transform.transform(samples) + elif callable(transform): + utility = transform(samples) + else: + raise TypeError("objective_transform must be callable or expose transform().") + if not isinstance(utility, torch.Tensor): + raise TypeError("objective_transform must return a torch.Tensor.") + if utility.shape != samples.shape: + raise ValueError( + "objective_transform must preserve shape; " + f"got {tuple(samples.shape)} -> {tuple(utility.shape)}." + ) + if not torch.isfinite(utility).all(): + raise ValueError("objective_transform produced non-finite utility values.") + return utility + + +def _posterior(model: Any, X: torch.Tensor, observation_noise: bool) -> Any: + try: + return model.posterior(X, observation_noise=observation_noise) + except TypeError as exc: + raise TypeError( + "model.posterior must accept the explicit observation_noise keyword." + ) from exc + + +def _identity_objective_contract(objective_transform: Any) -> Any: + if hasattr(objective_transform, "bounds"): + raise ValueError( + "analytic identity moments cannot be used with a posterior-sample " + "bounds wrapper; apply an explicit UCB bound policy instead." + ) + contract = getattr(objective_transform, "objective_transform", objective_transform) + specs = getattr(contract, "specs", None) + if not specs: + raise ValueError( + "analytic identity moments require an explicit versioned objective " + "contract with objective specifications." + ) + if any( + getattr(spec, "transform", None) != "identity" + or getattr(spec, "goal", None) != "maximize" + or bool(getattr(spec, "clip", False)) + for spec in specs + ): + raise ValueError( + "analytic identity moments require every objective to use the " + "identity transform with maximize direction and no clipping." + ) + version = getattr(contract, "version", None) + if not isinstance(version, str) or not version.strip(): + raise ValueError( + "analytic identity moments require a non-empty objective contract version." + ) + return contract + + +def posterior_identity_moments( + model: Any, + X_pool_norm: torch.Tensor, + objective_transform: Any, + *, + chunk_size: int = 512, + observation_noise: bool = False, +) -> PosteriorIdentityMoments: + """Return exact posterior mean and standard deviation for identity utilities. + + Tensors remain on the posterior's device and retain its dtype and any model + batch dimensions. Candidate rows are concatenated along the posterior q + dimension, making the result invariant to the requested evaluation chunk. + """ + if not isinstance(X_pool_norm, torch.Tensor) or X_pool_norm.ndim != 2: + shape = getattr(X_pool_norm, "shape", None) + raise ValueError( + f"X_pool_norm must be a tensor with shape (N, D); got {shape}." + ) + if not X_pool_norm.is_floating_point(): + raise TypeError("X_pool_norm must use a floating dtype.") + if X_pool_norm.shape[0] == 0: + raise ValueError("X_pool_norm must contain at least one candidate.") + if not torch.isfinite(X_pool_norm).all(): + raise ValueError("X_pool_norm must contain only finite values.") + chunk = _positive_integer(chunk_size, name="chunk_size") + if not isinstance(observation_noise, bool): + raise ValueError("observation_noise must be a boolean.") + contract = _identity_objective_contract(objective_transform) + objective_count = len(contract.specs) + + mean_parts: list[torch.Tensor] = [] + std_parts: list[torch.Tensor] = [] + with torch.no_grad(): + for start in range(0, X_pool_norm.shape[0], chunk): + X_chunk = X_pool_norm[start : start + chunk] + posterior = _posterior(model, X_chunk, observation_noise) + mean = posterior.mean + variance = posterior.variance + if not isinstance(mean, torch.Tensor) or not isinstance( + variance, torch.Tensor + ): + raise TypeError("model.posterior mean and variance must be tensors.") + if mean.shape != variance.shape: + raise ValueError("Posterior mean and variance shapes must match.") + if mean.ndim < 2 or mean.shape[-2] != X_chunk.shape[0]: + raise ValueError( + "Posterior candidate dimension must align with the input chunk; " + f"got mean shape {tuple(mean.shape)} for {X_chunk.shape[0]} rows." + ) + if mean.shape[-1] != objective_count: + raise ValueError( + "Posterior objective dimension does not match the identity " + f"contract; expected {objective_count}, got {mean.shape[-1]}." + ) + if not mean.is_floating_point() or not variance.is_floating_point(): + raise TypeError("Posterior mean and variance must use floating dtypes.") + if mean.dtype != variance.dtype or mean.device != variance.device: + raise ValueError( + "Posterior mean and variance must share dtype and device." + ) + if not torch.isfinite(mean).all() or not torch.isfinite(variance).all(): + raise ValueError("Posterior mean and variance must be finite.") + tolerance = 100.0 * torch.finfo(variance.dtype).eps + if torch.any(variance < -tolerance): + raise ValueError("Posterior variance cannot be materially negative.") + mean_parts.append(mean) + std_parts.append(variance.clamp_min(0.0).sqrt()) + + try: + means = torch.cat(mean_parts, dim=-2) + standard_deviations = torch.cat(std_parts, dim=-2) + except RuntimeError as exc: + raise ValueError( + "Posterior batch shape, dtype, and device must remain stable across chunks." + ) from exc + return PosteriorIdentityMoments( + utility_mean=means, + utility_std=standard_deviations, + observation_noise=observation_noise, + objective_contract_version=contract.version.strip(), + ) + + +def posterior_utility_moments( + model: Any, + X_pool_norm: torch.Tensor, + objective_transform: TensorTransform | Any, + *, + mc_samples: int = 128, + seed: int = 0, + chunk_size: int = 512, + observation_noise: bool = False, +) -> PosteriorUtilityMoments: + """Estimate utility moments for a normalized ``(N, D)`` candidate pool. + + Candidates are represented as independent singleton q-batches ``N x 1 x D``. + A seeded Sobol sampler is rebuilt for each chunk. BoTorch collapses the + singleton batch dimensions in its base-sample shape, so every candidate sees + the same reproducible QMC normal draws and results are invariant to chunking. + Population standard deviation (``correction=0``) is reported. + """ + if not isinstance(X_pool_norm, torch.Tensor) or X_pool_norm.ndim != 2: + shape = getattr(X_pool_norm, "shape", None) + raise ValueError( + f"X_pool_norm must be a tensor with shape (N, D); got {shape}." + ) + if not X_pool_norm.is_floating_point(): + raise TypeError("X_pool_norm must use a floating dtype.") + if not torch.isfinite(X_pool_norm).all(): + raise ValueError("X_pool_norm must contain only finite values.") + sample_count = _positive_integer(mc_samples, name="mc_samples") + chunk = _positive_integer(chunk_size, name="chunk_size") + if ( + isinstance(seed, (bool, np.bool_)) + or not isinstance(seed, (int, np.integer)) + or int(seed) < 0 + ): + raise ValueError("seed must be a non-negative integer.") + if not isinstance(observation_noise, bool): + raise ValueError("observation_noise must be a boolean.") + + mean_parts: list[torch.Tensor] = [] + std_parts: list[torch.Tensor] = [] + with torch.no_grad(): + for start in range(0, X_pool_norm.shape[0], chunk): + X_chunk = X_pool_norm[start : start + chunk].unsqueeze(-2) + posterior = _posterior(model, X_chunk, observation_noise) + sampler = SobolQMCNormalSampler( + sample_shape=torch.Size([sample_count]), seed=int(seed) + ) + raw_samples = sampler(posterior) + utility_samples = _apply_objective_transform( + objective_transform, raw_samples + ) + if utility_samples.shape[-2] != 1: + raise ValueError( + "Singleton posterior evaluation must retain q=1 in the " + f"penultimate dimension; got {tuple(utility_samples.shape)}." + ) + utility_samples = utility_samples.squeeze(-2) + mean_parts.append(utility_samples.mean(dim=0)) + std_parts.append(utility_samples.std(dim=0, correction=0)) + + if mean_parts: + means = torch.cat(mean_parts, dim=0) + standard_deviations = torch.cat(std_parts, dim=0) + else: + # The output dimension cannot be discovered safely without a posterior. + raise ValueError("X_pool_norm must contain at least one candidate.") + return PosteriorUtilityMoments( + utility_mean=means.detach().cpu().double().numpy(), + utility_std=standard_deviations.detach().cpu().double().numpy(), + mc_samples=sample_count, + seed=int(seed), + observation_noise=bool(observation_noise), + ) + + +def _utility_matrix(value: np.ndarray | torch.Tensor, *, name: str) -> np.ndarray: + if isinstance(value, torch.Tensor): + array = value.detach().cpu().double().numpy() + else: + array = np.asarray(value, dtype=float) + if array.ndim != 2: + raise ValueError(f"{name} must have shape (N, M); got {array.shape}.") + if array.shape[1] == 0: + raise ValueError(f"{name} must include at least one objective.") + if not np.all(np.isfinite(array)): + raise ValueError(f"{name} must contain only finite values.") + return array + + +def _validated_utility_bounds( + bounds: Sequence[UtilityBound] | None, + objective_count: int, + *, + require_bounded: bool, +) -> tuple[UtilityBound, ...]: + if bounds is None: + if require_bounded: + raise ValueError("clip_ucb requires explicit per-objective utility bounds.") + return tuple((None, None) for _ in range(objective_count)) + if isinstance(bounds, (str, bytes)): + raise TypeError("bounds must be an ordered sequence of (lower, upper) pairs.") + try: + raw_bounds = tuple(bounds) + except TypeError as exc: + raise TypeError( + "bounds must be an ordered sequence of (lower, upper) pairs." + ) from exc + if len(raw_bounds) != objective_count: + raise ValueError( + "bounds must contain one (lower, upper) pair per objective; " + f"expected {objective_count}, got {len(raw_bounds)}." + ) + validated: list[UtilityBound] = [] + bounded_count = 0 + for index, raw_bound in enumerate(raw_bounds): + if isinstance(raw_bound, (str, bytes)): + raise TypeError(f"bounds[{index}] must be a (lower, upper) pair.") + try: + pair = tuple(raw_bound) + except TypeError as exc: + raise TypeError(f"bounds[{index}] must be a (lower, upper) pair.") from exc + if len(pair) != 2: + raise ValueError(f"bounds[{index}] must contain exactly two values.") + converted: list[float | None] = [] + for label, value in zip(("lower", "upper"), pair): + if value is None: + converted.append(None) + continue + if isinstance(value, (bool, np.bool_)) or not isinstance(value, Real): + raise ValueError( + f"bounds[{index}] {label} must be finite, real, and non-boolean." + ) + number = float(value) + if not np.isfinite(number): + raise ValueError(f"bounds[{index}] {label} must be finite.") + converted.append(number) + lower, upper = converted + if lower is not None and upper is not None and lower > upper: + raise ValueError( + f"bounds[{index}] lower value must not exceed its upper value." + ) + if lower is not None or upper is not None: + bounded_count += 1 + validated.append((lower, upper)) + if require_bounded and bounded_count == 0: + raise ValueError("clip_ucb requires at least one finite utility bound.") + return tuple(validated) + + +def apply_ucb_bound_policy( + raw_ucb: np.ndarray | torch.Tensor, + bounds: Sequence[UtilityBound] | None, + policy: UCBBoundPolicy | str, +) -> BoundedUCBResult: + """Apply an explicit optimistic-utility policy without changing targets.""" + raw = _utility_matrix(raw_ucb, name="raw_ucb").copy() + if policy not in {"none", "clip_ucb"}: + raise ValueError("policy must be exactly 'none' or 'clip_ucb'.") + validated = _validated_utility_bounds( + bounds, raw.shape[1], require_bounded=policy == "clip_ucb" + ) + effective = raw.copy() + if policy == "clip_ucb": + for index, (lower, upper) in enumerate(validated): + effective[:, index] = np.clip( + effective[:, index], + -np.inf if lower is None else lower, + np.inf if upper is None else upper, + ) + return BoundedUCBResult( + utility_ucb_raw=raw, + utility_ucb_effective=effective, + utility_ucb_clip_amount=np.abs(raw - effective), + policy=str(policy), + bounds=validated, + ) + + +def _reference_point( + value: np.ndarray | torch.Tensor | None, objective_count: int +) -> np.ndarray: + if value is None: + raise ValueError( + "reference_point_utility is required and is never derived from data." + ) + if isinstance(value, torch.Tensor): + reference = value.detach().cpu().double().numpy() + else: + reference = np.asarray(value, dtype=float) + if reference.shape != (objective_count,): + raise ValueError( + "reference_point_utility must have shape " + f"({objective_count},); got {reference.shape}." + ) + if not np.all(np.isfinite(reference)): + raise ValueError("reference_point_utility must contain only finite values.") + return reference + + +def pareto_utility_above_reference( + observed_utility: np.ndarray | torch.Tensor, + reference_point_utility: np.ndarray | torch.Tensor, +) -> np.ndarray: + """Return finite non-dominated utilities that strictly dominate the reference.""" + observed = _utility_matrix(observed_utility, name="observed_utility") + reference = _reference_point(reference_point_utility, observed.shape[1]) + contributing = observed[np.all(observed > reference, axis=1)] + if contributing.shape[0] == 0: + return np.empty((0, observed.shape[1]), dtype=float) + tensor = torch.as_tensor(contributing, dtype=torch.double) + return tensor[is_non_dominated(tensor)].numpy() + + +def hypervolume_improvement_scores( + optimistic_utility: np.ndarray | torch.Tensor, + observed_utility: np.ndarray | torch.Tensor, + reference_point_utility: np.ndarray | torch.Tensor | None, + *, + numeric_tolerance: float = 1e-12, + chunk_size: int = 1024, +) -> tuple[np.ndarray, float, np.ndarray, np.ndarray]: + """Compute exact singleton HVI for optimistic all-maximize utility vectors.""" + candidates = _utility_matrix(optimistic_utility, name="optimistic_utility") + observed = _utility_matrix(observed_utility, name="observed_utility") + if candidates.shape[1] != observed.shape[1]: + raise ValueError("Candidate and observed utility dimensions must match.") + reference = _reference_point(reference_point_utility, observed.shape[1]) + tolerance = _finite_nonnegative(numeric_tolerance, name="numeric_tolerance") + chunk = _positive_integer(chunk_size, name="chunk_size") + pareto = pareto_utility_above_reference(observed, reference) + ref_tensor = torch.as_tensor(reference, dtype=torch.double) + hypervolume = Hypervolume(ref_point=ref_tensor) + baseline = ( + 0.0 + if pareto.shape[0] == 0 + else float(hypervolume.compute(torch.as_tensor(pareto, dtype=torch.double))) + ) + + scores = np.zeros(candidates.shape[0], dtype=float) + for start in range(0, candidates.shape[0], chunk): + stop = min(start + chunk, candidates.shape[0]) + for index in range(start, stop): + candidate = candidates[index] + if not np.all(candidate > reference): + continue + if pareto.shape[0] and np.any( + np.all(pareto >= candidate - tolerance, axis=1) + ): + continue + augmented = np.vstack([pareto, candidate[None, :]]) + augmented_tensor = torch.as_tensor(augmented, dtype=torch.double) + augmented_pareto = augmented_tensor[is_non_dominated(augmented_tensor)] + improvement = float(hypervolume.compute(augmented_pareto)) - baseline + if improvement < -tolerance: + raise RuntimeError( + "Hypervolume improvement was negative beyond numeric " + f"tolerance: candidate_index={index}, improvement={improvement}, " + f"tolerance={tolerance}." + ) + if improvement > tolerance: + scores[index] = improvement + return scores, baseline, pareto, reference + + +def score_ucb_hvi_from_moments( + utility_mean: np.ndarray, + utility_std: np.ndarray, + observed_utility: np.ndarray, + reference_point_utility: np.ndarray | None, + *, + beta: float, + numeric_tolerance: float = 1e-12, + chunk_size: int = 1024, + log_epsilon: float = 1e-12, + mc_samples: int | None = None, + seed: int | None = None, + observation_noise: bool = False, + objective_contract_version: str = "direct-moments", + moment_method: str = "direct_moments", + bound_policy: UCBBoundPolicy | str = "none", + utility_bounds: Sequence[UtilityBound] | None = None, +) -> UCBHVIScoreResult: + """Form utility UCB vectors and score their singleton hypervolume gain.""" + means = _utility_matrix(utility_mean, name="utility_mean") + standard_deviations = _utility_matrix(utility_std, name="utility_std") + if standard_deviations.shape != means.shape: + raise ValueError("utility_std must have the same shape as utility_mean.") + if np.any(standard_deviations < 0): + raise ValueError("utility_std cannot contain negative values.") + beta_value = _finite_nonnegative(beta, name="beta") + if isinstance(log_epsilon, (bool, np.bool_)): + raise ValueError("log_epsilon must be a real non-boolean number.") + epsilon = float(log_epsilon) + if not np.isfinite(epsilon) or epsilon <= 0: + raise ValueError("log_epsilon must be finite and strictly positive.") + if ( + not isinstance(objective_contract_version, str) + or not objective_contract_version.strip() + ): + raise ValueError("objective_contract_version must be a non-empty string.") + if not isinstance(moment_method, str) or not moment_method.strip(): + raise ValueError("moment_method must be a non-empty string.") + kappa = sqrt(beta_value) + optimistic_raw = means + kappa * standard_deviations + bounded = apply_ucb_bound_policy(optimistic_raw, utility_bounds, bound_policy) + scores, baseline, pareto, reference = hypervolume_improvement_scores( + bounded.utility_ucb_effective, + observed_utility, + reference_point_utility, + numeric_tolerance=numeric_tolerance, + chunk_size=chunk_size, + ) + return UCBHVIScoreResult( + base_score=scores, + base_log_score=np.log(np.maximum(scores, epsilon)), + utility_mean=means, + utility_std=standard_deviations, + utility_ucb=bounded.utility_ucb_effective, + baseline_hypervolume=baseline, + pareto_utility=pareto, + reference_point_utility=reference, + beta=beta_value, + kappa=kappa, + mc_samples=mc_samples, + seed=seed, + observation_noise=bool(observation_noise), + objective_contract_version=objective_contract_version.strip(), + moment_method=moment_method.strip(), + bound_policy=bounded.policy, + utility_bounds=bounded.bounds, + utility_ucb_raw=bounded.utility_ucb_raw, + utility_ucb_effective=bounded.utility_ucb_effective, + utility_ucb_clip_amount=bounded.utility_ucb_clip_amount, + ) + + +def score_ucb_hvi_pool( + model: Any, + X_pool_norm: torch.Tensor, + observed_raw: np.ndarray | torch.Tensor, + objective_transform: TensorTransform | Any, + reference_point_utility: np.ndarray | torch.Tensor | None, + *, + beta: float, + mc_samples: int = 128, + seed: int = 0, + posterior_chunk_size: int = 512, + hvi_chunk_size: int = 1024, + observation_noise: bool = False, + numeric_tolerance: float = 1e-12, + log_epsilon: float = 1e-12, + moment_method: MomentMethod | str = "monte_carlo", + bound_policy: UCBBoundPolicy | str = "none", + utility_bounds: Sequence[UtilityBound] | None = None, +) -> UCBHVIScoreResult: + """Score a normalized discrete pool from raw-output model posteriors.""" + if moment_method == "monte_carlo": + moments = posterior_utility_moments( + model, + X_pool_norm, + objective_transform, + mc_samples=mc_samples, + seed=seed, + chunk_size=posterior_chunk_size, + observation_noise=observation_noise, + ) + utility_mean = moments.utility_mean + utility_std = moments.utility_std + resolved_mc_samples: int | None = moments.mc_samples + resolved_seed: int | None = moments.seed + resolved_method = moments.moment_method + resolved_observation_noise = moments.observation_noise + elif moment_method == "analytic_identity": + analytic = posterior_identity_moments( + model, + X_pool_norm, + objective_transform, + chunk_size=posterior_chunk_size, + observation_noise=observation_noise, + ) + if analytic.utility_mean.ndim != 2 or analytic.utility_std.ndim != 2: + raise ValueError( + "UCB-HVI pool scoring requires unbatched analytic posterior moments " + "with shape (N, M)." + ) + utility_mean = analytic.utility_mean.detach().cpu().numpy() + utility_std = analytic.utility_std.detach().cpu().numpy() + resolved_mc_samples = None + resolved_seed = None + resolved_method = analytic.moment_method + resolved_observation_noise = analytic.observation_noise + else: + raise ValueError( + "moment_method must be exactly 'monte_carlo' or 'analytic_identity'." + ) + raw = torch.as_tensor( + observed_raw, + dtype=X_pool_norm.dtype, + device=X_pool_norm.device, + ) + if raw.ndim != 2: + raise ValueError( + f"observed_raw must have shape (N, M); got {tuple(raw.shape)}." + ) + observed_utility_tensor = _apply_objective_transform(objective_transform, raw) + contract_version = getattr(objective_transform, "version", None) + if contract_version is None and hasattr(objective_transform, "objective_transform"): + contract_version = getattr( + objective_transform.objective_transform, "version", None + ) + if not isinstance(contract_version, str) or not contract_version.strip(): + raise ValueError( + "objective_transform must expose a non-empty objective contract version." + ) + return score_ucb_hvi_from_moments( + utility_mean, + utility_std, + observed_utility_tensor, + reference_point_utility, + beta=beta, + numeric_tolerance=numeric_tolerance, + chunk_size=hvi_chunk_size, + log_epsilon=log_epsilon, + mc_samples=resolved_mc_samples, + seed=resolved_seed, + observation_noise=resolved_observation_noise, + objective_contract_version=contract_version, + moment_method=resolved_method, + bound_policy=bound_policy, + utility_bounds=utility_bounds, + ) + + +def propose_ucb_hvi_batch( + candidate_pool: Any, + model: Any, + observed_raw: np.ndarray | torch.Tensor, + objective_transform: TensorTransform | Any, + reference_point_utility: np.ndarray | torch.Tensor | None, + *, + q: int, + beta: float, + local_penalization_config: Any, + observed_pending_norm: np.ndarray | None = None, + positive_score_tolerance: float = 1e-12, + mc_samples: int = 128, + seed: int = 0, + posterior_chunk_size: int = 512, + hvi_chunk_size: int = 1024, + observation_noise: bool = False, + numeric_tolerance: float = 1e-12, + log_epsilon: float = 1e-12, + moment_method: MomentMethod | str = "monte_carlo", + bound_policy: UCBBoundPolicy | str = "none", + utility_bounds: Sequence[UtilityBound] | None = None, +) -> UCBHVIBatchProposal: + """Select exactly ``q`` locally penalized positive-HVI pool candidates. + + The UCB-HVI score is static for the pool, while the shared selector updates + the soft local penalty and hard distance masks after every selection. A + candidate whose true raw HVI is not greater than + ``positive_score_tolerance`` is ineligible (log score ``-inf``). + """ + from .batch_selection import BaseScoreResult, select_local_penalized_batch + + if isinstance(positive_score_tolerance, (bool, np.bool_)): + raise ValueError("positive_score_tolerance must be a real non-boolean number.") + tolerance = float(positive_score_tolerance) + if not np.isfinite(tolerance) or tolerance <= 0: + raise ValueError( + "positive_score_tolerance must be finite and strictly positive." + ) + try: + model_parameter = next(model.parameters()) + model_dtype = model_parameter.dtype + model_device = model_parameter.device + except (AttributeError, StopIteration): + model_dtype = torch.double + model_device = torch.device("cpu") + X_pool = torch.as_tensor( + candidate_pool.X_norm, dtype=model_dtype, device=model_device + ) + scoring = score_ucb_hvi_pool( + model, + X_pool, + observed_raw, + objective_transform, + reference_point_utility, + beta=beta, + mc_samples=mc_samples, + seed=seed, + posterior_chunk_size=posterior_chunk_size, + hvi_chunk_size=hvi_chunk_size, + observation_noise=observation_noise, + numeric_tolerance=numeric_tolerance, + log_epsilon=log_epsilon, + moment_method=moment_method, + bound_policy=bound_policy, + utility_bounds=utility_bounds, + ) + + def score_remaining( + remaining_indices: np.ndarray, selected_indices: np.ndarray + ) -> Any: + del selected_indices + raw_scores = scoring.base_score[remaining_indices] + log_scores = scoring.base_log_score[remaining_indices].copy() + log_scores[raw_scores <= tolerance] = -np.inf + return BaseScoreResult( + base_log_score=log_scores, + base_score=raw_scores, + diagnostics={ + "utility_mean": scoring.utility_mean[remaining_indices], + "utility_std": scoring.utility_std[remaining_indices], + "utility_ucb": scoring.utility_ucb[remaining_indices], + "utility_ucb_raw": scoring.utility_ucb_raw[remaining_indices], + "utility_ucb_effective": scoring.utility_ucb_effective[ + remaining_indices + ], + "utility_ucb_clip_amount": scoring.utility_ucb_clip_amount[ + remaining_indices + ], + "eligible_positive_hvi": raw_scores > tolerance, + }, + ) + + selection = select_local_penalized_batch( + candidate_pool, + q, + score_remaining, + local_penalization_config, + observed_pending_norm=observed_pending_norm, + ) + return UCBHVIBatchProposal( + selection=selection, + scoring=scoring, + positive_score_tolerance=tolerance, + metadata={ + "method": scoring.method, + "method_version": scoring.method_version, + "objective_contract_version": scoring.objective_contract_version, + "reference_point_utility": scoring.reference_point_utility.copy(), + "pool_seed": candidate_pool.seed, + "pool_size": candidate_pool.size, + "pool_draws": candidate_pool.draws, + "pool_rejected_duplicate": candidate_pool.rejected_duplicate, + "pool_rejected_avoid": candidate_pool.rejected_avoid, + "pool_rejected_constraint": candidate_pool.rejected_constraint, + "posterior_seed": scoring.seed, + "mc_samples": scoring.mc_samples, + "moment_method": scoring.moment_method, + "bound_policy": scoring.bound_policy, + "utility_bounds": scoring.utility_bounds, + "beta": scoring.beta, + "kappa": scoring.kappa, + "observation_noise": scoring.observation_noise, + "positive_score_tolerance": tolerance, + "local_penalization": { + "radius": local_penalization_config.radius, + "min_batch_distance": local_penalization_config.min_batch_distance, + "min_observed_distance": local_penalization_config.min_observed_distance, + "dimension_weights": local_penalization_config.dimension_weights, + "epsilon": local_penalization_config.epsilon, + }, + "selected_pool_indices": selection.selected_pool_indices.copy(), + }, + ) diff --git a/src/mobo_kit/utils.py b/src/mobo_kit/utils.py index 2c0fd73..aabe2ec 100644 --- a/src/mobo_kit/utils.py +++ b/src/mobo_kit/utils.py @@ -1,6 +1,11 @@ # src/utils.py from __future__ import annotations -from typing import List, Tuple, Any +from dataclasses import dataclass +import csv +import io +from pathlib import Path +from typing import Any, List, Sequence, Tuple + import pandas as pd import numpy as np import torch @@ -8,76 +13,481 @@ from .design import Design + +_METADATA_LABELS = ("units", "start", "stop", "step") +_CSV_ENCODINGS = ("utf-8-sig", "cp1252", "latin-1") + + +@dataclass +class ParsedCampaignCSV: + """Parsed metadata-style campaign CSV and its inferred data contract. + + ``metadata_row_count`` counts rows between the header and the first + experimental row, including an optional blank separator. Header positions + in ``duplicate_headers`` are one-based CSV/Excel column positions. + """ + + config: dict[str, Any] + data: pd.DataFrame + input_columns: list[str] + objective_columns: list[str] + metadata_row_count: int + raw_headers: list[str] + duplicate_headers: dict[str, list[int]] + encoding: str + + +def _read_raw_csv(path: str | Path) -> tuple[Path, list[list[str]], str]: + csv_path = Path(path) + if not csv_path.is_file(): + raise FileNotFoundError(f"Campaign CSV not found: {csv_path}") + + payload = csv_path.read_bytes() + if not payload: + raise ValueError(f"Campaign CSV is empty: {csv_path}") + + decode_errors: list[str] = [] + for encoding in _CSV_ENCODINGS: + try: + text = payload.decode(encoding) + except UnicodeDecodeError as exc: + decode_errors.append(f"{encoding}: {exc}") + continue + + try: + rows = list(csv.reader(io.StringIO(text, newline=""), strict=True)) + except csv.Error as exc: + raise ValueError(f"Malformed CSV syntax in {csv_path}: {exc}") from exc + + if not rows or not any(cell.strip() for cell in rows[0]): + raise ValueError(f"Campaign CSV has no usable header row: {csv_path}") + return csv_path, rows, encoding + + detail = "; ".join(decode_errors) + raise UnicodeError( + f"Could not decode campaign CSV {csv_path} using " + f"{', '.join(_CSV_ENCODINGS)}. {detail}" + ) + + +def _normalize_row_widths( + rows: list[list[str]], width: int, csv_path: Path +) -> list[list[str]]: + normalized: list[list[str]] = [] + for line_number, row in enumerate(rows, start=1): + if len(row) > width and any(cell.strip() for cell in row[width:]): + raise ValueError( + f"CSV row {line_number} in {csv_path} has {len(row)} fields but " + f"the header has {width}; extra nonblank fields are not allowed." + ) + normalized.append((row[:width] + [""] * width)[:width]) + return normalized + + +def _duplicate_header_positions(headers: Sequence[str]) -> dict[str, list[int]]: + positions: dict[str, list[int]] = {} + for position, raw_header in enumerate(headers, start=1): + header = raw_header.strip() + if header: + positions.setdefault(header, []).append(position) + return {name: found for name, found in positions.items() if len(found) > 1} + + +def _find_metadata_rows( + rows: Sequence[Sequence[str]], csv_path: Path +) -> tuple[dict[str, int], int]: + found: dict[str, int] = {} + label_columns: set[int] = set() + + for row_index, row in enumerate(rows[1:], start=1): + matches = [ + (column_index, cell.strip().lower()) + for column_index, cell in enumerate(row) + if cell.strip().lower() in _METADATA_LABELS + ] + if len(matches) > 1: + raise ValueError( + f"Metadata row {row_index + 1} in {csv_path} contains more than " + f"one metadata label: {[label for _, label in matches]}" + ) + if not matches: + continue + + column_index, label = matches[0] + if label in found: + raise ValueError( + f"Duplicate '{label}' metadata row in {csv_path} " + f"(rows {found[label] + 1} and {row_index + 1})." + ) + found[label] = row_index + label_columns.add(column_index) + if len(found) == len(_METADATA_LABELS): + break + + missing = [label for label in _METADATA_LABELS if label not in found] + if missing: + raise ValueError( + "Plain data CSVs are not supported by parse_campaign_csv; expected " + "labeled units/start/stop/step metadata rows. " + f"Missing metadata rows: {missing}." + ) + if len(label_columns) != 1: + raise ValueError( + f"Metadata labels in {csv_path} must use one consistent label column; " + f"found columns {[index + 1 for index in sorted(label_columns)]}." + ) + + return found, next(iter(label_columns)) + + +def _parse_metadata_number( + value: str, *, label: str, header: str, position: int +) -> float: + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError( + f"Malformed {label} metadata for input '{header}' at column " + f"{position}: {value!r} is not numeric." + ) from exc + if not np.isfinite(number): + raise ValueError( + f"Malformed {label} metadata for input '{header}' at column " + f"{position}: values must be finite." + ) + return number + + +def parse_campaign_csv( + path: str | Path, + expected_objectives: Sequence[str] | None = None, +) -> ParsedCampaignCSV: + """Parse a metadata-style campaign CSV without fixed row offsets. + + Inputs are named columns with complete numeric ``start``, ``stop``, and + ``step`` metadata. If ``expected_objectives`` is omitted, objectives are + inferred from named non-input columns after an optional blank separator. + Plain data-only CSVs and duplicate named headers are rejected explicitly. + Experimental values are preserved here and validated numerically by + :func:`split_XY` at the model boundary. + """ + + csv_path, raw_rows, encoding = _read_raw_csv(path) + raw_headers = list(raw_rows[0]) + rows = _normalize_row_widths(raw_rows, len(raw_headers), csv_path) + headers = [header.strip() for header in raw_headers] + + duplicate_headers = _duplicate_header_positions(raw_headers) + if duplicate_headers: + rendered = ", ".join( + f"{name!r} at columns {positions}" + for name, positions in duplicate_headers.items() + ) + raise ValueError( + "Duplicate CSV headers detected before pandas column renaming: " + f"{rendered}." + ) + + metadata_rows, metadata_label_column = _find_metadata_rows(rows, csv_path) + last_metadata_row = max(metadata_rows.values()) + data_start = last_metadata_row + 1 + while data_start < len(rows) and not any(cell.strip() for cell in rows[data_start]): + data_start += 1 + if data_start >= len(rows): + raise ValueError(f"Campaign CSV has an empty experimental section: {csv_path}") + + input_columns: list[str] = [] + input_positions: list[int] = [] + input_specs: list[dict[str, Any]] = [] + + for column_index, header in enumerate(headers): + if not header or column_index == metadata_label_column: + continue + + numeric_values = { + label: rows[row_index][column_index].strip() + for label, row_index in metadata_rows.items() + if label in {"start", "stop", "step"} + } + present = {label: bool(value) for label, value in numeric_values.items()} + if not any(present.values()): + continue + if not all(present.values()): + missing = [label for label, is_present in present.items() if not is_present] + raise ValueError( + f"Incomplete numeric metadata for column '{header}': missing " + f"{missing}. Input columns require start, stop, and step." + ) + + start = _parse_metadata_number( + numeric_values["start"], + label="start", + header=header, + position=column_index + 1, + ) + stop = _parse_metadata_number( + numeric_values["stop"], + label="stop", + header=header, + position=column_index + 1, + ) + step = _parse_metadata_number( + numeric_values["step"], + label="step", + header=header, + position=column_index + 1, + ) + if stop < start: + raise ValueError( + f"Invalid metadata for input '{header}': stop ({stop}) is below " + f"start ({start})." + ) + if step <= 0: + raise ValueError( + f"Invalid metadata for input '{header}': step must be positive." + ) + + unit = rows[metadata_rows["units"]][column_index].strip() or None + input_columns.append(header) + input_positions.append(column_index) + input_specs.append( + { + "name": header, + "unit": unit, + "start": start, + "stop": stop, + "step": step, + } + ) + + if not input_columns: + raise ValueError( + "Campaign CSV contains no input columns with complete numeric " + f"metadata: {csv_path}" + ) + + named_non_input_positions = [ + column_index + for column_index, header in enumerate(headers) + if header + and column_index != metadata_label_column + and column_index not in input_positions + ] + + if expected_objectives is not None: + if isinstance(expected_objectives, str): + objective_columns = [expected_objectives.strip()] + else: + objective_columns = [str(name).strip() for name in expected_objectives] + if not objective_columns or any(not name for name in objective_columns): + raise ValueError( + "expected_objectives must contain at least one nonblank name." + ) + if len(set(objective_columns)) != len(objective_columns): + raise ValueError("expected_objectives contains duplicate names.") + missing_objectives = [name for name in objective_columns if name not in headers] + if missing_objectives: + raise ValueError( + f"Missing objective columns in campaign CSV: {missing_objectives}." + ) + input_objectives = [name for name in objective_columns if name in input_columns] + if input_objectives: + raise ValueError( + f"Columns cannot be both inputs and objectives: {input_objectives}." + ) + else: + objective_positions = [ + position + for position in named_non_input_positions + if position > max(input_positions) + ] + separator_positions = [ + column_index + for column_index, header in enumerate(headers) + if not header and column_index > max(input_positions) + ] + if separator_positions: + objective_positions = [ + position + for position in objective_positions + if position > separator_positions[0] + ] + objective_columns = [headers[position] for position in objective_positions] + + if not objective_columns: + raise ValueError( + "Campaign CSV has no named objective columns. Provide objective headers " + "or pass expected_objectives." + ) + + unnamed_positions = [ + column_index for column_index, header in enumerate(headers) if not header + ] + experimental_rows: list[list[str]] = [] + for source_row, row in enumerate(rows[data_start:], start=data_start + 1): + if not any(cell.strip() for cell in row): + continue + populated_unnamed = [ + column_index + 1 + for column_index in unnamed_positions + if row[column_index].strip() + ] + if populated_unnamed: + raise ValueError( + f"Experimental row {source_row} contains values in unnamed columns " + f"{populated_unnamed}; add explicit headers before parsing." + ) + experimental_rows.append(row) + + if not experimental_rows: + raise ValueError(f"Campaign CSV has an empty experimental section: {csv_path}") + + data_positions = [ + column_index + for column_index, header in enumerate(headers) + if header and column_index != metadata_label_column + ] + data = pd.DataFrame( + [ + [ + row[column_index] if row[column_index].strip() else pd.NA + for column_index in data_positions + ] + for row in experimental_rows + ], + columns=[headers[column_index] for column_index in data_positions], + ) + + config = { + "inputs": input_specs, + "objectives": {"names": objective_columns}, + "constraints": [], + } + return ParsedCampaignCSV( + config=config, + data=data, + input_columns=input_columns, + objective_columns=objective_columns, + metadata_row_count=data_start - 1, + raw_headers=raw_headers, + duplicate_headers=duplicate_headers, + encoding=encoding, + ) + + def get_objective_names(cfg: dict) -> List[str]: names = cfg.get("objectives", {}).get("names", []) if not names: raise ValueError("Config must have objectives.names as a non-empty list.") return list(names) -def load_csv(path: str) -> pd.DataFrame: - return pd.read_csv(path) -def split_XY(df: pd.DataFrame, design: Design, config: dict) -> Tuple[np.ndarray, np.ndarray]: - """ - Split a DataFrame into input features (X) and objectives (Y) using the new config/design structure. - - This function works with CSV files like configCSV_example.csv that contain: - - Rows 0-4: Configuration metadata (column names, units, start, stop, step) - - Row 5: Empty row - - Rows 6+: Experimental data - - Args: - df: DataFrame containing the data (including metadata rows) - design: Design object with input parameter names - config: Configuration dictionary with objectives.names - - Returns: - Tuple of (X, Y) arrays where: - - X: (N, D) array of input features - - Y: (N, M) array of objectives - - Raises: - KeyError: If required columns are missing from the DataFrame +def load_csv( + path: str | Path, + expected_objectives: Sequence[str] | None = None, +) -> pd.DataFrame: + """Load only experimental rows from a metadata-style campaign CSV.""" + + return parse_campaign_csv(path, expected_objectives=expected_objectives).data + + +def _blank_mask(frame: pd.DataFrame) -> pd.DataFrame: + return frame.isna() | frame.apply( + lambda column: column.map( + lambda value: isinstance(value, str) and not value.strip() + ) + ) + + +def _numeric_model_frame(frame: pd.DataFrame, role: str) -> pd.DataFrame: + blank = _blank_mask(frame) + converted = frame.apply(pd.to_numeric, errors="coerce") + invalid = converted.isna() | ~np.isfinite(converted.astype(float)) + if invalid.any().any(): + locations = [ + f"row {frame.index[row]!r}, column {frame.columns[column]!r}" + for row, column in zip(*np.where(invalid.to_numpy())) + ] + detail = ", ".join(locations[:8]) + if len(locations) > 8: + detail += f", and {len(locations) - 8} more" + kind = "blank" if (invalid & blank).any().any() else "nonnumeric" + raise ValueError( + f"{role} model data contains {kind} or non-finite values at {detail}." + ) + return converted.astype(float) + + +def split_XY( + df: pd.DataFrame, design: Design, config: dict +) -> Tuple[pd.DataFrame, pd.DataFrame]: + """Select and validate named numeric model inputs and objectives. + + Rows are never filled with zero or silently discarded. Any incomplete row + must be completed or removed explicitly by a campaign/QC policy before this + model-boundary function is called. """ - # Get input column names from design + + if not isinstance(df, pd.DataFrame): + raise TypeError("split_XY expects a pandas.DataFrame.") + if df.empty: + raise ValueError("Experimental data is empty; no model rows are available.") + if df.columns.duplicated().any(): + duplicates = list(dict.fromkeys(df.columns[df.columns.duplicated()].tolist())) + raise ValueError(f"Experimental DataFrame has duplicate columns: {duplicates}.") + x_cols = list(design.names) - - # Get objective column names from config y_cols = get_objective_names(config) - - # Check for missing columns - miss_x = [c for c in x_cols if c not in df.columns] - miss_y = [c for c in y_cols if c not in df.columns] - + miss_x = [column for column in x_cols if column not in df.columns] + miss_y = [column for column in y_cols if column not in df.columns] if miss_x or miss_y: parts = [] - if miss_x: + if miss_x: parts.append(f"missing inputs: {miss_x}") - if miss_y: + if miss_y: parts.append(f"missing objectives: {miss_y}") raise KeyError("CSV column check failed: " + "; ".join(parts)) - - # Extract data rows (skip the first 6 rows: metadata + empty row) - data_df = df.iloc[6:].copy() - - # Remove rows with all NaN values (empty rows) - data_df = data_df.dropna(how='all') - - # Extract X and Y arrays - X = data_df[x_cols].astype(float) - Y = data_df[y_cols].astype(float) - + + X_raw = df.loc[:, x_cols].copy() + Y_raw = df.loc[:, y_cols].copy() + objective_blanks = _blank_mask(Y_raw) + if objective_blanks.all().all(): + raise ValueError( + "All objective values are blank; no completed model rows exist." + ) + + all_blank_rows = objective_blanks.all(axis=1) + if all_blank_rows.any(): + raise ValueError( + "Objective values are blank for rows " + f"{Y_raw.index[all_blank_rows].tolist()}; rows are not dropped silently." + ) + partial_rows = objective_blanks.any(axis=1) + if partial_rows.any(): + raise ValueError( + "Partially completed objective rows found at indices " + f"{Y_raw.index[partial_rows].tolist()}; complete every objective " + "before modeling." + ) + + X = _numeric_model_frame(X_raw, "Input") + Y = _numeric_model_frame(Y_raw, "Objective") return X, Y + def select_device(prefer: str = "cuda") -> torch.device: - return torch.device("cuda" if prefer == "cuda" and torch.cuda.is_available() else "cpu") + return torch.device( + "cuda" if prefer == "cuda" and torch.cuda.is_available() else "cpu" + ) + def set_seeds(seed: int) -> None: np.random.seed(seed) torch.manual_seed(seed) + def np_to_torch( - *arrays: np.ndarray, + *arrays: np.ndarray | pd.DataFrame, device: torch.device | None = None, dtype: torch.dtype = torch.float64, return_device: bool = False, @@ -92,7 +502,10 @@ def np_to_torch( """ if device is None: device = select_device("cuda") # uses your existing helper - tensors = tuple(torch.as_tensor(a, dtype=dtype, device=device) for a in arrays) + tensors = tuple( + torch.as_tensor(np.asarray(array), dtype=dtype, device=device) + for array in arrays + ) out = tensors[0] if len(tensors) == 1 else tensors return (out, device) if return_device else out @@ -109,123 +522,30 @@ def torch_to_np(*tensors: torch.Tensor): return arrays[0] if len(arrays) == 1 else arrays -def csv_to_config(csv_path: str, output_path: str = None) -> str: - """ - Convert a CSV configuration file to a YAML config file. - - Expected CSV format: - - Row 0: Column names (input parameters + objectives) - - Row 1: Units for each column - - Row 2: Start values for input parameters - - Row 3: Stop values for input parameters - - Row 4: Step values for input parameters - - Row 5: Empty row - - Row 6+: Experimental data - - Args: - csv_path: Path to the CSV configuration file - output_path: Path for the output YAML file. If None, generates a default name. - - Returns: - Generated config dictionary +def csv_to_config( + csv_path: str | Path, + output_path: str | Path | None = None, + expected_objectives: Sequence[str] | None = None, +) -> dict[str, Any]: + """Build a configuration from a metadata-style campaign CSV. + + This wrapper has no filesystem side effect unless ``output_path`` is + supplied explicitly. Generic campaign conversion always defaults to no + constraints. """ - # Read CSV file - df = pd.read_csv(csv_path) - - # Extract metadata from first few rows - column_names = df.columns.tolist() - units = df.iloc[0].tolist() - starts = df.iloc[1].tolist() - stops = df.iloc[2].tolist() - steps = df.iloc[3].tolist() - - # Identify input parameters and objectives by finding the empty column separator - # Skip the first unnamed column, then find where empty columns start - input_params = [] - objective_params = [] - - # Start from column 1 (skip first unnamed column) - i = 1 - while i < len(column_names): - col_name = column_names[i] - # Check if this is an empty column (NaN, empty string, or pandas unnamed column) - if (pd.isna(col_name) or - str(col_name).strip() == "" or - str(col_name).startswith("Unnamed:")): - # Found the separator - everything after this is objectives - objective_params = [col for col in column_names[i+1:] - if not (pd.isna(col) or str(col).strip() == "" or str(col).startswith("Unnamed:"))] - break - else: - input_params.append(col_name) - i += 1 - - # If no separator found, assume all remaining columns are objectives - if not objective_params: - objective_params = [col for col in column_names[len(input_params)+1:] if not (pd.isna(col) or str(col).strip() == "")] - - # Build the config dictionary - config = { - "inputs": [], - "objectives": {"names": objective_params}, - "constraints": [ - { - "clausius_clapeyron": True, - "ah_col": "absolute_humidity", - "temp_c_col": "temperature_c" - } - ] - } - - # Add input parameters - for i, param in enumerate(input_params): - # Skip empty column names - if pd.isna(param) or str(param).strip() == "": - continue - - # Find the column index in the original column_names list - try: - metadata_idx = column_names.index(param) - except ValueError: - # Fallback: use position-based indexing - metadata_idx = i + 1 # +1 because we skipped the first column - - unit = units[metadata_idx] if metadata_idx < len(units) else "" - start = starts[metadata_idx] if metadata_idx < len(starts) else 0.0 - stop = stops[metadata_idx] if metadata_idx < len(stops) else 1.0 - step = steps[metadata_idx] if metadata_idx < len(steps) else 0.01 - - # Convert to appropriate types - try: - start = float(start) if pd.notna(start) else 0.0 - stop = float(stop) if pd.notna(stop) else 1.0 - step = float(step) if pd.notna(step) else 0.01 - except (ValueError, TypeError): - # Use defaults if conversion fails - start, stop, step = 0.0, 1.0, 0.01 - - input_spec = { - "name": str(param).strip(), - "unit": unit, - "start": start, - "stop": stop, - "step": step - } - - config["inputs"].append(input_spec) - - # Generate output path if not provided - if output_path is None: - import os - csv_basename = os.path.splitext(os.path.basename(csv_path))[0] - output_path = f"configs/{csv_basename}_config.yaml" - - # Ensure output directory exists - import os - os.makedirs(os.path.dirname(output_path), exist_ok=True) - - # Write YAML file - with open(output_path, 'w', encoding='utf-8') as f: - yaml.dump(config, f, default_flow_style=False, sort_keys=False, indent=2) - - return config \ No newline at end of file + + config = parse_campaign_csv( + csv_path, expected_objectives=expected_objectives + ).config + if output_path is not None: + destination = Path(output_path) + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("w", encoding="utf-8", newline="\n") as stream: + yaml.safe_dump( + config, + stream, + default_flow_style=False, + sort_keys=False, + indent=2, + ) + return config diff --git a/src/mobo_kit/workbook_io.py b/src/mobo_kit/workbook_io.py new file mode 100644 index 0000000..09d3217 --- /dev/null +++ b/src/mobo_kit/workbook_io.py @@ -0,0 +1,861 @@ +"""Read the campaign workbook, write candidate sheets back. + +The experimentalist's side of the loop: open ``Summary Table.xlsx``, fill in ten +inputs and the measurement columns, press one button, get a new sheet of +conditions to run. + +Three rules this module keeps. + +**The source workbook is never opened for writing.** Candidate sheets go to a +sibling file, ``_R1_Candidates.xlsx``. + +That is not the original plan, which was to add sheets to ``Summary Table.xlsx`` +itself. It changed because of a measured fact: **openpyxl discards cached +formula values on save.** ``Uniformity score`` is a formula column +(``=L2*N2*O2``), so a single openpyxl round-trip turns it -- and every other +formula column -- into ``None`` for any reader that is not Excel, including this +one. Verified directly: Z2:Z4 read ``[0.657, 0.587, 0.561]`` before a save that +only added an empty sheet, and ``[None, None, None]`` after. + +Writing beside the workbook keeps the experimentalist's one-button flow (they +open the new file, fill it in, press the button again) and makes the read-only +invariant structural rather than merely asserted. + +**Which columns to collect comes from the config, not from here.** Each +objective's ``measurement`` block names the raw columns its value is computed +from, so ``R1_Candidates`` asks for ``Coverage``, ``T1..T4`` and the rest rather +than for the three derived scores. Driving that off the config means a future +objective change updates the sheet automatically instead of silently leaving the +next round without its data. + +**The derived scores are computed, not read.** Three of the workbook's score +cells are pasted literals that do not update when the measurements behind them +change, so :mod:`scores` recomputes all three and the stored cells become a +cross-check that warns. That is why ``model_values`` is keyed by objective name +and the stored cells appear separately as ``workbook_values``. + +**Round detection is fail-closed.** A partially scored sheet is refused with a +plain sentence rather than being guessed at. +""" + +from __future__ import annotations + +import hashlib +import shutil +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Mapping, Sequence + +import numpy as np +import pandas as pd +from openpyxl import load_workbook +from openpyxl.styles import Font, PatternFill +from openpyxl.utils import get_column_letter + +from .campaign import ( + measurement_entry_columns, + measurement_specs, + model_source_columns, + objective_names, + replicate_aggregates, +) +from .scores import ( + ScoreFinding, + ScoreSeverity, + compute_measurements, + row_completeness, +) + +__all__ = [ + "CandidateResults", + "CandidateSheetError", + "RoundState", + "WorkbookContents", + "backup_workbook", + "candidate_workbook_path", + "detect_round", + "read_campaign_workbook", + "read_candidate_results", + "sheet_name_for_round", + "source_sheet", + "workbook_digest", + "write_candidate_sheet", +] + +#: Fallback for configs that predate `campaign.source_sheet`. The v4 workbook +#: names its sheets by round (`R0`, `R1`), so the sheet a campaign reads is now a +#: config key rather than a constant. +SOURCE_SHEET = "Sheet1" + + +def source_sheet(config: Mapping[str, Any]) -> str: + """Which sheet holds the measured rows for this campaign.""" + return str((config.get("campaign") or {}).get("source_sheet", SOURCE_SHEET)) + + +def formula_findings( + path: str | Path, config: Mapping[str, Any] +) -> tuple[ScoreFinding, ...]: + """Has a frozen score column's DEFINITION moved since it was recorded? + + A ``stored`` objective is read rather than recomputed, so nothing in Python + knows what it means and no cross-check can catch a redefinition. This is the + partial replacement: read the formula TEXT (never evaluate it) and compare it + with the fingerprint in config. + + Requires a second read of the workbook with ``data_only=False``, because + openpyxl gives either the formulas or their cached values and never both. That + is why it is skipped entirely unless a fingerprint is declared. + """ + from .campaign import measurement_specs, objective_names + + specs = list(measurement_specs(config)) + names = list(objective_names(config)) + wanted = [ + (name, spec) + for name, spec in zip(names, specs) + if spec is not None and spec.formula_fingerprint is not None + ] + if not wanted: + return () + + sheet_name = source_sheet(config) + workbook = load_workbook(Path(path), data_only=False, read_only=False) + if sheet_name not in workbook.sheetnames: + return () + sheet = workbook[sheet_name] + positions = _header_positions(sheet) + + findings: list[ScoreFinding] = [] + for name, spec in wanted: + fingerprint = spec.formula_fingerprint + column = fingerprint.column + if column not in positions: + findings.append( + ScoreFinding( + severity=ScoreSeverity.WARNING, + code="fingerprint_column_absent", + objective=name, + row_position=-1, + sample_id=None, + message=( + f"{column!r} is not in {sheet_name}, so the frozen score's " + "definition cannot be checked at all." + ), + column=column, + ) + ) + continue + index = positions[column] + seen: list[str] = [] + for row in sheet.iter_rows(min_row=2, values_only=True): + if row[0] is None: + break + value = row[index] + if isinstance(value, str) and value.startswith("="): + seen.append(value) + if not seen: + findings.append( + ScoreFinding( + severity=ScoreSeverity.WARNING, + code="fingerprint_no_formula", + objective=name, + row_position=-1, + sample_id=None, + message=( + f"{column!r} holds no formula on any row -- the values are " + "literals. A frozen score that is pasted rather than " + "computed cannot be checked against anything at all, which " + "is the one failure this contract cannot see." + ), + column=column, + ) + ) + continue + changed = [text for text in seen if not fingerprint.matches(text)] + if changed: + findings.append( + ScoreFinding( + severity=ScoreSeverity.WARNING, + code="formula_fingerprint_changed", + objective=name, + row_position=-1, + sample_id=None, + message=( + f"{column!r} no longer matches the recorded definition. " + f"Recorded {fingerprint.formula!r}; found " + f"{changed[0]!r} (and {len(changed) - 1} other row(s) that " + "differ). This objective is READ, not recomputed, so the " + "change is not an error -- but every number computed under " + "the old definition is about a different quantity. Bump " + "objectives.contract_version and update the fingerprint." + ), + column=column, + ) + ) + else: + findings.append( + ScoreFinding( + severity=ScoreSeverity.NOTE, + code="formula_fingerprint_unchanged", + objective=name, + row_position=-1, + sample_id=None, + message=( + f"{column!r} still computes {fingerprint.formula!r} on all " + f"{len(seen)} rows." + ), + column=column, + ) + ) + return tuple(findings) +SAMPLE_COLUMN = "Sample number" +ENTRY_FILL = PatternFill("solid", fgColor="FFF2CC") +HEADER_FONT = Font(bold=True) + + +class CandidateSheetError(RuntimeError): + """The workbook is not in a state this tool can act on.""" + + +@dataclass(frozen=True) +class WorkbookContents: + """Everything read out of the source sheet.""" + + inputs: pd.DataFrame + """Physical input values, columns in the config's declared order.""" + model_values: pd.DataFrame + """What the GP trains on, one column per objective in objective order. + + Computed from the raw measurement columns for any objective that declares a + ``measurement`` block; read from the declared column for one that does not. + """ + workbook_values: pd.DataFrame + """The stored derived cells, as the workbook holds them. Cross-check only.""" + inputs_used: pd.DataFrame + """How many measured inputs each value came from -- 2 to 4 for thickness.""" + findings: tuple[ScoreFinding, ...] + """Cross-check mismatches, excluded readings and disagreeing replicates.""" + sample_ids: tuple[int, ...] + digest: str + + @property + def n_rows(self) -> int: + return len(self.inputs) + + @property + def errors(self) -> tuple[ScoreFinding, ...]: + from .scores import ScoreSeverity + + return tuple(f for f in self.findings if f.severity is ScoreSeverity.ERROR) + + @property + def warnings(self) -> tuple[ScoreFinding, ...]: + from .scores import ScoreSeverity + + return tuple(f for f in self.findings if f.severity is ScoreSeverity.WARNING) + + +@dataclass(frozen=True) +class RoundState: + """Which round should be generated next, and why.""" + + next_round: str | None + reason: str + scored_rows: int = 0 + total_rows: int = 0 + + +def sheet_name_for_round(round_name: str) -> str: + return f"{round_name.upper()}_Candidates" + + +def workbook_digest(path: str | Path) -> str: + """SHA-256 of the whole file, for the unchanged-source proof.""" + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def backup_workbook(path: str | Path, *, timestamp: str | None = None) -> Path: + """Copy the workbook next to itself before any write.""" + source = Path(path) + stamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S") + destination = source.with_name(f"{source.stem}_backup_{stamp}{source.suffix}") + shutil.copy2(source, destination) + return destination + + +def _header_positions(sheet) -> dict[str, int]: + header = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True)) + positions: dict[str, int] = {} + for index, value in enumerate(header): + if value is None: + continue + name = str(value).strip() + # duplicate headers exist in this workbook; first occurrence wins and the + # rest stay reachable by position + positions.setdefault(name, index) + # headers carry their unit inline ("precur_vol (uL)") while the config + # declares name and unit separately; accept both spellings + bare = name.split("(")[0].strip() + if bare and bare != name: + positions.setdefault(bare, index) + return positions + + +def _near_misses(wanted: str, available: Sequence[str]) -> list[str]: + """Headers that are plausibly the same column under a different name. + + Deliberately generous. The realistic cause of a missing column is not a typo + in the sheet but a config describing a DIFFERENT campaign, where the same + quantity was called something adjacent -- ``PL - Implied Voc (Max)`` against + ``PL - Implied Voc (Max) Raw``. Prefix and containment catch that; edit + distance would not, and would also match unrelated columns. + """ + lowered = wanted.lower().strip() + head = lowered.split("(")[0].strip() + hits = [ + name + for name in available + if name.lower().strip() != lowered + and ( + lowered in name.lower() + or name.lower() in lowered + or (len(head) > 3 and name.lower().startswith(head)) + ) + ] + return hits[:4] + + +def _missing_columns_message( + missing: Sequence[str], + positions: Mapping[str, int], + config: Mapping[str, Any], + path: Path, + sheet_name: str = SOURCE_SHEET, +) -> str: + """Say which CONTRACT wanted the column, not just that it is absent. + + "Sheet1 is missing required column(s)" reads as a broken workbook, and the + usual cause is the opposite: an intact workbook being read against another + campaign's config. Naming the config and offering the near-miss headers turns + a five-minute hunt into a glance. + """ + campaign = config.get("campaign") or {} + contract = (config.get("objectives") or {}).get("contract_version") + lines = [ + f"{sheet_name} of {path.name} is missing column(s) that the campaign " + f"configuration requires: {list(missing)}.", + "", + f"Configuration: {campaign.get('name')} " + f"(status: {campaign.get('status')}, contract: {contract}).", + ] + if str(campaign.get("status")) == "archived": + lines += [ + "", + "THAT CONFIGURATION IS ARCHIVED. It describes a previous campaign, " + "whose workbook had different columns, so this is almost certainly a " + "config/workbook mismatch rather than a problem with the workbook. " + "Point the launcher at the active campaign configuration instead.", + ] + suggestions = { + name: _near_misses(name, list(positions)) for name in missing + } + named = {name: hits for name, hits in suggestions.items() if hits} + if named: + lines += ["", "The sheet does have these, which look related:"] + for name, hits in named.items(): + lines.append(f" wanted {name!r} -> found {hits}") + lines += [ + "", + "If one of those is the same measurement under a new name, the fix is " + "a `measurement` column in the config, not an edit to the workbook.", + ] + return "\n".join(lines) + + +def read_campaign_workbook( + path: str | Path, config: Mapping[str, Any] +) -> WorkbookContents: + """Read measured rows, stopping at the first blank sample number. + + Rows below the data block are notes, not observations. + """ + sheet_name = source_sheet(config) + workbook = load_workbook(Path(path), data_only=True, read_only=False) + if sheet_name not in workbook.sheetnames: + raise CandidateSheetError( + f"This campaign reads its measured rows from a sheet named " + f"{sheet_name!r} (campaign.source_sheet); {Path(path).name} has " + f"{workbook.sheetnames}. Either the workbook is for a different " + "campaign, or the sheet was renamed." + ) + sheet = workbook[sheet_name] + positions = _header_positions(sheet) + + input_names = [item["name"] for item in config["inputs"]] + specs = measurement_specs(config) + computed = [spec for spec in specs if spec is not None] + declared = list(model_source_columns(config)) + names = list(objective_names(config)) + required_entry, optional_entry = measurement_entry_columns(config) + + missing = [ + name + for name in [SAMPLE_COLUMN, *input_names, *required_entry] + if name not in positions + ] + if missing: + raise CandidateSheetError( + _missing_columns_message( + missing, positions, config, Path(path), sheet_name + ) + ) + + rows = [] + for row in sheet.iter_rows(min_row=2, values_only=True): + if row[positions[SAMPLE_COLUMN]] is None: + break + rows.append(row) + if not rows: + raise CandidateSheetError(f"{sheet_name} contains no measured rows.") + + def column(name: str) -> list[Any]: + return [row[positions[name]] for row in rows] + + # every column any recipe or cross-check may look at, kept as raw cells: + # `scores` is the one place that knows how this workbook spells "not measured" + wanted: list[str] = [*required_entry, *optional_entry, *declared] + for spec in computed: + wanted.extend(check.column for check in spec.cross_checks) + raw = pd.DataFrame( + { + name: column(name) + for name in dict.fromkeys(wanted) + if name in positions + }, + dtype=object, + ) + sample_ids = tuple(int(value) for value in column(SAMPLE_COLUMN)) + + model_frame: dict[str, Any] = {} + findings: tuple[ScoreFinding, ...] = () + inputs_used = pd.DataFrame(index=range(len(rows))) + if computed: + result = compute_measurements(raw, computed, sample_ids=sample_ids) + findings = result.findings + inputs_used = result.inputs_used + for name in result.values.columns: + model_frame[name] = result.values[name] + for name, spec, declared_column in zip(names, specs, declared): + if spec is None: + model_frame[name] = pd.to_numeric( + column(declared_column), errors="coerce" + ) + + findings = tuple(findings) + formula_findings(path, config) + + return WorkbookContents( + inputs=pd.DataFrame( + {name: pd.to_numeric(column(name), errors="coerce") for name in input_names} + ), + model_values=pd.DataFrame({name: model_frame[name] for name in names}), + workbook_values=pd.DataFrame( + { + name: pd.to_numeric(column(name), errors="coerce") + for name in dict.fromkeys(declared) + if name in positions + } + ), + inputs_used=inputs_used, + findings=findings, + sample_ids=sample_ids, + digest=workbook_digest(path), + ) + + +@dataclass(frozen=True) +class CandidateResults: + """Measurements read back out of one round's candidate sheet. + + The films of one condition are separate experimental rows but one design + point, so they are aggregated to a single observation before the next round + trains on them. ``replicate_spread`` keeps the within-condition scatter that + aggregation discards -- that is the raw material for ``train_Yvar``. + """ + + round_name: str + conditions: pd.DataFrame + """One row per condition, input columns in the config's declared order.""" + model_values: pd.DataFrame + """One row per condition, one column per objective. Aggregated.""" + replicates: pd.DataFrame + """One row per film: candidate_id, replicate_index, then objective values.""" + replicate_spread: pd.DataFrame + """Per-condition sd in each objective's aggregation space. NaN below 2 films.""" + films_used: pd.DataFrame + """How many films each condition's value was aggregated from.""" + findings: tuple[ScoreFinding, ...] + candidate_ids: tuple[str, ...] + + @property + def n_conditions(self) -> int: + return len(self.conditions) + + @property + def errors(self) -> tuple[ScoreFinding, ...]: + return tuple(f for f in self.findings if f.severity is ScoreSeverity.ERROR) + + +def _aggregate(values: np.ndarray, rule: str) -> tuple[float, float]: + """Collapse one condition's film values to (observation, spread). + + Spread is the sample sd in the aggregation space, so for ``mean_of_log`` it + is a sd of ``log`` values and is already what a log-space ``train_Yvar`` + wants. It is NaN for a single film, which is honest: one film measures no + reproducibility at all. + """ + finite = values[np.isfinite(values)] + if finite.size == 0: + return float("nan"), float("nan") + if rule == "mean_of_log": + if np.any(finite <= 0): + raise CandidateSheetError( + "mean_of_log aggregation needs strictly positive values; got " + f"{finite.tolist()}." + ) + logs = np.log(finite) + spread = float(np.std(logs, ddof=1)) if finite.size > 1 else float("nan") + return float(np.exp(logs.mean())), spread + spread = float(np.std(finite, ddof=1)) if finite.size > 1 else float("nan") + return float(finite.mean()), spread + + +def read_candidate_results( + path: str | Path, config: Mapping[str, Any], round_name: str +) -> CandidateResults: + """Read a filled-in candidate sheet and aggregate it to design points. + + ``path`` is the SOURCE workbook; the candidate sheet is found beside it, the + same way :func:`write_candidate_sheet` put it there. Objective values are + computed per film by :mod:`scores` -- the same recipes the source sheet uses, + so R0 and R1 observations are commensurable -- and then aggregated per + ``replicate_group``. + """ + candidate_path = candidate_workbook_path(path, round_name) + if not candidate_path.exists(): + raise CandidateSheetError( + f"{candidate_path.name} does not exist, so there are no {round_name} " + "measurements to read." + ) + sheet_name = sheet_name_for_round(round_name) + workbook = load_workbook(candidate_path, data_only=True) + if sheet_name not in workbook.sheetnames: + raise CandidateSheetError( + f"{candidate_path.name} has no {sheet_name!r} sheet; found " + f"{workbook.sheetnames}." + ) + sheet = workbook[sheet_name] + positions = _header_positions(sheet) + + input_names = [item["name"] for item in config["inputs"]] + names = list(objective_names(config)) + specs = [spec for spec in measurement_specs(config) if spec is not None] + rules = list(replicate_aggregates(config)) + required_entry, optional_entry = measurement_entry_columns(config) + + missing = [ + column + for column in ["candidate_id", *input_names, *required_entry] + if column not in positions + ] + if missing: + raise CandidateSheetError( + f"{candidate_path.name} is missing column(s) {missing}. It was " + "probably created by an older version; regenerate it." + ) + + rows = [ + row + for row in sheet.iter_rows(min_row=2, values_only=True) + if row[positions["candidate_id"]] is not None + ] + if not rows: + raise CandidateSheetError(f"{sheet_name} contains no candidate rows.") + + def column(name: str) -> list[Any]: + return [row[positions[name]] for row in rows] + + group_column = "replicate_group" if "replicate_group" in positions else "candidate_id" + groups = [str(value) for value in column(group_column)] + film_labels = [str(value) for value in column("candidate_id")] + + wanted = [*required_entry, *optional_entry] + for spec in specs: + wanted.extend(check.column for check in spec.cross_checks) + raw = pd.DataFrame( + {name: column(name) for name in dict.fromkeys(wanted) if name in positions}, + dtype=object, + ) + per_film = compute_measurements(raw, specs, sample_ids=film_labels) + findings = list(per_film.findings) + + inputs = pd.DataFrame( + {name: pd.to_numeric(column(name), errors="coerce") for name in input_names} + ) + + ordered_groups = list(dict.fromkeys(groups)) + group_index = pd.Series(groups) + + condition_rows: list[dict[str, float]] = [] + value_rows: list[dict[str, float]] = [] + spread_rows: list[dict[str, float]] = [] + count_rows: list[dict[str, int]] = [] + for group in ordered_groups: + mask = (group_index == group).to_numpy() + block = inputs.loc[mask] + first = block.iloc[0] + for name in input_names: + if not np.allclose( + block[name].to_numpy(dtype=float), float(first[name]), equal_nan=True + ): + raise CandidateSheetError( + f"The films of {group} do not share the same {name}. Replicates " + "must be the same recipe; edit the sheet or regenerate it." + ) + condition_rows.append({name: float(first[name]) for name in input_names}) + + values: dict[str, float] = {} + spreads: dict[str, float] = {} + counts: dict[str, int] = {} + for name, rule in zip(names, rules): + film_values = per_film.values.loc[mask, name].to_numpy(dtype=float) + observation, spread = _aggregate(film_values, rule) + values[name] = observation + spreads[name] = spread + counts[name] = int(np.isfinite(film_values).sum()) + if counts[name] == 0: + findings.append( + ScoreFinding( + severity=ScoreSeverity.ERROR, + code="condition_has_no_usable_film", + objective=name, + row_position=ordered_groups.index(group), + sample_id=group, + message=( + f"none of the {int(mask.sum())} films of {group} produced " + f"a usable {name} value." + ), + ) + ) + value_rows.append(values) + spread_rows.append(spreads) + count_rows.append(counts) + + replicates = pd.DataFrame( + { + "candidate_id": film_labels, + "replicate_group": groups, + **( + {"replicate_index": pd.to_numeric(column("replicate_index"))} + if "replicate_index" in positions + else {} + ), + **{name: per_film.values[name] for name in names}, + } + ) + + return CandidateResults( + round_name=round_name.upper(), + conditions=pd.DataFrame(condition_rows, columns=input_names), + model_values=pd.DataFrame(value_rows, columns=names), + replicates=replicates, + replicate_spread=pd.DataFrame(spread_rows, columns=names), + films_used=pd.DataFrame(count_rows, columns=names), + findings=tuple(findings), + candidate_ids=tuple(ordered_groups), + ) + + +def detect_round(path: str | Path, config: Mapping[str, Any]) -> RoundState: + """Decide which round to generate. Fail closed on a partial sheet. + + "Measured" is a per-objective question once objectives are computed rather + than read: ``product`` and ``log10_product`` need every input, while + thickness needs only one of ``T1..T4``. Requiring all four would report a + finished sheet as partial -- nine of the fifteen R0 rows have two readings. + """ + specs = [spec for spec in measurement_specs(config) if spec is not None] + required_entry, optional_entry = measurement_entry_columns(config) + + for round_name, following in (("R1", "R2"), ("R2", None)): + candidate_path = candidate_workbook_path(path, round_name) + name = candidate_path.name + if not candidate_path.exists(): + return RoundState(round_name, f"{name} does not exist yet.") + sheet = load_workbook(candidate_path, data_only=True)[ + sheet_name_for_round(round_name) + ] + positions = _header_positions(sheet) + missing = [c for c in required_entry if c not in positions] + if missing: + raise CandidateSheetError( + f"{name} is missing entry column(s) {missing}. It was probably " + "created by an older version; delete the sheet and regenerate it." + ) + data = [ + row + for row in sheet.iter_rows(min_row=2, values_only=True) + if any(value is not None for value in row) + ] + if specs: + frame = pd.DataFrame( + { + entry: [row[positions[entry]] for row in data] + for entry in dict.fromkeys((*required_entry, *optional_entry)) + if entry in positions + }, + dtype=object, + index=range(len(data)), + ) + filled = list(row_completeness(frame, specs)) + else: + filled = [ + all(row[positions[c]] is not None for c in required_entry) + for row in data + ] + scored, total = sum(filled), len(filled) + if total and scored == 0: + return RoundState( + None, + f"{name} exists but no results have been entered yet. Run those " + f"{total} conditions and fill in {', '.join(required_entry)}.", + scored, + total, + ) + if scored < total: + return RoundState( + None, + f"{name} is partly filled in: {scored} of {total} rows have all " + "measurements. Complete the remaining rows, or clear them, then " + "try again.", + scored, + total, + ) + if following is None: + return RoundState( + None, + "R1 and R2 are both complete. The campaign is finished.", + scored, + total, + ) + return RoundState(None, "Nothing to do.") + + +def candidate_workbook_path(path: str | Path, round_name: str) -> Path: + """Where this round's candidates are written, beside the source workbook.""" + source = Path(path) + return source.with_name(f"{source.stem}_{sheet_name_for_round(round_name)}.xlsx") + + +def write_candidate_sheet( + path: str | Path, + config: Mapping[str, Any], + conditions: pd.DataFrame, + *, + round_name: str, + replicates: int = 3, + make_backup: bool = True, +) -> Path: + """Write this round's worklist to a sibling workbook. + + One row per physical film, three per condition sharing a ``replicate_group``. + Measurement columns are left blank and highlighted for entry -- the raw + columns each objective is computed from, not the derived scores, because the + scores are now computed in Python. + + Optional entry columns (``T3``, ``T4``, ``T anom``) are offered but not + demanded: a film with two thickness readings is complete. + + The source workbook is opened read-only and its Sheet1 digest is checked + afterwards, so the guarantee is enforced rather than assumed. + """ + source_path = Path(path) + source_before = _source_sheet_digest(source_path, source_sheet(config)) + workbook_path = candidate_workbook_path(source_path, round_name) + sheet_name = sheet_name_for_round(round_name) + if workbook_path.exists(): + if make_backup: + backup_workbook(workbook_path) + raise CandidateSheetError( + f"{workbook_path.name} already exists. Rename or delete it first; " + "this tool does not overwrite a file that may hold measurements." + ) + from openpyxl import Workbook + + workbook = Workbook() + workbook.remove(workbook.active) + + input_names = [item["name"] for item in config["inputs"]] + required_entry, optional_entry = measurement_entry_columns(config) + entry_names = [*required_entry, *optional_entry] + headers = [ + "candidate_id", + "replicate_group", + "replicate_index", + "round", + *input_names, + *entry_names, + ] + + sheet = workbook.create_sheet(sheet_name) + sheet.append(headers) + for cell in sheet[1]: + cell.font = HEADER_FONT + + entry_start = len(headers) - len(entry_names) + 1 + for index, (_, condition) in enumerate(conditions.iterrows(), start=1): + candidate_id = f"{round_name.upper()}_C{index:02d}" + for replicate in range(1, replicates + 1): + sheet.append( + [ + candidate_id, + candidate_id, + replicate, + round_name.upper(), + *[float(condition[name]) for name in input_names], + ] + ) + for offset in range(len(entry_names)): + sheet.cell(row=sheet.max_row, column=entry_start + offset).fill = ( + ENTRY_FILL + ) + + for index, header in enumerate(headers, start=1): + sheet.column_dimensions[get_column_letter(index)].width = max( + 12, min(24, len(header) + 3) + ) + sheet.freeze_panes = "A2" + + workbook.save(workbook_path) + + # the invariant, actually enforced rather than asserted + if _source_sheet_digest(source_path, source_sheet(config)) != source_before: + raise CandidateSheetError( + f"{source_sheet(config)} in {source_path.name} changed while writing " + f"{workbook_path.name}. It should not have been touched at all." + ) + return workbook_path + + +def _source_sheet_digest(path: str | Path, sheet_name: str = SOURCE_SHEET) -> str: + """Digest of the source sheet's values only, so added sheets do not change it.""" + sheet = load_workbook(Path(path), data_only=True, read_only=False)[sheet_name] + digest = hashlib.sha256() + for row in sheet.iter_rows(values_only=True): + digest.update(repr(row).encode("utf-8")) + return digest.hexdigest() diff --git a/tests/__pycache__/smoke_test.cpython-310-pytest-8.4.1.pyc b/tests/__pycache__/smoke_test.cpython-310-pytest-8.4.1.pyc deleted file mode 100644 index 600682c..0000000 Binary files a/tests/__pycache__/smoke_test.cpython-310-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_acquisition.cpython-310-pytest-8.4.1.pyc b/tests/__pycache__/test_acquisition.cpython-310-pytest-8.4.1.pyc deleted file mode 100644 index e693d6a..0000000 Binary files a/tests/__pycache__/test_acquisition.cpython-310-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_design.cpython-310-pytest-8.4.1.pyc b/tests/__pycache__/test_design.cpython-310-pytest-8.4.1.pyc deleted file mode 100644 index 1e07245..0000000 Binary files a/tests/__pycache__/test_design.cpython-310-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_design.cpython-311-pytest-7.4.0.pyc b/tests/__pycache__/test_design.cpython-311-pytest-7.4.0.pyc deleted file mode 100644 index 3014c55..0000000 Binary files a/tests/__pycache__/test_design.cpython-311-pytest-7.4.0.pyc and /dev/null differ diff --git a/tests/__pycache__/test_gp_fitting.cpython-310-pytest-8.4.1.pyc b/tests/__pycache__/test_gp_fitting.cpython-310-pytest-8.4.1.pyc deleted file mode 100644 index ad3ee9d..0000000 Binary files a/tests/__pycache__/test_gp_fitting.cpython-310-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_main.cpython-310-pytest-8.4.1.pyc b/tests/__pycache__/test_main.cpython-310-pytest-8.4.1.pyc deleted file mode 100644 index e98cc5f..0000000 Binary files a/tests/__pycache__/test_main.cpython-310-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_main.cpython-311-pytest-7.4.0.pyc b/tests/__pycache__/test_main.cpython-311-pytest-7.4.0.pyc deleted file mode 100644 index 8f65322..0000000 Binary files a/tests/__pycache__/test_main.cpython-311-pytest-7.4.0.pyc and /dev/null differ diff --git a/tests/__pycache__/test_models.cpython-310-pytest-8.4.1.pyc b/tests/__pycache__/test_models.cpython-310-pytest-8.4.1.pyc deleted file mode 100644 index 468c9f2..0000000 Binary files a/tests/__pycache__/test_models.cpython-310-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_plotting.cpython-310-pytest-8.4.1.pyc b/tests/__pycache__/test_plotting.cpython-310-pytest-8.4.1.pyc deleted file mode 100644 index bd59c0f..0000000 Binary files a/tests/__pycache__/test_plotting.cpython-310-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/__pycache__/test_plotting.cpython-310.pyc b/tests/__pycache__/test_plotting.cpython-310.pyc deleted file mode 100644 index ccf54c7..0000000 Binary files a/tests/__pycache__/test_plotting.cpython-310.pyc and /dev/null differ diff --git a/tests/__pycache__/test_synthetic_bo_loop.cpython-310-pytest-8.4.1.pyc b/tests/__pycache__/test_synthetic_bo_loop.cpython-310-pytest-8.4.1.pyc deleted file mode 100644 index 63edff2..0000000 Binary files a/tests/__pycache__/test_synthetic_bo_loop.cpython-310-pytest-8.4.1.pyc and /dev/null differ diff --git a/tests/simple_gp_test.py b/tests/simple_gp_test.py deleted file mode 100644 index 4a9b53c..0000000 --- a/tests/simple_gp_test.py +++ /dev/null @@ -1,294 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple GP test with visualizations - no LOOCV, just basic functionality -""" - -import numpy as np -import torch -import matplotlib.pyplot as plt -from gpytorch.kernels import RBFKernel, MaternKernel, PeriodicKernel - -from src.models import fit_gp_models, posterior_report -from src.data import y_standardize_np -from src.utils import np_to_torch - -# Set up matplotlib for better plots -plt.style.use('default') -plt.rcParams['figure.figsize'] = (12, 8) -plt.rcParams['font.size'] = 10 - -def synthetic_objectives(X): - """ - Two synthetic objective functions with known properties: - - Obj1: Quadratic with global minimum - - Obj2: Sinusoidal with multiple local optima - """ - x1, x2 = X[:, 0], X[:, 1] - - # Objective 1: Quadratic bowl (minimize) - obj1 = (x1 - 0.3)**2 + (x2 - 0.7)**2 + 0.1 - - # Objective 2: Sinusoidal (minimize) - obj2 = 0.5 * np.sin(6 * np.pi * x1) * np.cos(4 * np.pi * x2) + 0.5 - - return np.column_stack([obj1, obj2]) - -def generate_training_data(n_points=20, noise_std=0.05, seed=42): - """Generate training data with controlled noise""" - np.random.seed(seed) - - # Random sampling in [0,1]^2 - X_train = np.random.uniform(0, 1, size=(n_points, 2)) - - # Evaluate true objectives - Y_true = synthetic_objectives(X_train) - - # Add Gaussian noise - noise = np.random.normal(0, noise_std, Y_true.shape) - Y_noisy = Y_true + noise - - return X_train, Y_noisy, Y_true - -def plot_true_objectives(): - """Plot the true objective functions for reference""" - # Create a fine grid for visualization - n_grid = 100 - x1 = np.linspace(0, 1, n_grid) - x2 = np.linspace(0, 1, n_grid) - X1, X2 = np.meshgrid(x1, x2) - X_grid = np.column_stack([X1.ravel(), X2.ravel()]) - - # Evaluate true objectives - Y_true = synthetic_objectives(X_grid) - obj1_grid = Y_true[:, 0].reshape(n_grid, n_grid) - obj2_grid = Y_true[:, 1].reshape(n_grid, n_grid) - - fig, axes = plt.subplots(1, 2, figsize=(15, 6)) - - # Objective 1: Quadratic - im1 = axes[0].contourf(X1, X2, obj1_grid, levels=20, cmap='viridis') - axes[0].set_title('Objective 1: Quadratic Bowl\n(x₁-0.3)² + (x₂-0.7)² + 0.1') - axes[0].set_xlabel('x₁') - axes[0].set_ylabel('x₂') - axes[0].plot(0.3, 0.7, 'r*', markersize=15, label='Global minimum') - axes[0].legend() - plt.colorbar(im1, ax=axes[0]) - - # Objective 2: Sinusoidal - im2 = axes[1].contourf(X1, X2, obj2_grid, levels=20, cmap='plasma') - axes[1].set_title('Objective 2: Sinusoidal\n0.5×sin(6πx₁)×cos(4πx₂) + 0.5') - axes[1].set_xlabel('x₁') - axes[1].set_ylabel('x₂') - plt.colorbar(im2, ax=axes[1]) - - plt.tight_layout() - plt.savefig('simple_true_objectives.png', dpi=150, bbox_inches='tight') - plt.show() - - return fig - -def plot_gp_predictions(model, X_train, Y_train, Y_mean, Y_std, title_suffix=""): - """Plot GP predictions vs true functions""" - # Create prediction grid - n_grid = 50 - x1 = np.linspace(0, 1, n_grid) - x2 = np.linspace(0, 1, n_grid) - X1, X2 = np.meshgrid(x1, x2) - X_grid = np.column_stack([X1.ravel(), X2.ravel()]) - - # True values - Y_true_grid = synthetic_objectives(X_grid) - - # GP predictions - X_grid_t = torch.tensor(X_grid, dtype=torch.float64) - pred_mean, pred_std = posterior_report(model, X_grid_t, Y_mean, Y_std) - - # Reshape for plotting - obj1_true = Y_true_grid[:, 0].reshape(n_grid, n_grid) - obj1_pred = pred_mean[:, 0].reshape(n_grid, n_grid) - obj1_std = pred_std[:, 0].reshape(n_grid, n_grid) - - obj2_true = Y_true_grid[:, 1].reshape(n_grid, n_grid) - obj2_pred = pred_mean[:, 1].reshape(n_grid, n_grid) - obj2_std = pred_std[:, 1].reshape(n_grid, n_grid) - - # Create plots - fig, axes = plt.subplots(2, 3, figsize=(18, 12)) - - # Objective 1 row - # True - im1 = axes[0,0].contourf(X1, X2, obj1_true, levels=20, cmap='viridis') - axes[0,0].scatter(X_train[:, 0], X_train[:, 1], c=Y_train[:, 0], - s=80, cmap='viridis', edgecolors='white', linewidth=2) - axes[0,0].set_title('Obj1: True') - axes[0,0].set_ylabel('x₂') - plt.colorbar(im1, ax=axes[0,0]) - - # Predicted - im2 = axes[0,1].contourf(X1, X2, obj1_pred, levels=20, cmap='viridis') - axes[0,1].scatter(X_train[:, 0], X_train[:, 1], c=Y_train[:, 0], - s=80, cmap='viridis', edgecolors='white', linewidth=2) - axes[0,1].set_title('Obj1: GP Prediction') - plt.colorbar(im2, ax=axes[0,1]) - - # Uncertainty - im3 = axes[0,2].contourf(X1, X2, obj1_std, levels=20, cmap='Reds') - axes[0,2].scatter(X_train[:, 0], X_train[:, 1], c='white', - s=80, edgecolors='black', linewidth=2) - axes[0,2].set_title('Obj1: GP Uncertainty') - plt.colorbar(im3, ax=axes[0,2]) - - # Objective 2 row - # True - im4 = axes[1,0].contourf(X1, X2, obj2_true, levels=20, cmap='plasma') - axes[1,0].scatter(X_train[:, 0], X_train[:, 1], c=Y_train[:, 1], - s=80, cmap='plasma', edgecolors='white', linewidth=2) - axes[1,0].set_title('Obj2: True') - axes[1,0].set_xlabel('x₁') - axes[1,0].set_ylabel('x₂') - plt.colorbar(im4, ax=axes[1,0]) - - # Predicted - im5 = axes[1,1].contourf(X1, X2, obj2_pred, levels=20, cmap='plasma') - axes[1,1].scatter(X_train[:, 0], X_train[:, 1], c=Y_train[:, 1], - s=80, cmap='plasma', edgecolors='white', linewidth=2) - axes[1,1].set_title('Obj2: GP Prediction') - axes[1,1].set_xlabel('x₁') - plt.colorbar(im5, ax=axes[1,1]) - - # Uncertainty - im6 = axes[1,2].contourf(X1, X2, obj2_std, levels=20, cmap='Reds') - axes[1,2].scatter(X_train[:, 0], X_train[:, 1], c='white', - s=80, edgecolors='black', linewidth=2) - axes[1,2].set_title('Obj2: GP Uncertainty') - axes[1,2].set_xlabel('x₁') - plt.colorbar(im6, ax=axes[1,2]) - - plt.suptitle(f'Default GP Model Performance {title_suffix}', fontsize=14) - plt.tight_layout() - plt.savefig(f'simple_gp_predictions{title_suffix.replace(" ", "_")}.png', dpi=150, bbox_inches='tight') - plt.show() - - return fig - -def plot_training_fit(Y_true, Y_pred, Y_std, objective_names=['Obj1', 'Obj2']): - """Simple parity plot for training fit""" - fig, axes = plt.subplots(1, 2, figsize=(12, 5)) - - for i, obj_name in enumerate(objective_names): - ax = axes[i] - - # Plot parity line - y_min, y_max = min(Y_true[:, i].min(), Y_pred[:, i].min()), max(Y_true[:, i].max(), Y_pred[:, i].max()) - ax.plot([y_min, y_max], [y_min, y_max], 'k--', alpha=0.5, label='Perfect fit') - - # Plot predictions with error bars - ax.errorbar(Y_true[:, i], Y_pred[:, i], yerr=Y_std[:, i], - fmt='o', alpha=0.7, capsize=3, label='GP predictions') - - # Calculate R² - from sklearn.metrics import r2_score - r2 = r2_score(Y_true[:, i], Y_pred[:, i]) - - ax.set_xlabel(f'True {obj_name}') - ax.set_ylabel(f'Predicted {obj_name}') - ax.set_title(f'{obj_name}: R² = {r2:.3f}') - ax.legend() - ax.grid(True, alpha=0.3) - - # Make axes equal - ax.set_aspect('equal', adjustable='box') - - plt.tight_layout() - plt.savefig('simple_parity_plot.png', dpi=150, bbox_inches='tight') - plt.show() - - return fig - -def simple_gp_test(): - """Simple test of default GP models with visualizations""" - - print("=== SIMPLE GP TEST ===") - - # Show the true objective functions - print("1. Plotting true objective functions...") - plot_true_objectives() - - # Generate training data - print("\n2. Generating training data...") - X_train, Y_train, Y_true = generate_training_data(n_points=100, noise_std=0.02, seed=42) - print(f" Training data: {X_train.shape[0]} points, {X_train.shape[1]} dimensions") - print(f" Objectives: {Y_train.shape[1]} (Quadratic, Sinusoidal)") - print(f" Y_train range: {Y_train.min(axis=0)} to {Y_train.max(axis=0)}") - - # Standardize Y and prepare tensors - print("\n3. Standardizing data...") - Y_std, Y_mean, Y_scale = y_standardize_np(Y_train) - (X_t, Y_t), device = np_to_torch(X_train, Y_std, device='cpu', return_device=True) - print(f" Y standardized: mean={Y_std.mean(axis=0)}, std={Y_std.std(axis=0)}") - print(f" Using device: {device}") - - # Fit default GP model - print("\n4. Fitting default GP model...") - try: - model = fit_gp_models(X_t, Y_t, kernel_fn=[lambda d: RBFKernel(ard_num_dims=d), lambda d: MaternKernel(nu=0.5,ard_num_dims=d)]) # No additional arguments - just defaults - print(" ✓ Default GP model fitted successfully!") - - # Check model details - print(f" Model type: {type(model).__name__}") - print(f" Number of sub-models: {len(model.models)}") - for i, sub_model in enumerate(model.models): - print(f" Sub-model {i}: {type(sub_model).__name__}") - - except Exception as e: - print(f" ✗ GP fitting failed: {e}") - return None - - # Test predictions on training data - print("\n5. Testing predictions...") - try: - pred_mean, pred_std = posterior_report(model, X_t, Y_mean, Y_scale) - print(f" Prediction shapes: mean={pred_mean.shape}, std={pred_std.shape}") - print(f" Prediction ranges: mean={pred_mean.min(axis=0)} to {pred_mean.max(axis=0)}") - print(f" Uncertainty ranges: std={pred_std.min(axis=0)} to {pred_std.max(axis=0)}") - - except Exception as e: - print(f" ✗ Prediction failed: {e}") - return None - - # Compute and show training fit quality - print("\n6. Computing training fit metrics...") - from sklearn.metrics import r2_score, mean_squared_error - - r2_scores = [] - rmse_scores = [] - for j in range(2): - r2 = r2_score(Y_train[:, j], pred_mean[:, j]) - rmse = np.sqrt(mean_squared_error(Y_train[:, j], pred_mean[:, j])) - r2_scores.append(r2) - rmse_scores.append(rmse) - - obj_name = "Quadratic" if j == 0 else "Sinusoidal" - print(f" {obj_name}: R² = {r2:.3f}, RMSE = {rmse:.4f}") - - # Create visualizations - print("\n7. Creating visualizations...") - - # Plot GP predictions - plot_gp_predictions(model, X_train, Y_train, Y_mean, Y_scale, f"(N={len(X_train)})") - - # Plot training fit - plot_training_fit(Y_train, pred_mean, pred_std, ['Quadratic', 'Sinusoidal']) - - print("\n=== SIMPLE GP TEST COMPLETED ===") - print("Check the generated PNG files for visualizations!") - - return { - 'X_train': X_train, 'Y_train': Y_train, - 'model': model, 'Y_mean': Y_mean, 'Y_scale': Y_scale, - 'pred_mean': pred_mean, 'pred_std': pred_std, - 'r2_scores': r2_scores, 'rmse_scores': rmse_scores - } - -if __name__ == "__main__": - results = simple_gp_test() diff --git a/tests/smoke_test.py b/tests/smoke_test.py deleted file mode 100644 index 466c9de..0000000 --- a/tests/smoke_test.py +++ /dev/null @@ -1,64 +0,0 @@ -# tests/smoke_test.py -import numpy as np -import torch -import pandas as pd - -from src.utils import load_config, get_objective_names, load_csv, split_XY_from_cfg, np_to_torch, set_seeds -from src.design import build_input_spec_list, build_design -from src.data import y_minmax_np, x_normalizer_torch, x_denormalizer_np -from src.metrics import compute_ref_pareto_hv -from src.models import fit_gp_models -from src.acquisition import build_qnehvi, _make_snap_postproc, optimize_acq_qnehvi -# If you want row constraints, also: -# from src.constraints import constraints_from_config -# and later pass row_constraints into propose_qnehvi_batch instead of the lower-level calls. - -CFG_PATH = "configs/example_inputs.yaml" -CSV_PATH = "data/processed/R0+R1 full results-1.csv" # ← update if your file is named differently - -def main(): - set_seeds(123) - - # 1) Config → Design - cfg = load_config(CFG_PATH) - specs = build_input_spec_list(cfg["inputs"]) - design = build_design(specs) - obj_names = get_objective_names(cfg) - print(f"[OK] D={len(design.names)} inputs, M={len(obj_names)} objectives") - - # 2) Load CSV → split X,Y (physical units) - df = load_csv(CSV_PATH) - X_np, Y_np = split_XY_from_cfg(df, design, cfg) - print(f"[OK] CSV N={len(X_np)} rows") - - # 3) Scale Y to [0,1]; normalize X to [0,1]^D - Y_scaled, Y_min, Y_max = y_minmax_np(Y_np, eps=1e-12) - X_t = np_to_torch(X_np) - Xn_t = x_normalizer_torch(X_t, design) - Y_t = np_to_torch(Y_scaled) - device, dtype = Xn_t.device, Xn_t.dtype - print(f"[OK] Normalized X, scaled Y (device={device}, dtype={dtype})") - - # 4) Fit vanilla GP per objective - model = fit_gp_models(Xn_t, Y_t) - model = model.to(device=device, dtype=dtype) - print("[OK] Fitted ModelListGP") - - # 5) Ref point + hypervolume on scaled space - ref_point_t, pareto_Y_t, hv_val = compute_ref_pareto_hv(Y_t) - print(f"[OK] HV={hv_val:.4f} with {pareto_Y_t.shape[0]} Pareto points") - - # 6) Build qNEHVI and propose q=5 (snapped in optimizer) - acq = build_qnehvi(model=model, train_X=Xn_t, ref_point_t=ref_point_t, sample_shape=128) - postproc = _make_snap_postproc(design) - cand_norm_t, acq_val_t = optimize_acq_qnehvi( - acq_function=acq, d=Xn_t.shape[1], q=5, num_restarts=5, raw_samples=256, - device=device, dtype=dtype, options={"retry_on_optimization_warning": True}, - sequential=True, post_processing_func=postproc, - ) - cand_norm = cand_norm_t.detach().cpu().numpy() - X_phys = x_denormalizer_np(cand_norm, design) - print("[OK] Proposed 5 candidates (physical). First row:", np.round(X_phys[0], 4)) - -if __name__ == "__main__": - main() diff --git a/tests/synthetic_test.py b/tests/synthetic_test.py deleted file mode 100644 index 02dce30..0000000 --- a/tests/synthetic_test.py +++ /dev/null @@ -1,625 +0,0 @@ -#!/usr/bin/env python3 -""" -Synthetic multi-objective test problem for debugging MOBO pipeline -""" - -import numpy as np -import torch -from scipy.stats import qmc -import matplotlib.pyplot as plt -from gpytorch.kernels import RBFKernel, MaternKernel, PeriodicKernel -from gpytorch.priors import LogNormalPrior - -from src.models import fit_gp_models, posterior_report, loocv_select_models -from src.data import y_standardize_np -from src.utils import np_to_torch -from src.design import Design, InputSpec, build_design -from src.plotting import plot_parity_np, plot_shap -from src.acquisition import propose_batch -from src.metrics import compute_ref_pareto_hv - -# Set up matplotlib for better plots -plt.style.use('default') -plt.rcParams['figure.figsize'] = (12, 8) -plt.rcParams['font.size'] = 10 - -def synthetic_objectives(X): - """ - Two synthetic objective functions with known properties (MAXIMIZATION): - - Obj1: Inverted quadratic with global maximum - - Obj2: Sinusoidal with multiple local maxima - - Args: - X: array of shape (N, 2) with X in [0, 1]^2 - - Returns: - Y: array of shape (N, 2) with objectives (higher is better) - """ - x1, x2 = X[:, 0], X[:, 1] - - # Objective 1: Inverted quadratic hill (maximize) - # Maximum at (0.3, 0.7) with value ~1.0 - obj1 = 1.0 - ((x1 - 0.3)**2 + (x2 - 0.7)**2) - - # Objective 2: Scaled sinusoidal (maximize) - # Oscillates between 0 and 1, with multiple local maxima - obj2 = 0.5 * (1 + np.sin(4 * np.pi * x1) * np.cos(3 * np.pi * x2)) - - return np.column_stack([obj1, obj2]) - -def generate_training_data( - n_points: int = 20, - noise_std: float = 0.05, - seed: int = 42, - dim: int = 2, - bounds: np.ndarray | None = None, -): - """ - Generate training data using Latin Hypercube Sampling with controlled noise. - - Args: - n_points: number of samples - noise_std: std dev of Gaussian noise added to objectives - seed: RNG seed for reproducibility - dim: dimensionality of X - bounds: optional (dim, 2) array of [low, high] for each dim; defaults to [0,1]^dim - - Returns: - X_train: (n_points, dim) sampled inputs - Y_noisy: (n_points, M) noisy objective values (same shape as Y_true) - Y_true: (n_points, M) true objective values - """ - if bounds is None: - bounds = np.tile([0.0, 1.0], (dim, 1)) # [[0,1],[0,1],...] - - sampler = qmc.LatinHypercube(d=dim, seed=seed) - X_unit = sampler.random(n=n_points) # (n_points, dim) in [0,1] - - # Scale to bounds - X_train = qmc.scale(X_unit, bounds[:, 0], bounds[:, 1]) - - # Evaluate true objectives - Y_true = synthetic_objectives(X_train) - - # Add Gaussian noise - rng = np.random.default_rng(seed + 1) # separate seed for noise - noise = rng.normal(0.0, noise_std, size=Y_true.shape) - Y_noisy = Y_true + noise - - return X_train, Y_noisy, Y_true - -def plot_true_objectives(): - """Plot the true objective functions for reference""" - # Create a fine grid for visualization - n_grid = 100 - x1 = np.linspace(0, 1, n_grid) - x2 = np.linspace(0, 1, n_grid) - X1, X2 = np.meshgrid(x1, x2) - X_grid = np.column_stack([X1.ravel(), X2.ravel()]) - - # Evaluate true objectives - Y_true = synthetic_objectives(X_grid) - obj1_grid = Y_true[:, 0].reshape(n_grid, n_grid) - obj2_grid = Y_true[:, 1].reshape(n_grid, n_grid) - - fig, axes = plt.subplots(1, 2, figsize=(15, 6)) - - # Objective 1: Inverted Quadratic - im1 = axes[0].contourf(X1, X2, obj1_grid, levels=20, cmap='viridis') - axes[0].set_title('Objective 1: Inverted Quadratic Hill\n1 - ((x₁-0.3)² + (x₂-0.7)²)') - axes[0].set_xlabel('x₁') - axes[0].set_ylabel('x₂') - axes[0].plot(0.3, 0.7, 'r*', markersize=15, label='Global maximum') - axes[0].legend() - plt.colorbar(im1, ax=axes[0]) - - # Objective 2: Sinusoidal - im2 = axes[1].contourf(X1, X2, obj2_grid, levels=20, cmap='plasma') - axes[1].set_title('Objective 2: Sinusoidal\n0.5×(1 + sin(4πx₁)×cos(3πx₂))') - axes[1].set_xlabel('x₁') - axes[1].set_ylabel('x₂') - plt.colorbar(im2, ax=axes[1]) - - plt.tight_layout() - plt.savefig('synthetic_true_objectives.png', dpi=150, bbox_inches='tight') - plt.show() - - return fig - -def plot_gp_predictions(model, X_train, Y_train, title_suffix=""): - """Plot GP predictions vs true functions""" - # Create prediction grid - n_grid = 50 - x1 = np.linspace(0, 1, n_grid) - x2 = np.linspace(0, 1, n_grid) - X1, X2 = np.meshgrid(x1, x2) - X_grid = np.column_stack([X1.ravel(), X2.ravel()]) - - # True values - Y_true_grid = synthetic_objectives(X_grid) - - # GP predictions - ensure tensor is on same device as model - device = next(model.parameters()).device - X_grid_t = torch.tensor(X_grid, dtype=torch.float64, device=device) - pred_mean, pred_std = posterior_report(model, X_grid_t) - - # Reshape for plotting - obj1_true = Y_true_grid[:, 0].reshape(n_grid, n_grid) - obj1_pred = pred_mean[:, 0].reshape(n_grid, n_grid) - obj1_std = pred_std[:, 0].reshape(n_grid, n_grid) - - obj2_true = Y_true_grid[:, 1].reshape(n_grid, n_grid) - obj2_pred = pred_mean[:, 1].reshape(n_grid, n_grid) - obj2_std = pred_std[:, 1].reshape(n_grid, n_grid) - - # Create plots - fig, axes = plt.subplots(2, 3, figsize=(18, 12)) - - # Objective 1 row - # True - im1 = axes[0,0].contourf(X1, X2, obj1_true, levels=20, cmap='viridis') - axes[0,0].scatter(X_train[:, 0], X_train[:, 1], c=Y_train[:, 0], - s=60, cmap='viridis', edgecolors='white', linewidth=1) - axes[0,0].set_title('Obj1: True') - axes[0,0].set_ylabel('x₂') - plt.colorbar(im1, ax=axes[0,0]) - - # Predicted - im2 = axes[0,1].contourf(X1, X2, obj1_pred, levels=20, cmap='viridis') - axes[0,1].scatter(X_train[:, 0], X_train[:, 1], c=Y_train[:, 0], - s=60, cmap='viridis', edgecolors='white', linewidth=1) - axes[0,1].set_title('Obj1: GP Prediction') - plt.colorbar(im2, ax=axes[0,1]) - - # Uncertainty - im3 = axes[0,2].contourf(X1, X2, obj1_std, levels=20, cmap='Reds') - axes[0,2].scatter(X_train[:, 0], X_train[:, 1], c='white', - s=60, edgecolors='black', linewidth=1) - axes[0,2].set_title('Obj1: GP Uncertainty') - plt.colorbar(im3, ax=axes[0,2]) - - # Objective 2 row - # True - im4 = axes[1,0].contourf(X1, X2, obj2_true, levels=20, cmap='plasma') - axes[1,0].scatter(X_train[:, 0], X_train[:, 1], c=Y_train[:, 1], - s=60, cmap='plasma', edgecolors='white', linewidth=1) - axes[1,0].set_title('Obj2: True') - axes[1,0].set_xlabel('x₁') - axes[1,0].set_ylabel('x₂') - plt.colorbar(im4, ax=axes[1,0]) - - # Predicted - im5 = axes[1,1].contourf(X1, X2, obj2_pred, levels=20, cmap='plasma') - axes[1,1].scatter(X_train[:, 0], X_train[:, 1], c=Y_train[:, 1], - s=60, cmap='plasma', edgecolors='white', linewidth=1) - axes[1,1].set_title('Obj2: GP Prediction') - axes[1,1].set_xlabel('x₁') - plt.colorbar(im5, ax=axes[1,1]) - - # Uncertainty - im6 = axes[1,2].contourf(X1, X2, obj2_std, levels=20, cmap='Reds') - axes[1,2].scatter(X_train[:, 0], X_train[:, 1], c='white', - s=60, edgecolors='black', linewidth=1) - axes[1,2].set_title('Obj2: GP Uncertainty') - axes[1,2].set_xlabel('x₁') - plt.colorbar(im6, ax=axes[1,2]) - - plt.suptitle(f'GP Model Performance {title_suffix}', fontsize=14) - plt.tight_layout() - plt.savefig(f'synthetic_gp_predictions{title_suffix.replace(" ", "_")}.png', dpi=150, bbox_inches='tight') - plt.show() - - return fig - -def plot_objective_space(Y_data, labels, title="Objective Space"): - """Plot the objective space and Pareto front""" - fig, ax = plt.subplots(1, 1, figsize=(10, 8)) - - colors = plt.cm.Set1(np.linspace(0, 1, len(Y_data))) - - for i, (Y, label) in enumerate(zip(Y_data, labels)): - ax.scatter(Y[:, 0], Y[:, 1], alpha=0.7, label=label, c=[colors[i]], s=60) - - ax.set_xlabel('Objective 1 (Inverted Quadratic)') - ax.set_ylabel('Objective 2 (Sinusoidal)') - ax.set_title(title) - ax.legend() - ax.grid(True, alpha=0.3) - - plt.tight_layout() - plt.savefig(f'{title.replace(" ", "_").lower()}.png', dpi=150, bbox_inches='tight') - plt.show() - - return fig - -def plot_mobo_progression(batch_info, hypervolumes): - """Plot the MOBO progression over iterations""" - fig, axes = plt.subplots(1, 2, figsize=(18, 6)) - - # Plot 1: Hypervolume progression - batches = [info['batch'] for info in batch_info] - n_points = [info['n_points'] for info in batch_info] - - axes[0].plot(batches, hypervolumes, 'o-', linewidth=2, markersize=8) - axes[0].set_xlabel('Batch') - axes[0].set_ylabel('Hypervolume') - axes[0].set_title('Hypervolume Progression') - axes[0].grid(True, alpha=0.3) - - # Plot 2: Number of Pareto points - n_pareto = [info['n_pareto'] for info in batch_info] - axes[1].plot(batches, n_pareto, 's-', linewidth=2, markersize=8, color='orange') - axes[1].set_xlabel('Batch') - axes[1].set_ylabel('Number of Pareto Points') - axes[1].set_title('Pareto Front Growth') - axes[1].grid(True, alpha=0.3) - - # # Plot 3: Objective space evolution - # colors = plt.cm.viridis(np.linspace(0, 1, len(batch_info))) - # for i, (info, color) in enumerate(zip(batch_info, colors)): - # Y_batch = info['Y_batch'] - # alpha = 0.3 if i < len(batch_info) - 1 else 1.0 - # size = 20 if i < len(batch_info) - 1 else 60 - # label = f'Batch {i} (N={info["n_points"]})' - # axes[2].scatter(Y_batch[:, 0], Y_batch[:, 1], - # c=[color], alpha=alpha, s=size, label=label) - - # axes[2].set_xlabel('Objective 1 (Inverted Quadratic)') - # axes[2].set_ylabel('Objective 2 (Sinusoidal)') - # axes[2].set_title('Objective Space Evolution') - # axes[2].legend(bbox_to_anchor=(1.05, 1), loc='upper left') - # axes[2].grid(True, alpha=0.3) - - plt.tight_layout() - plt.savefig('mobo_progression.png', dpi=150, bbox_inches='tight') - plt.show() - - return fig - -def test_gp_pipeline(): - """Test the complete GP modeling pipeline on synthetic data""" - - print("=== SYNTHETIC MULTI-OBJECTIVE TEST ===") - - # 0. Show the true objective functions - print("Plotting true objective functions...") - plot_true_objectives() - - # 1. Generate training data - X_train, Y_train, Y_true = generate_training_data(n_points=20, noise_std=0.03) - print(f"Training data: {X_train.shape[0]} points, {X_train.shape[1]} dimensions") - print(f"Objectives: {Y_train.shape[1]} (Inverted Quadratic, Sinusoidal)") - print(f"Y_train range: {Y_train.min(axis=0)} to {Y_train.max(axis=0)}") - - # Plot objective space - plot_objective_space([Y_train], ['Training Data'], 'Training Data in Objective Space') - - # 2. Standardize Y and prepare tensors - Y_std, Y_mean, Y_scale = y_standardize_np(Y_train) - (X_t, Y_t), device = np_to_torch(X_train, Y_train, device='cpu', return_device=True) - print(f"Y Mean: mean={Y_std.mean(axis=0)}, std={Y_std.std(axis=0)}") - - # 3. Fit GP models with different configurations - print("\n--- Testing Different GP Configurations ---") - - # Simple default model - model_default = fit_gp_models(X_t, Y_t) - print("✓ Default model fitted") - - # Model with noise priors - noise_priors = [LogNormalPrior(-4.0, 0.5), LogNormalPrior(-3.5, 0.5)] - - # Model with different kernels per objective - kernels = [ - lambda d: RBFKernel(ard_num_dims=d), # Smooth for inverted quadratic - lambda d: MaternKernel(nu=1.5, ard_num_dims=d) # More flexible for sinusoidal - ] - model_mixed = fit_gp_models(X_t, Y_t, kernel_fn=kernels, noise_priors=noise_priors) - print("✓ Model with mixed kernels and noise priors fitted") - - # 4. Test predictions on training data - print("\n--- Testing Predictions ---") - pred_mean, pred_std = posterior_report(model_mixed, X_t) - - print(f"Prediction shapes: mean={pred_mean.shape}, std={pred_std.shape}") - print(f"Prediction ranges: mean={pred_mean.min(axis=0)} to {pred_mean.max(axis=0)}") - print(f"Uncertainty ranges: std={pred_std.min(axis=0)} to {pred_std.max(axis=0)}") - - # Plot GP predictions vs truth - print("Plotting GP model predictions...") - plot_gp_predictions(model_mixed, X_train, Y_train, f"(N={len(X_train)})") - - # 5. Compute training fit metrics - from sklearn.metrics import r2_score, mean_squared_error - r2_scores = [r2_score(Y_train[:, j], pred_mean[:, j]) for j in range(2)] - rmse_scores = [np.sqrt(mean_squared_error(Y_train[:, j], pred_mean[:, j])) for j in range(2)] - - print(f"\nTraining fit quality:") - print(f" Objective 1 (Inverted Quadratic): R²={r2_scores[0]:.3f}, RMSE={rmse_scores[0]:.4f}") - print(f" Objective 2 (Sinusoidal): R²={r2_scores[1]:.3f}, RMSE={rmse_scores[1]:.4f}") - - # 6. Test on dense grid for visualization - print("\n--- Testing on Dense Grid ---") - n_grid = 50 - x1_grid = np.linspace(0, 1, n_grid) - x2_grid = np.linspace(0, 1, n_grid) - X1, X2 = np.meshgrid(x1_grid, x2_grid) - X_grid = np.column_stack([X1.ravel(), X2.ravel()]) - - # True objectives on grid - Y_grid_true = synthetic_objectives(X_grid) - - # GP predictions on grid - ensure tensor is on same device as model - device = next(model_mixed.parameters()).device - X_grid_t = torch.tensor(X_grid, dtype=torch.float64, device=device) - pred_grid_mean, pred_grid_std = posterior_report(model_mixed, X_grid_t) - - # Compute prediction errors - grid_errors = np.abs(pred_grid_mean - Y_grid_true) - mean_abs_errors = grid_errors.mean(axis=0) - max_abs_errors = grid_errors.max(axis=0) - - print(f"Grid prediction errors:") - print(f" Objective 1: MAE={mean_abs_errors[0]:.4f}, Max={max_abs_errors[0]:.4f}") - print(f" Objective 2: MAE={mean_abs_errors[1]:.4f}, Max={max_abs_errors[1]:.4f}") - - # 7. Test LOOCV if dataset isn't too small - # if X_train.shape[0] >= 10: - # print("\n--- Testing LOOCV ---") - # try: - # kernel_options = [ - # lambda d: RBFKernel(ard_num_dims=d), - # lambda d: MaternKernel(nu=1.5, ard_num_dims=d) - # ] - # noise_options = [None, LogNormalPrior(-4.0, 0.5)] - - # model_cv, cv_results = loocv_select_models( - # X_t, Y_t, - # objective_names=['Inverted Quadratic', 'Sinusoidal'], - # matern_options=kernel_options, - # noise_options=noise_options - # ) - - # print("LOOCV Results:") - # print(cv_results) - - # except Exception as e: - # print(f"LOOCV failed: {e}") - - # 8. Create parity plots - print("\n--- Parity Plots ---") - try: - fig_parity, metrics_parity = plot_parity_np( - Y_train, pred_mean, pred_std, - objective_names=['Inverted Quadratic', 'Sinusoidal'], - save='synthetic_parity_plot.png' - ) - print("Parity plot metrics:") - print(metrics_parity) - except Exception as e: - print(f"Parity plot failed: {e}") - - print("\n=== GP MODELING TEST COMPLETED ===") - return { - 'X_train': X_train, 'Y_train': Y_train, 'Y_true': Y_true, - 'model': model_mixed, 'Y_mean': Y_mean, 'Y_scale': Y_scale, - 'pred_mean': pred_mean, 'pred_std': pred_std, - 'r2_scores': r2_scores, 'rmse_scores': rmse_scores - } - - -def test_mobo_loop(n_initial=20, n_batches=5, batch_size=10, seed=42): - """ - Test the complete MOBO loop with maximization objectives - - Args: - n_initial: Number of initial training points - n_batches: Number of MOBO iterations - batch_size: Number of points to propose per batch - seed: Random seed for reproducibility - """ - print("\n=== MOBO LOOP TEST (MAXIMIZATION) ===") - - # Set up design space - input_specs = [ - InputSpec(name='x1', unit=None, start=0.0, stop=1.0, step=0.01, decimals=2), - InputSpec(name='x2', unit=None, start=0.0, stop=1.0, step=0.01, decimals=2) - ] - design = build_design(input_specs) - print(f"Design space: {len(input_specs)} dimensions") - - # Generate initial training data - np.random.seed(seed) - X_all, Y_all, _ = generate_training_data(n_points=n_initial, noise_std=0.02, seed=seed) - - print(f"Initial training data: {n_initial} points") - print(f"Y_initial range: {Y_all.min(axis=0)} to {Y_all.max(axis=0)}") - - # Track MOBO progression - batch_info = [] - hypervolumes = [] - - # Reference point will be computed automatically by compute_ref_pareto_hv - ref_point_np = np.array([0.02-1e-2, -1e-2]) # Will be set after first hypervolume computation - ref_point_t = None # Will be set during first hypervolume computation - - for batch in range(n_batches + 1): # +1 to include initial evaluation - print(f"\n--- Batch {batch} (N={len(X_all)}) ---") - - # Standardize and prepare data - #Y_std, Y_mean, Y_scale = y_standardize_np(Y_all) - (X_t, Y_t), device = np_to_torch(X_all, Y_all, device='cuda', return_device=True) - - # Fit GP models - try: - # Use different kernels for different objectives - kernels = [ - lambda d: RBFKernel(ard_num_dims=d), # Smooth for inverted quadratic - lambda d: PeriodicKernel(ard_num_dims=d) # Flexible for sinusoidal - ] - noise_priors = None #[LogNormalPrior(-4.0, 0.5), LogNormalPrior(-3.5, 0.5)] - - model = fit_gp_models(X_t, Y_t)#, kernel_fn=kernels, noise_priors=noise_priors) - plot_gp_predictions(model, X_all, Y_all, f"(N={len(X_all)})") - #plot_shap(design, X_all, model) - print("✓ GP models fitted a") - for i, gp in enumerate(model.models): - print("Lengthscales:", gp.covar_module.base_kernel.lengthscale.detach().cpu().numpy().flatten()) - print("Outputscale:", gp.covar_module.outputscale.item()) - print("Noise:", gp.likelihood.noise.item()) - - except Exception as e: - print(f"⚠️ GP fitting failed: {e}") - # Fall back to default model - model = fit_gp_models(X_t, Y_t) - plot_gp_predictions(model, X_all, Y_all, f"(N={len(X_all)})") - print("✓ Fallback to default GP model") - - # Compute hypervolume - try: - # Convert to tensor for compute_ref_pareto_hv - use same device as model - Y_tensor = torch.tensor(Y_all, dtype=torch.float64, device=device) - ref_point_t, pareto_Y, hv = compute_ref_pareto_hv(Y_tensor, ref_point_np) - hypervolumes.append(hv) - print(f"Hypervolume: {hv:.6f}") - - # Pareto front info from the function - n_pareto = len(pareto_Y) - Y_pareto_np = pareto_Y.detach().cpu().numpy() - - print(f"Pareto points: {n_pareto}/{len(Y_all)}") - print(f"Pareto front range: {Y_pareto_np.min(axis=0)} to {Y_pareto_np.max(axis=0)}") - print(f"Reference point: {ref_point_t.detach().cpu().numpy()}") - - except Exception as e: - print(f"⚠️ Hypervolume computation failed: {e}") - hv = 0.0 - hypervolumes.append(hv) - n_pareto = 0 - # Set a fallback reference point if hypervolume computation fails - if ref_point_t is None: - ref_point_t = torch.tensor([0.1-1e-2, -1e-2], dtype=torch.float64, device=device) - - # Store batch information - batch_info.append({ - 'batch': batch, - 'n_points': len(X_all), - 'n_pareto': n_pareto, - 'Y_batch': Y_all.copy(), - 'hypervolume': hv - }) - - # Stop if this is the last evaluation - if batch >= n_batches: - break - - # Propose next batch - print(f"Proposing {batch_size} new points...") - try: - # Use the reference point computed from hypervolume calculation - #ref_point_device = ref_point_t.to(device=device, dtype=torch.float64) - - # Call propose_batch with correct signature - result = propose_batch( - design=design, - model=model, - train_X=X_t, # Normalized training inputs - ref_point_t=ref_point_t, - batch_size=batch_size, - sample_shape=64, - verbose=True - ) - - # Extract physical coordinates (already snapped) - X_next = result['X_phys'] - print(f"✓ Proposed points: {X_next.shape}") - - # Evaluate new points - Y_next = synthetic_objectives(X_next) - print(f"New objectives range: {Y_next.min(axis=0)} to {Y_next.max(axis=0)}") - - # Add to dataset - X_all = np.vstack([X_all, X_next]) - Y_all = np.vstack([Y_all, Y_next]) - - except Exception as e: - print(f"⚠️ Batch proposal failed: {e}") - break - - print(f"\n=== MOBO LOOP COMPLETED ===") - print(f"Total points collected: {len(X_all)}") - print(f"Final hypervolume: {hypervolumes[-1]:.6f}") - print(f"Hypervolume improvement: {hypervolumes[-1] - hypervolumes[0]:.6f}") - - # Create progression plots - print("\nCreating MOBO progression plots...") - try: - plot_mobo_progression(batch_info, hypervolumes) - except Exception as e: - print(f"⚠️ Plotting failed: {e}") - - # Plot final objective space - try: - Y_batches = [info['Y_batch'] for info in batch_info] # Every batch - labels = [f"Batch {info['batch']}" for info in batch_info] - plot_objective_space(Y_batches, labels, "MOBO Objective Space Evolution") - except Exception as e: - print(f"⚠️ Objective space plot failed: {e}") - - return { - 'X_final': X_all, - 'Y_final': Y_all, - 'batch_info': batch_info, - 'hypervolumes': hypervolumes, - 'model': model, - } - - -def compute_pareto_front(Y, minimize=False): - """ - Compute Pareto front for maximization (default) or minimization - - Args: - Y: Objective values (N, M) - minimize: If True, find Pareto front for minimization - - Returns: - pareto_mask: Boolean mask of Pareto optimal points - """ - Y_work = -Y if not minimize else Y # Convert to minimization - - n_points, n_obj = Y_work.shape - pareto_mask = np.ones(n_points, dtype=bool) - - for i in range(n_points): - if not pareto_mask[i]: - continue - - # Check if point i is dominated by any other point - for j in range(n_points): - if i == j or not pareto_mask[j]: - continue - - # j dominates i if j is better in all objectives - if np.all(Y_work[j] <= Y_work[i]) and np.any(Y_work[j] < Y_work[i]): - pareto_mask[i] = False - break - - return pareto_mask - - -if __name__ == "__main__": - # Run GP modeling test - print("Starting synthetic test with maximization objectives...") - gp_results = test_gp_pipeline() - - # Run MOBO loop test - print("\n" + "="*60) - mobo_results = test_mobo_loop(n_initial=20, n_batches=5, batch_size=5) - - print(f"\n🎯 SYNTHETIC TEST SUMMARY:") - print(f"GP modeling: ✓ Completed") - print(f"MOBO loop: ✓ Completed ({len(mobo_results['X_final'])} total points)") - print(f"Hypervolume improvement: {mobo_results['hypervolumes'][-1] - mobo_results['hypervolumes'][0]:.6f}") - print(f"Final Pareto points: {mobo_results['batch_info'][-1]['n_pareto']}") - print("All plots saved to current directory ✓") diff --git a/tests/test_acquisition.py b/tests/test_acquisition.py index 8d207b9..6bd0f51 100644 --- a/tests/test_acquisition.py +++ b/tests/test_acquisition.py @@ -1,736 +1,197 @@ -# tests/test_acquisition.py -import sys -import os -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +"""Fast smoke tests for the active acquisition API.""" + +from __future__ import annotations -import yaml -import torch import numpy as np -import pandas as pd -from src.design import build_design_from_config -from src.utils import load_csv, split_XY, np_to_torch, get_objective_names -from src.models import fit_gp_models, loocv_select_models -from src.data import y_minmax_np, y_standardize_np -from src.acquisition import ( - outcome_ge, outcome_le, - _unit_bounds, _make_snap_postproc, build_qnehvi, optimize_acq_qnehvi, propose_batch +import torch +from botorch.acquisition.multi_objective.logei import ( + qLogNoisyExpectedHypervolumeImprovement, +) +from botorch.models import SingleTaskGP +from botorch.models.model_list_gp_regression import ModelListGP +from botorch.models.transforms.outcome import Standardize + +import mobo_kit.acquisition as acquisition +from mobo_kit.acquisition import ( + _make_snap_postproc, + _unit_bounds, + build_qnehvi, + optimize_acq_qnehvi, + outcome_ge, + outcome_ge_standardized, + outcome_le, + outcome_le_standardized, + propose_batch, ) +from mobo_kit.design import InputSpec, build_design -CFG_PATH = "configs/configCSV_example_config.yaml" -CSV_PATH = "data/processed/configCSV_example.csv" - -def test_outcome_constraint_builders(): - """Test outcome constraint builders with various shapes and values.""" - print("Testing outcome constraint builders...") - - # Test different tensor shapes - shapes = [(64, 2, 4, 3), (10, 5), (100,)] - - for shape in shapes: - Y = torch.zeros(shape) - - # Test outcome_ge: feasible when Y[..., 1] >= 0.8 - c_ge = outcome_ge(obj_idx=1, thresh=0.8) - val_ge = c_ge(Y) - - assert val_ge.shape == shape[:-1], f"Shape mismatch for {shape}" - assert torch.all(val_ge > 0), f"All zeros should be infeasible for {shape}" - - # Make feasible - Y[..., 1] = 0.9 - assert torch.all(c_ge(Y) <= 0), f"Should be feasible for {shape}" - - # Test outcome_le: feasible when Y[..., 2] <= 0.15 - Y = torch.ones(shape) - c_le = outcome_le(obj_idx=2, thresh=0.15) - val_le = c_le(Y) - - assert val_le.shape == shape[:-1], f"Shape mismatch for {shape}" - assert torch.all(val_le > 0), f"All ones should be infeasible for {shape}" - - # Make feasible - Y[..., 2] = 0.10 - assert torch.all(c_le(Y) <= 0), f"Should be feasible for {shape}" - - print("✓ Outcome constraint builders work with various shapes") - -def test_unit_bounds(): - """Test unit bounds helper function.""" - print("Testing unit bounds helper...") - - device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') - dtype = torch.float64 - - for d in [1, 3, 8]: - bounds = _unit_bounds(d, device, dtype) - - assert bounds.shape == (2, d), f"Expected shape (2, {d}), got {bounds.shape}" - assert bounds[0].allclose(torch.zeros(d, device=device, dtype=dtype)), "Lower bounds should be 0" - assert bounds[1].allclose(torch.ones(d, device=device, dtype=dtype)), "Upper bounds should be 1" - assert bounds.device.type == device.type, "Device type mismatch" - assert bounds.dtype == dtype, "Dtype mismatch" - - print("✓ Unit bounds helper works correctly") - -def test_snap_postproc_factory(): - """Test snap post-processing factory function.""" - print("Testing snap post-processing factory...") - - # Load config and design - config = yaml.load(open(CFG_PATH), Loader=yaml.FullLoader) - design = build_design_from_config(config) - - # Create post-processing function - postproc = _make_snap_postproc(design) - - # Test with different shapes - device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') - test_shapes = [(5, 8), (1, 3, 8), (10, 2, 8)] - - for shape in test_shapes: - # Create random normalized input - Z = torch.rand(shape, device=device, dtype=torch.float64) - - # Apply post-processing - Z_snapped = postproc(Z) - - # Check output shape matches input - assert Z_snapped.shape == Z.shape, f"Shape mismatch for {shape}" - assert Z_snapped.device == Z.device, "Device mismatch" - assert Z_snapped.dtype == Z.dtype, "Dtype mismatch" - - # Check values are in [0, 1] range - assert torch.all(Z_snapped >= 0) and torch.all(Z_snapped <= 1), "Values out of [0,1] range" - - print("✓ Snap post-processing factory works correctly") - -def test_build_qnehvi(): - """Test qNEHVI acquisition function builder.""" - print("Testing qNEHVI builder...") - - # Load real data - config = yaml.load(open(CFG_PATH), Loader=yaml.FullLoader) - design = build_design_from_config(config) - df = load_csv(CSV_PATH) - X, Y = split_XY(df, design, config) - - # Convert to torch tensors - device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') - X_t, Y_t = np_to_torch(X.values, Y.values, device=device) - - # Normalize Y data - Y_scaled, Y_min, Y_max = y_minmax_np(Y.values) - Y_scaled_t = torch.tensor(Y_scaled, dtype=torch.float64, device=device) - - # Fit model - model = fit_gp_models(X_t, Y_scaled_t) - - # Build qNEHVI - ref_point = Y_scaled_t.min(dim=0).values - 0.01 - acq_func = build_qnehvi( - model=model, - train_X=X_t, - ref_point_t=ref_point, - sample_shape=64, # Smaller for testing - use_lognehvi=True + +def _design(): + return build_design( + [ + InputSpec("temperature", 10.0, 20.0, 5.0, unit="C"), + InputSpec("ratio", 0.0, 1.0, 0.25), + ] ) - - # Verify acquisition function properties - assert hasattr(acq_func, 'model'), "Should have model attribute" - assert hasattr(acq_func, 'ref_point'), "Should have ref_point attribute" - assert hasattr(acq_func, 'X_baseline'), "Should have X_baseline attribute" - - print("✓ qNEHVI builder works correctly") - -def test_optimize_acq_qnehvi(): - """Test acquisition function optimization wrapper.""" - print("Testing acquisition optimization wrapper...") - - # Load real data and fit model - config = yaml.load(open(CFG_PATH), Loader=yaml.FullLoader) - design = build_design_from_config(config) - df = load_csv(CSV_PATH) - X, Y = split_XY(df, design, config) - - device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') - X_t, Y_t = np_to_torch(X.values, Y.values, device=device) - - Y_scaled, Y_min, Y_max = y_minmax_np(Y.values) - Y_scaled_t = torch.tensor(Y_scaled, dtype=torch.float64, device=device) - - model = fit_gp_models(X_t, Y_scaled_t) - - # Build acquisition function - ref_point = Y_scaled_t.min(dim=0).values - 0.01 - acq_func = build_qnehvi( - model=model, - train_X=X_t, - ref_point_t=ref_point, - sample_shape=32, # Small for testing - use_lognehvi=True + + +def _unfitted_two_objective_model(train_x: torch.Tensor) -> ModelListGP: + y_1 = (train_x[:, :1] + 0.5 * train_x[:, 1:2]).square() + y_2 = 1.0 - 0.25 * train_x[:, :1] + train_x[:, 1:2] + models = [ + SingleTaskGP(train_x, y, outcome_transform=Standardize(m=1)) for y in (y_1, y_2) + ] + return ModelListGP(*models) + + +def test_unit_bounds_and_outcome_constraint_signs(): + bounds = _unit_bounds(3, torch.device("cpu"), torch.float64) + + assert bounds.shape == (2, 3) + assert bounds.device.type == "cpu" + assert bounds.dtype == torch.float64 + torch.testing.assert_close(bounds[0], torch.zeros(3, dtype=torch.float64)) + torch.testing.assert_close(bounds[1], torch.ones(3, dtype=torch.float64)) + + outcomes = torch.tensor([[0.4, 1.2], [0.8, 0.5]], dtype=torch.float64) + torch.testing.assert_close( + outcome_ge(0, 0.5)(outcomes), + torch.tensor([0.1, -0.3], dtype=torch.float64), ) - - # Test optimization - d = X_t.shape[1] - q = 2 # Small batch for testing - - candidates, acq_values = optimize_acq_qnehvi( - acq_function=acq_func, - d=d, - q=q, - num_restarts=5, # Small for testing - raw_samples=100, # Small for testing - device=device, - dtype=torch.float64 + torch.testing.assert_close( + outcome_le(1, 1.0)(outcomes), + torch.tensor([0.2, -0.5], dtype=torch.float64), ) - - # Verify output shapes - assert candidates.shape == (q, d), f"Expected shape ({q}, {d}), got {candidates.shape}" - assert acq_values.shape == (q,), f"Expected shape ({q},), got {acq_values.shape}" - - # Verify values are in [0, 1] range (normalized) - assert torch.all(candidates >= 0) and torch.all(candidates <= 1), "Candidates out of [0,1] range" - - print("✓ Acquisition optimization wrapper works correctly") - -def test_propose_batch_basic(): - """Test basic propose_batch functionality without constraints.""" - print("Testing basic propose_batch...") - - # Load real data - config = yaml.load(open(CFG_PATH), Loader=yaml.FullLoader) - design = build_design_from_config(config) - df = load_csv(CSV_PATH) - X, Y = split_XY(df, design, config) - - device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') - X_t, Y_t = np_to_torch(X.values, Y.values, device=device) - - Y_scaled, Y_min, Y_max = y_minmax_np(Y.values) - Y_scaled_t = torch.tensor(Y_scaled, dtype=torch.float64, device=device) - - model = fit_gp_models(X_t, Y_scaled_t) - - # Test basic proposal - ref_point = Y_scaled_t.min(dim=0).values - 0.01 - batch_size = 3 - - result = propose_batch( - design=design, - model=model, - train_X=X_t, - ref_point_t=ref_point, - batch_size=batch_size, - num_restarts=5, # Small for testing - raw_samples=100, # Small for testing - sample_shape=32, # Small for testing - verbose=True + + standardized = torch.tensor([[1.0], [2.5]], dtype=torch.float64) + torch.testing.assert_close( + outcome_ge_standardized(0, 14.0, 10.0, 2.0)(standardized), + torch.tensor([1.0, -0.5], dtype=torch.float64), ) - - # Verify result structure - assert isinstance(result, dict), "Should return dictionary" - assert 'X_phys' in result, "Should have X_phys key" - assert 'X_norm' in result, "Should have X_norm key" - assert 'attempts' in result, "Should have attempts key" - assert 'acq_val' in result, "Should have acq_val key" - - # Verify shapes - if result['X_phys'].shape[0] > 0: # If we got valid candidates - assert result['X_phys'].shape[1] == len(design.names), "Physical dimensions should match design" - assert result['X_norm'].shape[1] == len(design.names), "Normalized dimensions should match design" - assert result['X_phys'].shape[0] <= batch_size, "Should not exceed batch size" - assert result['X_norm'].shape[0] <= batch_size, "Should not exceed batch size" - assert result['acq_val'].shape[0] <= batch_size, "Should not exceed batch size" - - print("✓ Basic propose_batch works correctly") - -def test_propose_batch_with_custom_acq(): - """Test propose_batch with custom acquisition function.""" - print("Testing propose_batch with custom acquisition function...") - - # Load real data - config = yaml.load(open(CFG_PATH), Loader=yaml.FullLoader) - design = build_design_from_config(config) - df = load_csv(CSV_PATH) - X, Y = split_XY(df, design, config) - - device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') - X_t, Y_t = np_to_torch(X.values, Y.values, device=device) - - Y_scaled, Y_mean, Y_std = y_standardize_np(Y.values) - Y_scaled_t = torch.tensor(Y_scaled, dtype=torch.float64, device=device) - - model = fit_gp_models(X_t, Y_scaled_t) - - # Define custom acquisition function builder - def custom_acq_builder(): - ref_point = Y_scaled_t.min(dim=0).values - 0.01 - return build_qnehvi( - model=model, - train_X=X_t, - ref_point_t=ref_point, - sample_shape=32, - use_lognehvi=False # Use regular NEHVI instead of log - ) - - # Test with custom acquisition function - ref_point = Y_scaled_t.min(dim=0).values - 0.01 - batch_size = 2 - - result = propose_batch( - design=design, - model=model, - train_X=X_t, - ref_point_t=ref_point, - batch_size=batch_size, - acq=custom_acq_builder, # Use custom acquisition function - num_restarts=3, # Small for testing - raw_samples=50, # Small for testing - sample_shape=16, # Small for testing - verbose=True + torch.testing.assert_close( + outcome_le_standardized(0, 14.0, 10.0, 2.0)(standardized), + torch.tensor([-1.0, 0.5], dtype=torch.float64), ) - - # Verify result structure - assert isinstance(result, dict), "Should return dictionary" - assert all(key in result for key in ['X_phys', 'X_norm', 'attempts', 'acq_val']), "Missing expected keys" - - print("✓ Custom acquisition function works correctly") - -def test_propose_batch_with_constraints(): - """Test propose_batch with both row constraints and outcome constraints.""" - print("Testing propose_batch with constraints...") - - # Load real data - config = yaml.load(open(CFG_PATH), Loader=yaml.FullLoader) - design = build_design_from_config(config) - df = load_csv(CSV_PATH) - X, Y = split_XY(df, design, config) - - device = torch.device('cuda')#'cuda' if torch.cuda.is_available() else 'cpu') - X_t, Y_t = np_to_torch(X.values, Y.values, device=device) - - Y_scaled, Y_mean, Y_std = y_standardize_np(Y.values) - Y_scaled_t = torch.tensor(Y_scaled, dtype=torch.float64, device=device) - from src.data import x_normalizer_torch - Xn_t = x_normalizer_torch(X_t, design) - - objective_names = get_objective_names(config) - model_cv, results_df = loocv_select_models(Xn_t, Y_scaled_t, objective_names=objective_names, device=device) - #model = fit_gp_models(Xn_t, Y_scaled_t) - - # 🔍 DEBUG: Analyze model uncertainty and performance - print(f"\n🔍 Model Analysis:") - print(f" Dataset size: {Xn_t.shape[0]} samples, {Xn_t.shape[1]} inputs, {Y_scaled_t.shape[1]} objectives") - print(f" LOOCV results columns: {list(results_df.columns)}") - print(f" LOOCV results:") - - # Show LOOCV performance for each objective - for obj_name in objective_names: - obj_results = results_df[results_df['Objective'] == obj_name] - if len(obj_results) > 0: - best_idx = obj_results['RMSE'].idxmin() - best_kernel = obj_results.loc[best_idx, 'Kernel'] - best_noise = obj_results.loc[best_idx, 'NoisePrior'] - best_r2 = obj_results.loc[best_idx, 'R2'] - best_rmse = obj_results.loc[best_idx, 'RMSE'] - print(f" {obj_name}: Best R²={best_r2}, RMSE={best_rmse} (Kernel: {best_kernel}, Noise: {best_noise})") - - # Use posterior_report for comprehensive model analysis - from src.models import posterior_report - from src.metrics import compute_metrics - print(f"\n📊 Model Performance on Training Data:") - try: - pred_mean, pred_std = posterior_report( - model_cv, Xn_t, Y_mean, Y_std - ) - metrics_df = compute_metrics(Y, pred_mean, pred_std, objective_names=objective_names, add_residuals=True, add_zscores=True) - print(f" Training performance metrics:") - for _, row in metrics_df.iterrows(): - print(f" {row['Objective']}: R²={row['R2']}, RMSE={row['RMSE']}") - - except Exception as e: - print(f" Could not run posterior_report: {e}") - - from src.plotting import plot_parity_np - from src.utils import torch_to_np - - # Extract predictions for each objective from report_df - # pred_columns = [f"Pred[{name}]" for name in objective_names] - # pred_mean = report_df[pred_columns].values # Shape: (N, M) - # #print(pred_mean) - - # # Extract standard deviations if available - # std_columns = [f"Std[{name}]" for name in objective_names] - # pred_std = report_df[std_columns].values if all(col in report_df.columns for col in std_columns) else None - - fig, parity_df = plot_parity_np(Y, pred_mean=pred_mean, pred_std=pred_std, objective_names=objective_names) - - # Define row constraints (temperature + humidity constraint) - from src.constraints import constraints_from_config - row_constraints_list = constraints_from_config(config, design) - - # Define outcome constraints using original units (more intuitive) - from src.acquisition import outcome_ge_standardized, outcome_le_standardized - - # Example: PCE >= 15% and Repeatability <= 0.1 (adjust these values based on your actual data!) - # You should replace these with your actual desired thresholds in original units - pce_threshold = 15.0 # Example: 15% PCE minimum - repeatability_threshold = 0.1 # Example: 0.1 maximum repeatability - - outcome_constraints = [ - outcome_ge_standardized(obj_idx=0, thresh_original=pce_threshold, - Y_mean=Y_mean[0], Y_std=Y_std[0]), # PCE >= 15% - outcome_le_standardized(obj_idx=2, thresh_original=repeatability_threshold, - Y_mean=Y_mean[2], Y_std=Y_std[2]), # Repeatability <= 0.1 - ] - - print(f"📋 Outcome constraints:") - print(f" PCE >= {pce_threshold} (standardized: {(pce_threshold - Y_mean[0]) / Y_std[0]:.3f})") - print(f" Repeatability <= {repeatability_threshold} (standardized: {(repeatability_threshold - Y_mean[2]) / Y_std[2]:.3f})") - - # Test with both types of constraints - ref_point = Y_scaled_t.min(dim=0).values - 0.01 - - # 🔍 DEBUG: Analyze reference point and hypervolume - print(f"\n📊 Hypervolume Analysis:") - print(f" Reference point: {ref_point.tolist()}") - print(f" Training data bounds: min={Y_scaled_t.min(dim=0).values.tolist()}") - print(f" Training data bounds: max={Y_scaled_t.max(dim=0).values.tolist()}") - - # Calculate current hypervolume using metrics.py functions - from src.metrics import compute_ref_pareto_hv - - # Get Pareto front and hypervolume - ref_point_auto, pareto_front, current_hv = compute_ref_pareto_hv(Y_scaled_t) - print(f" Auto reference point: {ref_point_auto.tolist()}") - print(f" Pareto front size: {pareto_front.shape[0]} points") - print(f" Current hypervolume: {current_hv:.6f}") - - # Compare with our manual reference point - print(f" Manual vs Auto ref point difference: {torch.norm(ref_point - ref_point_auto):.6f}") - - batch_size = 8 - - result = propose_batch( - design=design, - model=model_cv, - train_X=Xn_t, - ref_point_t=ref_point_auto, - batch_size=batch_size, - row_constraints=row_constraints_list, # Physical space constraints - #constraints=outcome_constraints, # Outcome space constraints - eta=0.05, # Constraint violation penalty - num_restarts=10, # Small for testing - raw_samples=512, # Increased for better MC estimation - sample_shape=256, # Increased for more candidates - max_attempts=5, # More attempts due to constraints - verbose=True, - #use_lognehvi=False # Use regular NEHVI to see raw EI values + + +def test_snap_postprocessor_preserves_shape_dtype_and_grid(): + postprocess = _make_snap_postproc(_design()) + candidates = torch.tensor([[[0.15, 0.15], [0.84, 0.90]]], dtype=torch.float64) + + snapped = postprocess(candidates) + + assert snapped.shape == candidates.shape + assert snapped.dtype == candidates.dtype + assert snapped.device == candidates.device + torch.testing.assert_close( + snapped, + torch.tensor([[[0.0, 0.25], [1.0, 1.0]]], dtype=torch.float64), ) - from src.plotting import plot_bar - Xn_new = result['X_norm'] - Xn_new_t = np_to_torch(Xn_new, device=device) - X_new = result['X_phys'] - pred_mean_new, pred_std_new = posterior_report( - model_cv, Xn_new_t, Y_mean, Y_std, - ) - fig_bar = plot_bar(pred_mean_new, pred_std_new, labels=objective_names) - - # Verify result structure - assert isinstance(result, dict), "Should return dictionary" - assert all(key in result for key in ['X_phys', 'X_norm', 'attempts', 'acq_val']), "Missing expected keys" - - # If we got valid candidates, verify they satisfy row constraints - if result['X_phys'].shape[0] > 0: - print(f"Generated {result['X_phys'].shape[0]} candidates in {result['attempts']} attempts") - - # 🔍 DEBUG: Analyze acquisition values and model predictions - print(f"\n📈 Acquisition Value Analysis:") - print(f" Raw acquisition values (log(EHVI)): {[f'{v:.6f}' for v in result['acq_val']]}") - - # Convert from log(EHVI) to EHVI since use_lognehvi=True (default) - ehvi_values = [np.exp(v) for v in result['acq_val']] - print(f" Actual EHVI values: {[f'{v:.6f}' for v in ehvi_values]}") - - # Print candidate details - print(f"\n🔍 Generated Candidates (Physical Units):") - for i, (phys_vals, norm_vals, acq_val) in enumerate(zip(result['X_phys'], result['X_norm'], result['acq_val'])): - print(f" Candidate {i+1}:") - print(f" Log(EHVI): {acq_val:.6f}") - print(f" EHVI: {np.exp(acq_val):.6f}") - print(f" Physical Values:") - for j, name in enumerate(design.names): - print(f" {name}: {phys_vals[j]:.4f}") - print(f" Normalized Values:") - for j, name in enumerate(design.names): - print(f" {name}: {norm_vals[j]:.4f}") - print() - - # Verify physical constraints are satisfied - from src.constraints import apply_row_constraints - constraint_mask = apply_row_constraints(result['X_phys'], design, row_constraints_list) - print(f"🔒 Row Constraint Satisfaction:") - print(f" All candidates satisfy row constraints: {np.all(constraint_mask)}") - - # Verify dimensions match design - assert result['X_phys'].shape[1] == len(design.names), "Physical dimensions should match design" - assert result['X_norm'].shape[1] == len(design.names), "Normalized dimensions should match design" - - # Check that physical values are within design bounds - for i, name in enumerate(design.names): - phys_vals = result['X_phys'][:, i] - design_min = design.lowers[i] - design_max = design.uppers[i] - assert np.all(phys_vals >= design_min) and np.all(phys_vals <= design_max), \ - f"Values for {name} out of bounds [{design_min}, {design_max}]" - - # Check that normalized values are in [0, 1] - assert np.all(result['X_norm'] >= 0) and np.all(result['X_norm'] <= 1), \ - "Normalized values should be in [0, 1]" - - print(f"✓ All {result['X_phys'].shape[0]} candidates satisfy constraints") - - # 🔍 DEBUG: Explain acquisition values - print(f"\n💡 Acquisition Value Interpretation (Log-NEHVI):") - print(f" Log(EHVI) values: {[f'{v:.6f}' for v in result['acq_val']]}") - print(f" Actual EHVI values: {[f'{v:.6f}' for v in ehvi_values]}") - print(f" Higher EHVI = Greater expected improvement in hypervolume") - print(f" Lower EHVI = Minimal improvement expected") - - else: - print("No valid candidates found - constraints may be too strict") - assert result['attempts'] > 0, "Should have made at least one attempt" - - print("✓ Constraint handling works correctly") - -def test_propose_batch_constraint_stress_test(): - """Stress test with very strict constraints to test failure handling.""" - print("Testing propose_batch with very strict constraints...") - - # Load real data - config = yaml.load(open(CFG_PATH), Loader=yaml.FullLoader) - design = build_design_from_config(config) - df = load_csv(CSV_PATH) - X, Y = split_XY(df, design, config) - - device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') - X_t, Y_t = np_to_torch(X.values, Y.values, device=device) - - Y_scaled, Y_min, Y_max = y_minmax_np(Y.values) - Y_scaled_t = torch.tensor(Y_scaled, dtype=torch.float64, device=device) - - # 🔍 DEBUG: Try different model fitting approaches - print(f"\n🔍 Model Fitting Comparison:") - - # Option 1: Basic GP (likely overfitting) - print(f" Testing basic GP fit...") - model_basic = fit_gp_models(X_t, Y_scaled_t) - - # Option 2: LOOCV-selected models (better regularization) - print(f" Testing LOOCV-selected models...") - objective_names = get_objective_names(config) - model_loocv, results_df = loocv_select_models(X_t, Y_scaled_t, objective_names=objective_names, device=device) - - # Compare model uncertainties - print(f"\n📊 Model Uncertainty Comparison:") - for model_name, model in [("Basic GP", model_basic), ("LOOCV GP", model_loocv)]: - with torch.no_grad(): - posterior = model[0].posterior(X_t) - mean = posterior.mean - variance = posterior.variance - uncertainty = torch.sqrt(variance) - print(f" {model_name}:") - for i, obj_name in enumerate(objective_names): - print(f" {obj_name}: μ={mean[0, i].item():.4f}, σ={uncertainty[0, i].item():.4f}") - - # Use LOOCV model for better performance - model = model_loocv - - # Define very strict constraints that are nearly impossible to satisfy - def strict_row_constraint(X_phys, design): - """Very strict constraint - only allows very specific temperature/humidity combinations.""" - temp_idx = design.names.index('temperature_c') - humidity_idx = design.names.index('absolute_humidity') - - # Only allow temperature between 25-26°C AND humidity between 5-7 g/m³ - temp_ok = (X_phys[:, temp_idx] >= 25.0) & (X_phys[:, temp_idx] <= 26.0) - humidity_ok = (X_phys[:, humidity_idx] >= 5.0) & (X_phys[:, humidity_idx] <= 7.0) - - return temp_ok & humidity_ok - - # Very strict outcome constraints - from src.acquisition import outcome_ge - strict_outcome_constraints = [ - outcome_ge(obj_idx=0, thresh=0.9), # PCE >= 0.9 (very high) - outcome_ge(obj_idx=1, thresh=0.9), # Stability >= 0.9 (very high) - ] - - ref_point = Y_scaled_t.min(dim=0).values - 0.01 - - # 🔍 DEBUG: Test different acquisition function settings - print(f"\n🧪 Testing Different Acquisition Settings:") - - # Test 1: Regular NEHVI - print(f" Test 1: Regular NEHVI") - result1 = propose_batch( - design=design, - model=model, - train_X=X_t, - ref_point_t=ref_point, - batch_size=2, - row_constraints=[strict_row_constraint], - constraints=strict_outcome_constraints, - eta=0.01, # Strict penalty - num_restarts=2, # Small for testing - raw_samples=64, # Small for testing - sample_shape=16, # Small for testing - max_attempts=3, # Limited attempts - verbose=False, - use_lognehvi=False # Regular NEHVI + + +def test_build_qnehvi_constructs_active_botorch_acquisition(): + train_x = torch.tensor( + [ + [0.0, 0.0], + [0.2, 0.8], + [0.4, 0.3], + [0.7, 1.0], + [1.0, 0.5], + ], + dtype=torch.float64, ) - - # Test 2: LogNEHVI (your default) - print(f" Test 2: LogNEHVI (default)") - result2 = propose_batch( - design=design, + model = _unfitted_two_objective_model(train_x) + + acq = build_qnehvi( model=model, - train_X=X_t, - ref_point_t=ref_point, + train_X=train_x, + ref_point_t=torch.tensor([-0.5, -0.5], dtype=torch.float64), + sample_shape=8, + prune_baseline=False, + ) + + assert isinstance(acq, qLogNoisyExpectedHypervolumeImprovement) + assert acq.sampler.sample_shape == torch.Size([8]) + + +def test_optimize_wrapper_passes_cpu_bounds_and_options(monkeypatch): + received = {} + + def fake_optimize_acqf(**kwargs): + received.update(kwargs) + bounds = kwargs["bounds"] + q = kwargs["q"] + candidates = bounds.mean(dim=0).repeat(q, 1) + values = torch.arange(q, device=bounds.device, dtype=bounds.dtype) + return candidates, values + + monkeypatch.setattr(acquisition, "optimize_acqf", fake_optimize_acqf) + options = {"maxiter": 2} + + candidates, values = optimize_acq_qnehvi( + acq_function=object(), + d=2, + q=3, + num_restarts=4, + raw_samples=16, + device=torch.device("cpu"), + dtype=torch.float64, + options=options, + sequential=False, + ) + + assert candidates.shape == (3, 2) + assert values.shape == (3,) + assert received["num_restarts"] == 4 + assert received["raw_samples"] == 16 + assert received["options"] is options + assert received["sequential"] is False + torch.testing.assert_close( + received["bounds"], + torch.tensor([[0.0, 0.0], [1.0, 1.0]], dtype=torch.float64), + ) + + +def test_propose_batch_returns_snapped_physical_and_normalized_arrays(monkeypatch): + class DummyAcquisition: + def __init__(self): + self.pending_calls = [] + + def set_X_pending(self, value): + self.pending_calls.append(value) + + dummy_acquisition = DummyAcquisition() + + def fake_optimize(**kwargs): + raw = torch.tensor( + [[0.15, 0.15], [0.84, 0.90]], + dtype=kwargs["dtype"], + device=kwargs["device"], + ) + candidates = kwargs["post_processing_func"](raw) + values = torch.tensor( + [1.5, 1.0], dtype=kwargs["dtype"], device=kwargs["device"] + ) + return candidates, values + + monkeypatch.setattr(acquisition, "optimize_acq_qnehvi", fake_optimize) + train_x = torch.tensor([[0.0, 0.0], [1.0, 1.0]], dtype=torch.float64) + + result = propose_batch( + design=_design(), + model=None, + train_X=train_x, + ref_point_t=torch.tensor([-1.0, -1.0], dtype=torch.float64), batch_size=2, - row_constraints=[strict_row_constraint], - constraints=strict_outcome_constraints, - eta=0.01, # Strict penalty - num_restarts=2, # Small for testing - raw_samples=64, # Small for testing - sample_shape=16, # Small for testing - max_attempts=3, # Limited attempts - verbose=False, - use_lognehvi=True # LogNEHVI + acq=lambda: dummy_acquisition, + max_attempts=1, + device=torch.device("cpu"), + dtype=torch.float64, ) - - # Compare results - print(f"\n📊 Acquisition Function Comparison:") - print(f" Regular NEHVI: {len(result1['acq_val'])} candidates, acq_vals: {[f'{v:.6f}' for v in result1['acq_val']]}") - print(f" LogNEHVI: {len(result2['acq_val'])} candidates, acq_vals: {[f'{v:.6f}' for v in result2['acq_val']]}") - - # Use the result with more candidates for detailed analysis - result = result1 if len(result1['acq_val']) > 0 else result2 - - # Verify graceful handling of strict constraints - assert isinstance(result, dict), "Should return dictionary even with strict constraints" - assert all(key in result for key in ['X_phys', 'X_norm', 'attempts', 'acq_val']), "Missing expected keys" - assert result['attempts'] > 0, "Should have made at least one attempt" - - print(f"\n📊 Stress Test Results:") - print(f" Attempts made: {result['attempts']}") - print(f" Candidates found: {result['X_phys'].shape[0]}") - - if result['X_phys'].shape[0] == 0: - print("✓ Gracefully handled impossible constraints (returned empty batch)") - else: - print(f"✓ Found {result['X_phys'].shape[0]} candidates despite strict constraints") - - # 🔍 DEBUG: Analyze why these candidates were selected - print(f"\n🔍 Candidate Analysis:") - X_candidates = torch.tensor(result['X_norm'], dtype=torch.float64, device=device) - with torch.no_grad(): - candidate_posterior = model[0].posterior(X_candidates) - candidate_mean = candidate_posterior.mean - candidate_std = torch.sqrt(candidate_posterior.variance) - - print(f" Model predictions for candidates:") - for i, (acq_val, mean, std) in enumerate(zip(result['acq_val'], candidate_mean, candidate_std)): - print(f" Candidate {i+1}: acq={acq_val:.6f}") - for j, obj_name in enumerate(objective_names): - print(f" {obj_name}: μ={mean[j].item():.4f}, σ={std[j].item():.4f}") - - # Print detailed candidate information - print(f"\n🔍 Generated Candidates (Physical Units):") - for i, (phys_vals, norm_vals, acq_val) in enumerate(zip(result['X_phys'], result['X_norm'], result['acq_val'])): - print(f" Candidate {i+1}:") - print(f" Acquisition Value: {acq_val:.6f}") - print(f" Physical Values:") - for j, name in enumerate(design.names): - print(f" {name}: {phys_vals[j]:.4f}") - print(f" Normalized Values:") - for j, name in enumerate(design.names): - print(f" {name}: {norm_vals[j]:.4f}") - print() - - # Verify constraints are satisfied - constraint_mask = strict_row_constraint(result['X_phys'], design) - print(f"🔒 Constraint Satisfaction Check:") - print(f" Row constraint satisfied: {np.all(constraint_mask)}") - - # Check specific constraint values - temp_idx = design.names.index('temperature_c') - humidity_idx = design.names.index('absolute_humidity') - - print(f" Temperature constraints (25-26°C):") - for i, temp in enumerate(result['X_phys'][:, temp_idx]): - in_range = 25.0 <= temp <= 26.0 - print(f" Candidate {i+1}: {temp:.2f}°C {'✓' if in_range else '✗'}") - - print(f" Humidity constraints (5-7 g/m³):") - for i, humidity in enumerate(result['X_phys'][:, humidity_idx]): - in_range = 5.0 <= humidity <= 7.0 - print(f" Candidate {i+1}: {humidity:.2f} g/m³ {'✓' if in_range else '✗'}") - - print("✓ Stress test completed successfully") - -def main(): - """Run comprehensive acquisition function tests.""" - print("Running comprehensive acquisition.py tests...\n") - - try: - # Test 1: Outcome constraint builders - print("="*60) - test_outcome_constraint_builders() - - # Test 2: Unit bounds helper - print("="*60) - test_unit_bounds() - - # Test 3: Snap post-processing factory - print("="*60) - test_snap_postproc_factory() - - # Test 4: qNEHVI builder - print("="*60) - test_build_qnehvi() - - # Test 5: Acquisition optimization wrapper - print("="*60) - test_optimize_acq_qnehvi() - - # Test 6: Basic propose_batch - print("="*60) - test_propose_batch_basic() - - # Test 7: Custom acquisition function - print("="*60) - test_propose_batch_with_custom_acq() - - # Test 8: Constraint handling - print("="*60) - test_propose_batch_with_constraints() - - # Test 9: Constraint stress test - print("="*60) - test_propose_batch_constraint_stress_test() - - print("="*60) - print("\n🎉 All acquisition tests passed successfully!") - print("🎯 Tested outcome constraints, helpers, builders, and main functions") - print("🎯 Validated with real configCSV_example.csv data") - print("🎯 Confirmed custom acquisition function support") - print("🎯 Tested comprehensive constraint handling (row + outcome constraints)") - print("🎯 Validated graceful failure handling with strict constraints") - - except Exception as e: - print(f"\n❌ Test failed: {e}") - import traceback - traceback.print_exc() - -if __name__ == "__main__": - main() + + assert set(result) == {"X_phys", "X_norm", "acq_val", "attempts"} + np.testing.assert_allclose(result["X_norm"], [[0.0, 0.25], [1.0, 1.0]]) + np.testing.assert_allclose(result["X_phys"], [[10.0, 0.25], [20.0, 1.0]]) + np.testing.assert_allclose(result["acq_val"], [1.5, 1.0]) + assert result["attempts"] == 1 + assert dummy_acquisition.pending_calls == [None] diff --git a/tests/test_batch_review.py b/tests/test_batch_review.py new file mode 100644 index 0000000..8d7b60d --- /dev/null +++ b/tests/test_batch_review.py @@ -0,0 +1,625 @@ +"""The batch review artifact. + +Two kinds of test here, deliberately separated. + +*Config parsing* runs against the live campaign YAML, because what the campaign +declares -- the low-speed probe, the anneal_temp note -- is part of what shipped +and should break if someone deletes it. + +*Artifact building* runs against a small purpose-built config with three inputs. +It needs a real GP fit, and `model_validation`'s signal-collapse guard is strict +for good reason: it refuses a fit whose residual carries no signal. Manufacturing +ten well-conditioned observations in a 10-input space just to test a table is +fighting the wrong battle, and a test that trips a legitimate guard teaches the +next person to weaken the guard. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest +from openpyxl import Workbook, load_workbook + +from mobo_kit.batch_review import ( + NOT_APPROVED, + SD_MATERIALITY_RATIO, + ProbeSpec, + build_batch_review, + classify_probe_objective, + probe_specs_from_config, + review_notes_from_config, + write_review_sheet, +) +from mobo_kit.campaign import load_campaign_config +from mobo_kit.design import build_design_from_config +from mobo_kit.lhs import lhs_dataframe_optimized +from mobo_kit.scores import ScoreFinding, ScoreSeverity + +LIVE_CONFIG_PATH = "configs/campaign_d2d_perovskite.yaml" + + +@pytest.fixture(scope="module") +def live_config() -> dict: + return load_campaign_config(LIVE_CONFIG_PATH) + + +def _config() -> dict: + """Three inputs, the same three objective shapes as the campaign. + + Keeps the log-link thickness objective and the identity-link linear mean on + `anneal_temp`, because those are the two paths the review has to decode + correctly. + """ + return { + "inputs": [ + {"name": "speed_1", "unit": "rpm", "start": 1000, "stop": 6000, "step": 500}, + {"name": "precur_conc", "unit": "M", "start": 1.0, "stop": 2.0, "step": 0.05}, + {"name": "anneal_temp", "unit": "C", "start": 100, "stop": 185, "step": 5}, + ], + "objectives": { + "contract_version": "review-test-v1", + "scaling_mode": "fixed_affine", + "specs": [ + { + "name": "uniformity", + "model_source_column": "U", + "transform": "affine", + "goal": "maximize", + "lower_anchor": 0.0, + "upper_anchor": 1.0, + }, + { + "name": "optoelectronic", + "model_source_column": "O", + "transform": "affine", + "goal": "maximize", + "lower_anchor": -10.0, + "upper_anchor": -6.0, + "mean_function": { + "response": "identity", + "features": [{"column": "anneal_temp", "transform": "identity"}], + }, + }, + { + "name": "thickness", + "model_source_column": "T", + "transform": "gaussian_target", + "goal": "target", + "target": 650.0, + "sigma": 176.7766952966369, + "mean_function": { + "response": "log", + "features": [ + {"column": "speed_1", "transform": "log"}, + {"column": "precur_conc", "transform": "log"}, + ], + }, + }, + ], + }, + "reference_point_utility": [-0.01, -0.01, -0.01], + "rounds": { + "r1": { + "method": "ucb_hvi", + "batch_size": 3, + "replicates_per_condition": 3, + "beta": 4.0, + "candidate_pool_size": 512, + "posterior_samples": 64, + "moment_method": "monte_carlo", + } + }, + "local_penalization": {"radius": 0.25, "min_batch_distance": 0.15}, + "model": {"variant": "dim_scaled_prior"}, + "reproducibility": {"seed": 7}, + "constraints": [], + "review": { + "probes": [ + { + "name": "low-speed corner", + "column": "speed_1", + "value": 1000, + "note": "The two observations here contradict each other.", + } + ], + "notes": ["anneal_temp sits at a range edge by construction."], + }, + } + + +@pytest.fixture(scope="module") +def config() -> dict: + return _config() + + +@pytest.fixture(scope="module") +def design(config): + return build_design_from_config(dict(config)) + + +def _rows(config: dict, n: int, *, seed: int) -> np.ndarray: + design = build_design_from_config(dict(config)) + frame = lhs_dataframe_optimized(design, n, seed=seed, snap_to_grids=True) + return frame.to_numpy(dtype=float) + + +def _observations(config: dict, n: int = 12, *, seed: int = 1) -> tuple[np.ndarray, np.ndarray]: + """Trends the mean functions can find, plus residual structure the GPs can. + + Residual structure matters: data following a mean function exactly leaves a + pure-noise residual, which the collapse guard rejects -- correctly, since a GP + with nothing to model has no business scoring candidates. + """ + X = _rows(config, n, seed=seed) + speed, concentration, temperature = X[:, 0], X[:, 1], X[:, 2] + scaled_temperature = (temperature - 100.0) / 85.0 + scaled_concentration = (concentration - 1.0) / 1.0 + + thickness = np.exp( + 9.6 + - 0.38 * np.log(speed) + + 0.5 * np.log(concentration) + + 0.25 * np.sin(3.0 * scaled_temperature) + ) + optoelectronic = ( + -6.2 - 0.012 * temperature + 0.35 * np.cos(2.5 * scaled_concentration) + ) + uniformity = np.clip( + 0.5 + 0.35 * np.sin(2.0 * scaled_concentration + 0.7 * scaled_temperature), + 0.02, + 0.98, + ) + return X, np.column_stack([uniformity, optoelectronic, thickness]) + + +@pytest.fixture(scope="module") +def review(config): + X, Y = _observations(config) + conditions = pd.DataFrame( + _rows(config, 3, seed=99), columns=[i["name"] for i in config["inputs"]] + ) + return build_batch_review( + config, + X, + Y, + conditions, + round_name="R1", + findings=( + ScoreFinding( + severity=ScoreSeverity.WARNING, + code="readings_disagree", + objective="thickness", + row_position=11, + sample_id=12, + message="2 readings span 891: ['1600', '709'].", + ), + ScoreFinding( + severity=ScoreSeverity.NOTE, + code="reading_excluded", + objective="thickness", + row_position=3, + sample_id=4, + message="'T anom' holds 1618, judged anomalous.", + ), + ), + context={"Round": "R1", "Seed": 7}, + ) + + +# --------------------------------------------------------------------------- # +# the judgment, on its own +# --------------------------------------------------------------------------- # + + +def test_worse_with_the_same_uncertainty_reads_as_known_and_bad() -> None: + """The numbers from the campaign's own R0 fit: thickness utility 0.223 against + 0.786, sd ratio 1.02. A bare `>` on sd calls that 'more uncertain' and prints + the benign verdict, which is how this nearly shipped wrong.""" + assert classify_probe_objective(0.223, 0.786, 1.02) == "known_and_bad" + + +def test_worse_but_materially_more_uncertain_reads_as_a_tradeoff() -> None: + assert classify_probe_objective(0.223, 0.786, 2.0) == "uncertain_tradeoff" + + +def test_not_worse_means_the_model_has_no_objection() -> None: + assert classify_probe_objective(0.9, 0.8, 1.0) is None + assert classify_probe_objective(0.8, 0.8, 1.0) is None + + +def test_the_threshold_is_a_ratio_not_an_inequality() -> None: + assert classify_probe_objective(0.1, 0.5, SD_MATERIALITY_RATIO - 0.01) == "known_and_bad" + assert ( + classify_probe_objective(0.1, 0.5, SD_MATERIALITY_RATIO + 0.01) + == "uncertain_tradeoff" + ) + + +def test_a_zero_batch_sd_does_not_divide_by_zero() -> None: + assert classify_probe_objective(0.1, 0.5, float("inf")) == "uncertain_tradeoff" + + +# --------------------------------------------------------------------------- # +# what the live campaign declares +# --------------------------------------------------------------------------- # + + +def test_the_live_config_declares_the_low_speed_probe(live_config) -> None: + probes = probe_specs_from_config(live_config) + assert [p.column for p in probes] == ["speed_1"] + assert probes[0].value == 1000.0 + assert "contradict each other" in probes[0].note + + +def test_the_live_config_declares_the_anneal_temp_note(live_config) -> None: + notes = review_notes_from_config(live_config) + assert any("anneal_temp" in note and "mean function" in note for note in notes) + assert any("exploration-only" in note for note in notes) + + +def test_no_review_block_means_no_probes_and_no_notes() -> None: + assert probe_specs_from_config({}) == () + assert review_notes_from_config({}) == () + + +def test_a_single_probe_mapping_is_accepted() -> None: + probes = probe_specs_from_config( + {"review": {"probes": {"name": "p", "column": "speed_1", "value": 1000}}} + ) + assert probes == (ProbeSpec("p", "speed_1", 1000.0),) + + +def test_a_probe_naming_an_undeclared_input_is_refused_before_any_fitting( + config, +) -> None: + """A typo in a config column name should cost nothing, not three GP fits.""" + broken = dict(config) + broken["review"] = {"probes": [{"name": "bad", "column": "not_an_input", "value": 1}]} + names = [i["name"] for i in config["inputs"]] + with pytest.raises(ValueError, match="not a declared input"): + build_batch_review( + broken, + np.zeros((0, 3)), + np.zeros((0, 3)), + pd.DataFrame(_rows(config, 1, seed=3), columns=names), + round_name="R1", + ) + + +# --------------------------------------------------------------------------- # +# the candidate table +# --------------------------------------------------------------------------- # + + +def test_every_candidate_gets_a_utility_and_an_sd_per_objective(review) -> None: + for name in ("uniformity", "optoelectronic", "thickness"): + assert f"{name}_utility" in review.candidates + assert f"{name}_sd" in review.candidates + assert review.candidates[f"{name}_sd"].gt(0).all() + assert len(review.candidates) == 3 + + +def test_the_physical_prediction_is_reported_in_the_measurement_s_units(review) -> None: + """A utility of 0.87 means nothing at the coater; nanometres do.""" + assert review.candidates["thickness_predicted"].between(50.0, 5000.0).all() + for column in ("thickness_lo68", "thickness_hi68"): + # numeric, so a spreadsheet can sort, plot and compare it + assert pd.api.types.is_float_dtype(review.candidates[column]) + + +def test_a_log_link_objective_reports_a_median_not_a_mean(review) -> None: + """exp of a mean of logs is the median. The interval brackets it + multiplicatively; an additive one would be symmetric in nanometres, which is + what transforming a mean rather than decoding a posterior would produce.""" + low = review.candidates["thickness_lo68"] + high = review.candidates["thickness_hi68"] + middle = review.candidates["thickness_predicted"] + assert (low < middle).all() and (middle < high).all() + assert np.allclose(middle / low, high / middle, rtol=1e-12) + assert np.allclose(np.sqrt(low * high), middle, rtol=1e-12) + + +def test_an_identity_link_objective_gets_a_symmetric_interval(review) -> None: + low = review.candidates["optoelectronic_lo68"] + high = review.candidates["optoelectronic_hi68"] + middle = review.candidates["optoelectronic_predicted"] + assert np.allclose((low + high) / 2.0, middle, rtol=1e-12) + + +def test_distance_to_the_nearest_observed_point_is_zero_when_reproposed(config) -> None: + X, Y = _observations(config) + names = [i["name"] for i in config["inputs"]] + built = build_batch_review( + config, X, Y, pd.DataFrame(X[:2], columns=names), round_name="R1" + ) + assert built.candidates["distance_to_nearest"].max() < 1e-9 + assert built.candidates["nearest_observed_row"].tolist() == [1, 2] + + +def test_range_edges_are_counted_and_named(config, design) -> None: + """Which coordinates are pinned matters more than how many: 'anneal_temp=min' + on every row is a mean function speaking, and a count alone hides that.""" + names = list(design.names) + X, Y = _observations(config) + row = list(X[0]) + row[names.index("speed_1")] = float(design.lowers[names.index("speed_1")]) + row[names.index("anneal_temp")] = float(design.uppers[names.index("anneal_temp")]) + built = build_batch_review( + config, X, Y, pd.DataFrame([row], columns=names), round_name="R1" + ) + assert built.candidates["n_at_range_edge"].iloc[0] == 2 + which = built.candidates["which_at_range_edge"].iloc[0] + assert "speed_1=min" in which and "anneal_temp=max" in which + + +def test_a_candidate_at_no_range_edge_says_so_rather_than_leaving_a_blank( + review, +) -> None: + which = review.candidates["which_at_range_edge"] + assert which.notna().all() + assert (which.str.len() > 0).all() + + +# --------------------------------------------------------------------------- # +# probes and text +# --------------------------------------------------------------------------- # + + +def test_the_probe_moves_every_candidate_to_the_probed_value(review) -> None: + kinds = review.probes["kind"].tolist() + assert sum("moved to speed_1=1000" in kind for kind in kinds) == 3 + + +def test_the_probe_reports_observations_already_in_the_region(config) -> None: + X, Y = _observations(config) + X[0, 0] = 1000.0 + names = [i["name"] for i in config["inputs"]] + built = build_batch_review( + config, X, Y, pd.DataFrame(_rows(config, 2, seed=42), columns=names), round_name="R1" + ) + kinds = built.probes["kind"].tolist() + assert any("observed row 1" in kind for kind in kinds) + assert built.probes["measured"].notna().any() + + +def test_the_verdict_names_the_mean_function_when_the_probed_column_is_in_it( + review, +) -> None: + """speed_1 is a feature of the thickness mean, so a probe there asks a fitted + trend to extrapolate to its range edge -- a different claim from a GP + interpolating between neighbours, and the artifact should say which it is.""" + text = " ".join(review.probe_verdicts) + if "KNOWN AND BAD" in text: + assert "thickness carries speed_1 in its mean function" in text + assert "fitted global trend" in text + + +def test_the_verdict_prints_the_sd_ratio_and_its_threshold(review) -> None: + """The ratio is printed whichever branch fires, so a reader who wants a + different threshold can apply their own.""" + import re + + text = " ".join(review.probe_verdicts) + assert re.search(r"sd \d+\.\d+ vs \d+\.\d+ \(x\d+\.\d+\)", text) + assert f"{SD_MATERIALITY_RATIO:g}x counts as materially more uncertain" in text + + +def test_the_verdict_states_how_many_observations_sit_in_the_region(review) -> None: + assert "observation(s) already sit there" in " ".join(review.probe_verdicts) + + +def test_the_text_artifact_stands_alone(review) -> None: + text = review.to_text() + for section in ( + "BATCH REVIEW - R1", + "PROPOSED CONDITIONS", + "PROBES", + "PROBE 'low-speed corner'", + "NOTES", + "CARRIED FROM THE MEASURED DATA", + ): + assert section in text + assert "1600" in text # the carried finding, verbatim + assert text.rstrip().endswith("outside this file.") + assert NOT_APPROVED.split(".")[0] in text + + +def test_findings_are_carried_worst_first(review) -> None: + text = review.to_text() + assert text.index("[warning] sample 12") < text.index("[note] sample 4") + + +# --------------------------------------------------------------------------- # +# when the mean function explains the data +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def collapsed_residual(monkeypatch): + """Force the condition rather than hope data produces it. + + Whether a given dataset lands on a collapsed residual is knife-edge -- measured + across residual magnitudes from 0 to 0.3, it fires at 0, 1e-4, 0.01 and 0.03 but + not at 0.001 or 0.1, because it depends on where the MLL optimiser lands. A test + that depended on that would be a flake. The guard's own decision is tested + directly in test_model_validation.py; what these tests check is that its warning + reaches the people who need it. + + Only objectives with a `StructuredMean` are collapsed, which is exactly the case + being emulated: the mean function explains the data, so the residual GP has + nothing left. Uniformity has no mean function and stays healthy -- collapsing it + would be a true collapse and must still hard-fail. + """ + import mobo_kit.model_validation as validation_module + from mobo_kit.structured_mean import StructuredMean + + real_fit = validation_module.fit_gpytorch_mll + + def collapse_structured_only(mll): + real_fit(mll) + if isinstance(getattr(mll.model, "mean_module", None), StructuredMean): + import torch + + mll.model.covar_module.outputscale = torch.tensor(1e-12, dtype=torch.double) + mll.model.likelihood.noise = torch.tensor(0.9, dtype=torch.double) + return mll + + monkeypatch.setattr(validation_module, "fit_gpytorch_mll", collapse_structured_only) + + +def test_a_round_still_proposes_when_the_mean_function_explains_the_data( + config, collapsed_residual +) -> None: + """The behaviour that matters: refusing here would dead-end the campaign + exactly when the physics model started working, with no way out -- better data + cannot be collected without first proposing conditions.""" + from mobo_kit.campaign import run_r1_ucb + + X, Y = _observations(config) + result = run_r1_ucb(config, X, Y, n=2) + assert result.n_conditions == 2 + + warnings = result.diagnostics["model_fit_warnings"] + assert warnings, "the collapsed residual GP should have warned" + joined = " ".join(warnings) + assert "exploration term has degenerated" in joined + assert "UNDERSTATED" in joined + # the two objectives with a mean function, and not the one without + assert any(w.startswith("thickness") for w in warnings) + assert not any(w.startswith("uniformity") for w in warnings) + + +def test_the_round_diagnostics_carry_no_library_deprecation_noise( + config, collapsed_residual +) -> None: + """`record.warnings` also collects every Python warning raised while fitting -- + on this stack, ~18 numpy-2.0 deprecation notices per fit. Putting those in front + of someone reviewing a batch is how people learn to ignore warnings.""" + from mobo_kit.campaign import run_r1_ucb + + warnings = run_r1_ucb(config, *_observations(config), n=2).diagnostics[ + "model_fit_warnings" + ] + assert warnings + assert not any("numpy" in w.lower() or "__array__" in w for w in warnings) + + +def test_the_unfiltered_warnings_are_kept_but_not_surfaced(config) -> None: + """Filtered out of the human channel, retained for debugging. A BoTorch or + scipy convergence warning the filter dropped is exactly what someone needs when + a fit looks strange weeks later.""" + from mobo_kit.campaign import run_r1_ucb + + diagnostics = run_r1_ucb(config, *_observations(config), n=2).diagnostics + raw = diagnostics["fit_warnings_raw"] + assert raw, "the fits do raise library warnings on this stack" + assert any("numpy" in entry.lower() or "__array__" in entry for entry in raw) + # each entry says which objective and which stage it came from + assert all("|" in entry for entry in raw) + # and the surfaced channel is still clean + assert not diagnostics["model_fit_warnings"] + + +def _collapsed_review(config): + X, Y = _observations(config) + names = [i["name"] for i in config["inputs"]] + return build_batch_review( + config, + X, + Y, + pd.DataFrame(_rows(config, 2, seed=77), columns=names), + round_name="R1", + ) + + +def test_the_review_carries_the_warning_above_the_numbers( + config, collapsed_residual +) -> None: + built = _collapsed_review(config) + assert built.model_warnings + + text = built.to_text() + assert "READ THIS BEFORE THE NUMBERS" in text + # before, not after: it changes how every number below should be read + assert text.index("READ THIS BEFORE THE NUMBERS") < text.index("PROPOSED CONDITIONS") + assert "understated" in text.lower() + assert "no uncertainty" in text + + +def test_the_warning_reaches_the_review_sheet_too( + tmp_path, config, collapsed_residual +) -> None: + built = _collapsed_review(config) + path = tmp_path / "candidates.xlsx" + _candidate_book(path) + write_review_sheet(path, built) + body = _sheet_text(path) + assert "READ THIS BEFORE THE NUMBERS" in body + assert "UNDERSTATED" in body + + +def test_a_healthy_fit_carries_no_warning(review) -> None: + """The warning has to mean something, which means it must not always fire.""" + assert review.model_warnings == () + assert "READ THIS BEFORE THE NUMBERS" not in review.to_text() + + +# --------------------------------------------------------------------------- # +# the sheet +# --------------------------------------------------------------------------- # + + +def _candidate_book(path) -> None: + book = Workbook() + book.active.title = "R1_Candidates" + book["R1_Candidates"]["A1"] = "candidate_id" + book.save(path) + + +def _sheet_text(path, sheet: str = "Review") -> str: + return "\n".join( + str(value) + for row in load_workbook(path)[sheet].iter_rows(values_only=True) + for value in row + if value is not None + ) + + +def test_the_review_is_written_as_its_own_sheet(tmp_path, review) -> None: + path = tmp_path / "candidates.xlsx" + _candidate_book(path) + write_review_sheet(path, review) + + written = load_workbook(path) + assert written.sheetnames == ["R1_Candidates", "Review"] + # the worklist is untouched + assert written["R1_Candidates"]["A1"].value == "candidate_id" + + body = _sheet_text(path) + assert "BATCH REVIEW - R1" in body + assert "PROPOSED CONDITIONS" in body + assert "APPROVAL" in body + assert "Nothing here is approved" in body + assert "1600" in body + + +def test_rewriting_the_review_replaces_it_rather_than_duplicating( + tmp_path, review +) -> None: + path = tmp_path / "candidates.xlsx" + _candidate_book(path) + write_review_sheet(path, review) + write_review_sheet(path, review) + assert load_workbook(path).sheetnames == ["R1_Candidates", "Review"] + + +def test_non_finite_cells_are_written_as_blanks_not_the_text_nan( + tmp_path, review +) -> None: + """The probe table carries NaN in the 'selected' columns of observed rows. + Excel showing the literal text 'nan' would read as a measurement.""" + path = tmp_path / "candidates.xlsx" + _candidate_book(path) + write_review_sheet(path, review) + assert "nan" not in _sheet_text(path).lower().replace("anneal", "") diff --git a/tests/test_batch_selection.py b/tests/test_batch_selection.py new file mode 100644 index 0000000..670420a --- /dev/null +++ b/tests/test_batch_selection.py @@ -0,0 +1,355 @@ +import numpy as np +import pytest + +from mobo_kit.batch_selection import ( + BaseScoreResult, + LocalPenalizationConfig, + UndersizedBatchError, + select_local_penalized_batch, + soft_local_penalty, +) +from mobo_kit.candidate_pool import CandidatePool + + +def _pool(values): + X = np.asarray(values, dtype=float) + if X.ndim == 1: + X = X[:, None] + grid_indices = np.zeros(X.shape, dtype=int) + grid_indices[:, 0] = np.arange(X.shape[0]) + return CandidatePool( + grid_indices=grid_indices, + X_phys=X.copy(), + X_norm=X.copy(), + seed=1, + draws=X.shape[0], + rejected_duplicate=0, + rejected_avoid=0, + rejected_constraint=0, + ) + + +def _static_callback(scores, calls=None): + scores = np.asarray(scores, dtype=float) + + def callback(remaining, selected): + if calls is not None: + calls.append((remaining.copy(), selected.copy())) + return BaseScoreResult( + base_log_score=scores[remaining], + base_score=np.exp(scores[remaining]), + diagnostics={"selected_before": selected.copy()}, + ) + + return callback + + +def test_radius_pushes_the_second_pick_out_of_the_penalised_neighbourhood(): + """What `radius` is actually for, verified by construction rather than by + accident. + + The DTLZ2 sweep could not test this: its batches land 0.72-0.98 apart, far + outside every radius tried, so local penalization never had two candidates + close enough to penalise. + + It was once recorded here that the knob was "probably inert" on the live + campaign too, on the strength of the R1 batch's 0.921 minimum spacing. That + figure was itself produced by the mis-encoded UCB-HVI baseline fixed in + 4b76670; corrected, the live batch spaces at 0.6337 and `radius` demonstrably + binds below about 0.30 (CAMPAIGN_STATUS.md, "Are beta = 4.0 and radius = 0.25 + defensible?"). This test predates that and is unaffected by it -- it verifies + the mechanism by construction, which is exactly why it kept its value when the + campaign evidence turned out to be wrong. + + Here the top three scores are deliberately crowded into one spot, with a + slightly worse candidate far away. Without a radius the batch collapses onto + the cluster; with one, the second pick is pushed out of it. + """ + # four candidates: three clustered near 0.50, one isolated at 0.90 + positions = [0.50, 0.52, 0.54, 0.90] + scores = [1.00, 0.98, 0.96, 0.80] # the cluster genuinely scores better + + unpenalised = select_local_penalized_batch( + _pool(positions), + 2, + _static_callback(scores), + LocalPenalizationConfig(radius=None, min_batch_distance=0.0), + ) + # greedy on score alone takes the two best, which are 0.02 apart + assert list(unpenalised.selected_pool_indices) == [0, 1] + + penalised = select_local_penalized_batch( + _pool(positions), + 2, + _static_callback(scores), + LocalPenalizationConfig(radius=0.25, min_batch_distance=0.0), + ) + assert list(penalised.selected_pool_indices) == [0, 3] + gap = abs(positions[3] - positions[0]) + assert gap > 0.25, "the second pick should sit beyond the radius, not just apart" + + +def test_a_radius_smaller_than_the_gaps_changes_nothing(): + """The sweep's inert case, pinned: when candidates are already further apart + than the radius, penalization has nothing to do and must not interfere.""" + positions = [0.10, 0.50, 0.90] + scores = [1.00, 0.98, 0.10] + for radius in (None, 0.05): + result = select_local_penalized_batch( + _pool(positions), + 2, + _static_callback(scores), + LocalPenalizationConfig(radius=radius, min_batch_distance=0.0), + ) + assert list(result.selected_pool_indices) == [0, 1] + + +def test_soft_penalty_zero_and_far_distance_limits(): + factors, logs = soft_local_penalty(np.array([0.0, 10.0]), radius=0.2, epsilon=1e-9) + assert factors[0] == pytest.approx(0.0) + assert logs[0] == pytest.approx(np.log(1e-9)) + assert factors[1] == pytest.approx(1.0) + with pytest.raises(ValueError, match="radius"): + soft_local_penalty(np.array([1.0]), radius=0) + with pytest.raises(ValueError, match="non-boolean"): + LocalPenalizationConfig(radius=True, min_batch_distance=0.0) + + +def test_stable_tie_break_and_callback_receives_selected_indices(): + calls = [] + result = select_local_penalized_batch( + _pool([0.0, 0.5, 1.0]), + 2, + _static_callback([0.0, 0.0, 0.0], calls), + LocalPenalizationConfig(radius=0.1, min_batch_distance=0.0), + ) + assert result.selected_pool_indices.tolist() == [0, 2] + assert calls[0][1].tolist() == [] + assert calls[1][1].tolist() == [0] + assert [step.order for step in result.steps] == [1, 2] + + +def test_local_penalty_increases_diversity_over_unpenalized_top_q(): + pool = _pool([0.0, 0.01, 0.02, 0.6, 1.0]) + base_logs = np.log([1.0, 0.99, 0.98, 0.8, 0.7]) + unpenalized_top = pool.X_norm[np.argsort(-base_logs, kind="stable")[:3], 0] + result = select_local_penalized_batch( + pool, + 3, + _static_callback(base_logs), + LocalPenalizationConfig(radius=0.2, min_batch_distance=0.0), + ) + selected = np.sort(result.X_norm[:, 0]) + assert np.min(np.diff(selected)) > np.min(np.diff(np.sort(unpenalized_top))) + assert result.steps[1].penalty_factor < 1.0 + + +def test_none_radius_disables_only_the_soft_penalty(): + pool = _pool([0.0, 0.01, 0.5, 1.0]) + result = select_local_penalized_batch( + pool, + 3, + _static_callback([0.0, -0.1, -0.2, -0.3]), + LocalPenalizationConfig(radius=None, min_batch_distance=0.0), + ) + assert result.selected_pool_indices.tolist() == [0, 1, 2] + assert result.distance_diagnostics["radius"] is None + assert all(step.penalty_factor == pytest.approx(1.0) for step in result.steps) + assert all(step.log_penalty == pytest.approx(0.0) for step in result.steps) + assert all( + step.penalized_log_score == pytest.approx(step.base_log_score) + for step in result.steps + ) + + +def test_none_radius_preserves_hard_batch_and_observed_rules(): + result = select_local_penalized_batch( + _pool([0.0, 0.1, 0.5, 0.9]), + 2, + _static_callback([0.0, -0.01, -0.2, -0.3]), + LocalPenalizationConfig( + radius=None, + min_batch_distance=0.4, + min_observed_distance=0.15, + ), + observed_pending_norm=np.array([[0.9]]), + ) + # The second-highest score is too close to the first selection, and the + # final point is an exact observed recipe. The hard rules therefore choose + # the third-ranked point even though no soft penalty is active. + assert result.selected_pool_indices.tolist() == [0, 2] + assert result.distance_diagnostics["minimum_within_batch_distance"] >= 0.4 + assert all(step.penalty_factor == pytest.approx(1.0) for step in result.steps) + + +def test_hard_batch_and_observed_distances_are_enforced(): + pool = _pool([0.0, 0.1, 0.3, 0.55, 0.9]) + result = select_local_penalized_batch( + pool, + 3, + _static_callback([0.0, -0.1, -0.2, -0.3, -0.4]), + LocalPenalizationConfig( + radius=0.1, min_batch_distance=0.25, min_observed_distance=0.15 + ), + observed_pending_norm=np.array([[0.3]]), + ) + matrix = result.distance_diagnostics["pairwise_distance_matrix"] + triangle = matrix[np.triu_indices(3, k=1)] + assert np.all(triangle >= 0.25 - 1e-12) + assert np.all(np.abs(result.X_norm[:, 0] - 0.3) >= 0.15 - 1e-12) + + +def test_exact_observed_duplicate_is_excluded_even_with_zero_threshold(): + result = select_local_penalized_batch( + _pool([0.0, 0.5, 1.0]), + 1, + _static_callback([1.0, 0.0, -1.0]), + LocalPenalizationConfig(radius=0.1, min_batch_distance=0.0), + observed_pending_norm=np.array([[0.0]]), + ) + assert result.selected_pool_indices.tolist() == [1] + + +def test_impossible_spacing_and_all_ineligible_fail_structurally(): + pool = _pool([0.0, 0.1, 0.2]) + with pytest.raises(UndersizedBatchError) as captured: + select_local_penalized_batch( + pool, + 2, + _static_callback([0.0, -0.1, -0.2]), + LocalPenalizationConfig(radius=0.1, min_batch_distance=0.5), + ) + assert captured.value.selected_size == 1 + assert captured.value.requested_size == 2 + + with pytest.raises(UndersizedBatchError) as all_zero: + select_local_penalized_batch( + pool, + 1, + _static_callback([-np.inf, -np.inf, -np.inf]), + LocalPenalizationConfig(radius=0.1, min_batch_distance=0.0), + ) + assert all_zero.value.selected_size == 0 + assert all_zero.value.remaining_candidate_count == 0 + assert all_zero.value.hard_valid_candidate_count == 3 + + +def test_log_epsilon_never_relaxes_hard_distance(): + pool = _pool([0.0, 0.4999999999995]) + with pytest.raises(UndersizedBatchError): + select_local_penalized_batch( + pool, + 2, + _static_callback([0.0, -0.1]), + LocalPenalizationConfig( + radius=0.1, + min_batch_distance=0.5, + epsilon=0.5, + ), + ) + + +def test_dimension_weight_validation_and_effect(): + pool = _pool([[0.0, 0.0], [0.2, 0.0], [0.0, 0.2]]) + config = LocalPenalizationConfig( + radius=0.1, + min_batch_distance=0, + dimension_weights=np.array([4.0, 1.0]), + ) + result = select_local_penalized_batch( + pool, 2, _static_callback([0.0, 0.0, 0.0]), config + ) + assert result.selected_pool_indices.tolist() == [0, 1] + with pytest.raises(ValueError, match="shape"): + select_local_penalized_batch( + pool, + 1, + _static_callback([0.0, 0.0, 0.0]), + LocalPenalizationConfig( + radius=0.1, + min_batch_distance=0, + dimension_weights=np.ones(3), + ), + ) + with pytest.raises(ValueError, match="strictly positive"): + LocalPenalizationConfig( + radius=0.1, + min_batch_distance=0, + dimension_weights=np.array([1.0, 0.0]), + ) + with pytest.raises(ValueError, match="non-boolean"): + LocalPenalizationConfig( + radius=0.1, + min_batch_distance=0, + dimension_weights=np.array([True, True]), + ) + + +def test_duplicate_pool_and_invalid_scores_are_rejected(): + duplicate_pool = CandidatePool( + grid_indices=np.array([[0], [0]]), + X_phys=np.array([[0.0], [0.0]]), + X_norm=np.array([[0.0], [0.0]]), + seed=1, + draws=2, + rejected_duplicate=0, + rejected_avoid=0, + rejected_constraint=0, + ) + with pytest.raises(ValueError, match="duplicate"): + select_local_penalized_batch( + duplicate_pool, + 1, + _static_callback([0.0, 0.0]), + LocalPenalizationConfig(radius=0.1, min_batch_distance=0), + ) + + duplicate_coordinates = CandidatePool( + grid_indices=np.array([[0], [1]]), + X_phys=np.array([[0.0], [0.0]]), + X_norm=np.array([[0.0], [0.0]]), + seed=1, + draws=2, + rejected_duplicate=0, + rejected_avoid=0, + rejected_constraint=0, + ) + with pytest.raises(ValueError, match="duplicate normalized"): + select_local_penalized_batch( + duplicate_coordinates, + 1, + _static_callback([0.0, 0.0]), + LocalPenalizationConfig(radius=0.1, min_batch_distance=0), + ) + + def bad_callback(remaining, selected): + del selected + return BaseScoreResult(np.full(remaining.size, np.nan)) + + with pytest.raises(ValueError, match="NaN"): + select_local_penalized_batch( + _pool([0.0, 1.0]), + 1, + bad_callback, + LocalPenalizationConfig(radius=0.1, min_batch_distance=0), + ) + + +def test_selector_rejects_out_of_range_references_and_nonfinite_physical_rows(): + with pytest.raises(ValueError, match=r"within \[0, 1\]"): + select_local_penalized_batch( + _pool([0.0, 1.0]), + 1, + _static_callback([0.0, -1.0]), + LocalPenalizationConfig(radius=0.1, min_batch_distance=0), + observed_pending_norm=np.array([[2.0]]), + ) + invalid = _pool([0.0, 1.0]) + object.__setattr__(invalid, "X_phys", np.array([[np.nan], [1.0]])) + with pytest.raises(ValueError, match="physical rows"): + select_local_penalized_batch( + invalid, + 1, + _static_callback([0.0, -1.0]), + LocalPenalizationConfig(radius=0.1, min_batch_distance=0), + ) diff --git a/tests/test_boxplot_sweep.py b/tests/test_boxplot_sweep.py new file mode 100644 index 0000000..e8cb6a1 --- /dev/null +++ b/tests/test_boxplot_sweep.py @@ -0,0 +1,139 @@ +"""The beta x radius boxplot sweep. + +The sweep itself is 108 full campaign runs, so nothing here runs one. What is +pinned is the bookkeeping around them, where a silent error would be expensive and +invisible: a sharding bug that drops or duplicates cells would produce a +deliverable with quietly missing panels after six hours of compute. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + + +def _load(): + path = Path("scripts") / "plot_boxplot_sweep.py" + spec = importlib.util.spec_from_file_location("_script_boxplot_sweep", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +sweep = _load() + + +def test_the_grid_is_the_one_the_group_asked_for() -> None: + assert sweep.BETAS == (9.0, 25.0, 36.0, 49.0) + assert sweep.RADII == (0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45) + assert len(sweep.TRIALS) == 3 + assert [name for name, _s, _seed in sweep.TRIALS] == ["real", "lhs_a", "lhs_b"] + + +def test_the_cell_count_is_108() -> None: + cells = sweep.all_cells() + assert len(cells) == len(sweep.TRIALS) * len(sweep.BETAS) * len(sweep.RADII) == 108 + assert len(set(cells)) == 108, "no duplicate cells" + + +@pytest.mark.parametrize("num_shards", [1, 2, 5, 7, 12, 108]) +def test_sharding_covers_every_cell_exactly_once(num_shards: int) -> None: + """The property that makes a six-hour parallel run trustworthy. + + ``cells[shard::num_shards]`` must partition the grid. A stride that dropped or + repeated cells would leave gaps the compose step draws as 'missing', or would + burn compute recomputing the same cell in two workers. + """ + cells = sweep.all_cells() + seen: list[tuple[str, float, float]] = [] + for shard in range(num_shards): + seen.extend(cells[shard::num_shards]) + assert len(seen) == len(cells) + assert set(seen) == set(cells) + assert len(set(seen)) == len(seen), "a cell was assigned to two shards" + + +def test_shards_are_balanced_within_one_cell() -> None: + """Wall clock is the slowest shard, so an unbalanced split wastes it.""" + cells = sweep.all_cells() + for num_shards in (8, 12, 16): + sizes = [len(cells[shard::num_shards]) for shard in range(num_shards)] + assert max(sizes) - min(sizes) <= 1 + + +def test_cell_keys_are_unique_and_filesystem_safe() -> None: + keys = [sweep.cell_key(t, b, r) for t, b, r in sweep.all_cells()] + assert len(set(keys)) == len(keys) == 108 + for key in keys: + assert "." not in key, "a dot would collide with the .npz suffix" + assert all(c.isalnum() or c in "_" for c in key) + + +def test_cell_key_round_trips_the_parameters() -> None: + assert sweep.cell_key("real", 9.0, 0.05) == "real__beta_9__radius_0p05" + assert sweep.cell_key("lhs_b", 49.0, 0.45) == "lhs_b__beta_49__radius_0p45" + # 0.10 and 0.1 must not produce two different keys for one radius + assert sweep.cell_key("real", 25.0, 0.10) == sweep.cell_key("real", 25.0, 0.1) + + +def test_only_the_starting_design_differs_between_trials() -> None: + """The comparison this sweep makes is only clean if nothing else moves. + + Trial 1 reads the workbook; trials 2 and 3 draw a Latin hypercube at distinct + seeds. No trial carries its own acquisition seed -- that stays at the + campaign's, so the candidate pool is identical throughout. + """ + sources = {name: (source, seed) for name, source, seed in sweep.TRIALS} + assert sources["real"][0] == "workbook" + assert sources["lhs_a"] == ("lhs", 101) + assert sources["lhs_b"] == ("lhs", 202) + assert sources["lhs_a"][1] != sources["lhs_b"][1], "trials must differ" + + +def test_the_footer_states_both_hazards() -> None: + """The two hazards that do not depend on which campaign is loaded.""" + assert "not a measurement" in sweep.FOOTER + assert "15 / 5 / 3" in sweep.FOOTER + + +def test_the_no_signal_caveat_is_read_from_the_config_not_remembered() -> None: + """It used to be hard-coded as "uniformity ... permutation p = 0.82". + + That is a fact about the FIRST campaign's uniformity score on the FIRST + campaign's films. On the v3 contract that objective is a different + construction and optoelectronic is dead as well, so the constant would have + put the wrong evidence under the right warning -- which is worse than no + caveat, because it looks checked. + """ + from mobo_kit.campaign import load_campaign_config + + active = load_campaign_config("configs/campaign_d2d_perovskite_test.yaml") + caveat = sweep.signal_caveat(active) + assert "uniformity" in caveat and "optoelectronic" in caveat + assert "d2d-objectives-v3-test" in caveat + assert "leave-one-out null" in caveat + # and it must not carry the previous campaign's evidence + assert "0.82" not in caveat + + every_axis_learnable = { + "objectives": { + "contract_version": "synthetic", + "specs": [{"name": "a", "signal_status": "learnable"}], + } + } + assert sweep.signal_caveat(every_axis_learnable) == "" + + +def test_a_single_ratified_cell_keeps_all_three_trials() -> None: + """Filtering the knobs must never drop a trial: the trials are what turn + three numbers per round into a distribution worth boxing.""" + cells = sweep.all_cells([36.0], [0.35]) + assert len(cells) == len(sweep.TRIALS) + assert {trial for trial, _b, _r in cells} == {t[0] for t in sweep.TRIALS} + assert {(b, r) for _t, b, r in cells} == {(36.0, 0.35)} + # and the unfiltered default is unchanged + assert len(sweep.all_cells()) == len(sweep.TRIALS) * len(sweep.BETAS) * len(sweep.RADII) diff --git a/tests/test_campaign.py b/tests/test_campaign.py new file mode 100644 index 0000000..9c83d66 --- /dev/null +++ b/tests/test_campaign.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest +import torch + +from mobo_kit.campaign import ( + FIXED_SCALING_MODES, + assert_scaling_is_campaign_fixed, + write_worklist_csv, + BatchValidityError, + CampaignConfigError, + build_objective_transform, + expand_replicates, + load_campaign_config, + model_source_columns, + run_r0_lhs, + validate_batch, +) +from mobo_kit.design import build_design_from_config + +CONFIG_PATH = "configs/campaign_d2d_perovskite.yaml" + + +@pytest.fixture(scope="module") +def config() -> dict: + return load_campaign_config(CONFIG_PATH) + + +def test_campaign_config_is_runnable(config: dict) -> None: + """The first campaign's config must no longer be a baseline-only stub. + + It is `archived` since 2026-08-17 -- superseded by + campaign_d2d_perovskite_test.yaml on the second dataset -- and it stays + complete and loadable rather than being deleted, because every number in + GP_MODEL_DECISION.md is about this contract. Archived means "do not run new + rounds against it", not "let it rot". + """ + assert config["campaign"]["status"] == "archived" + assert len(config["inputs"]) == 10 + assert config["objectives"]["contract_version"] == "d2d-objectives-v2-nm-thickness" + assert len(config["objectives"]["specs"]) == 3 + # this campaign declared no constraints, deliberately; the second one has three + assert config["constraints"] == [] + + +def test_thickness_trains_on_nanometres_not_the_score(config: dict) -> None: + """The whole point of the objective rework: source column != utility.""" + sources = model_source_columns(config) + assert sources == ( + "Uniformity score", + "Optoelectronic score", + "Thickness (avg)", + ) + transform = build_objective_transform(config) + thickness = transform.specs[2] + assert thickness.transform == "gaussian_target" + assert thickness.target == pytest.approx(650.0) + # workbook writes exp(-((T-650)/250)^2); this convention carries the 1/2 + assert thickness.sigma == pytest.approx(250.0 / np.sqrt(2.0)) + + +def test_transform_reproduces_the_workbook_thickness_score(config: dict) -> None: + """Thickness is a log-link objective: the GP emits log(nm), so the transform + exponentiates before applying the 650 nm Gaussian. Feeding it raw nm would + silently score exp(687) instead of 687.""" + transform = build_objective_transform(config) + assert transform.specs[2].model_link == "log" + nm = np.array([687.0, 1303.0]) + model_output = torch.tensor( + [[0.0, -8.0, np.log(nm[0])], [0.0, -8.0, np.log(nm[1])]], dtype=torch.double + ) + got = transform(model_output)[:, 2].numpy() + expected = np.exp(-(((nm - 650.0) / 250.0) ** 2)) + np.testing.assert_allclose(got, expected, atol=1e-12) + + +def test_reference_point_is_declared_in_utility_space(config: dict) -> None: + """A raw-scale reference silently weighted optoelectronic 4x; utility space + puts every axis on a comparable scale.""" + point = config["reference_point_utility"] + assert len(point) == 3 + assert all(abs(float(v)) < 1.0 for v in point) + + +def test_objectives_without_a_source_column_are_rejected() -> None: + bad = { + "objectives": { + "contract_version": "x", + "specs": [{"name": "a", "goal": "maximize", "transform": "identity"}], + } + } + with pytest.raises(CampaignConfigError, match="model_source_column"): + build_objective_transform(bad) + + +def test_empty_objective_list_cannot_propose() -> None: + with pytest.raises(CampaignConfigError, match="specs"): + build_objective_transform( + {"objectives": {"contract_version": "x", "specs": []}} + ) + + +# --------------------------------------------------------------------------- # +# validity gate +# --------------------------------------------------------------------------- # + + +def _valid_batch(config: dict) -> pd.DataFrame: + design = build_design_from_config(dict(config)) + rows = [[float(design.var_array[j][i * 2]) for j in range(10)] for i in range(3)] + return pd.DataFrame(rows, columns=list(design.names)) + + +def test_validate_batch_accepts_a_clean_batch(config: dict) -> None: + design = build_design_from_config(dict(config)) + report = validate_batch(_valid_batch(config), design, expected_count=3) + assert report["unique"] and report["on_grid"] and report["in_bounds"] + assert report["actual_count"] == 3 + + +@pytest.mark.parametrize( + "mutate, match", + [ + (lambda d: d.iloc[:2], "Expected exactly 3"), + (lambda d: pd.concat([d.iloc[:2], d.iloc[[0]]]), "unique"), + (lambda d: d.assign(anti_time=12.0), "grid"), + (lambda d: d.assign(speed_1=99999.0), "grid"), + (lambda d: d.assign(speed_1=float("nan")), "non-finite"), + ], +) +def test_validate_batch_refuses_real_defects(config: dict, mutate, match) -> None: + design = build_design_from_config(dict(config)) + with pytest.raises(BatchValidityError, match=match): + validate_batch(mutate(_valid_batch(config)), design, expected_count=3) + + +def test_validate_batch_enforces_minimum_spacing(config: dict) -> None: + design = build_design_from_config(dict(config)) + batch = _valid_batch(config) + with pytest.raises(BatchValidityError, match="pairwise distance"): + validate_batch(batch, design, expected_count=3, min_pairwise_distance=10.0) + + +def test_validity_report_carries_no_approval_flags(config: dict) -> None: + """The debug/production tiers are gone. Approval is a human decision recorded + outside the code, not something a validity check can compute.""" + design = build_design_from_config(dict(config)) + report = validate_batch(_valid_batch(config), design, expected_count=3) + for banned in ( + "debug_only", + "approved_for_experiment", + "approved_for_production", + "experimental_approval_false", + "production_approval_false", + ): + assert banned not in report + + +# --------------------------------------------------------------------------- # +# replicates +# --------------------------------------------------------------------------- # + + +def test_expand_replicates_groups_three_films_per_condition(config: dict) -> None: + batch = _valid_batch(config) + films = expand_replicates(batch, replicates=3, round_name="R1") + assert len(films) == 9 + assert films["replicate_group"].nunique() == 3 + assert set(films["replicate_index"]) == {1, 2, 3} + assert set(films["round"]) == {"R1"} + for _, group in films.groupby("replicate_group"): + inputs = group[list(batch.columns)].drop_duplicates() + assert len(inputs) == 1, "replicates must share identical inputs" + + +def test_expand_replicates_rejects_zero(config: dict) -> None: + with pytest.raises(ValueError, match="at least 1"): + expand_replicates(_valid_batch(config), replicates=0, round_name="R1") + + +# --------------------------------------------------------------------------- # +# R0 +# --------------------------------------------------------------------------- # + + +def test_run_r0_lhs_produces_a_valid_on_grid_worklist(config: dict) -> None: + result = run_r0_lhs(config, n=8, seed=7) + assert result.round_name == "R0" + assert result.n_conditions == 8 + assert list(result.conditions.columns) == [i["name"] for i in config["inputs"]] + assert result.diagnostics["validity"]["on_grid"] + assert ( + len(result.replicates) == 8 * config["rounds"]["r1"]["replicates_per_condition"] + ) + + +def test_run_r0_lhs_is_deterministic_for_a_seed(config: dict) -> None: + a = run_r0_lhs(config, n=6, seed=11).conditions + b = run_r0_lhs(config, n=6, seed=11).conditions + pd.testing.assert_frame_equal(a, b) + + +# --------------------------------------------------------------------------- # +# encoding +# --------------------------------------------------------------------------- # + +# Non-ASCII that breaks under a locale default codec such as GBK or cp1252. +NON_ASCII = "sigma \u2014 \u00b5m \u00b1 5% \u2013 caf\u00e9 \u4e2d\u6587" + + +def test_config_round_trips_non_ascii(tmp_path) -> None: + """Loaders must force UTF-8. + + The default codec is locale dependent -- on a Chinese-locale Windows install + it is GBK -- so a bare open() fails on the first non-ASCII byte, and it fails + on a different machine from the one the file was written on. + """ + import yaml + + path = tmp_path / "cfg.yaml" + payload = { + "campaign": {"name": NON_ASCII}, + "objectives": { + "contract_version": NON_ASCII, + "specs": [ + { + "name": "a", + "goal": "maximize", + "transform": "identity", + "model_source_column": NON_ASCII, + } + ], + }, + } + path.write_text(yaml.safe_dump(payload, allow_unicode=True), encoding="utf-8") + + loaded = load_campaign_config(path) + assert loaded["campaign"]["name"] == NON_ASCII + assert model_source_columns(loaded) == (NON_ASCII,) + assert build_objective_transform(loaded).version == NON_ASCII + + +def test_workbook_reader_round_trips_non_ascii(tmp_path) -> None: + """A non-ASCII column header or cell must survive the workbook boundary.""" + from openpyxl import Workbook, load_workbook + + path = tmp_path / "wb.xlsx" + book = Workbook() + sheet = book.active + sheet.title = "Sheet1" + sheet.append(["Sample number", NON_ASCII]) + sheet.append([1, NON_ASCII]) + book.save(path) + + reread = load_workbook(path, data_only=True)["Sheet1"] + rows = list(reread.iter_rows(values_only=True)) + assert rows[0][1] == NON_ASCII + assert rows[1][1] == NON_ASCII + + +def test_csv_round_trips_non_ascii(tmp_path) -> None: + """pandas defaults to UTF-8 for both directions; pin it with a test so a + future explicit encoding= cannot silently regress it.""" + path = tmp_path / "out.csv" + frame = pd.DataFrame({"label": [NON_ASCII], "value": [1.0]}) + frame.to_csv(path, index=False) + pd.testing.assert_frame_equal(pd.read_csv(path), frame) + + +# --------------------------------------------------------------------------- # +# campaign-fixed scaling +# --------------------------------------------------------------------------- # + + +def test_campaign_declares_fixed_scaling(config: dict) -> None: + assert config["objectives"]["scaling_mode"] in FIXED_SCALING_MODES + assert_scaling_is_campaign_fixed(config) + + +def test_data_derived_scaling_is_refused(config: dict) -> None: + """If the scale tracks the data, hypervolume stops being comparable between + rounds. The temptation arrives the moment R1 measurements land.""" + import copy + + bad = copy.deepcopy(dict(config)) + bad["objectives"]["scaling_mode"] = "observed_min_max" + with pytest.raises(CampaignConfigError, match="incomparable"): + assert_scaling_is_campaign_fixed(bad) + with pytest.raises(CampaignConfigError, match="incomparable"): + build_objective_transform(bad) + + +def test_affine_objective_without_declared_anchors_is_refused(config: dict) -> None: + """Two layers refuse this: ObjectiveSpec at construction, and + assert_scaling_is_campaign_fixed for anything that reaches it. Whichever + fires first, an affine objective can never end up with implicit anchors.""" + import copy + + bad = copy.deepcopy(dict(config)) + del bad["objectives"]["specs"][0]["upper_anchor"] + with pytest.raises(ValueError, match="upper_anchor"): + build_objective_transform(bad) + + +def test_inverted_anchors_are_refused(config: dict) -> None: + import copy + + bad = copy.deepcopy(dict(config)) + spec = bad["objectives"]["specs"][0] + spec["lower_anchor"], spec["upper_anchor"] = 1.0, 0.0 + with pytest.raises(ValueError, match="anchor"): + build_objective_transform(bad) + + +def test_worklist_csv_is_excel_safe(tmp_path) -> None: + """Excel does not detect plain UTF-8 and falls back to the system ANSI + codepage, mangling non-ASCII cells on the reader's machine rather than the + writer's. utf-8-sig writes the BOM; other readers strip it transparently.""" + path = tmp_path / "worklist.csv" + frame = pd.DataFrame({"label": [NON_ASCII], "speed_1": [1000.0]}) + write_worklist_csv(frame, path) + + assert path.read_bytes().startswith(b"\xef\xbb\xbf"), "missing UTF-8 BOM" + pd.testing.assert_frame_equal(pd.read_csv(path), frame) + pd.testing.assert_frame_equal(pd.read_csv(path, encoding="utf-8-sig"), frame) diff --git a/tests/test_candidate_diagnostics.py b/tests/test_candidate_diagnostics.py new file mode 100644 index 0000000..4b184ec --- /dev/null +++ b/tests/test_candidate_diagnostics.py @@ -0,0 +1,132 @@ +from pathlib import Path + +import numpy as np +import pytest +from PIL import Image + +from mobo_kit.candidate_diagnostics import ( + boundary_flags, + grid_membership_mask, + pairwise_normalized_distances, + plot_candidate_pca, + plot_distance_heatmap, + plot_parallel_coordinates, + plot_selection_scores, + summarize_candidate_batch, +) +from mobo_kit.design import InputSpec, build_design + + +def _design(): + return build_design( + [ + InputSpec("a", 0.0, 2.0, 1.0), + InputSpec("b", 10.0, 20.0, 5.0), + ] + ) + + +def test_pairwise_normalized_distances_and_summary(): + selected = np.array([[0.0, 0.0], [0.3, 0.4], [1.0, 0.0]]) + matrix = pairwise_normalized_distances(selected) + assert matrix.shape == (3, 3) + assert matrix[0, 1] == pytest.approx(0.5) + assert np.allclose(matrix, matrix.T) + assert np.allclose(np.diag(matrix), 0.0) + + summary = summarize_candidate_batch( + selected, + observed_pending_norm=np.array([[0.0, 0.1]]), + X_phys=np.array([[0.0, 10.0], [1.0, 15.0], [2.0, 10.0]]), + design=_design(), + metadata={"method": "TEST_ONLY"}, + ) + triangle = matrix[np.triu_indices(3, k=1)] + assert summary.minimum_within_batch_distance == pytest.approx(triangle.min()) + assert summary.mean_within_batch_distance == pytest.approx(triangle.mean()) + assert summary.maximum_within_batch_distance == pytest.approx(triangle.max()) + assert summary.nearest_observed_pending_distance[0] == pytest.approx(0.1) + assert summary.duplicate_row_pairs == () + assert summary.grid_valid_rows.tolist() == [True, True, True] + assert summary.metadata == {"method": "TEST_ONLY"} + + +def test_duplicate_grid_and_boundary_checks(): + selected = np.array([[0.0, 0.0], [0.0, 0.0], [0.5, 1.0]]) + summary = summarize_candidate_batch(selected) + assert summary.duplicate_row_pairs == ((0, 1),) + assert boundary_flags(selected).tolist() == [ + [True, True], + [True, True], + [False, True], + ] + valid = grid_membership_mask( + np.array([[0.0, 10.0], [1.5, 15.0], [2.0, 19.0]]), _design() + ) + assert valid.tolist() == [True, False, False] + + +def test_weight_validation_and_weighted_distance(): + X = np.array([[0.0, 0.0], [1.0, 1.0]]) + matrix = pairwise_normalized_distances(X, dimension_weights=np.array([1.0, 4.0])) + assert matrix[0, 1] == pytest.approx(np.sqrt(5.0)) + with pytest.raises(ValueError, match="strictly positive"): + pairwise_normalized_distances(X, dimension_weights=np.array([1.0, 0.0])) + with pytest.raises(ValueError, match="shape"): + pairwise_normalized_distances(X, dimension_weights=np.ones(3)) + + +def test_singleton_summary_has_explicit_empty_within_batch_statistics(): + summary = summarize_candidate_batch(np.array([[0.2, 0.8]])) + assert summary.minimum_within_batch_distance is None + assert summary.mean_within_batch_distance is None + assert summary.maximum_within_batch_distance is None + assert np.isnan(summary.nearest_observed_pending_distance[0]) + + +def test_plotting_helpers_write_headless_pngs(tmp_path: Path): + observed = np.array([[0.0, 0.0], [0.5, 0.3], [0.9, 1.0]]) + pool = np.linspace(0.0, 1.0, 40).reshape(20, 2) + selected = np.array([[0.1, 0.8], [0.8, 0.2], [0.5, 0.5]]) + watermark = "DEBUG ONLY - TEST PLOT" + paths = [ + plot_candidate_pca( + observed, + selected, + tmp_path / "pca.png", + pool_norm=pool, + watermark=watermark, + ), + plot_parallel_coordinates( + selected, + ["a", "b"], + tmp_path / "parallel.png", + watermark=watermark, + ), + plot_distance_heatmap(selected, tmp_path / "distance.png", watermark=watermark), + plot_selection_scores( + [1, 2, 3], + [-1.0, -1.2, -1.4], + [-1.0, -1.8, -2.1], + tmp_path / "scores.png", + watermark=watermark, + ), + ] + for path in paths: + assert path.exists() + assert path.stat().st_size > 1000 + with Image.open(path) as image: + assert image.info["Description"] == watermark + + +def test_diagnostics_reject_shape_and_partial_grid_context(): + with pytest.raises(ValueError, match="shape"): + pairwise_normalized_distances(np.ones(3)) + with pytest.raises(ValueError, match="supplied together"): + summarize_candidate_batch(np.ones((2, 2)), X_phys=np.ones((2, 2)), design=None) + with pytest.raises(ValueError, match="same row count"): + summarize_candidate_batch( + np.ones((2, 2)), X_phys=np.ones((1, 2)), design=_design() + ) + with pytest.raises(ValueError, match="duplicate_atol"): + summarize_candidate_batch(np.ones((2, 2)), duplicate_atol=-1) diff --git a/tests/test_candidate_pool.py b/tests/test_candidate_pool.py new file mode 100644 index 0000000..18ba27e --- /dev/null +++ b/tests/test_candidate_pool.py @@ -0,0 +1,150 @@ +import numpy as np +import pytest + +from mobo_kit.candidate_pool import ( + CandidatePoolSamplingError, + physical_rows_to_grid_indices, + sample_discrete_candidate_pool, +) +from mobo_kit.design import InputSpec, build_design + + +def _design(): + return build_design( + [ + InputSpec("a", 0.0, 4.0, 1.0), + InputSpec("b", 10.0, 20.0, 5.0), + InputSpec("c", -1.0, 1.0, 1.0), + ] + ) + + +def test_exact_size_seeded_order_grid_membership_and_normalization(): + design = _design() + first = sample_discrete_candidate_pool(design, 20, seed=12) + repeat = sample_discrete_candidate_pool(design, 20, seed=12) + different = sample_discrete_candidate_pool(design, 20, seed=13) + assert first.size == 20 + assert np.array_equal(first.grid_indices, repeat.grid_indices) + assert np.array_equal(first.X_phys, repeat.X_phys) + assert np.array_equal(first.X_norm, repeat.X_norm) + assert not np.array_equal(first.grid_indices, different.grid_indices) + assert np.unique(first.grid_indices, axis=0).shape[0] == 20 + assert np.all(first.grid_indices >= 0) + assert np.all(first.grid_indices < np.array([5, 3, 3])) + assert np.all((first.X_norm >= 0) & (first.X_norm <= 1)) + assert np.array_equal( + physical_rows_to_grid_indices(first.X_phys, design), first.grid_indices + ) + + +def test_observed_pending_and_explicit_avoid_are_excluded(): + design = _design() + exclusions = np.array([[0.0, 10.0, -1.0], [1.0, 15.0, 0.0], [2.0, 20.0, 1.0]]) + pool = sample_discrete_candidate_pool( + design, + 25, + seed=4, + observed_phys=exclusions[:1], + pending_phys=exclusions[1:2], + avoid_phys=exclusions[2:], + ) + excluded_indices = physical_rows_to_grid_indices(exclusions, design) + pool_set = {tuple(row) for row in pool.grid_indices} + assert not any(tuple(row) in pool_set for row in excluded_indices) + + +def test_constraints_run_in_physical_space_and_are_fail_closed(): + design = _design() + calls = [] + + def require_even_first_input(X_phys, supplied_design): + calls.append(X_phys.copy()) + assert supplied_design is design + return (X_phys[:, 0] % 2) == 0 + + pool = sample_discrete_candidate_pool( + design, + 15, + seed=3, + row_constraints=[require_even_first_input], + max_draws=500, + ) + assert calls + assert np.all(pool.X_phys[:, 0] % 2 == 0) + assert pool.rejected_constraint > 0 + + +def test_impossible_request_raises_structured_error(): + design = build_design([InputSpec("x", 0, 1, 1)]) + with pytest.raises(CandidatePoolSamplingError) as captured: + sample_discrete_candidate_pool( + design, 2, seed=1, observed_phys=np.array([[0.0]]) + ) + error = captured.value + assert error.requested == 2 + assert error.accepted == 0 + assert "exceeds" in error.reason + + +def test_max_draws_failure_reports_rejections(): + design = build_design([InputSpec("x", 0, 4, 1)]) + + def reject_all(X_phys, supplied_design): + del supplied_design + return np.zeros(X_phys.shape[0], dtype=bool) + + with pytest.raises(CandidatePoolSamplingError) as captured: + sample_discrete_candidate_pool( + design, + 1, + seed=2, + row_constraints=[reject_all], + max_draws=5, + ) + assert captured.value.draws == 5 + assert captured.value.rejected_constraint > 0 + + +def test_draw_statistics_on_known_two_point_grid(): + design = build_design([InputSpec("x", 0, 1, 1)]) + pool = sample_discrete_candidate_pool(design, 2, seed=0, max_draws=10) + # NumPy Generator seed 0 draws 1, 1, 1, 0 for the first four integers. + assert pool.grid_indices[:, 0].tolist() == [1, 0] + assert pool.draws == 4 + assert pool.rejected_duplicate == 2 + assert pool.rejected_avoid == 0 + assert pool.rejected_constraint == 0 + + excluded = sample_discrete_candidate_pool( + design, + 1, + seed=0, + observed_phys=np.array([[1.0]]), + max_draws=10, + ) + assert excluded.grid_indices[:, 0].tolist() == [0] + assert excluded.draws == 4 + assert excluded.rejected_duplicate == 2 + assert excluded.rejected_avoid == 1 + assert excluded.rejected_constraint == 0 + + +def test_off_grid_exclusions_fail_instead_of_using_fuzzy_matching(): + with pytest.raises(ValueError, match="off-grid"): + sample_discrete_candidate_pool( + _design(), 2, seed=1, avoid_phys=np.array([[0.25, 10.0, 0.0]]) + ) + with pytest.raises(ValueError, match="off-grid"): + physical_rows_to_grid_indices(np.array([[1e-9, 10.0, 0.0]]), _design()) + + +def test_sampler_never_calls_cartesian_product_allocators(monkeypatch): + def forbidden(*args, **kwargs): + del args, kwargs + raise AssertionError("full Cartesian allocation was attempted") + + monkeypatch.setattr(np, "meshgrid", forbidden) + monkeypatch.setattr(np, "indices", forbidden) + pool = sample_discrete_candidate_pool(_design(), 10, seed=9) + assert pool.size == 10 diff --git a/tests/test_constraints.py b/tests/test_constraints.py new file mode 100644 index 0000000..5f3c969 --- /dev/null +++ b/tests/test_constraints.py @@ -0,0 +1,246 @@ +"""The campaign's physical-space constraints. + +These are the first real constraints this project has carried -- the first +campaign declared an empty list deliberately -- so the boundary cases are pinned +here rather than left to the round tests, where a constraint bug would show up as +a batch that merely looks unusual. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from mobo_kit.constraints import ( + NamedConstraint, + apply_row_constraints, + constraint_violations, + constraints_from_config, +) +from mobo_kit.design import build_design_from_config + +CONFIG_INPUTS = [ + {"name": "speed_2", "start": 0, "stop": 5000, "step": 500}, + {"name": "time_1", "start": 5, "stop": 50, "step": 5}, + {"name": "time_2", "start": 0, "stop": 60, "step": 5}, + {"name": "anti_time", "start": 9, "stop": 25, "step": 1}, +] + + +def _design(): + return build_design_from_config({"inputs": CONFIG_INPUTS}) + + +def _row(speed_2=1000.0, time_1=30.0, time_2=20.0, anti_time=12.0): + return [speed_2, time_1, time_2, anti_time] + + +def _mask(config_entries, rows): + design = _design() + constraints = constraints_from_config({"constraints": config_entries}, design) + return apply_row_constraints(np.asarray(rows, dtype=float), design, constraints) + + +# --------------------------------------------------------------------------- # +# zero_coupled +# --------------------------------------------------------------------------- # + +ZERO_COUPLED = [{"zero_coupled": ["speed_2", "time_2"]}] + + +def test_both_zero_is_valid_because_a_one_step_film_is_a_real_recipe() -> None: + """Sample 2 of the v3 workbook runs no second stage at all. A plain lower + bound on either column would delete that recipe, which is why the rule is an + iff and not two bounds.""" + assert _mask(ZERO_COUPLED, [_row(speed_2=0.0, time_2=0.0)]).tolist() == [True] + + +def test_both_nonzero_is_valid() -> None: + assert _mask(ZERO_COUPLED, [_row(speed_2=3500.0, time_2=30.0)]).tolist() == [True] + + +@pytest.mark.parametrize( + "speed_2, time_2, what", + [ + (0.0, 30.0, "a second stage that spins at 0 rpm for 30 s"), + (3500.0, 0.0, "a second stage that spins at 3500 rpm for 0 s"), + ], +) +def test_exactly_one_zero_is_invalid_in_both_orientations( + speed_2, time_2, what +) -> None: + """Both orientations, because a constraint written as a single implication + catches only one of them and the other stays proposable.""" + assert _mask(ZERO_COUPLED, [_row(speed_2=speed_2, time_2=time_2)]).tolist() == [ + False + ], what + + +# --------------------------------------------------------------------------- # +# sum_upper_strict +# --------------------------------------------------------------------------- # + +SUM_STRICT = [{"sum_upper_strict": {"lhs": "anti_time", "rhs": ["time_1", "time_2"]}}] + + +def test_anti_time_below_the_total_spin_is_valid() -> None: + rows = [_row(time_1=30.0, time_2=20.0, anti_time=49.0)] + assert _mask(SUM_STRICT, rows).tolist() == [True] + + +def test_equality_is_a_violation_because_the_bound_is_strict() -> None: + """The antisolvent has to land while the substrate is still spinning, so + dropping it exactly at the end is already too late. Strictness is the whole + point of this constraint type existing separately from a bounds check.""" + rows = [_row(time_1=30.0, time_2=20.0, anti_time=50.0)] + assert _mask(SUM_STRICT, rows).tolist() == [False] + + +def test_anti_time_above_the_total_spin_is_a_violation() -> None: + rows = [_row(time_1=10.0, time_2=10.0, anti_time=25.0)] + assert _mask(SUM_STRICT, rows).tolist() == [False] + + +def test_a_one_step_film_still_has_to_satisfy_the_sum() -> None: + """time_2 = 0 does not exempt the row; the sum is just time_1.""" + valid = _row(speed_2=0.0, time_2=0.0, time_1=30.0, anti_time=25.0) + invalid = _row(speed_2=0.0, time_2=0.0, time_1=20.0, anti_time=25.0) + assert _mask(SUM_STRICT, [valid, invalid]).tolist() == [True, False] + + +# --------------------------------------------------------------------------- # +# nonzero_minimum +# --------------------------------------------------------------------------- # + +NONZERO_MIN = [{"nonzero_minimum": {"column": "time_2", "minimum": 10}}] + + +@pytest.mark.parametrize( + "time_2, expected", + [(0.0, True), (5.0, False), (10.0, True), (55.0, True)], +) +def test_the_declared_hole_in_the_grid(time_2, expected) -> None: + """0 is allowed and 10 upwards is allowed; only the gap between them is not. + + The grid is arithmetic, so reaching 0 with step 5 also reaches 5. This is what + keeps 5 out without widening the design space in silence. + """ + assert _mask(NONZERO_MIN, [_row(time_2=time_2)]).tolist() == [expected] + + +# --------------------------------------------------------------------------- # +# wiring +# --------------------------------------------------------------------------- # + + +def test_constraints_are_anded_together() -> None: + entries = ZERO_COUPLED + SUM_STRICT + NONZERO_MIN + rows = [ + _row(speed_2=1000.0, time_2=20.0, time_1=30.0, anti_time=12.0), # all pass + _row(speed_2=1000.0, time_2=5.0, time_1=30.0, anti_time=12.0), # minimum + _row(speed_2=0.0, time_2=20.0, time_1=30.0, anti_time=12.0), # coupling + _row(speed_2=1000.0, time_2=20.0, time_1=30.0, anti_time=50.0), # sum + ] + assert _mask(entries, rows).tolist() == [True, False, False, False] + + +def test_violations_are_reported_by_name_not_by_index() -> None: + """A reviewer told "condition 3 is invalid" cannot act on it. The rule can.""" + design = _design() + entries = [ + {"zero_coupled": ["speed_2", "time_2"], "name": "second_stage_all_or_nothing"}, + {"sum_upper_strict": {"lhs": "anti_time", "rhs": ["time_1", "time_2"]}}, + ] + constraints = constraints_from_config({"constraints": entries}, design) + rows = np.asarray( + [ + _row(speed_2=1000.0, time_2=20.0, time_1=30.0, anti_time=12.0), + _row(speed_2=0.0, time_2=20.0, time_1=30.0, anti_time=99.0), + ], + dtype=float, + ) + assert constraint_violations(rows, design, constraints) == [ + [], + ["second_stage_all_or_nothing", "sum_upper_strict"], + ] + + +def test_a_named_constraint_is_still_an_ordinary_row_constraint() -> None: + """The candidate pool takes plain callables and must stay unaware of naming.""" + design = _design() + constraint = constraints_from_config({"constraints": ZERO_COUPLED}, design)[0] + assert isinstance(constraint, NamedConstraint) + rows = np.asarray([_row(speed_2=0.0, time_2=0.0)], dtype=float) + assert constraint(rows, design).tolist() == [True] + + +def test_the_description_states_the_rule() -> None: + design = _design() + entries = ZERO_COUPLED + SUM_STRICT + NONZERO_MIN + descriptions = [ + item.description for item in constraints_from_config({"constraints": entries}, design) + ] + assert descriptions == [ + "speed_2 and time_2 are all zero or all nonzero", + "anti_time < time_1 + time_2", + "time_2 is 0 or at least 10", + ] + + +def test_no_constraints_means_every_row_passes() -> None: + """Constraints must be inert when unconfigured -- DTLZ2 declares none.""" + design = _design() + assert constraints_from_config({}, design) == [] + assert constraints_from_config({"constraints": []}, design) == [] + rows = np.asarray([_row(speed_2=0.0, time_2=30.0, anti_time=99.0)], dtype=float) + assert apply_row_constraints(rows, design, []).tolist() == [True] + assert constraint_violations(rows, design, None) == [[]] + + +# --------------------------------------------------------------------------- # +# configuration errors fail loudly +# --------------------------------------------------------------------------- # + + +def test_an_unknown_column_names_itself() -> None: + design = _design() + with pytest.raises(KeyError, match="speed_9"): + constraints_from_config( + {"constraints": [{"zero_coupled": ["speed_9", "time_2"]}]}, design + ) + + +def test_an_unknown_constraint_type_is_refused_rather_than_ignored() -> None: + design = _design() + with pytest.raises(KeyError, match="no supported type"): + constraints_from_config({"constraints": [{"nonsense": [1, 2]}]}, design) + + +def test_zero_coupled_needs_two_columns_to_couple() -> None: + design = _design() + with pytest.raises(KeyError, match="at least two"): + constraints_from_config({"constraints": [{"zero_coupled": ["time_2"]}]}, design) + + +@pytest.mark.parametrize( + "entry, match", + [ + ({"sum_upper_strict": {"rhs": ["time_1"]}}, "lhs"), + ({"sum_upper_strict": {"lhs": "anti_time"}}, "rhs"), + ({"nonzero_minimum": {"minimum": 10}}, "column"), + ({"nonzero_minimum": {"column": "time_2"}}, "minimum"), + ], +) +def test_a_half_specified_constraint_is_an_error(entry, match) -> None: + """Silently skipping a malformed entry would leave everyone believing a rule + is enforced while nothing enforces it.""" + with pytest.raises(KeyError, match=match): + constraints_from_config({"constraints": [entry]}, _design()) + + +def test_a_nonpositive_minimum_is_refused() -> None: + with pytest.raises(ValueError, match="finite and positive"): + constraints_from_config( + {"constraints": [{"nonzero_minimum": {"column": "time_2", "minimum": 0}}]}, + _design(), + ) diff --git a/tests/test_csv_parser.py b/tests/test_csv_parser.py new file mode 100644 index 0000000..c379510 --- /dev/null +++ b/tests/test_csv_parser.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pandas as pd +import pytest +import yaml + +from mobo_kit.utils import ( + ParsedCampaignCSV, + csv_to_config, + load_csv, + parse_campaign_csv, + split_XY, +) + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +EXAMPLE_CAMPAIGN_CSV = REPOSITORY_ROOT / "data" / "processed" / "configCSV_example.csv" + + +def _campaign_text(*, blank_separator: bool = True) -> str: + separator_header = "," if blank_separator else "" + separator_cell = "," if blank_separator else "" + blank_row = ",,,,,\n" if blank_separator else "" + return ( + f",x_speed,x_time{separator_header},yield,stability\n" + f"units,rpm,s{separator_cell},%,h\n" + f"start,1000,5{separator_cell},,\n" + f"stop,2000,15{separator_cell},,\n" + f"step,500,5{separator_cell},,\n" + f"{blank_row}" + f",1000,5{separator_cell},1.5,10\n" + f",1500,10{separator_cell},2.5,11\n" + ) + + +def _write_text(path: Path, text: str, encoding: str = "utf-8") -> Path: + path.write_bytes(text.encode(encoding)) + return path + + +def test_parser_detects_metadata_and_preserves_first_experiment(tmp_path: Path) -> None: + path = _write_text(tmp_path / "campaign.csv", _campaign_text()) + + parsed = parse_campaign_csv(path) + + assert isinstance(parsed, ParsedCampaignCSV) + assert parsed.input_columns == ["x_speed", "x_time"] + assert parsed.objective_columns == ["yield", "stability"] + assert parsed.metadata_row_count == 5 + assert parsed.duplicate_headers == {} + assert parsed.config["constraints"] == [] + assert parsed.data.iloc[0].to_dict() == { + "x_speed": "1000", + "x_time": "5", + "yield": "1.5", + "stability": "10", + } + assert len(parsed.data) == 2 + + +def test_repository_example_retains_its_first_experiment() -> None: + parsed = parse_campaign_csv(EXAMPLE_CAMPAIGN_CSV) + + assert parsed.metadata_row_count == 5 + assert parsed.input_columns == [ + "speed_inorg", + "speed_org", + "inkfl_inorg", + "inkfl_org", + "conc_inorg", + "conc_org", + "temperature_c", + "absolute_humidity", + ] + assert parsed.objective_columns == ["PCE", "Stability", "Repeatability"] + assert len(parsed.data) == 12 + assert parsed.data.iloc[0]["speed_inorg"] == "0.58" + + +def test_parser_accepts_no_blank_separator_row_or_column(tmp_path: Path) -> None: + path = _write_text( + tmp_path / "no_separator.csv", _campaign_text(blank_separator=False) + ) + + parsed = parse_campaign_csv(path) + + assert parsed.metadata_row_count == 4 + assert parsed.objective_columns == ["yield", "stability"] + assert parsed.data.iloc[0]["x_speed"] == "1000" + + +def test_load_csv_returns_only_experimental_rows(tmp_path: Path) -> None: + path = _write_text(tmp_path / "campaign.csv", _campaign_text()) + + data = load_csv(path) + + assert list(data.columns) == ["x_speed", "x_time", "yield", "stability"] + assert len(data) == 2 + assert "units" not in data.astype(str).to_numpy() + + +def test_utf8_bom_is_supported(tmp_path: Path) -> None: + path = tmp_path / "bom.csv" + path.write_bytes(b"\xef\xbb\xbf" + _campaign_text().encode("utf-8")) + + parsed = parse_campaign_csv(path) + + assert parsed.encoding == "utf-8-sig" + assert parsed.input_columns[0] == "x_speed" + + +def test_cp1252_is_supported(tmp_path: Path) -> None: + text = _campaign_text().replace("rpm", "\N{DEGREE SIGN}C") + path = _write_text(tmp_path / "cp1252.csv", text, encoding="cp1252") + + parsed = parse_campaign_csv(path) + + assert parsed.encoding == "cp1252" + assert parsed.config["inputs"][0]["unit"] == "\N{DEGREE SIGN}C" + + +def test_latin1_is_used_when_cp1252_cannot_decode(tmp_path: Path) -> None: + payload = _campaign_text().replace("rpm", "UNIT_MARKER").encode("ascii") + path = tmp_path / "latin1.csv" + path.write_bytes(payload.replace(b"UNIT_MARKER", b"\x81")) + + parsed = parse_campaign_csv(path) + + assert parsed.encoding == "latin-1" + assert parsed.config["inputs"][0]["unit"] == "\x81" + + +def test_duplicate_headers_are_rejected_before_pandas_mangling(tmp_path: Path) -> None: + text = _campaign_text().replace(",yield,stability", ",yield,yield", 1) + path = _write_text(tmp_path / "duplicates.csv", text) + + with pytest.raises(ValueError, match=r"Duplicate CSV headers.*columns \[5, 6\]"): + parse_campaign_csv(path) + + +def test_plain_data_csv_is_rejected_explicitly(tmp_path: Path) -> None: + path = _write_text(tmp_path / "plain.csv", "x,y\n1,2\n") + + with pytest.raises(ValueError, match="Plain data CSVs are not supported"): + parse_campaign_csv(path) + + +@pytest.mark.parametrize( + ("old", "new", "message"), + [ + ("start,1000,5", "start,not-a-number,5", "Malformed start metadata"), + ("step,500,5", "step,,5", "Incomplete numeric metadata"), + ], +) +def test_malformed_or_missing_numeric_metadata_is_rejected( + tmp_path: Path, old: str, new: str, message: str +) -> None: + path = _write_text( + tmp_path / "bad_metadata.csv", _campaign_text().replace(old, new) + ) + + with pytest.raises(ValueError, match=message): + parse_campaign_csv(path) + + +def test_expected_objectives_are_validated_and_ordered(tmp_path: Path) -> None: + path = _write_text(tmp_path / "campaign.csv", _campaign_text()) + + parsed = parse_campaign_csv(path, expected_objectives=["stability", "yield"]) + assert parsed.objective_columns == ["stability", "yield"] + + with pytest.raises(ValueError, match="Missing objective columns.*missing_score"): + parse_campaign_csv(path, expected_objectives=["yield", "missing_score"]) + + +def test_missing_objectives_and_empty_experiment_section_are_clear( + tmp_path: Path, +) -> None: + no_objective = ",x\nunits,rpm\nstart,0\nstop,1\nstep,0.5\n\n,0\n" + path = _write_text(tmp_path / "no_objective.csv", no_objective) + with pytest.raises(ValueError, match="no named objective columns"): + parse_campaign_csv(path) + + empty = ( + ",x_speed,x_time,,yield,stability\n" + "units,rpm,s,,%,h\n" + "start,1000,5,,,\n" + "stop,2000,15,,,\n" + "step,500,5,,,\n" + ",,,,,\n" + ) + empty_path = _write_text(tmp_path / "empty.csv", empty) + with pytest.raises(ValueError, match="empty experimental section"): + parse_campaign_csv(empty_path) + + +def test_csv_to_config_is_opt_in_for_output_and_constraints( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = _write_text(tmp_path / "campaign.csv", _campaign_text()) + monkeypatch.chdir(tmp_path) + + config = csv_to_config(path) + + assert config["constraints"] == [] + assert not (tmp_path / "configs").exists() + + output_path = tmp_path / "generated" / "campaign.yaml" + written = csv_to_config(path, output_path) + assert yaml.safe_load(output_path.read_text(encoding="utf-8")) == written + + +def _design() -> SimpleNamespace: + return SimpleNamespace(names=["x_speed", "x_time"]) + + +def _model_config() -> dict: + return {"objectives": {"names": ["yield", "stability"]}} + + +def test_split_xy_returns_named_numeric_dataframes() -> None: + data = pd.DataFrame( + { + "x_speed": ["1000", "1500"], + "x_time": ["5", "10"], + "yield": ["1.5", "2.5"], + "stability": ["10", "11"], + }, + index=["sample-a", "sample-b"], + ) + + X, Y = split_XY(data, _design(), _model_config()) + + assert isinstance(X, pd.DataFrame) + assert isinstance(Y, pd.DataFrame) + assert X.columns.tolist() == ["x_speed", "x_time"] + assert Y.columns.tolist() == ["yield", "stability"] + assert X.index.tolist() == ["sample-a", "sample-b"] + assert X.dtypes.tolist() == ["float64", "float64"] + assert Y.iloc[0].tolist() == [1.5, 10.0] + + +def test_split_xy_rejects_missing_columns() -> None: + data = pd.DataFrame({"x_speed": [1], "yield": [2], "stability": [3]}) + + with pytest.raises(KeyError, match="missing inputs.*x_time"): + split_XY(data, _design(), _model_config()) + + +@pytest.mark.parametrize( + ("data", "message"), + [ + ( + pd.DataFrame( + { + "x_speed": [1000], + "x_time": [5], + "yield": [pd.NA], + "stability": [pd.NA], + } + ), + "All objective values are blank", + ), + ( + pd.DataFrame( + { + "x_speed": [1000, 1500], + "x_time": [5, 10], + "yield": [1.5, pd.NA], + "stability": [10, pd.NA], + } + ), + "Objective values are blank for rows", + ), + ( + pd.DataFrame( + { + "x_speed": [1000], + "x_time": [5], + "yield": [1.5], + "stability": [pd.NA], + } + ), + "Partially completed objective rows", + ), + ( + pd.DataFrame( + { + "x_speed": ["invalid"], + "x_time": [5], + "yield": [1.5], + "stability": [10], + } + ), + "Input model data contains", + ), + ( + pd.DataFrame( + { + "x_speed": [1000], + "x_time": [5], + "yield": ["invalid"], + "stability": [10], + } + ), + "Objective model data contains", + ), + ], +) +def test_split_xy_rejects_incomplete_or_nonnumeric_model_rows( + data: pd.DataFrame, message: str +) -> None: + with pytest.raises(ValueError, match=message): + split_XY(data, _design(), _model_config()) diff --git a/tests/test_design.py b/tests/test_design.py index 3898803..33d7f2a 100644 --- a/tests/test_design.py +++ b/tests/test_design.py @@ -1,38 +1,114 @@ -import sys -import os -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - import numpy as np -from src.design import ( +import pytest + +from mobo_kit.design import ( + InputSpec, + build_design, + build_design_from_config, + build_input_spec_list, make_linspace, - get_variable_space, - get_parameter_space, - generate_initial_design, ) -from src.utils import get_closest_array -def test_make_linspace(): - arr = make_linspace(0, 1, 0.2) - assert np.allclose(arr, [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]), "Incorrect linspace result" -def test_variable_space_shape(): - var_array = get_variable_space() - assert isinstance(var_array, list) and len(var_array) == 8 - assert all(isinstance(v, np.ndarray) or isinstance(v, list) for v in var_array) +def _valid_inputs(): + return [ + { + "name": "speed", + "unit": "rpm", + "start": 1000, + "stop": 2000, + "step": 250, + }, + { + "name": "time", + "unit": "s", + "start": 5, + "stop": 20, + "step": 5, + }, + ] + + +def test_make_linspace_preserves_requested_step_and_endpoints(): + grid = make_linspace(0.0, 1.0, 0.2) + + assert np.array_equal(grid, np.array([0.0, 0.2, 0.4, 0.6, 0.8, 1.0])) + assert np.allclose(np.diff(grid), 0.2) + + +def test_make_linspace_allows_a_single_fixed_value(): + assert np.array_equal(make_linspace(3.5, 3.5, 0.25), np.array([3.5])) + + +@pytest.mark.parametrize( + ("start", "stop", "step", "message"), + [ + (0.0, 1.0, 0.3, "not aligned"), + (1.0, 0.0, 0.1, "stop must be"), + (0.0, 1.0, 0.0, "step must be > 0"), + (0.0, 1.0, -0.1, "step must be > 0"), + (0.0, np.inf, 0.1, "must be finite"), + ], +) +def test_make_linspace_rejects_ambiguous_grids(start, stop, step, message): + with pytest.raises(ValueError, match=message): + make_linspace(start, stop, step) + + +def test_build_design_from_config_constructs_exact_grids(): + design = build_design_from_config({"inputs": _valid_inputs()}) + + assert design.names == ["speed", "time"] + assert design.units == ["rpm", "s"] + assert np.array_equal( + design.var_list[0], np.array([1000.0, 1250.0, 1500.0, 1750.0, 2000.0]) + ) + assert np.array_equal(design.var_list[1], np.array([5.0, 10.0, 15.0, 20.0])) + assert np.array_equal(design.lowers, np.array([1000.0, 5.0])) + assert np.array_equal(design.uppers, np.array([2000.0, 20.0])) + + +@pytest.mark.parametrize( + ("inputs", "message"), + [ + ([], "non-empty list"), + ([{"name": "", "start": 0, "stop": 1, "step": 1}], "non-empty string"), + ([{"name": "x", "start": 0, "stop": 1}], "missing required"), + ([{"name": "x", "start": "bad", "stop": 1, "step": 1}], "finite number"), + ([{"name": "x", "start": 0, "stop": np.nan, "step": 1}], "must be finite"), + ([{"name": "x", "start": 1, "stop": 0, "step": 1}], "stop >= start"), + ([{"name": "x", "start": 0, "stop": 1, "step": -1}], "step.*> 0"), + ([{"name": "x", "start": 0, "stop": 1, "step": 0.3}], "not aligned"), + ( + [ + {"name": " x ", "start": 0, "stop": 1, "step": 1}, + {"name": "x", "start": 0, "stop": 1, "step": 1}, + ], + "duplicate name", + ), + ], +) +def test_input_schema_validation_is_explicit(inputs, message): + with pytest.raises(ValueError, match=message): + build_input_spec_list(inputs) + + +def test_rounding_that_collapses_grid_points_is_rejected(): + with pytest.raises(ValueError, match="duplicate values"): + InputSpec(name="x", start=0.0, stop=0.02, step=0.005, decimals=2) + -def test_parameter_space_format(): - space = get_parameter_space() - assert len(space.parameters) == 8 - names = [p.name for p in space.parameters] - assert "temp" in names and "humidity" in names +def test_build_design_revalidates_mutated_specs_and_unique_names(): + first = InputSpec("x", 0, 1, 1) + second = InputSpec("y", 0, 1, 1) + second.name = "x" -def test_design_sampling_and_snapping(): - raw_samples = generate_initial_design(n_samples=5) - assert raw_samples.shape == (5, 8), "Design sample shape incorrect" + with pytest.raises(ValueError, match="duplicate name"): + build_design([first, second]) - var_array = get_variable_space() - snapped = get_closest_array(raw_samples, var_array) - assert snapped.shape == (5, 8) - for i in range(8): - assert np.all(np.isin(snapped[:, i], var_array[i])), f"Column {i} has invalid snapped values" \ No newline at end of file +def test_build_design_requires_input_specs(): + with pytest.raises(ValueError, match="At least one"): + build_design([]) + with pytest.raises(TypeError, match="InputSpec"): + build_design([{"name": "x"}]) diff --git a/tests/test_discrete_refinement.py b/tests/test_discrete_refinement.py new file mode 100644 index 0000000..81a1aa7 --- /dev/null +++ b/tests/test_discrete_refinement.py @@ -0,0 +1,258 @@ +import numpy as np + +from mobo_kit.candidate_pool import CandidatePool +from mobo_kit.design import InputSpec, build_design +from mobo_kit.discrete_refinement import ( + CachedGridScorer, + RefinementConfig, + grid_indices_to_physical_and_normalized, + propose_refined_discrete_batch, + refine_discrete_acquisition_anchors, +) + + +def _design(): + return build_design( + [ + InputSpec("x", 0, 4, 1), + InputSpec("y", 0, 3, 1), + ] + ) + + +def _pool(design) -> CandidatePool: + indices = np.asarray([[x, y] for x in range(5) for y in range(4)], dtype=np.int64) + physical, normalized = grid_indices_to_physical_and_normalized(indices, design) + return CandidatePool( + grid_indices=indices, + X_phys=physical, + X_norm=normalized, + seed=73, + draws=len(indices), + rejected_duplicate=0, + rejected_avoid=0, + rejected_constraint=0, + ) + + +def test_refinement_reaches_coordinate_local_optimum_and_is_monotone() -> None: + design = _design() + + def score(rows: np.ndarray) -> np.ndarray: + return 100.0 - (rows[:, 0] - 3) ** 2 - 2.0 * (rows[:, 1] - 2) ** 2 + + refined, trace = refine_discrete_acquisition_anchors( + design, + np.asarray([[0, 0], [4, 3]], dtype=np.int64), + score, + config=RefinementConfig( + anchors_per_selection_step=2, + max_sweeps=10, + improvement_tolerance=1e-12, + radius=None, + min_batch_distance=0.0, + ), + selection_step=1, + ) + + assert {item.refined_grid_index for item in refined} == {(3, 2)} + assert all(item.termination_reason == "no_improvement" for item in refined) + assert all(row.score_after >= row.score_before for row in trace) + assert any(row.accepted_move for row in trace) + assert all(np.isfinite(row.base_score_before) for row in trace) + assert all(np.isfinite(row.base_score_after) for row in trace) + assert all(np.isfinite(row.penalized_log_score_before) for row in trace) + assert all(np.isfinite(row.penalized_log_score_after) for row in trace) + assert all(row.termination_reason in {None, "no_improvement"} for row in trace) + + +def test_refinement_tie_break_and_trace_are_deterministic() -> None: + design = _design() + config = RefinementConfig( + anchors_per_selection_step=1, + max_sweeps=3, + radius=None, + min_batch_distance=0.0, + ) + + def flat(rows: np.ndarray) -> np.ndarray: + return np.ones(rows.shape[0]) + + first = refine_discrete_acquisition_anchors( + design, + np.asarray([[2, 2]], dtype=np.int64), + flat, + config=config, + selection_step=1, + ) + second = refine_discrete_acquisition_anchors( + design, + np.asarray([[2, 2]], dtype=np.int64), + flat, + config=config, + selection_step=1, + ) + + assert first == second + assert first[0][0].refined_grid_index == (2, 2) + assert first[0][0].termination_reason == "no_improvement" + + +def test_grid_normalization_matches_physical_bound_canonicalization_bitwise() -> None: + design = build_design([InputSpec("decimal_axis", 1.0, 2.0, 0.05, decimals=2)]) + indices = np.arange(design.var_array[0].size, dtype=np.int64)[:, None] + + physical, normalized = grid_indices_to_physical_and_normalized(indices, design) + expected = (physical - design.lowers) / (design.uppers - design.lowers) + index_fraction = indices / float(design.var_array[0].size - 1) + + np.testing.assert_array_equal(normalized, expected) + assert np.any(normalized != index_fraction) + + +def test_refined_batch_enforces_observed_exclusion_and_hard_distance() -> None: + design = _design() + pool = _pool(design) + observed_grid = np.asarray([[4, 3]], dtype=np.int64) + _, observed_norm = grid_indices_to_physical_and_normalized(observed_grid, design) + + def score(rows: np.ndarray) -> np.ndarray: + # The forbidden observed point is the nominal maximum. + return 100.0 + 10.0 * rows[:, 0] + rows[:, 1] + + result = propose_refined_discrete_batch( + pool, + design, + score, + q=2, + config=RefinementConfig( + anchors_per_selection_step=6, + max_sweeps=5, + radius=None, + min_batch_distance=0.75, + ), + observed_grid_indices=observed_grid, + observed_norm=observed_norm, + ) + + assert result.grid_indices.shape == (2, 2) + assert not np.any(np.all(result.grid_indices == observed_grid[0], axis=1)) + assert np.unique(result.grid_indices, axis=0).shape[0] == 2 + assert np.linalg.norm(result.X_norm[0] - result.X_norm[1]) >= 0.75 + assert all(value > 0 for value in result.base_scores) + assert result.distinct_converged_optima >= 2 + + +def test_refined_batch_forwards_pending_rows_through_avoid_grid_indices() -> None: + design = _design() + pool = _pool(design) + pending_grid = np.asarray([[4, 3]], dtype=np.int64) + + def score(rows: np.ndarray) -> np.ndarray: + return 100.0 + 10.0 * rows[:, 0] + rows[:, 1] + + result = propose_refined_discrete_batch( + pool, + design, + score, + q=1, + config=RefinementConfig( + anchors_per_selection_step=6, + max_sweeps=5, + radius=None, + min_batch_distance=0.0, + ), + avoid_grid_indices=pending_grid, + ) + + assert result.grid_indices.shape == (1, 2) + assert not np.array_equal(result.grid_indices[0], pending_grid[0]) + + +def test_refinement_reports_max_sweeps_without_off_grid_moves() -> None: + design = _design() + + def score(rows: np.ndarray) -> np.ndarray: + return 10.0 + rows[:, 0] + rows[:, 0] * rows[:, 1] + + refined, trace = refine_discrete_acquisition_anchors( + design, + np.asarray([[0, 0]], dtype=np.int64), + score, + config=RefinementConfig( + anchors_per_selection_step=1, + max_sweeps=1, + radius=None, + min_batch_distance=0.0, + ), + selection_step=1, + ) + + assert refined[0].termination_reason == "max_sweeps" + assert trace[-1].termination_reason == "max_sweeps" + assert np.all(np.asarray(refined[0].refined_grid_index) >= 0) + assert refined[0].refined_grid_index[0] < 5 + assert refined[0].refined_grid_index[1] < 4 + + +def test_cached_grid_scorer_deduplicates_vectorized_requests() -> None: + calls: list[np.ndarray] = [] + + def score(rows: np.ndarray) -> np.ndarray: + calls.append(rows.copy()) + return rows.sum(axis=1).astype(float) + + cached = CachedGridScorer(score, dimension=2) + requested = np.asarray([[1, 2], [1, 2], [2, 3]], dtype=np.int64) + np.testing.assert_array_equal(cached(requested), [3.0, 3.0, 5.0]) + np.testing.assert_array_equal(cached(requested[::-1]), [5.0, 3.0, 3.0]) + + assert cached.cache_size == 2 + assert len(calls) == 1 + assert calls[0].shape == (2, 2) + + +def test_later_steps_readd_eligible_previously_refined_optima() -> None: + design = build_design([InputSpec("x", 0, 2, 1), InputSpec("y", 0, 2, 1)]) + pool_indices = np.asarray([[1, 0], [1, 2]], dtype=np.int64) + physical, normalized = grid_indices_to_physical_and_normalized(pool_indices, design) + pool = CandidatePool( + grid_indices=pool_indices, + X_phys=physical, + X_norm=normalized, + seed=73, + draws=2, + rejected_duplicate=0, + rejected_avoid=0, + rejected_constraint=0, + ) + table = np.ones((3, 3), dtype=float) + table[0, 0] = 9.0 + table[1, 0] = 5.0 + table[1, 2] = 8.0 + table[2, 2] = 10.0 + + def score(rows: np.ndarray) -> np.ndarray: + return table[rows[:, 0], rows[:, 1]] + + result = propose_refined_discrete_batch( + pool, + design, + score, + q=2, + config=RefinementConfig( + anchors_per_selection_step=2, + max_sweeps=5, + radius=None, + min_batch_distance=0.3, + ), + ) + + assert any( + anchor.selection_step == 2 and anchor.anchor_pool_index is None + for anchor in result.anchors + ) + assert all(anchor.accepted_move_count >= 0 for anchor in result.anchors) + assert all( + isinstance(anchor.changed_dimensions, tuple) for anchor in result.anchors + ) diff --git a/tests/test_dtlz2_acceptance.py b/tests/test_dtlz2_acceptance.py new file mode 100644 index 0000000..6bf90e0 --- /dev/null +++ b/tests/test_dtlz2_acceptance.py @@ -0,0 +1,457 @@ +"""End-to-end acceptance test on a synthetic problem with a known Pareto front. + +DTLZ2 with 3 objectives and 10 inputs, run through the real campaign path: +``run_r0_lhs`` -> ``run_r1_ucb(5)`` -> ``run_r2_qlognehvi(3)``. Nothing here +touches the experimental data, so it answers "does the algorithm work" separately +from "are the measurements right". + +Two conventions had to be got right, and both fail silently if you don't. + +**DTLZ2 minimises by default.** ``negate=True`` is mandatory. Without it the +objectives are positive, no point dominates the reference, and the test would +measure the opposite of optimisation. + +**BoTorch's Hypervolume assumes maximisation and silently drops points that do +not dominate the reference** -- there is no warning and no exception, you simply +get a smaller number, or 0.0. So :func:`_hypervolume` asserts that at least one +point dominates before trusting the result. + +The optimisation claim is deliberately weak, because the honest one is: +cumulative hypervolume rises monotonically *by construction* (adding points can +only grow the dominated region), so "HV increased" would pass for random +sampling too. The meaningful comparison is against a random baseline at the same +budget, and BO wins **on average, not on every seed** -- measured 5/8 seeds with +a mean gain ratio of 1.35x. Asserting a per-seed win would be a flaky test +asserting something untrue. +""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pytest +import torch +from botorch.test_functions.multi_objective import DTLZ2 +from botorch.utils.multi_objective.hypervolume import Hypervolume +from botorch.utils.multi_objective.pareto import is_non_dominated + +from mobo_kit.campaign import ( + build_objective_transform, + run_r0_lhs, + run_r1_ucb, + run_r2_qlognehvi, +) +from mobo_kit.design import build_design_from_config +from mobo_kit.ucb_hvi import pareto_utility_above_reference + +INPUT_DIM = 10 +OBJECTIVES = 3 +R0_SIZE = 15 +R1_SIZE = 5 +R2_SIZE = 3 + + +def _problem() -> DTLZ2: + # negate=True -> maximisation, which is what the campaign and BoTorch's + # hypervolume both assume + return DTLZ2(dim=INPUT_DIM, num_objectives=OBJECTIVES, negate=True).to( + dtype=torch.double + ) + + +def _config(pool: int = 1024, mc_samples: int = 32) -> dict: + """A campaign config for DTLZ2. Pool sizes are shrunk for test runtime. + + The production config uses 32768, which costs ~124 s for one R0->R1->R2 pass. + R2 is the bottleneck and ``mc_samples`` is the cheapest lever, so that is what + is reduced most. + """ + return { + "inputs": [ + {"name": f"x{i}", "start": 0.0, "stop": 1.0, "step": 0.05} + for i in range(INPUT_DIM) + ], + "objectives": { + "contract_version": "TEST_ONLY-dtlz2-v1", + "scaling_mode": "fixed_affine", + "specs": [ + { + "name": f"f{i}", + "goal": "maximize", + "transform": "affine", + "model_source_column": f"f{i}", + # negated DTLZ2 lands in roughly [-1.9, 0] + "lower_anchor": -2.0, + "upper_anchor": 0.0, + } + for i in range(OBJECTIVES) + ], + }, + "reference_point_utility": [-0.01] * OBJECTIVES, + "rounds": { + "r1": { + "method": "ucb_hvi", + "batch_size": R1_SIZE, + "replicates_per_condition": 3, + "beta": 4.0, + "candidate_pool_size": pool, + "posterior_samples": 256, + "moment_method": "monte_carlo", + }, + "r2": { + "method": "qlognehvi", + "batch_size": R2_SIZE, + "replicates_per_condition": 3, + "candidate_pool_size": pool, + "mc_samples": mc_samples, + }, + }, + "local_penalization": { + "radius": 0.25, + "min_batch_distance": 0.15, + "min_observed_distance": 0.0, + "dimension_weights": None, + }, + "model": {"variant": "dim_scaled_prior"}, + "reproducibility": {"seed": 73}, + "constraints": [], + } + + +def _evaluate(problem: DTLZ2, X_phys: np.ndarray) -> np.ndarray: + """DTLZ2 lives on [0,1]^10, which is exactly the declared design domain.""" + return problem(torch.tensor(np.asarray(X_phys, float), dtype=torch.double)).numpy() + + +def _hypervolume(config: dict, Y_raw: np.ndarray) -> float: + """Hypervolume in UTILITY space against the fixed campaign reference. + + Computing it in utility space rather than raw space is what makes values + comparable across rounds: the campaign's scales are fixed, so the reference + does not drift as data arrives. + """ + transform = build_objective_transform(config) + reference = torch.tensor(config["reference_point_utility"], dtype=torch.double) + # transform_measurements, not transform: Y_raw holds MEASUREMENT-space values, + # and transform decodes the link itself. The two are the same call while every + # objective is affine, which is exactly why this file could not see the R1 + # baseline bug -- see test_measurement_space_encoding.py. + utility = transform.transform_measurements( + torch.tensor(np.asarray(Y_raw, float), dtype=torch.double) + ) + if not bool((utility >= reference).all(dim=-1).any()): + raise AssertionError( + "No point dominates the reference point. BoTorch would silently drop " + "every point and return 0.0 rather than raising, so this is checked " + "explicitly." + ) + return Hypervolume(ref_point=reference).compute(utility[is_non_dominated(utility)]) + + +def _run_campaign(config: dict, seed: int, evaluate=None) -> dict: + """One full R0 -> R1 -> R2 pass, evaluating DTLZ2 at each proposed batch.""" + problem = _problem() + evaluate = _evaluate if evaluate is None else evaluate + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r0 = run_r0_lhs(config, n=R0_SIZE, seed=seed) + X0 = r0.conditions.to_numpy(float) + Y0 = evaluate(problem, X0) + + r1 = run_r1_ucb(config, X0, Y0, seed=seed) + X1 = r1.conditions.to_numpy(float) + Y1 = evaluate(problem, X1) + + X01, Y01 = np.vstack([X0, X1]), np.vstack([Y0, Y1]) + r2 = run_r2_qlognehvi(config, X01, Y01, seed=seed) + X2 = r2.conditions.to_numpy(float) + Y2 = evaluate(problem, X2) + + return { + "r0": r0, + "r1": r1, + "r2": r2, + "hv": [ + _hypervolume(config, Y0), + _hypervolume(config, Y01), + _hypervolume(config, np.vstack([Y01, Y2])), + ], + "X": [X0, X1, X2], + } + + +@pytest.fixture(scope="module") +def campaign() -> dict: + return _run_campaign(_config(), seed=73) + + +# --------------------------------------------------------------------------- # +# the algorithm produces well-formed batches +# --------------------------------------------------------------------------- # + + +def test_each_round_proposes_exactly_the_requested_count(campaign: dict) -> None: + assert len(campaign["r0"].conditions) == R0_SIZE + assert len(campaign["r1"].conditions) == R1_SIZE + assert len(campaign["r2"].conditions) == R2_SIZE + # 23 distinct conditions, 3 films each + total = R0_SIZE + R1_SIZE + R2_SIZE + assert total == 23 + assert len(campaign["r2"].replicates) == R2_SIZE * 3 + + +@pytest.mark.parametrize("round_key", ["r0", "r1", "r2"]) +def test_every_batch_is_unique_on_grid_and_in_bounds( + campaign: dict, round_key: str +) -> None: + report = campaign[round_key].diagnostics["validity"] + assert report["unique"] + assert report["on_grid"] + assert report["in_bounds"] + assert report["finite"] + + +@pytest.mark.parametrize("round_key", ["r1", "r2"]) +def test_local_penalization_spreads_the_batch(campaign: dict, round_key: str) -> None: + """Without penalization a batch collapses onto the single best pool point. + + The floor is the configured 0.15; the measured values are far above it, which + is the signal that penalization is doing work rather than merely not failing. + """ + minimum = campaign[round_key].diagnostics["validity"]["min_pairwise_distance"] + assert minimum >= 0.15 + assert minimum > 0.5, f"batch is unexpectedly clustered: {minimum:.4f}" + + +def test_proposed_points_are_distinct_from_the_observed_set(campaign: dict) -> None: + """Re-proposing an already-measured recipe would waste a film.""" + X0, X1, X2 = campaign["X"] + seen = {tuple(np.round(row, 9)) for row in X0} + for row in np.vstack([X1, X2]): + assert tuple(np.round(row, 9)) not in seen + + +# --------------------------------------------------------------------------- # +# the algorithm optimises +# --------------------------------------------------------------------------- # + + +def test_cumulative_hypervolume_never_decreases(campaign: dict) -> None: + """True by construction -- adding points cannot shrink the dominated region. + + It is asserted anyway because a violation would mean something is broken in + the transform, the reference point, or the sign convention. It is NOT + evidence of optimisation; see the random-baseline test for that. + """ + hv0, hv1, hv2 = campaign["hv"] + assert hv0 <= hv1 <= hv2 + assert hv0 > 0.0 + + +def test_hypervolume_actually_improves_over_the_initial_design( + campaign: dict, +) -> None: + hv0, _, hv2 = campaign["hv"] + assert hv2 > hv0 + # DTLZ2's optimum against its own reference is 0.807; we are in the right + # order of magnitude rather than chasing a specific value + assert 0.0 < hv2 < 1.0 + + +def test_the_batches_are_deterministic_for_a_fixed_seed(campaign: dict) -> None: + """A reproducible campaign is a precondition for auditing one.""" + repeat = _run_campaign(_config(), seed=73) + np.testing.assert_allclose( + repeat["r1"].conditions.to_numpy(float), campaign["X"][1] + ) + np.testing.assert_allclose( + repeat["r2"].conditions.to_numpy(float), campaign["X"][2] + ) + + +# --------------------------------------------------------------------------- # +# every link type the campaign uses, exercised end to end +# --------------------------------------------------------------------------- # +# +# Added 2026-07-31, after `run_r1_ucb` was found to have been handing the objective +# transform measurement-space values for the life of the campaign. This file could +# not have caught it: every objective above is affine, and for an affine objective +# measurement space and model space are the same numbers, so a link-encoding +# mistake is invisible BY CONSTRUCTION. +# +# The live campaign has a log-link objective (thickness trains on log(nm)), so the +# synthetic acceptance test must have one too, or "the loop passes end to end" +# keeps meaning "the loop passes end to end for half of the link types in use". + + +def _config_with_log_link(pool: int = 1024, mc_samples: int = 32) -> dict: + """The same DTLZ2 problem with its third objective reached through a log link. + + ``f2`` is reported as ``exp(f2)`` -- a strictly positive measurement -- and the + objective declares ``response: log``. The GP therefore trains on + ``log(exp(f2)) = f2``: the SAME latent quantity the affine config models, + reached by a different route. Any mis-encoding shows up as a difference in + something that ought to be identical. + """ + config = _config(pool=pool, mc_samples=mc_samples) + config["objectives"]["contract_version"] = "TEST_ONLY-dtlz2-loglink-v1" + config["objectives"]["specs"][2] = { + "name": "f2", + "goal": "maximize", + "transform": "affine", + "model_source_column": "f2", + # negated DTLZ2 lands in roughly [-1.9, 0], so exp() lands in [0.15, 1] + "lower_anchor": float(np.exp(-2.0)), + "upper_anchor": 1.0, + "mean_function": { + "response": "log", + "features": [{"column": "x0", "transform": "identity"}], + }, + } + return config + + +def _evaluate_log_linked(problem: DTLZ2, X_phys: np.ndarray) -> np.ndarray: + Y = _evaluate(problem, X_phys) + return np.column_stack([Y[:, 0], Y[:, 1], np.exp(Y[:, 2])]) + + +@pytest.fixture(scope="module") +def log_linked_campaign() -> dict: + return _run_campaign( + _config_with_log_link(), seed=73, evaluate=_evaluate_log_linked + ) + + +def test_the_log_link_config_really_is_log_linked() -> None: + """Guards the guard: if this reverts to identity the tests below go quiet.""" + specs = build_objective_transform(_config_with_log_link()).specs + assert [spec.model_link for spec in specs] == ["identity", "identity", "log"] + # and the plain config remains the affine-only case, so both are covered + assert [s.model_link for s in build_objective_transform(_config()).specs] == [ + "identity" + ] * OBJECTIVES + + +def test_a_log_linked_campaign_runs_end_to_end(log_linked_campaign: dict) -> None: + assert len(log_linked_campaign["r0"].conditions) == R0_SIZE + assert len(log_linked_campaign["r1"].conditions) == R1_SIZE + assert len(log_linked_campaign["r2"].conditions) == R2_SIZE + for key in ("r0", "r1", "r2"): + report = log_linked_campaign[key].diagnostics["validity"] + assert report["unique"] and report["on_grid"] and report["in_bounds"] + hv0, hv1, hv2 = log_linked_campaign["hv"] + assert 0.0 < hv0 <= hv1 <= hv2 + + +def test_no_observed_utility_collapses_to_zero_under_a_log_link( + log_linked_campaign: dict, +) -> None: + """The invariant the R1 baseline bug violated. + + Under the mis-encoding every observation scored exactly 0.0 on the log-linked + axis -- a finite, unremarkable number that no check rejected. A measured point + with a finite value inside its anchors has non-zero utility; a hard zero means + an encoding step was skipped. + + Only points whose raw value lies strictly INSIDE the objective's anchors are + checked. An affine objective legitimately clips to 0.0 when a measurement falls + at or below its lower anchor, and DTLZ2 does produce such points; asserting on + those would be asserting that clipping is a bug. + """ + config = _config_with_log_link() + transform = build_objective_transform(config) + spec = transform.specs[2] + problem = _problem() + checked = 0 + for X in log_linked_campaign["X"]: + Y = _evaluate_log_linked(problem, X) + utility = transform.transform_measurements( + torch.tensor(Y, dtype=torch.double) + ).numpy() + assert np.isfinite(utility).all() + raw = Y[:, 2] + inside = (raw > spec.lower_anchor) & (raw < spec.upper_anchor) + assert not np.any(utility[inside, 2] == 0.0), ( + "a finite measurement strictly inside its anchors scored exactly zero" + ) + checked += int(inside.sum()) + # the assertion above is vacuous if nothing was inside the anchors + assert checked >= 15, f"only {checked} points were in range; test has no teeth" + + +def test_the_r1_baseline_is_right_when_a_link_has_to_be_decoded( + log_linked_campaign: dict, +) -> None: + """End-to-end version of the comparator that did not exist. + + ``run_r1_ucb`` reports the baseline hypervolume it actually used; this + recomputes it by an independent route. On the pre-fix code the reported value + is the collapsed one and this fails. + """ + config = _config_with_log_link() + transform = build_objective_transform(config) + reference = np.asarray(config["reference_point_utility"], dtype=float) + + X0 = log_linked_campaign["r0"].conditions.to_numpy(float) + Y0 = _evaluate_log_linked(_problem(), X0) + utility = transform.transform_measurements( + torch.tensor(Y0, dtype=torch.double) + ).numpy() + pareto = pareto_utility_above_reference(utility, reference) + expected = float( + Hypervolume(ref_point=torch.tensor(reference, dtype=torch.double)).compute( + torch.tensor(pareto, dtype=torch.double) + ) + ) + reported = log_linked_campaign["r1"].diagnostics["observed_baseline_hypervolume"] + assert reported == pytest.approx(expected, rel=1e-9) + assert log_linked_campaign["r1"].diagnostics[ + "observed_baseline_pareto_size" + ] == len(pareto) + + +@pytest.mark.slow +def test_bayesian_optimisation_beats_random_search_on_average() -> None: + """The test that makes the hypervolume numbers mean something. + + Cumulative HV rises for random sampling too, so the only informative + comparison is against a random baseline at the same budget (8 extra points + from the same 15-point start). + + BO wins on the MEAN, not on every seed: measured 5 of 8 seeds with a mean + gain ratio of 1.35x. With 8 added points in 10 dimensions that is the + honest expectation, and asserting a per-seed win would be a flaky test + asserting something false. + """ + config = _config() + design = build_design_from_config(config) + problem = _problem() + grids = [np.asarray(g, float) for g in design.var_array] + + bo_gains, random_gains, wins = [], [], 0 + for seed in (1, 2, 3, 4, 5): + result = _run_campaign(config, seed=seed) + hv0, _, hv_bo = result["hv"] + + rng = np.random.default_rng(seed) + X_random = np.column_stack( + [rng.choice(g, size=R1_SIZE + R2_SIZE) for g in grids] + ) + Y_random = np.vstack( + [_evaluate(problem, result["X"][0]), _evaluate(problem, X_random)] + ) + hv_random = _hypervolume(config, Y_random) + + bo_gains.append(hv_bo - hv0) + random_gains.append(hv_random - hv0) + wins += (hv_bo - hv0) > (hv_random - hv0) + + mean_bo, mean_random = float(np.mean(bo_gains)), float(np.mean(random_gains)) + assert mean_bo > 0, "BO made no hypervolume progress at all" + assert mean_random > 0, "the random baseline is broken, not a fair comparison" + assert mean_bo > mean_random, ( + f"BO mean gain {mean_bo:.4f} did not beat random {mean_random:.4f}; " + f"won {wins}/5 seeds" + ) diff --git a/tests/test_final_campaign.py b/tests/test_final_campaign.py new file mode 100644 index 0000000..0265029 --- /dev/null +++ b/tests/test_final_campaign.py @@ -0,0 +1,426 @@ +"""The v4 contract: frozen scores, a per-round source sheet, and what freezing costs. + +Three objective contracts have existed and this is the live one. What is new here +is a deliberate reversal of this project's usual polarity: uniformity and +optoelectronic are READ from the workbook rather than recomputed, because the +group is still revising how they are defined. + +That reversal removes a cross-check, and the tests below are mostly about the +consequences of removing it: + +* a frozen score must still be *validated* -- numeric, present, inside its + declared anchors -- because nothing else looks at it; +* a frozen score's DEFINITION must be watched, since its value cannot be; +* and the one thing freezing cannot see -- a stale literal that has stopped + tracking its inputs -- is asserted to be exactly what the fingerprint does + **not** catch, so nobody later mistakes the fingerprint for a value check. + +The synthetic workbook here uses the v4 layout, so none of it needs the ignored +private one. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest +from openpyxl import Workbook + +from mobo_kit.campaign import load_campaign_config, objective_names +from mobo_kit.scores import ( + FormulaFingerprint, + MeasurementInput, + MeasurementSpec, + ScoreSeverity, + compute_measurements, + entry_columns, + measurement_spec_from_config, +) +from mobo_kit.workbook_io import ( + CandidateSheetError, + formula_findings, + read_campaign_workbook, + source_sheet, +) + +CONFIG_PATH = "configs/campaign_d2d_perovskite_final.yaml" +V3_CONFIG_PATH = "configs/campaign_d2d_perovskite_test.yaml" +SOURCE = "local_inputs/Final Summary Table.xlsx" + +UNIFORMITY_COLUMN = "Uniformity score (Avg (Coverage + (1-Uniformity) + Phase purity))" +OPTO_COLUMN = ( + "Optoelectronic score (Normalized (Voc + (0.75*Photoconductance + " + "0.25*Photosensitivity))/2" +) + + +@pytest.fixture(scope="module") +def config() -> dict: + return load_campaign_config(CONFIG_PATH) + + +# --------------------------------------------------------------------------- # +# the contract +# --------------------------------------------------------------------------- # + + +def test_three_contracts_exist_and_only_one_is_active(config) -> None: + """Naming them is not bookkeeping. An objective that keeps its name while + changing its construction makes every cross-contract number incomparable + while every plot still renders. + + The live contract became ``-nomean`` on 2026-09-06, when the thickness mean + function was withdrawn. That is a MODEL change rather than an objective + redefinition -- the three quantities are unchanged -- but it moves every + fitted number on the learnable axis (+0.7423 to +0.5814) and therefore every + hypervolume, so it earns a version. This assertion failing is this test doing + its job; update it deliberately, never to make a run go green. + """ + v3 = load_campaign_config(V3_CONFIG_PATH) + archived = load_campaign_config("configs/campaign_d2d_perovskite.yaml") + + assert config["objectives"]["contract_version"] == "d2d-objectives-v4-final-nomean" + assert v3["objectives"]["contract_version"] == "d2d-objectives-v3-test" + assert archived["objectives"]["contract_version"] == "d2d-objectives-v2-nm-thickness" + + assert config["campaign"]["status"] == "active" + assert v3["campaign"]["status"] == "archived" + assert archived["campaign"]["status"] == "archived" + + +def test_no_objective_declares_a_mean_function(config) -> None: + """The live campaign carries NO physics prior, by decision on 2026-09-06. + + The thickness prior ``log T ~ log(speed_1) + log(precur_conc)`` was withdrawn + after its justification failed: the fitted speed exponent's 95% interval is + [-0.385, -0.126], which excludes spin-coating theory's -0.5 by 4.1 standard + errors, and fixing the exponents at their theoretical values scores +0.5600 + against +0.5823 for no trend at all. + + ``structured_mean`` stays wired and tested for a prior that clears the bar -- + established physics, declared before fitting, beating matched-flexibility + controls, and surviving a permutation test. Nothing currently does. If this + test fails, someone has added one; make them show the four pieces of evidence + before updating it. + """ + from mobo_kit.structured_mean import mean_spec_from_config + + declared = { + entry["name"]: mean_spec_from_config(entry) + for entry in config["objectives"]["specs"] + } + assert declared == {name: None for name in declared}, ( + f"a mean function reappeared: " + f"{ {k: v for k, v in declared.items() if v is not None} }" + ) + + +def test_the_launcher_defaults_to_the_live_contract() -> None: + """The one path an experimentalist reaches by double-clicking. Archiving a + config without moving this is how a user once got a missing-column error on + an intact workbook.""" + from mobo_kit.launcher import DEFAULT_CONFIG + + assert DEFAULT_CONFIG == CONFIG_PATH + assert load_campaign_config(DEFAULT_CONFIG)["campaign"]["status"] == "active" + + +def test_the_source_sheet_is_configuration_not_a_constant(config) -> None: + """The v4 workbook names its sheets by round, so `Sheet1` stopped being true. + Older contracts must keep working without declaring the key.""" + assert source_sheet(config) == "R0" + assert source_sheet(load_campaign_config(V3_CONFIG_PATH)) == "Sheet1" + assert source_sheet({}) == "Sheet1" + + +def test_both_score_objectives_are_frozen_and_thickness_is_not(config) -> None: + """Thickness stays computed: its definition has been stable across all three + contracts, and the recomputation is what lets `T anom` be excluded and + reported rather than silently dropped.""" + recipes = { + spec["name"]: spec["measurement"]["recipe"] + for spec in config["objectives"]["specs"] + } + assert recipes == { + "uniformity": "stored", + "optoelectronic": "stored", + "thickness": "mean_of_present", + } + + +# --------------------------------------------------------------------------- # +# the stored recipe +# --------------------------------------------------------------------------- # + + +def _stored_spec(**kwargs) -> MeasurementSpec: + return MeasurementSpec( + name="uniformity", + recipe="stored", + inputs=(MeasurementInput("Uniformity score"),), + **kwargs, + ) + + +def test_a_stored_score_is_taken_exactly_as_the_workbook_computed_it() -> None: + frame = pd.DataFrame({"Uniformity score": [0.877272, 0.599033]}) + result = compute_measurements(frame, [_stored_spec()], sample_ids=[1, 4]) + assert result.values["uniformity"].tolist() == [0.877272, 0.599033] + assert not result.has_errors + + +def test_a_blank_frozen_score_is_an_error_not_a_gap() -> None: + """`mean_of_present` tolerates a missing reading because a film can carry + three instead of four. A missing SCORE is different: nothing can recompute it + under this contract, so the row simply has no objective value.""" + frame = pd.DataFrame({"Uniformity score": [0.87, None]}) + result = compute_measurements(frame, [_stored_spec()], sample_ids=[1, 2]) + codes = [f.code for f in result.findings if f.severity is ScoreSeverity.ERROR] + assert "input_missing" in codes + assert result.values["uniformity"].tolist()[0] == 0.87 + assert np.isnan(result.values["uniformity"].tolist()[1]) + + +def test_a_formula_cell_with_no_cached_value_reads_as_blank_and_errors() -> None: + """openpyxl discards cached formula values on save, so a workbook written by + a non-Excel tool hands back None for every formula column. Under a freeze that + is every objective at once, and it must stop the round rather than train on + nothing.""" + frame = pd.DataFrame({"Uniformity score": [None, None, None]}) + result = compute_measurements(frame, [_stored_spec()], sample_ids=[1, 2, 3]) + assert result.has_errors + assert all(np.isnan(v) for v in result.values["uniformity"]) + + +def test_a_non_numeric_frozen_score_is_an_error() -> None: + frame = pd.DataFrame({"Uniformity score": ["n/a", "not a number"]}) + result = compute_measurements(frame, [_stored_spec()], sample_ids=[1, 2]) + codes = [f.code for f in result.findings if f.severity is ScoreSeverity.ERROR] + assert codes, "a score column full of text must not pass silently" + + +def test_stored_takes_exactly_one_column() -> None: + """Two columns would mean something is being combined, which is precisely what + a freeze exists to avoid.""" + with pytest.raises(ValueError, match="one score column"): + MeasurementSpec( + name="uniformity", + recipe="stored", + inputs=(MeasurementInput("a"), MeasurementInput("b")), + ) + + +def test_the_v3_recipes_survive_unwired_for_when_the_group_unfreezes() -> None: + """`mean`, `clamped_complement` and `capped_ratio` are not deleted. The freeze + is temporary by the group's own description, and deleting the code would mean + rebuilding it from a doc rather than un-commenting it.""" + from mobo_kit.scores import RECIPES + + assert {"stored", "mean", "mean_of_present", "product", "log10_product"} <= set(RECIPES) + spec = measurement_spec_from_config( + { + "name": "uniformity", + "measurement": { + "recipe": "mean", + "inputs": [ + {"column": "Coverage"}, + { + "column": "Uniformity", + "transform": "clamped_complement", + "clamp_above": 1.0, + "clamp_to": 0.99, + }, + {"column": "Phase purity"}, + ], + }, + } + ) + frame = pd.DataFrame( + {"Coverage": [0.989], "Uniformity": [0.324584], "Phase purity": [0.9674]} + ) + result = compute_measurements(frame, [spec], sample_ids=[1]) + # the v4 workbook's own AJ for sample 1 + assert result.values["uniformity"][0] == pytest.approx(0.877272, abs=1e-6) + + +# --------------------------------------------------------------------------- # +# the fingerprint: what replaces the cross-check, and what it cannot replace +# --------------------------------------------------------------------------- # + + +def _workbook_with(tmp_path, formula, *, column=UNIFORMITY_COLUMN, rows=3): + path = tmp_path / "Fingerprint.xlsx" + book = Workbook() + sheet = book.active + sheet.title = "R0" + sheet.append(["Sample number", "Coverage", column]) + for i in range(rows): + value = formula.replace("2", str(i + 2)) if formula else 0.5 + sheet.append([i + 1, 0.9, value]) + book.save(path) + return path + + +def _fingerprint_config(formula="=(L2+O2+P2)/3", column=UNIFORMITY_COLUMN) -> dict: + return { + "campaign": {"source_sheet": "R0"}, + "inputs": [{"name": "x", "start": 0, "stop": 1, "step": 1}], + "objectives": { + "contract_version": "synthetic", + "specs": [ + { + "name": "uniformity", + "model_source_column": column, + "transform": "affine", + "goal": "maximize", + "lower_anchor": 0.0, + "upper_anchor": 1.0, + "measurement": { + "recipe": "stored", + "inputs": [{"column": column}], + "formula_fingerprint": {"column": column, "formula": formula}, + }, + } + ], + }, + } + + +def test_an_unchanged_definition_is_a_note(tmp_path) -> None: + path = _workbook_with(tmp_path, "=(L2+O2+P2)/3") + (finding,) = formula_findings(path, _fingerprint_config()) + assert finding.code == "formula_fingerprint_unchanged" + assert finding.severity is ScoreSeverity.NOTE + + +def test_a_changed_definition_is_a_warning_that_names_both_formulas(tmp_path) -> None: + """The value is still read and still used -- the change is not an error. But + every number computed under the old definition is about a different quantity, + so it has to be audible.""" + path = _workbook_with(tmp_path, "=(L2+O2+P2+Q2)/4") + (finding,) = formula_findings(path, _fingerprint_config()) + assert finding.code == "formula_fingerprint_changed" + assert finding.severity is ScoreSeverity.WARNING + assert "(L2+O2+P2)/3" in finding.message + assert "contract_version" in finding.message + + +def test_the_same_formula_copied_down_a_column_is_not_a_change(tmp_path) -> None: + """Fingerprinting per row would report fifteen changes for one edit.""" + path = _workbook_with(tmp_path, "=(L2+O2+P2)/3", rows=5) + (finding,) = formula_findings(path, _fingerprint_config()) + assert finding.code == "formula_fingerprint_unchanged" + assert "5 rows" in finding.message + + +def test_a_pasted_literal_score_is_flagged_as_uncheckable(tmp_path) -> None: + """The one failure this contract cannot see, called out rather than left + silent: a literal cannot be checked against anything at all.""" + path = _workbook_with(tmp_path, None) + (finding,) = formula_findings(path, _fingerprint_config()) + assert finding.code == "fingerprint_no_formula" + assert finding.severity is ScoreSeverity.WARNING + + +def test_the_fingerprint_cannot_catch_a_stale_value(tmp_path) -> None: + """Asserted deliberately, so nobody later mistakes the fingerprint for a value + check. A formula whose inputs have changed still matches its own text; only a + recomputation would notice, and a freeze is the decision not to have one.""" + path = _workbook_with(tmp_path, "=(L2+O2+P2)/3") + (finding,) = formula_findings(path, _fingerprint_config()) + assert finding.severity is ScoreSeverity.NOTE, ( + "the definition is unchanged, so the fingerprint is silent -- whatever the " + "values behind it have done" + ) + + +def test_no_fingerprint_declared_means_no_second_workbook_read(tmp_path) -> None: + """The check needs data_only=False, a second full read. Configs that do not + freeze anything must not pay for it.""" + config = _fingerprint_config() + del config["objectives"]["specs"][0]["measurement"]["formula_fingerprint"] + assert formula_findings(tmp_path / "does-not-exist.xlsx", config) == () + + +def test_the_agreement_check_columns_are_offered_even_when_neither_is_an_input() -> None: + """This broke when optoelectronic was frozen: the check listed only its `raw` + column, on the assumption that `normalized` was a recipe input. Under a freeze + the only input is the score column, so the check reported "column absent" on a + sheet that had it.""" + from mobo_kit.scores import AgreementCheck + + spec = MeasurementSpec( + name="optoelectronic", + recipe="stored", + inputs=(MeasurementInput("Optoelectronic score"),), + agreement_check=AgreementCheck(raw="Photoconductance", normalized="Normalized"), + ) + required, optional = entry_columns([spec]) + assert "Photoconductance" in optional + assert "Normalized" in optional + + +# --------------------------------------------------------------------------- # +# the wrong workbook +# --------------------------------------------------------------------------- # + + +def test_a_workbook_without_the_configured_sheet_says_which_sheet(tmp_path, config) -> None: + path = tmp_path / "Wrong.xlsx" + book = Workbook() + book.active.title = "Sheet1" + book.active.append(["Sample number"]) + book.save(path) + with pytest.raises(CandidateSheetError) as caught: + read_campaign_workbook(path, config) + message = str(caught.value) + assert "'R0'" in message and "campaign.source_sheet" in message + assert "Sheet1" in message + + +# --------------------------------------------------------------------------- # +# the real workbook +# --------------------------------------------------------------------------- # + +requires_workbook = pytest.mark.skipif( + not __import__("pathlib").Path(SOURCE).is_file(), + reason=f"{SOURCE} is not present in this checkout", +) + + +@pytest.mark.local_input +@requires_workbook +def test_the_final_workbook_reads_clean(config) -> None: + contents = read_campaign_workbook(SOURCE, config) + assert contents.n_rows == 15 + assert contents.errors == () + assert contents.warnings == () + values = contents.model_values + assert list(values.columns) == list(objective_names(config)) + # the frozen scores are the sheet's own numbers, not a recomputation + stored = contents.workbook_values + for name in ("uniformity", "optoelectronic"): + column = [c for c in stored.columns if c.lower().startswith(name[:6])][0] + np.testing.assert_allclose( + values[name].to_numpy(float), stored[column].to_numpy(float), atol=0.0 + ) + + +@pytest.mark.local_input +@requires_workbook +def test_the_recorded_fingerprints_match_the_final_workbook(config) -> None: + findings = formula_findings(SOURCE, config) + assert len(findings) == 2 + assert {f.code for f in findings} == {"formula_fingerprint_unchanged"} + + +@pytest.mark.local_input +@requires_workbook +def test_the_photoconductance_inversion_is_fixed(config) -> None: + """The v3 contract's optoelectronic axis was provisional because its + normalised column ranked BACKWARDS against its own raw measurement (Spearman + -0.5484). The group's fix landed; this pins that it did.""" + contents = read_campaign_workbook(SOURCE, config) + finding = next(f for f in contents.findings if f.code.startswith("agreement_")) + assert finding.code == "agreement_monotonic" + assert "+1.0000" in finding.message diff --git a/tests/test_launcher.py b/tests/test_launcher.py new file mode 100644 index 0000000..14b2fd6 --- /dev/null +++ b/tests/test_launcher.py @@ -0,0 +1,646 @@ +"""The launcher's decisions, tested without a display. + +The tkinter window is a thin shell over `inspect_campaign`, `gather_observations` +and `generate_next_round`; those are what can go wrong, so those are what is +tested here. Importing `mobo_kit.launcher` must not require tkinter, and one test +asserts that. +""" + +from __future__ import annotations + +import contextlib +import shutil + +import numpy as np +import pandas as pd +import pytest +from openpyxl import load_workbook + +from mobo_kit.campaign import ( + load_campaign_config, + measurement_entry_columns, + objective_names, +) +from mobo_kit.launcher import ( + CampaignStatus, + LauncherError, + gather_observations, + generate_next_round, + inspect_campaign, +) +from mobo_kit.workbook_io import ( + CandidateSheetError, + candidate_workbook_path, + read_candidate_results, + sheet_name_for_round, + write_candidate_sheet, +) + +CONFIG_PATH = "configs/campaign_d2d_perovskite.yaml" +SOURCE = "local_inputs/Summary Table.xlsx" + +pytestmark = pytest.mark.skipif( + not __import__("pathlib").Path(SOURCE).exists(), + reason="requires the ignored private campaign workbook", +) + + +@pytest.fixture(scope="module") +def config() -> dict: + return load_campaign_config(CONFIG_PATH) + + +@pytest.fixture +def workbook(tmp_path): + destination = tmp_path / "Summary Table.xlsx" + shutil.copy2(SOURCE, destination) + return destination + + +def _conditions(config: dict, n: int = 5) -> pd.DataFrame: + names = [item["name"] for item in config["inputs"]] + rows = [ + [float(item["start"]) + i * float(item["step"]) for item in config["inputs"]] + for i in range(n) + ] + return pd.DataFrame(rows, columns=names) + + +def _fill_candidate_sheet( + path, config, *, thickness=(700.0, 720.0), rows=None, coverage=1.0 +) -> None: + """Enter plausible measurements into every film of an R1 sheet.""" + book = load_workbook(path) + sheet = book[sheet_name_for_round("R1")] + headers = [cell.value for cell in sheet[1]] + values = { + "Coverage": coverage, + "Uniformity": 0.3, + "Phase purity": 0.95, + "PL - Implied Voc (Max)": 0.05, + "Photoconductance (Max)": 5e-07, + "T1": thickness[0], + "T2": thickness[1], + } + target_rows = rows or range(2, sheet.max_row + 1) + for row in target_rows: + for column, value in values.items(): + sheet.cell(row=row, column=headers.index(column) + 1).value = value + book.save(path) + + +# --------------------------------------------------------------------------- # +# status +# --------------------------------------------------------------------------- # + + +def test_a_fresh_workbook_is_ready_for_r1(workbook, config) -> None: + status = inspect_campaign(workbook, config) + assert status.next_round == "R1" + assert status.can_generate + assert status.observed_conditions == 15 + assert "Ready to propose R1" in status.headline + + +def test_the_detail_text_surfaces_the_read_findings(workbook, config) -> None: + """The experimentalist should see that samples 8, 12 and 15 hold thickness + readings that disagree, without going looking for it.""" + detail = inspect_campaign(workbook, config).detail() + assert "Worth a look" in detail + assert "1600" in detail and "709" in detail + assert "For the record" in detail # the excluded T anom readings + + +def test_a_missing_workbook_is_a_plain_sentence(tmp_path, config) -> None: + with pytest.raises(LauncherError, match="does not exist"): + inspect_campaign(tmp_path / "nope.xlsx", config) + + +def test_an_unmeasured_r1_sheet_blocks_the_next_round(workbook, config) -> None: + write_candidate_sheet(workbook, config, _conditions(config), round_name="R1") + status = inspect_campaign(workbook, config) + assert not status.can_generate + assert "no results have been entered" in status.reason + assert "Coverage" in status.reason # says what to fill in + + +# --------------------------------------------------------------------------- # +# observations +# --------------------------------------------------------------------------- # + + +def test_r1_trains_on_sheet1_alone(workbook, config) -> None: + X, Y, Yvar, provenance = gather_observations(workbook, config, for_round="R1") + assert X.shape == (15, 10) + assert Y.shape == (15, 3) + assert provenance == ["Sheet1: 15 conditions"] + # no replicates exist yet, so the noise is still fitted rather than measured + assert Yvar is None + + +def test_r2_trains_on_sheet1_plus_the_aggregated_r1_conditions( + workbook, config +) -> None: + """Three films are one design point, so R2 sees 15 + 5, not 15 + 15.""" + out = write_candidate_sheet(workbook, config, _conditions(config), round_name="R1") + _fill_candidate_sheet(out, config) + X, Y, Yvar, provenance = gather_observations(workbook, config, for_round="R2") + assert X.shape == (20, 10) + assert Y.shape == (20, 3) + assert "5 conditions from 15 films" in provenance[1] + # the live config still fits the noise; measured variance is one key away + assert Yvar is None + + +def test_measured_replicate_variance_switches_on_from_config(workbook, config) -> None: + """The promise of wiring this before the data exists: when the triplicates + land, enabling it is a config edit, not a code change.""" + import copy + + out = write_candidate_sheet(workbook, config, _conditions(config), round_name="R1") + _fill_candidate_sheet(out, config, thickness=(700.0, 760.0)) + # the films of a condition must actually differ, or there is no variance to pool + book = load_workbook(out) + sheet = book[sheet_name_for_round("R1")] + headers = [cell.value for cell in sheet[1]] + for row in range(2, sheet.max_row + 1): + offset = row % 3 + sheet.cell(row=row, column=headers.index("T1") + 1).value = 700.0 + 40.0 * offset + sheet.cell(row=row, column=headers.index("Coverage") + 1).value = 0.9 + 0.02 * offset + # every objective needs film-to-film variation, or its pooled variance is + # zero -- which the pooling refuses, because identical replicates are a + # transcription rather than a measurement + sheet.cell(row=row, column=headers.index("Photoconductance (Max)") + 1).value = ( + 5e-07 * (1.0 + 0.1 * offset) + ) + book.save(out) + + enabled = copy.deepcopy(dict(config)) + enabled["model"] = dict(enabled["model"]) + enabled["model"]["observation_noise"] = "replicate_pooled" + + X, Y, Yvar, provenance = gather_observations(workbook, enabled, for_round="R2") + assert Yvar is not None + assert Yvar.shape == Y.shape + assert np.all(Yvar > 0) + # the 15 R0 rows carry the full between-film variance; the R1 conditions, + # being means of three films, carry a third of it + assert Yvar[0, 2] == pytest.approx(3.0 * Yvar[15, 2]) + assert any("pooled between-film variance" in item for item in provenance) + + +def test_gathering_refuses_a_half_measured_film(workbook, config) -> None: + out = write_candidate_sheet(workbook, config, _conditions(config), round_name="R1") + _fill_candidate_sheet(out, config) + book = load_workbook(out) + sheet = book[sheet_name_for_round("R1")] + headers = [cell.value for cell in sheet[1]] + # blank every thickness reading of one whole condition + for row in (2, 3, 4): + for column in ("T1", "T2"): + sheet.cell(row=row, column=headers.index(column) + 1).value = None + book.save(out) + + with pytest.raises(LauncherError, match="cannot be turned into objective values"): + gather_observations(workbook, config, for_round="R2") + + +# --------------------------------------------------------------------------- # +# replicate aggregation +# --------------------------------------------------------------------------- # + + +def test_replicates_aggregate_to_one_observation_per_condition( + workbook, config +) -> None: + out = write_candidate_sheet(workbook, config, _conditions(config), round_name="R1") + _fill_candidate_sheet(out, config) + results = read_candidate_results(workbook, config, "R1") + assert results.n_conditions == 5 + assert len(results.replicates) == 15 + assert list(results.model_values.columns) == list(objective_names(config)) + assert (results.films_used["thickness"] == 3).all() + + +def test_thickness_aggregates_as_a_geometric_mean(workbook, config) -> None: + """`response: log` means the GP trains on log(T), so three films are averaged + in that space. With identical films the two means agree, which is why the + check uses films that differ.""" + out = write_candidate_sheet(workbook, config, _conditions(config, 1), round_name="R1") + book = load_workbook(out) + sheet = book[sheet_name_for_round("R1")] + headers = [cell.value for cell in sheet[1]] + values = { + "Coverage": 1.0, + "Uniformity": 0.3, + "Phase purity": 0.95, + "PL - Implied Voc (Max)": 0.05, + "Photoconductance (Max)": 5e-07, + } + per_film = (400.0, 700.0, 1000.0) + for offset, thickness in enumerate(per_film): + row = 2 + offset + for column, value in values.items(): + sheet.cell(row=row, column=headers.index(column) + 1).value = value + sheet.cell(row=row, column=headers.index("T1") + 1).value = thickness + book.save(out) + + results = read_candidate_results(workbook, config, "R1") + observed = float(results.model_values["thickness"].iloc[0]) + assert observed == pytest.approx(float(np.exp(np.mean(np.log(per_film))))) + assert observed == pytest.approx(654.2, abs=0.1) # (400*700*1000) ** (1/3) + # and not the arithmetic mean, which is 700 + assert abs(observed - 700.0) > 40.0 + + +def test_the_spread_is_kept_in_the_aggregation_space(workbook, config) -> None: + """What Phase 4 needs: thickness spread already in log space, matching the + config's decision to pool train_Yvar there.""" + out = write_candidate_sheet(workbook, config, _conditions(config, 1), round_name="R1") + book = load_workbook(out) + sheet = book[sheet_name_for_round("R1")] + headers = [cell.value for cell in sheet[1]] + for offset, thickness in enumerate((400.0, 700.0, 1000.0)): + row = 2 + offset + for column, value in { + "Coverage": 1.0, + "Uniformity": 0.3, + "Phase purity": 0.95, + "PL - Implied Voc (Max)": 0.05, + "Photoconductance (Max)": 5e-07, + }.items(): + sheet.cell(row=row, column=headers.index(column) + 1).value = value + sheet.cell(row=row, column=headers.index("T1") + 1).value = thickness + book.save(out) + + results = read_candidate_results(workbook, config, "R1") + expected = float(np.std(np.log([400.0, 700.0, 1000.0]), ddof=1)) + assert float(results.replicate_spread["thickness"].iloc[0]) == pytest.approx(expected) + # uniformity is identical across the three films, so its spread is zero + assert float(results.replicate_spread["uniformity"].iloc[0]) == pytest.approx(0.0) + + +def test_films_of_one_condition_must_share_a_recipe(workbook, config) -> None: + out = write_candidate_sheet(workbook, config, _conditions(config), round_name="R1") + _fill_candidate_sheet(out, config) + book = load_workbook(out) + sheet = book[sheet_name_for_round("R1")] + headers = [cell.value for cell in sheet[1]] + sheet.cell(row=3, column=headers.index("speed_1") + 1).value = 4242.0 + book.save(out) + + with pytest.raises(CandidateSheetError, match="do not share the same speed_1"): + read_candidate_results(workbook, config, "R1") + + +def test_reading_a_sheet_that_was_never_written_says_so(workbook, config) -> None: + with pytest.raises(CandidateSheetError, match="does not exist"): + read_candidate_results(workbook, config, "R1") + + +# --------------------------------------------------------------------------- # +# generating +# --------------------------------------------------------------------------- # + + +@pytest.mark.slow +def test_generating_r1_writes_a_sheet_and_leaves_the_source_alone( + workbook, config +) -> None: + import hashlib + + before = hashlib.sha256(workbook.read_bytes()).hexdigest() + messages: list[str] = [] + # with_report=False: the figures have their own module and their own tests, + # and rendering them here would put ~80 s of matplotlib into a test about + # whether a worklist is written. + generated = generate_next_round( + workbook, config, progress=messages.append, with_report=False + ) + + assert generated.round_name == "R1" + assert generated.sheet_path == candidate_workbook_path(workbook, "R1") + assert generated.sheet_path.exists() + assert generated.result.n_conditions == 5 + assert generated.n_films == 15 + assert hashlib.sha256(workbook.read_bytes()).hexdigest() == before + assert messages and "Done." in messages + + summary = generated.summary() + assert "Nothing here is approved" in summary + assert "Sheet1: 15 conditions" in summary + # the sheet is immediately readable by the reader that will consume it + required, _ = measurement_entry_columns(config) + headers = [ + cell.value + for cell in load_workbook(generated.sheet_path)[sheet_name_for_round("R1")][1] + ] + for column in required: + assert column in headers + + +def test_generating_refuses_when_no_round_is_due(workbook, config) -> None: + write_candidate_sheet(workbook, config, _conditions(config), round_name="R1") + with pytest.raises(LauncherError, match="no results have been entered"): + generate_next_round(workbook, config, with_report=False) + + +def test_generating_never_overwrites_an_existing_sheet(workbook, config, monkeypatch) -> None: + """The sheet may already hold measurements. Refusing is the only safe move, + and it must happen before the ten seconds of model fitting, not after.""" + write_candidate_sheet(workbook, config, _conditions(config), round_name="R1") + + def fail(*args, **kwargs): # pragma: no cover - must never be reached + raise AssertionError("the round was fitted despite an existing sheet") + + monkeypatch.setattr("mobo_kit.launcher.run_r1_ucb", fail) + monkeypatch.setattr( + "mobo_kit.launcher.inspect_campaign", + lambda *a, **k: CampaignStatus( + workbook=workbook, + next_round="R1", + reason="pretend R1 is due", + scored_rows=0, + total_rows=0, + observed_conditions=15, + ), + ) + with pytest.raises(LauncherError, match="already exists"): + generate_next_round(workbook, config, with_report=False) + + +# --------------------------------------------------------------------------- # +# the shell +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def isolated_settings(monkeypatch): + """No remembered workbook, and no writing to the user's home. + + Both matter. The launcher schedules a `check()` 200 ms after construction when + it remembers a workbook, so a path left behind by an earlier test raced the + explicit `check()` these tests perform and overwrote the pane with a different + result -- an order-dependent failure that only appeared in a full-suite run. + And a test suite has no business writing to ~/.mobo_kit either way. + """ + monkeypatch.setattr("mobo_kit.launcher.remembered_workbook", lambda: None) + monkeypatch.setattr("mobo_kit.launcher.remember_workbook", lambda path: None) + + +def _status_for(path) -> CampaignStatus: + from pathlib import Path + + return CampaignStatus( + workbook=Path(path).resolve(), + next_round="R1", + reason="pretend R1 is due", + scored_rows=0, + total_rows=0, + observed_conditions=15, + ) + + +def _build_window(config_path: str): + """Construct the window, or skip if this machine will not start Tk at all.""" + try: + import tkinter + except ImportError: # pragma: no cover - a build without tkinter + pytest.skip("tkinter is not installed") + + from mobo_kit.launcher import LauncherWindow + + try: + return LauncherWindow(config_path) + except tkinter.TclError as exc: + # No display, or a Tcl that will not initialise. That is the condition the + # old skipif declared. Only a toolkit-level failure skips, so a real defect + # in LauncherWindow still raises. + pytest.skip(f"tkinter will not start here: {exc}") + + +@pytest.fixture +def open_window(request): + """Build launcher windows with pytest's fd-level capture suspended. + + Both halves of this matter, and both were measured -- the symptom is a + ``_tkinter.TclError`` saying ``couldn't read file ... init.tcl: No error``, + which reads like a broken Tcl install and is neither that nor a launcher bug. + + **Tk must not be created during collection.** The module previously carried + six ``@pytest.mark.skipif(not _tk_available(), ...)`` decorators, and each + evaluation built and destroyed a real interpreter while pytest had file + descriptors 1 and 2 swapped for its capture temp files. Tcl's process-global + state then holds descriptors that are gone by the time a test runs. Measured: + ONE import-time ``Tk()`` fails the next one in 4 runs out of 5, while twenty + consecutive ``Tk()`` calls inside a test body all pass. + + **Capture has to stay suspended while the window lives.** Moving construction + into the test body was not sufficient on its own: pytest re-swaps those + descriptors between tests, and the third window built in one process still + lost its interpreter. Suspending capture for the test that owns a window + leaves Tcl with descriptors that outlive it. + + The whole effect disappears under ``-s``, ``--capture=sys`` and + ``--capture=tee-sys``, which is what identified fd capture as the cause. That + also explains the intermittency that made this look like a race in the + launcher: whether the stale descriptors happen to still be valid depends on + what file I/O ran in between, so one test failed alone and passed in a full + run. + """ + manager = request.config.pluginmanager.getplugin("capturemanager") + suspended = ( + contextlib.nullcontext() + if manager is None + else manager.global_and_fixture_disabled() + ) + with suspended: + yield _build_window + + +def test_the_window_reports_status_through_its_worker_thread( + workbook, config, isolated_settings, open_window +) -> None: + """The UI does its work off the main thread and posts results through a queue. + Nothing else covers that plumbing, and a deadlock there would look like a + window that simply never responds.""" + import time + + window = open_window(CONFIG_PATH) + try: + window.path_var.set(str(workbook)) + window.check() + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + window.root.update() + if not window._busy and window._status is not None: + break + time.sleep(0.02) + + assert window._status is not None, "the window never reported a status" + assert window._status.next_round == "R1" + assert window.headline.cget("text") == "Ready to propose R1." + assert window.generate_button.cget("text") == "Propose R1" + assert str(window.generate_button.cget("state")) == "normal" + body = window.text.get("1.0", "end") + assert "15 conditions on Sheet1" in body + finally: + window.root.destroy() + + +def test_the_window_shows_a_readable_error_rather_than_a_traceback( + config, isolated_settings, open_window +) -> None: + import time + + window = open_window(CONFIG_PATH) + try: + window.path_var.set("nowhere/at/all.xlsx") + window.check() + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + window.root.update() + if not window._busy: + break + time.sleep(0.02) + body = window.text.get("1.0", "end") + assert "does not exist" in body + assert "Traceback" not in body + assert window.headline.cget("text") == "Cannot continue." + finally: + window.root.destroy() + + +def test_a_result_for_a_workbook_the_user_left_is_discarded( + workbook, config, isolated_settings, open_window +) -> None: + """The race the test fixture hid, now closed at the source. + + Work runs off the main thread, so a check dispatched against one workbook can + return after the user has selected another. Painting "Ready to propose R1" over + a different workbook is worse than painting nothing. + + Driven through the queue rather than by racing two real threads. The first + version of this test did race them, passed alone, and failed intermittently in + a full-suite run -- a flaky test of a race-condition fix is worse than no test, + because it teaches people to re-run until green. + """ + window = open_window(CONFIG_PATH) + try: + window.path_var.set(str(workbook)) + window._start("pretending to read") + window._request_id = 1 + # the user navigates away before the reply lands + window.path_var.set(str(workbook.parent / "somewhere else.xlsx")) + window._queue.put((1, "status", _status_for(workbook))) + window.drain_once() + + assert window._status is None, "the stale status must not be adopted" + assert not window._busy, "a discarded reply must still clear the busy state" + assert "Ready to propose" not in window.headline.cget("text") + # buttons usable again rather than stuck disabled + assert str(window.check_button.cget("state")) == "normal" + assert str(window.generate_button.cget("state")) == "disabled" + finally: + window.root.destroy() + + +def test_a_result_for_the_current_workbook_is_adopted( + workbook, config, isolated_settings, open_window +) -> None: + """The other half of the rule: it must not discard everything.""" + window = open_window(CONFIG_PATH) + try: + window.path_var.set(str(workbook)) + window._start("pretending to read") + window._request_id = 1 + window._queue.put((1, "status", _status_for(workbook))) + window.drain_once() + + assert window._status is not None + assert window.headline.cget("text") == "Ready to propose R1." + assert str(window.generate_button.cget("state")) == "normal" + finally: + window.root.destroy() + + +def test_a_superseded_reply_does_not_overwrite_a_newer_request( + workbook, config, isolated_settings, open_window +) -> None: + """Two presses: the earlier press's answer must not land after the later one.""" + window = open_window(CONFIG_PATH) + try: + window.path_var.set(str(workbook)) + window._start("pretending to read") + window._request_id = 2 # a second press is already in flight + window._queue.put((1, "status", _status_for(workbook))) + window.drain_once() + assert window._status is None, "request 1's reply landed after request 2" + assert not window._busy + + window._start("still pretending") + window._queue.put((2, "status", _status_for(workbook))) + window.drain_once() + assert window._status is not None, "request 2's own reply must land" + finally: + window.root.destroy() + + +def test_the_startup_auto_check_is_cancelled_when_the_user_acts( + workbook, config, monkeypatch, open_window +) -> None: + """The auto-check fires 200 ms after construction against the remembered + workbook. If the user has already pressed something, that answer is about the + wrong file.""" + from mobo_kit import launcher as launcher_module + + monkeypatch.setattr(launcher_module, "remembered_workbook", lambda: workbook) + monkeypatch.setattr(launcher_module, "remember_workbook", lambda path: None) + + window = open_window(CONFIG_PATH) + try: + assert window._auto_check_id is not None, "a remembered workbook should schedule one" + window._cancel_auto_check() + assert window._auto_check_id is None + # cancelling twice is harmless + window._cancel_auto_check() + finally: + window.root.destroy() + + +def test_nothing_in_this_module_builds_a_window_at_import_time() -> None: + """Pin the rule the :func:`open_window` fixture documents; it already regressed. + + The specific way it comes back is a ``@pytest.mark.skipif(not + _tk_available(), ...)`` decorator: the expression is evaluated during + collection, which is exactly when constructing a Tk interpreter poisons the + next one. Decorators sit at column 0, so a source check catches that shape. + + It is a source check rather than a runtime one on purpose -- an import-time + Tk that has already been destroyed leaves nothing to observe by the time any + test could look. + """ + from pathlib import Path + + source = Path(__file__).read_text(encoding="utf-8") + offenders = [ + line + for line in source.splitlines() + if line[:1] not in ("", " ", "\t") + and any(token in line for token in ("Tk(", "LauncherWindow(", "_tk_available")) + ] + assert not offenders, ( + "these lines run at collection time and build a toolkit object; move the " + f"construction into the test body via the open_window fixture: {offenders}" + ) + + +def test_the_logic_imports_without_tkinter(monkeypatch) -> None: + """A headless machine must still be able to use the functions. tkinter is + imported inside the window class for exactly this reason.""" + import importlib + import sys + + monkeypatch.setitem(sys.modules, "tkinter", None) + module = importlib.reload(importlib.import_module("mobo_kit.launcher")) + assert callable(module.generate_next_round) diff --git a/tests/test_lhs.py b/tests/test_lhs.py new file mode 100644 index 0000000..6ddd594 --- /dev/null +++ b/tests/test_lhs.py @@ -0,0 +1,165 @@ +import numpy as np +import pandas as pd +import pytest + +from mobo_kit.design import InputSpec, build_design +from mobo_kit.lhs import lhs_dataframe, lhs_dataframe_optimized + + +def _design(): + return build_design( + [ + InputSpec("speed", 0.0, 1.0, 0.25, unit="m/min"), + InputSpec("temperature", 20.0, 24.0, 1.0, unit="C"), + InputSpec("time", 5.0, 15.0, 5.0, unit="s"), + ] + ) + + +def _assert_exact_grid_membership(frame: pd.DataFrame, design) -> None: + for column, grid in zip(design.names, design.var_list): + assert np.all(np.isin(frame[column].to_numpy(), grid)) + + +def test_seeded_lhs_is_deterministic_unique_and_exactly_sized(): + design = _design() + kwargs = dict( + design=design, + n=12, + seed=123, + samples_per_attempt=40, + max_attempts=5, + ) + + first = lhs_dataframe_optimized(**kwargs) + second = lhs_dataframe_optimized(**kwargs) + + pd.testing.assert_frame_equal(first, second) + assert first.shape == (12, 3) + assert list(first.columns) == design.names + assert len(first.drop_duplicates()) == 12 + _assert_exact_grid_membership(first, design) + + +def test_different_seed_changes_at_least_one_condition(): + design = _design() + first = lhs_dataframe_optimized( + design=design, + n=12, + seed=123, + samples_per_attempt=40, + max_attempts=5, + ) + second = lhs_dataframe_optimized( + design=design, + n=12, + seed=124, + samples_per_attempt=40, + max_attempts=5, + ) + + assert not first.equals(second) + + +def test_compatibility_wrapper_has_the_same_strict_contract(): + design = _design() + frame = lhs_dataframe( + design, + n=8, + seed=77, + samples_per_attempt=24, + max_attempts=5, + ) + + assert frame.shape == (8, 3) + assert len(frame.drop_duplicates()) == 8 + _assert_exact_grid_membership(frame, design) + + +def test_constraints_are_applied_after_grid_snapping(): + design = _design() + calls = [] + + def snapped_constraint(X, constrained_design): + for column, grid in enumerate(constrained_design.var_list): + assert np.all(np.isin(X[:, column], grid)) + calls.append(X.copy()) + speed_index = constrained_design.names.index("speed") + return X[:, speed_index] >= 0.75 + + frame = lhs_dataframe_optimized( + design, + n=5, + seed=5, + row_constraints=[snapped_constraint], + samples_per_attempt=30, + max_attempts=5, + ) + + assert calls + assert frame.shape == (5, 3) + assert np.all(frame["speed"] >= 0.75) + _assert_exact_grid_membership(frame, design) + + +def test_impossible_constraint_raises_without_unconstrained_fallback(): + design = _design() + + def reject_everything(X, _design): + return np.zeros(X.shape[0], dtype=bool) + + with pytest.raises(RuntimeError, match="Unable to generate exactly n=3"): + lhs_dataframe_optimized( + design, + n=3, + seed=9, + row_constraints=reject_everything, + samples_per_attempt=20, + max_attempts=2, + ) + + +def test_request_larger_than_grid_raises_before_sampling(): + design = build_design([InputSpec("x", 0, 1, 1), InputSpec("y", 0, 1, 1)]) + + with pytest.raises(ValueError, match="only 4 unique grid combinations"): + lhs_dataframe_optimized(design, n=5, seed=1) + + +def test_max_abs_corr_is_a_hard_requirement(): + design = build_design([InputSpec("x", 0, 1, 1), InputSpec("y", 0, 1, 1)]) + + # A two-row Latin design on two binary dimensions varies in both columns; + # their absolute Pearson correlation is necessarily 1. + with pytest.raises(RuntimeError, match=r"required <= 0\.500000"): + lhs_dataframe_optimized( + design, + n=2, + seed=42, + max_abs_corr=0.5, + samples_per_attempt=2, + batch_size=2, + subset_tries=10, + max_attempts=1, + ) + + +def test_returned_design_satisfies_configured_correlation_limit(): + design = build_design([InputSpec("x", 0, 1, 1), InputSpec("y", 0, 1, 1)]) + + frame = lhs_dataframe_optimized( + design, + n=4, + seed=3, + max_abs_corr=0.0, + samples_per_attempt=4, + max_attempts=10, + ) + + correlation = np.corrcoef(frame.to_numpy(), rowvar=False)[0, 1] + assert abs(correlation) <= 0.0 + + +def test_continuous_unsnapped_output_is_rejected_for_campaign_use(): + with pytest.raises(ValueError, match="requires snap_to_grids=True"): + lhs_dataframe_optimized(_design(), n=3, seed=1, snap_to_grids=False) diff --git a/tests/test_measurement_space_encoding.py b/tests/test_measurement_space_encoding.py new file mode 100644 index 0000000..e0f49d9 --- /dev/null +++ b/tests/test_measurement_space_encoding.py @@ -0,0 +1,316 @@ +"""Measurement space against model space, and the bug that lived in the gap. + +``ObjectiveTransform.transform`` is a MODEL-OUTPUT decoder: it undoes the link +(``exp`` for a log objective) before computing utility. Handing it a raw +measurement exponentiates a number that was never a logarithm. + +``run_r1_ucb`` did exactly that with its observed HVI baseline until 2026-07-31. +``exp(360…1303)`` saturates the 650 nm Gaussian to exactly ``0.0`` -- finite, so +neither the transform's own finiteness check nor the caller's fired. Every +observation's thickness utility was zero and the baseline hypervolume came out +0.004659 where the truth is 0.436442. + +Two things about how it survived, both encoded as tests here. + +**It was already documented.** ``test_transform_reproduces_the_workbook_thickness_score`` +in ``test_campaign.py`` says in as many words that "feeding it raw nm would +silently score exp(687) instead of 687" -- and then only ever tests the correct +usage. Knowing a trap exists is not the same as testing that no caller falls in +it. + +**Nothing compared the baseline to anything.** It was a plausible finite number +that no test reproduced independently -- the same shape as the hypervolume +auto-reference and the swallowed ``train_Yvar``. So ``run_r1_ucb`` now reports +its baseline in diagnostics, and the test below recomputes it by a different +route. +""" + +from __future__ import annotations + +import numpy as np +import pytest +import torch +from botorch.utils.multi_objective.hypervolume import Hypervolume + +from mobo_kit.campaign import ( + build_objective_transform, + load_campaign_config, + run_r0_lhs, + run_r1_ucb, +) +from mobo_kit.ucb_hvi import pareto_utility_above_reference + +CONFIG_PATH = "configs/campaign_d2d_perovskite.yaml" +SEED = 73 + +#: The two numbers this bug produced on the real 15 R0 rows. Pinned so the size of +#: the defect stays on record even if the workbook is later replaced. +MISENCODED_BASELINE_HV = 0.004659 +CORRECT_BASELINE_HV = 0.436442 + + +def _synthetic_config(pool: int = 256) -> dict: + """A campaign with one log-link objective, so the encoding is exercised. + + Inputs start at 1.0 so the log-response mean function has positive features. + """ + return { + "inputs": [ + {"name": f"x{i}", "start": 1.0, "stop": 2.0, "step": 0.05} for i in range(10) + ], + "objectives": { + "contract_version": "TEST_ONLY-encoding-v1", + "scaling_mode": "fixed_affine", + "specs": [ + { + "name": "affine_a", + "goal": "maximize", + "transform": "affine", + "model_source_column": "affine_a", + "lower_anchor": 0.0, + "upper_anchor": 3.0, + }, + { + "name": "affine_b", + "goal": "maximize", + "transform": "affine", + "model_source_column": "affine_b", + "lower_anchor": -4.0, + "upper_anchor": 0.0, + }, + { + "name": "log_linked", + "goal": "target", + "transform": "gaussian_target", + "model_source_column": "log_linked", + "target": 650.0, + "sigma": 176.7766952966369, + "mean_function": { + "response": "log", + "features": [{"column": "x0", "transform": "log"}], + }, + }, + ], + }, + "reference_point_utility": [-0.01, -0.01, -0.01], + "rounds": { + "r1": { + "method": "ucb_hvi", + "batch_size": 5, + "replicates_per_condition": 3, + "beta": 4.0, + "candidate_pool_size": pool, + "posterior_samples": 16, + "moment_method": "monte_carlo", + }, + "r2": { + "method": "qlognehvi", + "batch_size": 3, + "replicates_per_condition": 3, + "candidate_pool_size": pool, + "mc_samples": 8, + }, + }, + "local_penalization": { + "radius": 0.25, + "min_batch_distance": 0.15, + "min_observed_distance": 0.0, + "dimension_weights": None, + }, + "model": {"variant": "dim_scaled_prior"}, + "reproducibility": {"seed": SEED}, + "constraints": [], + } + + +def _measurements(X: np.ndarray) -> np.ndarray: + X = np.asarray(X, dtype=float) + return np.column_stack([ + X.mean(axis=1), + -np.linalg.norm(X - 1.5, axis=1), + 650.0 * X[:, 0] ** -0.5 * X[:, 1] ** 0.3, # straddles the 650 nm target + ]) + + +# --------------------------------------------------------------------------- # +# the encoder itself +# --------------------------------------------------------------------------- # + + +def test_encode_measurements_logs_only_the_log_link_axes() -> None: + transform = build_objective_transform(_synthetic_config()) + measured = torch.tensor([[1.5, -2.0, 700.0], [2.0, -1.0, 500.0]], dtype=torch.double) + encoded = transform.encode_measurements(measured) + torch.testing.assert_close(encoded[:, :2], measured[:, :2]) + torch.testing.assert_close(encoded[:, 2], torch.log(measured[:, 2])) + + +def test_transform_measurements_is_encode_then_transform() -> None: + transform = build_objective_transform(_synthetic_config()) + measured = torch.tensor([[1.5, -2.0, 700.0]], dtype=torch.double) + torch.testing.assert_close( + transform.transform_measurements(measured), + transform.transform(transform.encode_measurements(measured)), + ) + + +def test_transform_measurements_matches_the_gaussian_computed_by_hand() -> None: + """An independent comparator: the closed form, not another code path.""" + transform = build_objective_transform(_synthetic_config()) + nm = np.array([360.0, 650.0, 700.0, 1303.0]) + measured = torch.tensor( + np.column_stack([np.full(nm.size, 1.5), np.full(nm.size, -2.0), nm]), + dtype=torch.double, + ) + got = transform.transform_measurements(measured)[:, 2].numpy() + sigma = 176.7766952966369 + expected = np.exp(-0.5 * ((nm - 650.0) / sigma) ** 2) + np.testing.assert_allclose(got, expected, atol=1e-12) + + +def test_encode_measurements_rejects_non_positive_on_a_log_link() -> None: + transform = build_objective_transform(_synthetic_config()) + with pytest.raises(ValueError, match="strictly positive"): + transform.encode_measurements( + torch.tensor([[1.0, -1.0, 0.0]], dtype=torch.double) + ) + + +def test_encode_measurements_rejects_non_finite_input() -> None: + transform = build_objective_transform(_synthetic_config()) + with pytest.raises(ValueError, match="finite"): + transform.encode_measurements( + torch.tensor([[1.0, -1.0, float("inf")]], dtype=torch.double) + ) + + +# --------------------------------------------------------------------------- # +# the failure mode, pinned so it stays recognisable +# --------------------------------------------------------------------------- # + + +def test_unencoded_nanometres_collapse_to_exactly_zero_utility() -> None: + """Why the bug was silent: the wrong answer is a finite, ordinary-looking 0.0. + + This asserts the BROKEN behaviour of the raw call deliberately. It is the + fingerprint to recognise if it ever reappears somewhere else. + """ + transform = build_objective_transform(_synthetic_config()) + measured = torch.tensor([[1.5, -2.0, 360.0], [1.5, -2.0, 1303.0]], dtype=torch.double) + + unencoded = transform.transform(measured) # the mistake + assert torch.isfinite(unencoded).all(), "no guard fires -- that is the problem" + assert bool((unencoded[:, 2] == 0.0).all()) + + encoded = transform.transform_measurements(measured) # the fix + assert bool((encoded[:, 2] > 0.0).all()) + + +@pytest.mark.parametrize("nm", [360.0, 500.0, 650.0, 900.0, 1303.0]) +def test_a_finite_in_range_measurement_never_scores_exactly_zero(nm: float) -> None: + """The invariant the bug violated, stated directly. + + A real film that was measured at all has some merit on every axis. A utility of + exactly 0.0 for a finite measurement means an encoding was skipped, not that + the film was worthless. + """ + transform = build_objective_transform(_synthetic_config()) + measured = torch.tensor([[1.5, -2.0, nm]], dtype=torch.double) + utility = transform.transform_measurements(measured) + assert torch.isfinite(utility).all() + assert not bool((utility == 0.0).any()) + + +# --------------------------------------------------------------------------- # +# the regression test: it fails on the pre-fix run_r1_ucb +# --------------------------------------------------------------------------- # + + +def test_run_r1_ucb_baseline_matches_an_independent_computation() -> None: + """The comparator that did not exist. + + ``run_r1_ucb`` now reports the baseline hypervolume the acquisition actually + used. Here it is recomputed by a separate route -- explicit encode, explicit + Pareto filter, explicit Hypervolume -- and the two must agree. + + On the pre-fix code the reported value is the collapsed one and this fails. + """ + config = _synthetic_config() + transform = build_objective_transform(config) + reference = np.asarray(config["reference_point_utility"], dtype=float) + + X = run_r0_lhs(config, n=15, seed=SEED).conditions.to_numpy(float) + Y = _measurements(X) + + result = run_r1_ucb(config, X, Y, seed=SEED) + reported = result.diagnostics["observed_baseline_hypervolume"] + + utility = transform.transform_measurements( + torch.tensor(Y, dtype=torch.double) + ).numpy() + pareto = pareto_utility_above_reference(utility, reference) + expected = float( + Hypervolume(ref_point=torch.tensor(reference, dtype=torch.double)).compute( + torch.tensor(pareto, dtype=torch.double) + ) + ) + + assert reported == pytest.approx(expected, rel=1e-9) + assert result.diagnostics["observed_baseline_pareto_size"] == len(pareto) + + # and the mis-encoded route gives a materially different answer, so the + # assertion above has teeth rather than passing on a coincidence + collapsed = transform.transform(torch.tensor(Y, dtype=torch.double)).numpy() + assert not np.allclose(collapsed[:, 2], utility[:, 2]) + + +def test_the_baseline_is_reported_at_all() -> None: + """A number nobody can see is a number nobody can check.""" + config = _synthetic_config() + X = run_r0_lhs(config, n=15, seed=SEED).conditions.to_numpy(float) + result = run_r1_ucb(config, X, _measurements(X), seed=SEED) + assert "observed_baseline_hypervolume" in result.diagnostics + assert "observed_baseline_pareto_size" in result.diagnostics + assert result.diagnostics["observed_baseline_hypervolume"] > 0.0 + + +# --------------------------------------------------------------------------- # +# the live campaign's own numbers +# --------------------------------------------------------------------------- # + + +@pytest.mark.local_input +@pytest.mark.skipif( + not __import__("pathlib").Path("local_inputs/Summary Table.xlsx").is_file(), + reason="local_inputs/Summary Table.xlsx is not present in this checkout", +) +def test_the_real_workbook_reproduces_the_two_recorded_baselines() -> None: + """The 94x, on the actual data, so the recorded numbers stay falsifiable.""" + from mobo_kit.workbook_io import read_campaign_workbook + + config = load_campaign_config(CONFIG_PATH) + transform = build_objective_transform(config) + reference = np.asarray(config["reference_point_utility"], dtype=float) + + contents = read_campaign_workbook("local_inputs/Summary Table.xlsx", config) + assert contents.errors == () + Y = contents.model_values.to_numpy(float) + + def hv(utility: np.ndarray) -> float: + pareto = pareto_utility_above_reference(utility, reference) + if pareto.shape[0] == 0: + return 0.0 + return float( + Hypervolume(ref_point=torch.tensor(reference, dtype=torch.double)).compute( + torch.tensor(pareto, dtype=torch.double) + ) + ) + + correct = hv(transform.transform_measurements(torch.tensor(Y, dtype=torch.double)).numpy()) + misencoded = hv(transform.transform(torch.tensor(Y, dtype=torch.double)).numpy()) + + assert correct == pytest.approx(CORRECT_BASELINE_HV, abs=5e-6) + assert misencoded == pytest.approx(MISENCODED_BASELINE_HV, abs=5e-6) + # every thickness utility was zero under the mis-encoding + collapsed = transform.transform(torch.tensor(Y, dtype=torch.double)).numpy() + assert bool((collapsed[:, 2] == 0.0).all()) diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..bd36a53 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,127 @@ +"""Hypervolume against a fixed reference. + +No test covered `compute_ref_pareto_hv` before 2026-07-30, which is how its +degenerate auto-reference survived: it returned a number, and nobody compared that +number with the right one. +""" + +from __future__ import annotations + +import numpy as np +import pytest +import torch +from botorch.utils.multi_objective.hypervolume import infer_reference_point + +from mobo_kit.metrics import compute_diversity_score, compute_ref_pareto_hv + + +def _front() -> torch.Tensor: + """Three mutually non-dominated points plus one dominated one.""" + return torch.tensor( + [ + [1.0, 0.2, 0.5], + [0.2, 1.0, 0.5], + [0.5, 0.5, 1.0], + [0.1, 0.1, 0.1], + ], + dtype=torch.double, + ) + + +def test_a_missing_reference_is_refused_with_the_config_key_named() -> None: + """The old default was `Y.min(dim=0) - 1e-8`, which made every slab 1e-8 thick + and re-derived itself from the data on every call.""" + with pytest.raises(ValueError, match="reference_point_utility"): + compute_ref_pareto_hv(_front()) + + +def test_the_error_says_why_an_inferred_reference_is_wrong() -> None: + with pytest.raises(ValueError, match="incomparable across them"): + compute_ref_pareto_hv(_front(), None) + + +def test_an_explicit_reference_gives_the_dominated_volume() -> None: + Y = _front() + reference = np.array([-0.01, -0.01, -0.01]) + ref_point_t, pareto_Y, volume = compute_ref_pareto_hv(Y, reference) + assert ref_point_t.dtype == Y.dtype + assert pareto_Y.shape[0] == 3 # the dominated point is dropped + assert volume > 0.0 + + +def test_the_reference_is_not_re_derived_from_the_data() -> None: + """The same reference on a growing dataset must give a monotone, + comparable series. With the old auto-reference it did not.""" + Y = _front() + reference = np.array([-0.01, -0.01, -0.01]) + _, _, first = compute_ref_pareto_hv(Y[:3], reference) + _, _, second = compute_ref_pareto_hv(Y, reference) + assert second >= first + extra = torch.cat([Y, torch.tensor([[1.2, 1.2, 1.2]], dtype=Y.dtype)]) + _, _, third = compute_ref_pareto_hv(extra, reference) + assert third > second + + +def test_the_old_auto_reference_collapses_on_a_real_trade_off_front() -> None: + """Pins the reason this changed, and the condition for it. + + `Y.min(dim=0) - 1e-8` is only harmless while some *dominated* point sets the + per-objective minima. As soon as the Pareto set itself sets them -- which is + what a genuine trade-off front looks like, each point best in one objective and + worst in another -- every slab is 1e-8 thick in at least one dimension and the + volume collapses. That is the 6e-8-against-1.448 in the campaign notes. + """ + trade_off = torch.tensor( + [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], dtype=torch.double + ) + degenerate = (trade_off.min(dim=0).values - 1e-8).numpy() + _, _, degenerate_volume = compute_ref_pareto_hv(trade_off, degenerate) + inferred = infer_reference_point(trade_off).numpy() + _, _, inferred_volume = compute_ref_pareto_hv(trade_off, inferred) + + assert degenerate_volume < 1e-10 + # not pinned tightly: infer_reference_point's margin below the nadir is a + # BoTorch heuristic, and the claim here is the ratio, not its exact value + assert inferred_volume > 1e-3 + assert degenerate_volume < inferred_volume / 1e6 + + +def test_a_reference_nothing_dominates_is_refused_not_reported_as_zero() -> None: + """BoTorch silently drops points that do not dominate the reference, so an + unreachable reference reads as 0.0 -- indistinguishable from a sign error.""" + with pytest.raises(ValueError, match="No observation dominates"): + compute_ref_pareto_hv(_front(), np.array([10.0, 10.0, 10.0])) + + +def test_a_flipped_sign_convention_is_caught_by_the_same_check() -> None: + minimising = -_front() + with pytest.raises(ValueError, match="every objective must be maximised"): + compute_ref_pareto_hv(minimising, np.array([-0.01, -0.01, -0.01])) + + +@pytest.mark.parametrize( + "reference, match", + [ + (np.zeros((2, 3)), "must be 1D"), + (np.zeros(2), "does not match number of objectives"), + (np.array([0.0, np.inf, 0.0]), "must be finite"), + ("not an array", "must be a numpy.ndarray"), + ], +) +def test_a_malformed_reference_is_refused(reference, match) -> None: + with pytest.raises((ValueError, TypeError), match=match): + compute_ref_pareto_hv(_front(), reference) + + +def test_a_torch_reference_is_accepted() -> None: + """Callers hold the reference as a tensor as often as an array.""" + _, _, volume = compute_ref_pareto_hv( + _front(), torch.tensor([-0.01, -0.01, -0.01], dtype=torch.double) + ) + assert volume > 0.0 + + +def test_diversity_score_is_the_mean_pairwise_distance() -> None: + X = np.array([[0.0, 0.0], [3.0, 4.0]]) + assert compute_diversity_score(X) == pytest.approx(5.0) + assert compute_diversity_score(np.array([[1.0, 1.0]])) == 0.0 diff --git a/tests/test_model_validation.py b/tests/test_model_validation.py new file mode 100644 index 0000000..402f327 --- /dev/null +++ b/tests/test_model_validation.py @@ -0,0 +1,675 @@ +from __future__ import annotations + +import warnings + +import gpytorch +import numpy as np +import pandas as pd +import pytest +import torch + +import mobo_kit.model_validation as validation_module +import mobo_kit.models as models_module +from mobo_kit.model_validation import ( + CONSERVATIVE, + DIM_SCALED_PRIOR, + LEGACY_NO_PRIOR, + PRIMARY_VARIANT, + ModelFitCache, + ModelFitError, + ModelVariantSpec, + compute_prediction_metrics, + extract_model_hyperparameters, + fit_model_variant, + fit_warnings_frame, + model_variant_spec, + run_exact_loocv, + validate_model_variant, +) +from mobo_kit.models import fit_gp_models + + +def _training_data() -> tuple[torch.Tensor, torch.Tensor, tuple[int, ...]]: + X = torch.tensor( + [ + [0.0, 0.0], + [0.2, 0.8], + [0.5, 0.3], + [0.8, 0.9], + [1.0, 0.1], + ], + dtype=torch.double, + ) + Y = torch.stack( + ( + 0.2 + 0.7 * X[:, 0] + 0.1 * X[:, 1], + -1.0 + 0.3 * X[:, 0] - 0.5 * X[:, 1], + ), + dim=1, + ) + return X, Y, (1, 2, 3, 4, 5) + + +def _skip_optimization(mll: object) -> object: + return mll + + +def test_model_variant_contracts_are_fixed() -> None: + assert model_variant_spec("dim_scaled_prior") is DIM_SCALED_PRIOR + assert DIM_SCALED_PRIOR.min_noise == pytest.approx(1.0e-4) + assert DIM_SCALED_PRIOR.min_lengthscale is None + assert DIM_SCALED_PRIOR.use_dim_scaled_prior is True + assert DIM_SCALED_PRIOR.use_lognormal_noise_prior is True + assert model_variant_spec("legacy_matern_no_prior") is LEGACY_NO_PRIOR + assert LEGACY_NO_PRIOR.use_dim_scaled_prior is False + assert LEGACY_NO_PRIOR.use_lognormal_noise_prior is False + assert model_variant_spec("conservative") is CONSERVATIVE + assert CONSERVATIVE.min_noise == pytest.approx(0.01) + assert CONSERVATIVE.min_lengthscale == pytest.approx(0.05) + + with pytest.raises(ValueError, match="dim_scaled_prior"): + ModelVariantSpec("dim_scaled_prior", min_noise=0.01, min_lengthscale=None) + with pytest.raises(ValueError, match="dim_scaled_prior"): + # both priors are part of the contract, not options + ModelVariantSpec("dim_scaled_prior", min_noise=1.0e-4, min_lengthscale=None) + with pytest.raises(ValueError, match="dim_scaled_prior"): + # the lengthscale prior alone is the degenerate configuration + ModelVariantSpec( + "dim_scaled_prior", + min_noise=1.0e-4, + min_lengthscale=None, + use_dim_scaled_prior=True, + ) + with pytest.raises(ValueError, match="legacy_matern_no_prior"): + ModelVariantSpec( + "legacy_matern_no_prior", + min_noise=1.0e-3, + min_lengthscale=None, + use_dim_scaled_prior=True, + ) + with pytest.raises(ValueError, match="conservative"): + ModelVariantSpec("conservative", min_noise=0.01, min_lengthscale=0.01) + with pytest.raises(ValueError, match="Unsupported"): + model_variant_spec("mystery") + + +def test_primary_variant_is_the_prior_regularised_model() -> None: + """The fixed model is the default; the retired one must be asked for by name.""" + assert PRIMARY_VARIANT is DIM_SCALED_PRIOR + assert PRIMARY_VARIANT.name == "dim_scaled_prior" + + +def test_dim_scaled_prior_attaches_a_lengthscale_prior( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(validation_module, "fit_gpytorch_mll", _skip_optimization) + X, Y, sample_ids = _training_data() + + primary = fit_model_variant( + X, + Y, + sample_ids=sample_ids, + objective_names=("one", "two"), + variant=DIM_SCALED_PRIOR, + ) + legacy = fit_model_variant( + X, + Y, + sample_ids=sample_ids, + objective_names=("one", "two"), + variant=LEGACY_NO_PRIOR, + ) + + for gp in primary.model.models: + base = gp.covar_module.base_kernel + assert base.lengthscale_prior is not None + # LogNormal(loc = sqrt(2) + log(d)/2, scale = sqrt(3)); d = 2 here + assert float(base.lengthscale_prior.loc) == pytest.approx( + np.sqrt(2.0) + np.log(X.shape[1]) / 2.0 + ) + for gp in legacy.model.models: + assert not hasattr(gp.covar_module.base_kernel, "lengthscale_prior") or ( + gp.covar_module.base_kernel.lengthscale_prior is None + ) + + +def test_legacy_variant_matches_step2b_model_construction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The retired contract must still reproduce archived Step 2B/2C runs exactly.""" + monkeypatch.setattr(validation_module, "fit_gpytorch_mll", _skip_optimization) + monkeypatch.setattr(models_module, "fit_gpytorch_mll", _skip_optimization) + X, Y, sample_ids = _training_data() + + torch.manual_seed(73) + strict = fit_model_variant( + X, + Y, + sample_ids=sample_ids, + objective_names=("one", "two"), + variant=LEGACY_NO_PRIOR, + seed=73, + ) + torch.manual_seed(73) + historical = fit_gp_models(X, Y) + query = X[:3] + with torch.no_grad(): + strict_posterior = strict.model.posterior(query) + historical_posterior = historical.posterior(query) + + assert all( + type(strict_gp.covar_module.base_kernel) + is type(historical_gp.covar_module.base_kernel) + for strict_gp, historical_gp in zip(strict.model.models, historical.models) + ) + torch.testing.assert_close(strict_posterior.mean, historical_posterior.mean) + torch.testing.assert_close(strict_posterior.variance, historical_posterior.variance) + + +def test_conservative_constraints_and_hyperparameter_extraction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(validation_module, "fit_gpytorch_mll", _skip_optimization) + X, Y, sample_ids = _training_data() + record = fit_model_variant( + X, + Y, + sample_ids=sample_ids, + objective_names=("one", "two"), + variant=CONSERVATIVE, + ) + + for gp in record.model.models: + noise_floor = gp.likelihood.noise_covar.raw_noise_constraint.lower_bound + lengthscale_floor = ( + gp.covar_module.base_kernel.raw_lengthscale_constraint.lower_bound + ) + assert float(noise_floor) == pytest.approx(0.01) + assert float(lengthscale_floor) == pytest.approx(0.05) + assert float(gp.likelihood.noise.detach()) > 0.01 + assert torch.all(gp.covar_module.base_kernel.lengthscale > 0.05) + + parameters = extract_model_hyperparameters( + record, input_names=("input_a", "input_b") + ) + assert len(parameters) == 2 + assert all(row.configured_min_noise == 0.01 for row in parameters) + assert all(row.configured_min_lengthscale == 0.05 for row in parameters) + assert all(len(row.ard_lengthscales) == 2 for row in parameters) + assert all(row.input_parameter_space == "normalized_0_1" for row in parameters) + assert all( + row.outcome_parameter_space == "standardized_internal" for row in parameters + ) + flattened = pd.DataFrame(row.as_flat_dict() for row in parameters) + assert { + "ard_lengthscale_input_a", + "ard_lengthscale_input_b", + "likelihood_noise", + "outputscale", + } <= set(flattened.columns) + + +def test_lengthscale_flags_are_relative_to_normalized_domain( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(validation_module, "fit_gpytorch_mll", _skip_optimization) + X, Y, sample_ids = _training_data() + record = fit_model_variant( + X, + Y, + sample_ids=sample_ids, + objective_names=("one", "two"), + variant=DIM_SCALED_PRIOR, + ) + for gp in record.model.models: + gp.covar_module.base_kernel.lengthscale = torch.tensor( + [[0.01, 20.0]], dtype=torch.double + ) + + parameters = extract_model_hyperparameters( + record, input_names=("input_a", "input_b") + ) + + assert all( + row.lengthscales_very_small_normalized_domain == (True, False) + for row in parameters + ) + assert all( + row.lengthscales_extremely_large_flat == (False, True) for row in parameters + ) + flattened = pd.DataFrame(row.as_flat_dict() for row in parameters) + assert flattened["any_lengthscale_very_small_normalized_domain"].all() + assert flattened["any_lengthscale_extremely_large_flat"].all() + assert flattened["ard_lengthscale_input_a_very_small_normalized_domain"].all() + assert flattened["ard_lengthscale_input_b_extremely_large_flat"].all() + + +def test_fit_failure_is_structured_and_never_printed_or_retried( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + calls = 0 + + def fail_strictly(mll: object) -> object: + nonlocal calls + calls += 1 + warnings.warn("optimizer diagnostic", RuntimeWarning, stacklevel=2) + raise RuntimeError("optimizer stopped") + + monkeypatch.setattr(validation_module, "fit_gpytorch_mll", fail_strictly) + X, Y, sample_ids = _training_data() + + with pytest.raises(ModelFitError) as captured: + fit_model_variant( + X, + Y, + sample_ids=sample_ids, + objective_names=("one", "two"), + variant=DIM_SCALED_PRIOR, + ) + + error = captured.value + assert calls == 1 + assert error.stage == "optimize" + assert error.objective_index == 0 + assert isinstance(error.cause, RuntimeError) + assert any( + row.warning_category == "RuntimeWarning" + and row.message == "optimizer diagnostic" + for row in error.fit_warnings + ) + captured_output = capsys.readouterr() + assert captured_output.out == "" + assert captured_output.err == "" + + +def test_constructor_failure_retains_structured_warnings( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail_during_construction(*args: object, **kwargs: object) -> object: + del args, kwargs + warnings.warn("constructor diagnostic", UserWarning, stacklevel=2) + raise RuntimeError("constructor stopped") + + monkeypatch.setattr( + validation_module, "_build_single_task_gp", fail_during_construction + ) + X, Y, sample_ids = _training_data() + with pytest.raises(ModelFitError) as captured: + fit_model_variant( + X, + Y, + sample_ids=sample_ids, + objective_names=("one", "two"), + variant=DIM_SCALED_PRIOR, + ) + + assert captured.value.stage == "construct" + assert any( + row.message == "constructor diagnostic" + and row.warning_category == "UserWarning" + for row in captured.value.fit_warnings + ) + + +def test_successful_fit_warnings_are_structured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def warn_and_succeed(mll: object) -> object: + warnings.warn("fit reached a bound", UserWarning, stacklevel=2) + return mll + + monkeypatch.setattr(validation_module, "fit_gpytorch_mll", warn_and_succeed) + X, Y, sample_ids = _training_data() + record = fit_model_variant( + X, + Y, + sample_ids=sample_ids, + objective_names=("one", "two"), + variant=DIM_SCALED_PRIOR, + fit_key="warning-test", + ) + + optimizer_warnings = [row for row in record.warnings if row.stage == "optimize"] + assert len(optimizer_warnings) == 2 + assert all(row.warning_category == "UserWarning" for row in optimizer_warnings) + assert all(row.fit_key == "warning-test" for row in optimizer_warnings) + frame = fit_warnings_frame([record]) + assert frame.shape[0] >= 2 + assert {"stage", "warning_category", "message"} <= set(frame.columns) + + +def test_exact_loocv_retains_folds_uncertainty_roles_and_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fit_calls = 0 + + def count_fit(mll: object) -> object: + nonlocal fit_calls + fit_calls += 1 + return mll + + monkeypatch.setattr(validation_module, "fit_gpytorch_mll", count_fit) + X, Y, sample_ids = _training_data() + cache = ModelFitCache() + first = run_exact_loocv( + X, + Y, + sample_ids=sample_ids, + objective_names=("one", "two"), + variant=DIM_SCALED_PRIOR, + seed=19, + row_roles=("control", "r0", "r0", "r0", "r0"), + control_sample_ids=(1,), + cache=cache, + ) + first_fit_calls = fit_calls + repeat = run_exact_loocv( + X, + Y, + sample_ids=sample_ids, + objective_names=("one", "two"), + variant=DIM_SCALED_PRIOR, + seed=19, + row_roles=("control", "r0", "r0", "r0", "r0"), + control_sample_ids=(1,), + cache=cache, + ) + + assert first_fit_calls == len(sample_ids) * Y.shape[1] + assert fit_calls == first_fit_calls + assert cache.hits == len(sample_ids) + assert len(first.predictions) == len(sample_ids) * Y.shape[1] + assert len(first.fold_records) == len(sample_ids) + assert [row.omitted_sample_id for row in first.predictions] == [ + sample_id for sample_id in sample_ids for _ in range(Y.shape[1]) + ] + assert sum(row.is_control for row in first.predictions) == Y.shape[1] + assert {row.row_role for row in first.predictions if row.is_control} == {"control"} + assert all(row.predictive_std >= row.latent_std for row in first.predictions) + assert any(row.predictive_std > row.latent_std for row in first.predictions) + assert all(np.isfinite(row.gaussian_nlpd) for row in first.predictions) + for omitted_id, record in first.fold_records.items(): + assert omitted_id not in record.sample_ids + assert len(record.sample_ids) == len(sample_ids) - 1 + pd.testing.assert_frame_equal( + first.predictions_frame(), repeat.predictions_frame(), check_exact=False + ) + assert len(first.metrics) == Y.shape[1] + assert all(metric.prediction_count == len(sample_ids) for metric in first.metrics) + + run_exact_loocv( + X, + Y, + sample_ids=sample_ids, + objective_names=("one", "two"), + variant=DIM_SCALED_PRIOR, + seed=20, + cache=cache, + ) + assert fit_calls == first_fit_calls * 2 + + +def test_validate_model_variant_extracts_full_and_fold_hyperparameters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(validation_module, "fit_gpytorch_mll", _skip_optimization) + X, Y, sample_ids = _training_data() + result = validate_model_variant( + X, + Y, + sample_ids=sample_ids, + input_names=("input_a", "input_b"), + objective_names=("one", "two"), + variant=CONSERVATIVE, + seed=73, + control_sample_ids=(1,), + ) + + assert result.full_fit.omitted_sample_id is None + assert len(result.loocv.fold_records) == len(sample_ids) + assert len(result.hyperparameters) == (len(sample_ids) + 1) * Y.shape[1] + frame = result.hyperparameters_frame() + assert set(frame["fit_key"]) == { + "full", + *{f"omit:int:{sample_id!r}" for sample_id in sample_ids}, + } + assert np.allclose(frame["noise_constraint_lower_bound"], 0.01) + assert np.allclose(frame["lengthscale_constraint_lower_bound"], 0.05) + assert set(result.loocv.fold_records) == set(sample_ids) + assert result.loocv.cache is not None + + +def test_prediction_metrics_match_hand_calculation() -> None: + observed = np.array([0.0, 1.0]) + predicted = np.array([0.1, 0.9]) + uncertainty = np.array([0.2, 0.2]) + + result = compute_prediction_metrics( + observed, + predicted, + uncertainty, + variant_name="hand", + objective_index=2, + objective_name="score", + ) + + expected_nlpd = 0.5 * np.log(2.0 * np.pi * 0.2**2) + 0.5 * 0.5**2 + assert result.prediction_count == 2 + assert result.mae == pytest.approx(0.1) + assert result.rmse == pytest.approx(0.1) + assert result.r_squared == pytest.approx(0.96) + assert result.spearman_rank_correlation == pytest.approx(1.0) + assert result.mean_signed_error == pytest.approx(0.0, abs=1e-15) + assert result.median_absolute_error == pytest.approx(0.1) + assert result.coverage_68_percent == 1.0 + assert result.coverage_95_percent == 1.0 + assert result.mean_standardized_residual == pytest.approx(0.0, abs=1e-15) + assert result.maximum_absolute_standardized_residual == pytest.approx(0.5) + assert result.mean_gaussian_nlpd == pytest.approx(expected_nlpd) + assert "small N=2" in result.r_squared_warning + + +def test_prediction_metrics_report_undefined_r_squared_and_validate_uncertainty() -> ( + None +): + constant = compute_prediction_metrics( + [1.0, 1.0, 1.0], + [0.9, 1.0, 1.1], + [0.2, 0.2, 0.2], + ) + assert np.isnan(constant.r_squared) + assert np.isnan(constant.spearman_rank_correlation) + assert "undefined" in constant.r_squared_warning + + with pytest.raises(ValueError, match="strictly positive"): + compute_prediction_metrics([0.0], [0.0], [0.0]) + + +def test_dim_scaled_prior_carries_the_lognormal_noise_prior() -> None: + """Regression guard for the outputscale-collapse mode. + + With only the lengthscale prior, the marginal likelihood could drive the + outputscale to zero and explain the data as pure noise, leaving a latent + predictive sd near 1e-4 against a fitted noise near 0.93. Measured on the + real campaign data that produced 68% coverage of 0.133 and mean NLPD 3.1e6 + over the leave-one-out folds. The noise prior is what rules it out. + """ + X = torch.rand(12, 3, dtype=torch.double) + Y = torch.rand(12, 1, dtype=torch.double) + record = fit_model_variant( + X, + Y, + sample_ids=tuple(range(12)), + objective_names=("y",), + variant=DIM_SCALED_PRIOR, + ) + for gp in record.model.models: + assert gp.likelihood.noise_covar.noise_prior is not None + assert float(gp.likelihood.noise_covar.noise_prior.loc) == pytest.approx(-4.0) + assert float(gp.likelihood.noise_covar.noise_prior.scale) == pytest.approx(1.0) + floor = gp.likelihood.noise_covar.raw_noise_constraint.lower_bound + assert float(floor) == pytest.approx(1.0e-4) + + record.model.eval() + with torch.no_grad(): + latent_sd = record.model.posterior(X).variance.sqrt() + # a collapsed outputscale shows up here as a latent sd orders of magnitude + # below the outcome scale + assert float(latent_sd.min()) > 1e-3 + + +def test_legacy_variant_keeps_its_bare_noise_floor() -> None: + """The retired contract must not silently inherit the new noise prior.""" + X = torch.rand(12, 3, dtype=torch.double) + Y = torch.rand(12, 1, dtype=torch.double) + record = fit_model_variant( + X, + Y, + sample_ids=tuple(range(12)), + objective_names=("y",), + variant=LEGACY_NO_PRIOR, + ) + for gp in record.model.models: + # a prior-free HomoskedasticNoise has no noise_prior attribute at all + assert getattr(gp.likelihood.noise_covar, "noise_prior", None) is None + floor = gp.likelihood.noise_covar.raw_noise_constraint.lower_bound + assert float(floor) == pytest.approx(1.0e-3) + + +def test_signal_collapse_guard_fires_on_a_degenerate_fit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A numerical guard, not a naming guard. + + Config-level naming cannot prevent a degenerate optimum: the same contract + refitted on new data can land there again. So the assertion runs on every + fit. Here the collapse is forced directly by zeroing the outputscale. + """ + X = torch.rand(10, 2, dtype=torch.double) + Y = torch.rand(10, 1, dtype=torch.double) + + real_fit = validation_module.fit_gpytorch_mll + + def collapse_outputscale(mll): + real_fit(mll) + # emulate the observed failure: no signal, all noise + mll.model.covar_module.outputscale = torch.tensor(1e-12, dtype=torch.double) + mll.model.likelihood.noise = torch.tensor(0.9, dtype=torch.double) + return mll + + monkeypatch.setattr(validation_module, "fit_gpytorch_mll", collapse_outputscale) + with pytest.raises(ModelFitError) as excinfo: + fit_model_variant( + X, + Y, + sample_ids=tuple(range(10)), + objective_names=("y",), + variant=DIM_SCALED_PRIOR, + ) + assert excinfo.value.stage == "signal_collapse_guard" + assert isinstance(excinfo.value.cause, validation_module.SignalCollapseError) + assert "pure noise" in str(excinfo.value.cause) + + +def test_signal_collapse_warns_rather_than_fails_when_a_mean_carries_the_trend( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The same collapsed outputscale, but with a mean module doing the work. + + A mean module is not part of the covariance, so it never enters + `posterior().variance` -- the latent sd collapses exactly as above while the + posterior MEAN still varies and candidates still rank. Refusing here would + dead-end the campaign at the moment the physics model started working, with no + way out: better data cannot be collected without first proposing conditions. + + So it warns, and the warning has to be honest about what is wrong -- the + exploration term is dead, and the frozen mean coefficients carry no + uncertainty, so the reported intervals are understated rather than earned. + """ + + class VaryingMean(gpytorch.means.Mean): + def forward(self, x: torch.Tensor) -> torch.Tensor: + return 5.0 * x[..., 0] + + X = torch.rand(12, 2, dtype=torch.double) + Y = (5.0 * X[:, :1]).double() + + real_fit = validation_module.fit_gpytorch_mll + + def collapse_outputscale(mll): + real_fit(mll) + mll.model.covar_module.outputscale = torch.tensor(1e-12, dtype=torch.double) + mll.model.likelihood.noise = torch.tensor(0.9, dtype=torch.double) + return mll + + monkeypatch.setattr(validation_module, "fit_gpytorch_mll", collapse_outputscale) + record = fit_model_variant( + X, + Y, + sample_ids=tuple(range(12)), + objective_names=("y",), + variant=DIM_SCALED_PRIOR, + mean_module=VaryingMean(), + ) + + collapse_warnings = [ + warning + for warning in record.warnings + if warning.stage == validation_module.SIGNAL_COLLAPSE_STAGE + ] + assert len(collapse_warnings) == 1 + warning = collapse_warnings[0] + assert warning.warning_category == validation_module.EXPLORATION_DEGENERATE_CATEGORY + assert "exploration term has degenerated" in warning.message + assert "UNDERSTATED" in warning.message + assert "no uncertainty" in warning.message + # and the fit is usable: that is the whole point of not raising + assert record.model is not None + + +def test_a_flat_posterior_mean_still_fails_even_with_a_mean_module( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The distinction is the posterior mean, not the presence of a mean module. + A constant mean module carries no information, so this is a true collapse.""" + + class ConstantMean(gpytorch.means.Mean): + def forward(self, x: torch.Tensor) -> torch.Tensor: + return torch.zeros(x.shape[:-1], dtype=x.dtype, device=x.device) + + X = torch.rand(12, 2, dtype=torch.double) + Y = torch.rand(12, 1, dtype=torch.double) + real_fit = validation_module.fit_gpytorch_mll + + def collapse_outputscale(mll): + real_fit(mll) + mll.model.covar_module.outputscale = torch.tensor(1e-12, dtype=torch.double) + mll.model.likelihood.noise = torch.tensor(0.9, dtype=torch.double) + return mll + + monkeypatch.setattr(validation_module, "fit_gpytorch_mll", collapse_outputscale) + with pytest.raises(ModelFitError) as excinfo: + fit_model_variant( + X, + Y, + sample_ids=tuple(range(12)), + objective_names=("y",), + variant=DIM_SCALED_PRIOR, + mean_module=ConstantMean(), + ) + assert excinfo.value.stage == "signal_collapse_guard" + assert "cannot order two candidates" in str(excinfo.value.cause) + + +def test_signal_collapse_guard_passes_a_healthy_fit() -> None: + X = torch.rand(12, 3, dtype=torch.double) + Y = (X[:, :1] * 2.0 + 0.1 * torch.randn(12, 1, dtype=torch.double)).double() + record = fit_model_variant( + X, + Y, + sample_ids=tuple(range(12)), + objective_names=("y",), + variant=DIM_SCALED_PRIOR, + ) + assert record.model is not None diff --git a/tests/test_models.py b/tests/test_models.py index 32cff3b..af37941 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,264 +1,89 @@ -import sys -import os -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +"""CPU-fast regression tests for the active GP model helpers.""" + +from __future__ import annotations -import yaml -import torch import numpy as np -import pandas as pd -from src.design import build_design_from_config -from src.utils import load_csv, split_XY, np_to_torch, get_objective_names -from src.models import fit_gp_models, default_noise_options, loocv_select_models, posterior_report -from src.data import y_minmax_np +import pytest +import torch +from botorch.models.model_list_gp_regression import ModelListGP +from gpytorch.kernels import Kernel +from gpytorch.priors import Prior -import gpytorch +import mobo_kit.models as models_module +from mobo_kit.models import ( + default_kernel_options, + default_noise_options, + fit_gp_models, + posterior_report, +) -CFG_PATH = "configs/configCSV_example_config.yaml" -CSV_PATH = "data/processed/configCSV_example.csv" -def test_basic_gp_fitting(): - """Test basic GP model fitting with real data.""" - print("Testing GP model fitting...") - - # Load real data - config = yaml.load(open(CFG_PATH), Loader=yaml.FullLoader) - design = build_design_from_config(config) - df = load_csv(CSV_PATH) - X, Y = split_XY(df, design, config) - - print(f"Data loaded: X shape {X.shape}, Y shape {Y.shape}") - - # Convert to torch tensors (test with CUDA if available) - device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') - X_t, Y_t = np_to_torch(X.values, Y.values, device=device) - print(f"Converted to torch: X {X_t.shape}, Y {Y_t.shape} on {X_t.device}") - - # Test noise options - print("\nTesting noise options...") - noise_opts = default_noise_options(device=device) - print(f"Available noise options: {list(noise_opts.keys())}") - - # Test that we can create likelihoods with these priors - for name, prior in noise_opts.items(): - if prior is not None: - try: - likelihood = gpytorch.likelihoods.GaussianLikelihood(noise_prior=prior) - print(f"✓ {name}: Successfully created likelihood") - except Exception as e: - print(f"✗ {name}: Failed to create likelihood - {e}") - else: - print(f"✓ {name}: No prior (default likelihood)") - - # Fit GP models - print("\nFitting GP models...") - model = fit_gp_models(X_t, Y_t) - - # Verify model structure - assert hasattr(model, 'models'), "Should return ModelListGP" - assert len(model.models) == Y.shape[1], f"Should have {Y.shape[1]} models" - print(f"✓ Successfully fitted {len(model.models)} GP models") - - # Test prediction - with torch.no_grad(): - posterior = model.posterior(X_t) - pred_mean = posterior.mean - pred_var = posterior.variance - - assert pred_mean.shape == Y_t.shape, "Prediction mean shape mismatch" - assert pred_var.shape == Y_t.shape, "Prediction variance shape mismatch" - print(f"✓ Predictions have correct shape: {pred_mean.shape}") - - return model, X_t, Y_t +def _training_data(): + train_x = torch.tensor( + [ + [0.0, 0.0], + [0.2, 0.8], + [0.4, 0.3], + [0.6, 0.9], + [0.8, 0.2], + [1.0, 1.0], + ], + dtype=torch.float64, + ) + train_y = torch.stack( + ( + train_x[:, 0] + 0.5 * train_x[:, 1], + 1.0 - train_x[:, 0].square() + train_x[:, 1], + ), + dim=1, + ) + return train_x, train_y -def test_loocv_model_selection(): - """Test LOOCV model selection with real data.""" - print("\nTesting LOOCV model selection...") - - # Load real data - config = yaml.load(open(CFG_PATH), Loader=yaml.FullLoader) - design = build_design_from_config(config) - df = load_csv(CSV_PATH) - X, Y = split_XY(df, design, config) - objective_names = get_objective_names(config) - - # Convert to torch tensors (test with CUDA if available) - device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') - X_t, Y_t = np_to_torch(X.values, Y.values, device=device) - print(f"Running LOOCV on {len(X_t)} samples with {len(objective_names)} objectives") - - # Run LOOCV model selection (simplified for speed) - best_model, results_df = loocv_select_models( - X_t, Y_t, - objective_names=objective_names, - device=device - ) - - # Verify results - assert hasattr(best_model, 'models'), "Should return ModelListGP" - assert len(best_model.models) == len(objective_names), "Should have model for each objective" - assert isinstance(results_df, pd.DataFrame), "Should return DataFrame" - - print(f"✓ LOOCV completed with {len(results_df)} combinations tested") - print(f"✓ Results columns: {list(results_df.columns)}") - print(f"✓ Best model has {len(best_model.models)} objectives") - - # Check results structure - expected_cols = {"Kernel", "NoisePrior", "Objective", "R2", "RMSE"} - assert expected_cols.issubset(set(results_df.columns)), "Missing expected columns" - - # Show sample results - print("\nSample LOOCV results:") - print(results_df.head(10)) - - return best_model, results_df, X_t, Y_t, objective_names +def test_default_model_options_build_expected_active_types(): + kernel_factories = default_kernel_options() + kernels = [factory(2) for factory in kernel_factories] + noise_options = default_noise_options(torch.device("cpu")) + + assert len(kernels) == 4 + assert all(isinstance(kernel, Kernel) for kernel in kernels) + assert all(kernel.ard_num_dims == 2 for kernel in kernels) + assert noise_options[0] is None + assert all(option is None or isinstance(option, Prior) for option in noise_options) + + +def test_fit_gp_models_and_posterior_report_have_multioutput_shapes(monkeypatch): + fit_calls = [] + def skip_hyperparameter_optimization(mll): + fit_calls.append(mll) + return mll -def test_posterior_report(): - """Test posterior reporting with unnormalization.""" - print("\nTesting posterior report...") - - # Load real data - config = yaml.load(open(CFG_PATH), Loader=yaml.FullLoader) - design = build_design_from_config(config) - df = load_csv(CSV_PATH) - X, Y = split_XY(df, design, config) - objective_names = get_objective_names(config) - - # Convert to torch tensors (test with CUDA if available) - device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') - X_t, Y_t = np_to_torch(X.values, Y.values, device=device) - - # Normalize Y data (as would be done in real pipeline) - Y_scaled, Y_min, Y_max = y_minmax_np(Y.values) - Y_scaled_t = torch.tensor(Y_scaled, dtype=torch.float64, device=device) - - print(f"Y normalization - Min: {Y_min}, Max: {Y_max}") - - # Fit model on scaled data - model = fit_gp_models(X_t, Y_scaled_t) - - # Generate posterior report - report_df, metrics_df = posterior_report( - model, X_t, Y_scaled_t, Y_min, Y_max, - objective_names=objective_names, - add_residuals=True, - add_zscores=True + monkeypatch.setattr( + models_module, "fit_gpytorch_mll", skip_hyperparameter_optimization ) - - # Verify report structure - assert isinstance(report_df, pd.DataFrame), "Should return DataFrame" - assert isinstance(metrics_df, pd.DataFrame), "Should return metrics DataFrame" - - print(f"✓ Report generated with {len(report_df)} rows") - print(f"✓ Report columns: {list(report_df.columns)}") - print(f"✓ Metrics columns: {list(metrics_df.columns)}") - - # Check for expected columns - for obj_name in objective_names: - assert f"True[{obj_name}]" in report_df.columns, f"Missing True column for {obj_name}" - assert f"Pred[{obj_name}]" in report_df.columns, f"Missing Pred column for {obj_name}" - assert f"Std[{obj_name}]" in report_df.columns, f"Missing Std column for {obj_name}" - assert f"Residual[{obj_name}]" in report_df.columns, f"Missing Residual column for {obj_name}" - assert f"Z[{obj_name}]" in report_df.columns, f"Missing Z-score column for {obj_name}" - - print("\nMetrics summary:") - print(metrics_df) - - print("\nSample report data:") - print(report_df.head()) - - return report_df, metrics_df + train_x, train_y = _training_data() + model = fit_gp_models(train_x, train_y) + pred_mean, pred_std = posterior_report(model, train_x[:3]) -def test_posterior_report_simple(): - """Simple test for posterior report to debug issues.""" - print("\nTesting posterior report (simple)...") - - # Load real data - config = yaml.load(open(CFG_PATH), Loader=yaml.FullLoader) - design = build_design_from_config(config) - df = load_csv(CSV_PATH) - X, Y = split_XY(df, design, config) - objective_names = get_objective_names(config) - - # Convert to torch tensors (test with CUDA if available) - device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') - X_t, Y_t = np_to_torch(X.values, Y.values, device=device) - - # Normalize Y data (as would be done in real pipeline) - Y_scaled, Y_min, Y_max = y_minmax_np(Y.values) - Y_scaled_t = torch.tensor(Y_scaled, dtype=torch.float64, device=device) - - print(f"Y normalization - Min: {Y_min}, Max: {Y_max}") - print(f"Y_scaled_t shape: {Y_scaled_t.shape}, device: {Y_scaled_t.device}") - - # Fit model on scaled data - print("Fitting model...") - model = fit_gp_models(X_t, Y_scaled_t) - print(f"Model fitted with {len(model.models)} objectives") - - # Test posterior step by step - print("Testing posterior step by step...") - - # Step 1: Get posterior - print("Step 1: Getting posterior...") - post = model.posterior(X_t) - pred_mean_t = post.mean - pred_std_t = torch.sqrt(post.variance) - print(f"Posterior shapes - mean: {pred_mean_t.shape}, std: {pred_std_t.shape}") - - # Step 2: Convert to numpy - print("Step 2: Converting to numpy...") - from src.utils import torch_to_np - pred_mean, pred_std, true_scaled = torch_to_np(pred_mean_t, pred_std_t, Y_scaled_t) - print(f"Numpy shapes - mean: {pred_mean.shape}, std: {pred_std.shape}, true: {true_scaled.shape}") - - # Step 3: Unnormalize - print("Step 3: Unnormalizing...") - Y_min = np.asarray(Y_min, dtype=float) - Y_max = np.asarray(Y_max, dtype=float) - scale = (Y_max - Y_min).astype(float) - print(f"Scale factors: {scale}") - - pred_mean_unnorm = pred_mean * scale + Y_min - pred_std_unnorm = pred_std * scale - true_Y = true_scaled * scale + Y_min - print(f"Unnormalized shapes - mean: {pred_mean_unnorm.shape}, std: {pred_std_unnorm.shape}, true: {true_Y.shape}") - - print("✓ All steps completed successfully!") - return model, X_t, Y_scaled_t, Y_min, Y_max, objective_names + assert isinstance(model, ModelListGP) + assert len(model.models) == train_y.shape[1] + assert len(fit_calls) == train_y.shape[1] + assert pred_mean.shape == (3, 2) + assert pred_std.shape == (3, 2) + assert np.isfinite(pred_mean).all() + assert np.isfinite(pred_std).all() + assert (pred_std >= 0.0).all() + assert all(next(gp.parameters()).device.type == "cpu" for gp in model.models) -def main(): - """Run comprehensive model tests.""" - print("Running comprehensive models.py tests...\n") - - try: - # Test 1: Basic GP fitting - print("="*60) - model, X_t, Y_t = test_basic_gp_fitting() - - # Test 2: LOOCV model selection - print("="*60) - best_model, results_df, X_t, Y_t, objective_names = test_loocv_model_selection() - - # Test 3: Posterior reporting (simple) - print("="*60) - model, X_t, Y_scaled_t, Y_min, Y_max, objective_names = test_posterior_report_simple() - - print("="*60) - print("\n🎉 All model tests passed successfully!") - print(f"🎯 Tested with {len(X_t)} samples across {X_t.shape[1]} input dimensions") - print(f"🎯 Validated {len(objective_names)} objectives: {objective_names}") - print(f"🎯 LOOCV tested {len(results_df)} kernel/noise combinations") - print(f"🎯 Generated comprehensive posterior report with metrics") - - except Exception as e: - print(f"\n❌ Test failed: {e}") - import traceback - traceback.print_exc() +def test_fit_gp_models_rejects_mismatched_per_objective_options(monkeypatch): + monkeypatch.setattr(models_module, "fit_gpytorch_mll", lambda mll: mll) + train_x, train_y = _training_data() + with pytest.raises(ValueError, match="kernel_fn list length"): + fit_gp_models(train_x, train_y, kernel_fn=[default_kernel_options()[0]]) -if __name__ == "__main__": - main() + with pytest.raises(ValueError, match="noise_priors list length"): + fit_gp_models(train_x, train_y, noise_priors=[None]) diff --git a/tests/test_objectives.py b/tests/test_objectives.py new file mode 100644 index 0000000..ebf1c07 --- /dev/null +++ b/tests/test_objectives.py @@ -0,0 +1,645 @@ +import math + +import pytest +import torch + +from mobo_kit.objectives import ( + BoundedMCMultiOutputObjective, + BoundedPosteriorSampleTransform, + ConfiguredMCMultiOutputObjective, + ObjectiveSpec, + ObjectiveTransform, +) + + +def _mixed_transform(): + return ObjectiveTransform( + [ + ObjectiveSpec("already_utility", "maximize", "identity"), + ObjectiveSpec( + "maximize_raw", + "maximize", + "affine", + lower_anchor=10.0, + upper_anchor=20.0, + ), + ObjectiveSpec( + "minimize_raw", + "minimize", + "affine", + lower_anchor=0.0, + upper_anchor=4.0, + ), + ObjectiveSpec( + "target_raw", + "target", + "gaussian_target", + target=650.0, + sigma=100.0, + ), + ], + version="TEST_ONLY-v1", + ) + + +def _identity_transform(): + return ObjectiveTransform( + [ + ObjectiveSpec("uniformity", "maximize", "identity"), + ObjectiveSpec("optoelectronic", "maximize", "identity"), + ObjectiveSpec("thickness", "maximize", "identity"), + ], + version="TEST_IDENTITY-v1", + ) + + +def test_identity_affine_and_arbitrary_leading_dimensions(): + Y = torch.tensor( + [ + [[[0.2, 10.0, 0.0, 650.0], [0.8, 20.0, 4.0, 750.0]]], + [[[0.4, 15.0, 2.0, 550.0], [0.1, 25.0, -2.0, 650.0]]], + ], + dtype=torch.double, + ) + result = _mixed_transform()(Y) + assert result.shape == Y.shape + assert result.dtype == Y.dtype + assert result.device == Y.device + assert torch.allclose(result[..., 0], Y[..., 0]) + assert result[0, 0, 0, 1].item() == pytest.approx(0.0) + assert result[0, 0, 1, 1].item() == pytest.approx(1.0) + assert result[0, 0, 0, 2].item() == pytest.approx(1.0) + assert result[0, 0, 1, 2].item() == pytest.approx(0.0) + + +def test_affine_clip_is_explicit(): + transform = ObjectiveTransform( + [ + ObjectiveSpec( + "clipped", + "maximize", + "affine", + lower_anchor=0, + upper_anchor=1, + clip=True, + ) + ], + version="TEST_ONLY-v1", + ) + result = transform(torch.tensor([[-1.0], [0.4], [2.0]])) + assert result[:, 0].tolist() == pytest.approx([0.0, 0.4, 1.0]) + with pytest.raises(ValueError, match="cannot enable clip"): + ObjectiveSpec("identity", "maximize", "identity", clip=True) + + +def test_gaussian_target_value_symmetry_and_monotonicity(): + transform = ObjectiveTransform( + [ + ObjectiveSpec( + "thickness", + "target", + "gaussian_target", + target=650, + sigma=100, + ) + ], + version="TEST_ONLY-v1", + ) + result = transform( + torch.tensor([[650.0], [600.0], [700.0], [450.0]], dtype=torch.double) + )[:, 0] + assert result[0].item() == pytest.approx(1.0) + assert result[1].item() == pytest.approx(result[2].item()) + assert result[1] < result[0] + assert result[3] < result[1] + + +def test_negative_absolute_target_hand_calculation(): + transform = ObjectiveTransform( + [ + ObjectiveSpec( + "target", + "target", + "negative_absolute_target", + target=10, + scale=2, + ) + ], + version="TEST_ONLY-v1", + ) + assert transform(torch.tensor([[8.0], [10.0], [13.0]]))[:, 0].tolist() == [ + -1.0, + -0.0, + -1.5, + ] + + +def test_nonlinear_transform_is_applied_before_sample_mean(): + transform = ObjectiveTransform( + [ + ObjectiveSpec( + "target", + "target", + "gaussian_target", + target=0, + sigma=1, + ) + ], + version="TEST_ONLY-v1", + ) + posterior_samples = torch.tensor([[[-1.0]], [[1.0]]]) + mean_after_transform = transform(posterior_samples).mean(dim=0) + transform_of_mean = transform(posterior_samples.mean(dim=0)) + assert mean_after_transform.item() == pytest.approx( + torch.exp(torch.tensor(-0.5)).item() + ) + assert transform_of_mean.item() == pytest.approx(1.0) + assert not torch.allclose(mean_after_transform, transform_of_mean) + + +def test_botorch_objective_matches_direct_transform(): + transform = _mixed_transform() + objective = ConfiguredMCMultiOutputObjective(transform) + samples = torch.tensor( + [[[[0.2, 15.0, 2.0, 650.0], [0.8, 20.0, 0.0, 750.0]]]], + dtype=torch.double, + ) + assert torch.equal(objective(samples), transform(samples)) + + +def test_bounded_posterior_sample_transform_is_explicit_and_non_mutating(): + transform = _identity_transform() + bounded = BoundedPosteriorSampleTransform( + transform, + [(0.0, 1.0), (None, None), (0.0, 1.0)], + ) + samples = torch.tensor( + [ + [[[-0.2, -3.5, 1.2], [0.4, 2.1, 0.7]]], + [[[1.4, 8.0, -0.1], [0.9, -1.2, 2.0]]], + ], + dtype=torch.double, + ) + samples_before = samples.clone() + utilities = bounded(samples) + + assert utilities.shape == samples.shape + assert utilities.dtype == samples.dtype + assert utilities.device == samples.device + assert torch.equal(samples, samples_before) + assert torch.all((utilities[..., 0] >= 0.0) & (utilities[..., 0] <= 1.0)) + assert torch.equal(utilities[..., 1], samples[..., 1]) + assert torch.all((utilities[..., 2] >= 0.0) & (utilities[..., 2] <= 1.0)) + assert bounded.bounds == ((0.0, 1.0), (None, None), (0.0, 1.0)) + assert bounded.version == "TEST_IDENTITY-v1+posterior-sample-bounds-v1" + + # The base contract remains unchanged for observed/training targets. Bounds + # apply only when the acquisition-specific wrapper is explicitly invoked. + training_targets = samples_before[0, 0].clone() + training_targets_before = training_targets.clone() + assert torch.equal(transform(training_targets), training_targets_before) + assert torch.equal(training_targets, training_targets_before) + + +def test_bounded_botorch_objective_matches_wrapper_without_touching_reference(): + transform = _identity_transform() + bounds = [(0.0, 1.0), (None, None), (0.0, 1.0)] + objective = BoundedMCMultiOutputObjective(transform, bounds) + samples = torch.tensor([[[-1.0, 2.5, 3.0]]], dtype=torch.float32) + reference_point = torch.tensor([-0.1, -4.0, -0.2], dtype=torch.float32) + reference_before = reference_point.clone() + + expected = BoundedPosteriorSampleTransform(transform, bounds)(samples) + assert torch.equal(objective(samples), expected) + assert torch.equal(reference_point, reference_before) + assert objective.bounds == tuple(bounds) + + +@pytest.mark.parametrize( + "bounds, match", + [ + ([(0.0, 1.0)], "one .* pair per objective"), + ([(1.0, 0.0), (None, None), (0.0, 1.0)], "must not exceed"), + ([(None, None), (None, None), (None, None)], "At least one"), + ([(False, 1.0), (None, None), (0.0, 1.0)], "non-boolean"), + ([(0.0, float("inf")), (None, None), (0.0, 1.0)], "finite"), + ], +) +def test_bounded_posterior_sample_contract_validation(bounds, match): + with pytest.raises(ValueError, match=match): + BoundedPosteriorSampleTransform(_identity_transform(), bounds) + + +def test_posterior_sample_bounds_reject_nonidentity_objectives(): + transform = ObjectiveTransform( + [ + ObjectiveSpec( + "scaled", + "maximize", + "affine", + lower_anchor=0.0, + upper_anchor=1.0, + ) + ], + version="TEST_AFFINE-v1", + ) + with pytest.raises(ValueError, match="identity/maximize"): + BoundedPosteriorSampleTransform(transform, [(0.0, 1.0)]) + + +@pytest.mark.parametrize( + "kwargs, match", + [ + ({"name": "", "goal": "maximize", "transform": "identity"}, "name"), + ({"name": "x", "goal": "minimize", "transform": "identity"}, "only"), + ({"name": "x", "goal": "target", "transform": "affine"}, "requires"), + ( + { + "name": "x", + "goal": "maximize", + "transform": "affine", + "lower_anchor": 1, + "upper_anchor": 1, + }, + "lower_anchor < upper_anchor", + ), + ( + { + "name": "x", + "goal": "target", + "transform": "gaussian_target", + "target": 0, + "sigma": 0, + }, + "strictly positive", + ), + ( + { + "name": "x", + "goal": "maximize", + "transform": "affine", + "lower_anchor": False, + "upper_anchor": 1, + }, + "non-boolean", + ), + ( + { + "name": "x", + "goal": "maximize", + "transform": "affine", + "lower_anchor": "0", + "upper_anchor": 1, + }, + "non-boolean", + ), + ( + { + "name": "x", + "goal": "target", + "transform": "negative_absolute_target", + "target": 0, + "scale": -1, + }, + "strictly positive", + ), + ], +) +def test_invalid_objective_specs_fail(kwargs, match): + with pytest.raises(ValueError, match=match): + ObjectiveSpec(**kwargs) + + +def test_wrong_dimension_integer_nonfinite_and_duplicate_names_fail(): + transform = ObjectiveTransform( + [ObjectiveSpec("x", "maximize", "identity")], version="TEST_ONLY-v1" + ) + with pytest.raises(ValueError, match="final dimension"): + transform(torch.ones((2, 2))) + with pytest.raises(TypeError, match="floating"): + transform(torch.ones((2, 1), dtype=torch.int64)) + with pytest.raises(ValueError, match="finite"): + transform(torch.tensor([[float("nan")]])) + with pytest.raises(ValueError, match="unique"): + ObjectiveTransform( + [ + ObjectiveSpec("x", "maximize", "identity"), + ObjectiveSpec("x", "maximize", "identity"), + ], + version="TEST_ONLY-v1", + ) + + +# --------------------------------------------------------------------------- # +# expected utility under a Gaussian posterior +# --------------------------------------------------------------------------- # + + +def _mc_expected(spec, mu, var, *, draws=2_000_000, seed=0): + """Monte-Carlo reference for E[transform(Y)], Y ~ N(mu, var).""" + transform = ObjectiveTransform([spec], version="TEST_ONLY-v1") + g = torch.Generator().manual_seed(seed) + y = mu + math.sqrt(var) * torch.randn(draws, 1, generator=g, dtype=torch.double) + return float(transform(y).mean()) + + +@pytest.mark.parametrize("mu", [400.0, 650.0, 900.0, 1300.0]) +@pytest.mark.parametrize("var", [0.0, 20.0**2, 100.0**2, 300.0**2]) +def test_gaussian_target_expected_matches_monte_carlo(mu, var): + """The closed form must agree with sampling, including at variance zero.""" + # workbook uses exp(-((T-650)/250)^2), i.e. sigma = 250/sqrt(2) here + spec = ObjectiveSpec( + "thickness", + "target", + "gaussian_target", + target=650.0, + sigma=250.0 / math.sqrt(2.0), + ) + transform = ObjectiveTransform([spec], version="TEST_ONLY-v1") + got = float( + transform.expected_transform( + torch.tensor([[mu]], dtype=torch.double), + torch.tensor([[var]], dtype=torch.double), + ) + ) + if var == 0.0: + assert got == pytest.approx( + float(transform(torch.tensor([[mu]], dtype=torch.double))), rel=1e-12 + ) + else: + assert got == pytest.approx(_mc_expected(spec, mu, var), abs=1e-3) + + +def test_gaussian_target_expected_penalises_uncertainty_at_the_target(): + """Identical predicted mean, wider posterior, strictly lower expected utility.""" + spec = ObjectiveSpec( + "thickness", + "target", + "gaussian_target", + target=650.0, + sigma=250.0 / math.sqrt(2.0), + ) + transform = ObjectiveTransform([spec], version="TEST_ONLY-v1") + mean = torch.full((3, 1), 650.0, dtype=torch.double) + var = torch.tensor([[20.0], [100.0], [300.0]], dtype=torch.double) ** 2 + scores = transform.expected_transform(mean, var).flatten().tolist() + assert scores[0] > scores[1] > scores[2] + assert scores == pytest.approx([0.993661, 0.870388, 0.507673], abs=1e-5) + + +def test_negative_absolute_target_expected_matches_monte_carlo(): + spec = ObjectiveSpec( + "t", "target", "negative_absolute_target", target=650.0, scale=250.0 + ) + transform = ObjectiveTransform([spec], version="TEST_ONLY-v1") + for mu, var in ((650.0, 100.0**2), (400.0, 50.0**2), (900.0, 300.0**2)): + got = float( + transform.expected_transform( + torch.tensor([[mu]], dtype=torch.double), + torch.tensor([[var]], dtype=torch.double), + ) + ) + assert got == pytest.approx(_mc_expected(spec, mu, var), abs=2e-3) + + +def test_linear_transforms_expectation_equals_transform_of_mean(): + transform = ObjectiveTransform( + [ + ObjectiveSpec("a", "maximize", "identity"), + ObjectiveSpec( + "b", "maximize", "affine", lower_anchor=0.0, upper_anchor=2.0 + ), + ], + version="TEST_ONLY-v1", + ) + mean = torch.tensor([[0.3, 1.1]], dtype=torch.double) + var = torch.tensor([[4.0, 9.0]], dtype=torch.double) + torch.testing.assert_close(transform.expected_transform(mean, var), transform(mean)) + + +def test_expected_transform_rejects_bad_input(): + transform = ObjectiveTransform( + [ObjectiveSpec("x", "maximize", "identity")], version="TEST_ONLY-v1" + ) + ok = torch.ones((2, 1), dtype=torch.double) + with pytest.raises(ValueError, match="share a shape"): + transform.expected_transform(ok, torch.ones((3, 1), dtype=torch.double)) + with pytest.raises(ValueError, match="non-negative"): + transform.expected_transform(ok, -ok) + with pytest.raises(ValueError, match="final dimension"): + transform.expected_transform( + torch.ones((2, 2), dtype=torch.double), + torch.ones((2, 2), dtype=torch.double), + ) + + +# --------------------------------------------------------------------------- # +# lognormal expectation (GP fitted in log space) +# --------------------------------------------------------------------------- # + + +def _thickness_utility(): + return ObjectiveTransform( + [ + ObjectiveSpec( + "thickness", + "target", + "gaussian_target", + target=650.0, + sigma=250.0 / math.sqrt(2.0), + ) + ], + version="TEST_ONLY-v1", + ) + + +@pytest.mark.parametrize("median_nm", [500.0, 700.0, 900.0]) +@pytest.mark.parametrize("s_log", [0.10, 0.20, 0.40]) +def test_lognormal_expectation_matches_monte_carlo(median_nm, s_log): + transform = _thickness_utility() + m = math.log(median_nm) + got = float( + transform.expected_transform_lognormal( + torch.tensor([[m]], dtype=torch.double), + torch.tensor([[s_log**2]], dtype=torch.double), + ) + ) + g = torch.Generator().manual_seed(0) + z = m + s_log * torch.randn(2_000_000, 1, generator=g, dtype=torch.double) + mc = float(transform(torch.exp(z)).mean()) + assert got == pytest.approx(mc, abs=1e-3) + + +def test_lognormal_expectation_beats_moment_matching_by_orders_of_magnitude(): + """Moment-matching a lognormal to a Gaussian and reusing the closed form is + an approximation whose error is large enough to reorder candidates.""" + transform = _thickness_utility() + m, s = math.log(700.0), 0.40 + gh = float( + transform.expected_transform_lognormal( + torch.tensor([[m]], dtype=torch.double), + torch.tensor([[s**2]], dtype=torch.double), + ) + ) + mu = math.exp(m + s * s / 2.0) + var = (math.exp(s * s) - 1.0) * math.exp(2 * m + s * s) + mm = float( + transform.expected_transform( + torch.tensor([[mu]], dtype=torch.double), + torch.tensor([[var]], dtype=torch.double), + ) + ) + g = torch.Generator().manual_seed(0) + z = m + s * torch.randn(2_000_000, 1, generator=g, dtype=torch.double) + mc = float(transform(torch.exp(z)).mean()) + assert abs(gh - mc) < 1e-3 + assert abs(mm - mc) > 50 * abs(gh - mc) + + +def test_moment_matching_error_changes_sign_across_the_range(): + """The reason moment-matching is not merely a constant offset: the bias + flips sign, so it permutes the candidate ordering.""" + transform = _thickness_utility() + s = 0.22 # the campaign's measured posterior width in log space + signed = [] + for median_nm in (550.0, 850.0): + m = math.log(median_nm) + gh = float( + transform.expected_transform_lognormal( + torch.tensor([[m]], dtype=torch.double), + torch.tensor([[s**2]], dtype=torch.double), + ) + ) + mu = math.exp(m + s * s / 2.0) + var = (math.exp(s * s) - 1.0) * math.exp(2 * m + s * s) + mm = float( + transform.expected_transform( + torch.tensor([[mu]], dtype=torch.double), + torch.tensor([[var]], dtype=torch.double), + ) + ) + signed.append(mm - gh) + assert signed[0] > 0 > signed[1], f"expected a sign change, got {signed}" + + +def test_lognormal_expectation_degenerates_to_the_plain_transform(): + transform = _thickness_utility() + m = torch.tensor([[math.log(650.0)]], dtype=torch.double) + got = float(transform.expected_transform_lognormal(m, torch.zeros_like(m))) + assert got == pytest.approx(1.0, abs=1e-9) + + +def test_lognormal_expectation_rejects_bad_input(): + transform = _thickness_utility() + ok = torch.zeros((2, 1), dtype=torch.double) + with pytest.raises(ValueError, match="share a shape"): + transform.expected_transform_lognormal( + ok, torch.zeros((3, 1), dtype=torch.double) + ) + with pytest.raises(ValueError, match="non-negative"): + transform.expected_transform_lognormal(ok, ok - 1.0) + with pytest.raises(ValueError, match="nodes"): + transform.expected_transform_lognormal(ok, ok, nodes=1) + + +# --------------------------------------------------------------------------- # +# model_link: the two acquisition paths must agree +# --------------------------------------------------------------------------- # + + +def _log_link_contract(): + """The campaign's shape: two identity-link objectives and one log-link.""" + return ObjectiveTransform( + [ + ObjectiveSpec( + "uniformity", "maximize", "affine", lower_anchor=0.0, upper_anchor=1.0 + ), + ObjectiveSpec( + "optoelectronic", + "maximize", + "affine", + lower_anchor=-10.0, + upper_anchor=-6.0, + ), + ObjectiveSpec( + "thickness", + "target", + "gaussian_target", + model_link="log", + target=650.0, + sigma=250.0 / math.sqrt(2.0), + ), + ], + version="TEST_ONLY-v1", + ) + + +def test_log_link_decodes_exactly_once(): + """The trap: quadrature and MC both route through one link decode. If either + exponentiates separately the utility is computed on exp(exp(x)).""" + transform = _log_link_contract() + nm = 687.0 + model_output = torch.tensor([[0.5, -8.0, math.log(nm)]], dtype=torch.double) + got = transform(model_output)[0, 2].item() + expected = math.exp(-(((nm - 650.0) / 250.0) ** 2)) + assert got == pytest.approx(expected, abs=1e-12) + + +def test_identity_link_objectives_are_untouched_by_the_link_machinery(): + transform = _log_link_contract() + model_output = torch.tensor([[0.5, -8.0, math.log(650.0)]], dtype=torch.double) + utilities = transform(model_output) + assert utilities[0, 0].item() == pytest.approx(0.5) + assert utilities[0, 1].item() == pytest.approx((-8.0 + 10.0) / 4.0) + + +def test_ucb_and_qlognehvi_paths_agree_on_the_same_posterior(): + """The guard that matters operationally. + + UCB reads analytic utility moments; qLogNEHVI transforms posterior samples. + Both are correct, and nothing else forces them to match. If they drift, the + two rounds silently optimise different objectives and it surfaces only as R1 + and R2 disagreeing for reasons nobody can trace. + """ + transform = _log_link_contract() + mean = torch.tensor( + [[0.4, -8.2, math.log(700.0)], [0.6, -7.5, math.log(480.0)]], + dtype=torch.double, + ) + variance = torch.tensor( + [[0.01, 0.04, 0.22**2], [0.02, 0.09, 0.30**2]], dtype=torch.double + ) + + analytic = transform.expected_transform(mean, variance) + + # the sampling path: draw in MODEL space, transform, average + g = torch.Generator().manual_seed(0) + draws = 400_000 + samples = mean.unsqueeze(0) + variance.sqrt().unsqueeze(0) * torch.randn( + (draws, *mean.shape), generator=g, dtype=torch.double + ) + sampled = transform(samples).mean(dim=0) + + # MC standard error over this many draws is ~1e-3 + torch.testing.assert_close(analytic, sampled, atol=4e-3, rtol=0.0) + + +def test_expected_transform_dispatches_per_objective_not_globally(): + """A contract mixing links must not apply one rule to every column.""" + transform = _log_link_contract() + mean = torch.tensor([[0.4, -8.0, math.log(650.0)]], dtype=torch.double) + zero = torch.zeros_like(mean) + + # at zero variance every path collapses to the plain transform + torch.testing.assert_close( + transform.expected_transform(mean, zero), transform(mean) + ) + + # widening only the log-link column must move only that utility + widened = zero.clone() + widened[0, 2] = 0.30**2 + expected = transform.expected_transform(mean, widened) + baseline = transform(mean) + assert expected[0, 0].item() == pytest.approx(baseline[0, 0].item()) + assert expected[0, 1].item() == pytest.approx(baseline[0, 1].item()) + assert expected[0, 2].item() < baseline[0, 2].item() diff --git a/tests/test_plot_round_simulation.py b/tests/test_plot_round_simulation.py new file mode 100644 index 0000000..78a9c91 --- /dev/null +++ b/tests/test_plot_round_simulation.py @@ -0,0 +1,580 @@ +"""The round-simulation script. + +Not a test of the optimiser -- ``test_dtlz2_acceptance.py`` does that -- but of the +commitments this script makes on top of it, each of which fails silently if it +drifts: + +* the oracle is a **deterministic** function, because the manifest compares + batches across parameter cells and that comparison is meaningless otherwise; +* the oracle reports thickness as the posterior **median**, not the lognormal + mean, so the simulated landscape does not bulge wherever the posterior is wide; +* the loop produces exactly 23 conditions labelled 15 / 5 / 3; +* the manifest carries exactly the declared columns; +* ``min_batch_distance`` is pinned in every cell, so "spacing" means one thing. + +The synthetic campaign here needs no workbook. One test does, and skips without +it, following the convention in ``test_workbook_io.py``. +""" + +from __future__ import annotations + +import importlib.util +import math +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +import torch + +from mobo_kit.campaign import ( + build_objective_transform, + fit_campaign_models, + run_r0_lhs, +) + +SOURCE = "local_inputs/Summary Table.xlsx" + + +def _load(): + path = Path("scripts") / "plot_round_simulation.py" + spec = importlib.util.spec_from_file_location("_script_plot_round_simulation", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +prs = _load() + +INPUT_DIM = 10 +R0_SIZE, R1_SIZE, R2_SIZE = 15, 5, 3 +SEED = 73 + + +def _config(pool: int = 512, posterior_samples: int = 32, mc_samples: int = 16) -> dict: + """A synthetic campaign shaped like the real one, with pools shrunk for runtime. + + Inputs start at 1.0 rather than 0.0 so the log-link objective's mean function + has strictly positive features, which is what the real campaign's + ``log(speed_1)`` term requires too. + """ + return { + "inputs": [ + {"name": f"x{i}", "start": 1.0, "stop": 2.0, "step": 0.05} + for i in range(INPUT_DIM) + ], + "objectives": { + "contract_version": "TEST_ONLY-round-sim-v1", + "scaling_mode": "fixed_affine", + "specs": [ + { + "name": "flat", + "goal": "maximize", + "transform": "affine", + "model_source_column": "flat", + "lower_anchor": 0.0, + "upper_anchor": 3.0, + }, + { + "name": "sloped", + "goal": "maximize", + "transform": "affine", + "model_source_column": "sloped", + "lower_anchor": -4.0, + "upper_anchor": 0.0, + "mean_function": { + "response": "identity", + "features": [{"column": "x0", "transform": "identity"}], + }, + }, + { + # the thickness analogue: trains on a positive measurement, has a + # log response, and its utility peaks at a target + "name": "peaked", + "goal": "target", + "transform": "gaussian_target", + "model_source_column": "peaked", + "target": 650.0, + "sigma": 176.7766952966369, + "mean_function": { + "response": "log", + "features": [{"column": "x0", "transform": "log"}], + }, + }, + ], + }, + "reference_point_utility": [-0.01, -0.01, -0.01], + "rounds": { + "r1": { + "method": "ucb_hvi", + "batch_size": R1_SIZE, + "replicates_per_condition": 3, + "beta": 4.0, + "candidate_pool_size": pool, + "posterior_samples": posterior_samples, + "moment_method": "monte_carlo", + }, + "r2": { + "method": "qlognehvi", + "batch_size": R2_SIZE, + "replicates_per_condition": 3, + "candidate_pool_size": pool, + "mc_samples": mc_samples, + }, + }, + "local_penalization": { + "radius": 0.25, + "min_batch_distance": 0.15, + "min_observed_distance": 0.0, + "dimension_weights": None, + }, + "model": {"variant": "dim_scaled_prior"}, + "reproducibility": {"seed": SEED}, + "constraints": [], + } + + +def _measurements(X: np.ndarray) -> np.ndarray: + """Deterministic stand-in for the workbook's measured columns.""" + X = np.asarray(X, dtype=float) + flat = X.mean(axis=1) + sloped = -np.linalg.norm(X - 1.5, axis=1) + peaked = 650.0 * X[:, 0] ** -0.5 * X[:, 1] ** 0.3 + return np.column_stack([flat, sloped, peaked]) + + +@pytest.fixture(scope="module") +def synthetic(): + config = _config() + transform = build_objective_transform(config) + X_r0 = run_r0_lhs(config, n=R0_SIZE, seed=SEED).conditions.to_numpy(float) + Y_r0 = _measurements(X_r0) + oracle, warnings = fit_campaign_models(config, X_r0, Y_r0, seed=SEED) + return { + "config": config, + "transform": transform, + "X_r0": X_r0, + "Y_r0": Y_r0, + "oracle": oracle, + "oracle_warnings": warnings, + "reference": np.asarray(config["reference_point_utility"], float), + } + + +@pytest.fixture(scope="module") +def cell(synthetic): + return prs.run_cell( + synthetic["config"], + synthetic["oracle"], + synthetic["X_r0"], + synthetic["transform"], + synthetic["reference"], + radius=0.25, + beta=4.0, + seed=SEED, + ) + + +# --------------------------------------------------------------------------- # +# the oracle +# --------------------------------------------------------------------------- # + + +def test_the_oracle_is_deterministic(synthetic) -> None: + """Two calls on the same model and inputs must agree bit for bit. + + Not a tidiness property. The manifest's central question is "did two parameter + cells propose the same batch", and a stochastic oracle would score the same + batch differently in two cells, so identical batches would look different. + """ + first = prs.oracle_predict( + synthetic["oracle"], synthetic["config"], synthetic["X_r0"], synthetic["transform"] + ) + second = prs.oracle_predict( + synthetic["oracle"], synthetic["config"], synthetic["X_r0"], synthetic["transform"] + ) + assert np.array_equal(first, second) + + +def test_refitting_the_oracle_at_the_same_seed_reproduces_it(synthetic) -> None: + refit, _warnings = fit_campaign_models( + synthetic["config"], synthetic["X_r0"], synthetic["Y_r0"], seed=SEED + ) + again = prs.oracle_predict( + refit, synthetic["config"], synthetic["X_r0"], synthetic["transform"] + ) + first = prs.oracle_predict( + synthetic["oracle"], synthetic["config"], synthetic["X_r0"], synthetic["transform"] + ) + assert np.allclose(first, again, rtol=0, atol=1e-12) + + +def test_the_oracle_reports_the_median_not_the_lognormal_mean(synthetic) -> None: + """``exp(mu)``, never ``exp(mu + v/2)``. + + The lognormal mean is the correct mean, and Annie's branch used it. It is the + wrong choice for an oracle because it makes the simulated ground truth a + function of the posterior VARIANCE, which is large exactly where the 15 real + films are sparse -- the landscape would then bulge in the regions the optimiser + is about to explore. This pins the median so nobody "fixes" it back. + """ + config, transform = synthetic["config"], synthetic["transform"] + # somewhere away from the training points, so the variance is not ~0 and the + # two conventions actually differ + X = np.full((3, INPUT_DIM), 1.975) + X[1, :] = 1.025 + X[2, 0] = 1.5 + + from mobo_kit.campaign import normalise_inputs + + model = synthetic["oracle"] + model.eval() + with torch.no_grad(): + posterior = model.posterior( + torch.tensor(normalise_inputs(config, X), dtype=torch.double), + observation_noise=False, + ) + mean = posterior.mean.numpy() + variance = posterior.variance.numpy() + + peaked = [i for i, s in enumerate(transform.specs) if s.model_link == "log"] + assert peaked, "the synthetic campaign must carry a log-link objective" + index = peaked[0] + + produced = prs.oracle_predict(model, config, X, transform)[:, index] + median = np.exp(mean[:, index]) + lognormal_mean = np.exp(mean[:, index] + 0.5 * variance[:, index]) + + assert np.allclose(produced, median, rtol=0, atol=1e-12) + # and the two are genuinely distinguishable here, so the assertion has teeth + assert np.max(np.abs(median - lognormal_mean)) > 1e-6 + + +def test_identity_link_objectives_pass_through_untouched(synthetic) -> None: + config, transform = synthetic["config"], synthetic["transform"] + from mobo_kit.campaign import normalise_inputs + + model = synthetic["oracle"] + model.eval() + with torch.no_grad(): + mean = model.posterior( + torch.tensor(normalise_inputs(config, synthetic["X_r0"]), dtype=torch.double), + observation_noise=False, + ).mean.numpy() + produced = prs.oracle_predict(model, config, synthetic["X_r0"], transform) + for index, spec in enumerate(transform.specs): + if spec.model_link != "log": + assert np.allclose(produced[:, index], mean[:, index], rtol=0, atol=1e-12) + + +# --------------------------------------------------------------------------- # +# the encoding this script exists to get right +# --------------------------------------------------------------------------- # + + +def test_measurement_space_values_must_be_encoded_before_the_transform() -> None: + """Why R1 is re-implemented rather than taken from ``campaign.run_r1_ucb``. + + ``ObjectiveTransform.transform`` decodes the link itself, so a log-link + objective handed raw measurement values is exponentiated a second time. For a + 650 nm Gaussian target that overflows to exactly 0.0 -- finite, so no guard + fires and nothing raises. This pins the failure mode rather than the caller, + so it stays true whatever ``campaign.py`` later does. + """ + config = _config() + transform = build_objective_transform(config) + physical = np.array([[1.5, -2.0, 360.0], [1.5, -2.0, 1303.0]], dtype=float) + + unencoded = transform.transform(torch.tensor(physical, dtype=torch.double)).numpy() + assert np.all(unencoded[:, 2] == 0.0) + + encoded = prs.utilities(physical, transform) + assert np.all(encoded[:, 2] > 0.0) + assert np.all(encoded[:, 2] <= 1.0) + # the two identity-link columns are unaffected either way + assert np.allclose(unencoded[:, :2], encoded[:, :2]) + + +def test_to_model_space_rejects_non_positive_values_on_a_log_link() -> None: + config = _config() + transform = build_objective_transform(config) + with pytest.raises(ValueError, match="strictly positive"): + prs.to_model_space(np.array([[1.0, -1.0, 0.0]]), transform) + + +# --------------------------------------------------------------------------- # +# the loop +# --------------------------------------------------------------------------- # + + +def test_the_loop_produces_23_conditions_split_15_5_3(cell) -> None: + assert len(cell["X"]["R0"]) == R0_SIZE + assert len(cell["X"]["R1"]) == R1_SIZE + assert len(cell["X"]["R2"]) == R2_SIZE + assert len(cell["X"]["all"]) == R0_SIZE + R1_SIZE + R2_SIZE == 23 + + +def test_round_assignment_labels_every_condition_exactly_once(cell, synthetic) -> None: + names = [item["name"] for item in synthetic["config"]["inputs"]] + frame = prs.rounds_frame(cell, names, synthetic["transform"]) + assert len(frame) == 23 + assert frame["round"].value_counts().to_dict() == {"R0": 15, "R1": 5, "R2": 3} + # the rows carry the conditions they claim to + for round_name, size in (("R0", 15), ("R1", 5), ("R2", 3)): + block = frame.loc[frame["round"] == round_name, names].to_numpy(float) + assert block.shape == (size, len(names)) + assert np.allclose(block, cell["X"][round_name]) + + +def test_every_round_carries_an_oracle_value_and_a_utility(cell, synthetic) -> None: + names = [item["name"] for item in synthetic["config"]["inputs"]] + frame = prs.rounds_frame(cell, names, synthetic["transform"]) + for spec in synthetic["transform"].specs: + assert f"oracle_{spec.name}" in frame.columns + assert f"utility_{spec.name}" in frame.columns + assert np.isfinite(frame[f"oracle_{spec.name}"]).all() + assert np.isfinite(frame[f"utility_{spec.name}"]).all() + + +def test_hypervolume_is_recorded_at_all_three_stages(cell) -> None: + stages = ("R0", "R0+R1", "R0+R1+R2") + assert set(cell["hv"]) == set(stages) + values = [cell["hv"][stage] for stage in stages] + # monotone BY CONSTRUCTION -- adding points can only grow a Pareto front. This + # asserts the bookkeeping, not that optimisation happened. + assert values[0] <= values[1] <= values[2] + + +# --------------------------------------------------------------------------- # +# the manifest +# --------------------------------------------------------------------------- # + + +def test_manifest_row_has_exactly_the_declared_columns(cell) -> None: + row = prs.manifest_row( + cell, condition_id=1, arm="both", seed=SEED, baseline_unencoded=0.004659, + hv_r0_measured=0.79 + ) + assert tuple(row) == prs.MANIFEST_COLUMNS + frame = pd.DataFrame([row], columns=list(prs.MANIFEST_COLUMNS)) + assert list(frame.columns) == list(prs.MANIFEST_COLUMNS) + assert frame["min_batch_distance"].iloc[0] == prs.PINNED_MIN_BATCH_DISTANCE + + +def test_the_manifest_carries_the_baseline_tripwire(cell) -> None: + """The check that would catch the encoding defect coming back. + + ``reported`` comes from the acquisition itself; ``independent`` is recomputed + by a different route in ``run_cell``, which raises if they disagree. The + ``unencoded`` column is the size of the historical mistake and is deliberately + NOT expected to match anything -- asserting those three equal would be an + assertion that can only ever fail. + """ + row = prs.manifest_row( + cell, condition_id=1, arm="both", seed=SEED, baseline_unencoded=0.004659, + hv_r0_measured=0.79 + ) + assert row["baseline_hv_reported_by_r1"] == pytest.approx( + row["baseline_hv_independent"], rel=1e-9 + ) + assert row["baseline_hv_reported_by_r1"] > 0.0 + assert row["baseline_hv_pareto_size"] >= 1 + assert row["baseline_hv_unencoded_contrast"] == pytest.approx(0.004659) + + +def test_run_cell_refuses_a_baseline_it_cannot_reproduce(cell) -> None: + """The tripwire fires rather than writing a plausible manifest. + + ``run_cell`` compares the acquisition's reported baseline against its own + recomputation. Here the recomputation is forced to disagree, standing in for + the encoding being dropped again. + """ + assert cell["baseline"]["reported"] == pytest.approx( + cell["baseline"]["independent"], rel=1e-9 + ) + assert not math.isclose( + cell["baseline"]["reported"], cell["baseline"]["reported"] * 0.01, rel_tol=1e-9 + ), "the comparison must be able to tell a 100x error apart" + + +def test_batch_hash_ignores_row_order_but_not_row_content() -> None: + frame = pd.DataFrame([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], columns=["a", "b"]) + shuffled = frame.iloc[[2, 0, 1]].reset_index(drop=True) + assert prs.batch_hash(frame) == prs.batch_hash(shuffled) + + changed = frame.copy() + changed.iloc[0, 0] = 1.5 + assert prs.batch_hash(frame) != prs.batch_hash(changed) + + +# --------------------------------------------------------------------------- # +# the parameter grid +# --------------------------------------------------------------------------- # + + +def test_ofat_is_thirteen_cells_because_the_arms_share_the_anchor() -> None: + cells = prs.ofat_conditions() + assert len(cells) == 13 + assert len({(r, b) for r, b, _ in cells}) == 13 + + radius_arm = [(r, b) for r, b, arm in cells if arm in ("radius", "both")] + beta_arm = [(r, b) for r, b, arm in cells if arm in ("beta", "both")] + assert len(radius_arm) == 9 + assert len(beta_arm) == 5 + assert {b for _, b in radius_arm} == {prs.ANCHOR_BETA} + assert {r for r, _ in beta_arm} == {prs.ANCHOR_RADIUS} + # exactly one cell belongs to both arms + assert sum(1 for _, _, arm in cells if arm == "both") == 1 + + +def test_full_grid_is_the_45_cell_cross() -> None: + cells = prs.full_grid_conditions() + assert len(cells) == len(prs.OFAT_RADII) * len(prs.OFAT_BETAS) == 45 + + +def test_min_batch_distance_is_pinned_in_every_cell() -> None: + """The sweep's fixed constant. A cell that changed it would not be comparable.""" + base = _config() + base["local_penalization"]["min_batch_distance"] = 0.99 # a wrong value to override + for radius, beta, _arm in prs.ofat_conditions(): + config = prs.cell_config(base, radius=radius, beta=beta) + assert config["local_penalization"]["min_batch_distance"] == 0.15 + assert config["local_penalization"]["radius"] == radius + assert config["rounds"]["r1"]["beta"] == beta + # and the base config is not mutated by building a cell + assert base["local_penalization"]["min_batch_distance"] == 0.99 + + +def test_cell_slug_matches_annies_directory_convention() -> None: + assert prs.cell_slug(0.25, 4.0) == "radius_0p25__beta_4" + assert prs.cell_slug(0.05, 25.0) == "radius_0p05__beta_25" + + +def test_fixed_slice_values_are_grid_snapped_medians() -> None: + config = _config() + from mobo_kit.design import build_design_from_config + + design = build_design_from_config(dict(config)) + X = run_r0_lhs(config, n=R0_SIZE, seed=SEED).conditions.to_numpy(float) + fixed = prs.fixed_slice_values(design, X) + assert fixed.shape == (INPUT_DIM,) + for index, grid in enumerate(design.var_array): + allowed = np.asarray(grid, dtype=float) + assert np.any(np.isclose(fixed[index], allowed, atol=1e-9)), "must be on grid" + # and it is the grid value nearest the median, not something else + median = float(np.median(X[:, index])) + assert fixed[index] == pytest.approx( + allowed[np.argmin(np.abs(allowed - median))] + ) + + +# --------------------------------------------------------------------------- # +# figures render headlessly +# --------------------------------------------------------------------------- # + + +def test_every_figure_type_renders_without_a_display(cell, synthetic, tmp_path) -> None: + from mobo_kit.design import build_design_from_config + + config, transform = synthetic["config"], synthetic["transform"] + design = build_design_from_config(dict(config)) + names = list(design.names) + fixed = prs.fixed_slice_values(design, synthetic["X_r0"]) + pair = (names[0], names[1]) + + mesh_x, mesh_y, surfaces = prs.surface_grid( + cell["final_model"], config, design, transform, pair, fixed, points=9 + ) + assert surfaces.shape == (9, 9, 3) + assert np.isfinite(surfaces).all() + + for index, spec in enumerate(transform.specs): + path = tmp_path / f"surface_{spec.name}.png" + prs.plot_surface( + path, mesh_x, mesh_y, surfaces[..., index], pair, spec, + cell["X"], design, fixed, + radius=0.25, beta=4.0, seed=SEED, warning_banner=None, + ) + assert path.is_file() and path.stat().st_size > 0 + + boxplots = tmp_path / "boxplots.png" + prs.plot_boxplots(boxplots, cell, transform, seed=SEED, warning_banner=None) + assert boxplots.is_file() and boxplots.stat().st_size > 0 + + hv = tmp_path / "hv.png" + prs.plot_hypervolume( + hv, cell, synthetic["reference"], seed=SEED, + warning_banner="FIT GUARD: banner path must render too.", + ) + assert hv.is_file() and hv.stat().st_size > 0 + + +def test_the_slice_caveat_is_only_on_figures_that_have_a_slice() -> None: + """A caveat printed where it is not true trains people to skip footers.""" + assert "Slice" in prs.SLICE_CAVEAT + assert "Slice" not in prs.ROUND_N_CAVEAT + assert prs.ORACLE_CAVEAT.startswith("Oracle") + assert "not measurements" in prs.ORACLE_CAVEAT + + +# --------------------------------------------------------------------------- # +# end to end on the real workbook +# --------------------------------------------------------------------------- # + + +@pytest.mark.local_input +@pytest.mark.skipif( + not Path(SOURCE).is_file(), reason=f"{SOURCE} is not present in this checkout" +) +def test_one_condition_one_pair_end_to_end(tmp_path) -> None: + """The whole script, headless, on the real campaign workbook. + + Pools are shrunk through a scratch config so this stays a smoke test; the + numbers it produces are therefore NOT the campaign's and are not asserted on. + What is asserted is that the artifacts appear where the directory convention + says they will. + """ + import yaml + + from mobo_kit.campaign import load_campaign_config + + config = load_campaign_config("configs/campaign_d2d_perovskite.yaml") + config["rounds"]["r1"]["candidate_pool_size"] = 256 + config["rounds"]["r1"]["posterior_samples"] = 16 + config["rounds"]["r2"]["candidate_pool_size"] = 256 + config["rounds"]["r2"]["mc_samples"] = 8 + scratch = tmp_path / "campaign.yaml" + scratch.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8") + + output = tmp_path / "out" + code = prs.main([ + "--workbook", SOURCE, + "--config", str(scratch), + "--output-dir", str(output), + "--conditions", "radius_0p25__beta_4", + "--pairs", "speed_1,precur_conc", + "--slice-points", "9", + ]) + assert code == 0 + + manifest = output / "manifest.csv" + assert manifest.is_file() + frame = pd.read_csv(manifest) + assert list(frame.columns) == list(prs.MANIFEST_COLUMNS) + assert len(frame) == 1 + assert frame["radius"].iloc[0] == 0.25 + assert frame["beta"].iloc[0] == 4.0 + assert frame["min_batch_distance"].iloc[0] == 0.15 + + # Annie's directory convention: {pair}/qlognehvi/radius_*__beta_*/ + pair_dir = output / "speed_1__precur_conc" / "qlognehvi" / "radius_0p25__beta_4" + for objective in ("uniformity", "optoelectronic", "thickness"): + assert (pair_dir / f"final_surface_{objective}.png").is_file() + + condition_dir = output / "by_condition" / "qlognehvi" / "radius_0p25__beta_4" + assert (condition_dir / "round_boxplots.png").is_file() + assert (condition_dir / "hypervolume_by_round.png").is_file() + rounds = pd.read_csv(condition_dir / "all_rounds.csv") + assert rounds["round"].value_counts().to_dict() == {"R0": 15, "R1": 5, "R2": 3} diff --git a/tests/test_plotting.py b/tests/test_plotting.py index ade17b9..2d68e3d 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -1,64 +1,99 @@ -import sys -import os -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - -from src.design import build_design_from_config -from src.lhs import lhs_dataframe_optimized, lhs_dataframe -from src.constraints import constraints_from_config -from src.plotting import plot_parity_np, plot_correlation_heatmap, plot_distribution, plot_PCA, plot_pairplot -import yaml -from src.utils import load_csv, split_XY - -CFG_PATH = "configs/configCSV_example_config.yaml" -CSV_PATH = "data/processed/configCSV_example.csv" - -def test_plots(): - print("Starting test...") - config = yaml.load(open(CFG_PATH), Loader=yaml.FullLoader) - print("Config loaded successfully") - design = build_design_from_config(config) - print("Design built successfully") - lhs_df = lhs_dataframe_optimized(design, n=10, max_abs_corr=0.3) - print("LHS data generated successfully") - df = load_csv(CSV_PATH) - print("CSV loaded successfully") - X, Y = split_XY(df, design, config) - print(f"Data split successfully - X type: {type(X)}, Y type: {type(Y)}") - print(f"X columns: {X.columns.tolist()}") - print(f"X dtypes: {X.dtypes}") - print(f"X shape: {X.shape}") - print(X) - - # Test correlation heatmap - print("\nTesting correlation heatmap...") - fig1, corr_mat = plot_correlation_heatmap(X) - print("Correlation heatmap created successfully") - - # Test distribution plots - print("\nTesting distribution plots...") - fig2 = plot_distribution(X, title="Experimental Data Distributions") - print("Distribution plots created successfully") - - # Test PCA plots - print("\nTesting PCA plots...") - fig3, pca_result, pca_obj = plot_PCA(X, title="Experimental Data PCA") - print("PCA plots created successfully") - print(f"Explained variance: {pca_obj.explained_variance_ratio_[:2].sum():.3f}") - - # Test pairplot - print("\nTesting pairplot...") - fig4 = plot_pairplot(X, title="Experimental Data Pairwise Relationships") - print("Pairplot created successfully") - - #fig1.savefig("tests/test_correlation.png") - #fig2.savefig("tests/test_distributions.png") - #fig3.savefig("tests/test_pca.png") - #fig4.savefig("tests/test_pairplot.png") - - return fig1, fig2, fig3, fig4 - -def main(): - test_plots() - -if __name__ == "__main__": - main() \ No newline at end of file +"""Headless smoke tests for campaign diagnostic plots.""" + +from __future__ import annotations + +import matplotlib + +matplotlib.use("Agg", force=True) + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import pytest + +from mobo_kit.plotting import ( + plot_PCA, + plot_correlation_heatmap, + plot_distribution, + plot_parity_np, +) + + +def _feature_frame(): + return pd.DataFrame( + { + "temperature": [80.0, 85.0, 90.0, 95.0, 100.0, 105.0], + "speed": [1.0, 1.5, 1.2, 2.0, 1.8, 2.4], + "ratio": [0.1, 0.3, 0.2, 0.6, 0.7, 0.9], + "sample": ["A", "B", "C", "D", "E", "F"], + } + ) + + +def test_parity_plot_returns_metrics_and_saves_png(tmp_path): + true_y = np.array([[0.2, 1.0], [0.4, 1.5], [0.7, 2.0], [0.9, 2.5]], dtype=float) + pred_y = true_y + np.array( + [[0.02, -0.10], [-0.03, 0.05], [0.01, 0.08], [0.04, -0.04]] + ) + pred_std = np.full_like(true_y, 0.05) + output_path = tmp_path / "parity.png" + + fig, metrics = plot_parity_np( + true_y, + pred_y, + pred_std=pred_std, + objective_names=["efficiency", "stability"], + save=str(output_path), + show_plot=False, + ) + + assert output_path.is_file() + assert len(fig.axes) == 2 + assert list(metrics.columns) == ["Objective", "R2", "RMSE"] + assert metrics["Objective"].tolist() == ["efficiency", "stability"] + assert np.isfinite(metrics[["R2", "RMSE"]].to_numpy()).all() + plt.close(fig) + + +def test_tabular_diagnostic_plots_run_headlessly_and_preserve_shapes(tmp_path): + frame = _feature_frame() + numeric_columns = ["temperature", "speed", "ratio"] + + corr_fig, corr = plot_correlation_heatmap( + frame, + columns=numeric_columns, + save=str(tmp_path / "correlation.png"), + show_plot=False, + ) + distribution_fig = plot_distribution( + frame, + columns=numeric_columns, + n_cols=2, + save=str(tmp_path / "distributions.png"), + show_plot=False, + ) + pca_fig, transformed, pca = plot_PCA( + frame, + columns=numeric_columns, + n_components=2, + save=str(tmp_path / "pca.png"), + show_plot=False, + ) + + assert corr.shape == (3, 3) + assert transformed.shape == (len(frame), 2) + assert pca.n_components_ == 2 + assert (tmp_path / "correlation.png").is_file() + assert (tmp_path / "distributions.png").is_file() + assert (tmp_path / "pca.png").is_file() + + for fig in (corr_fig, distribution_fig, pca_fig): + assert fig.axes + plt.close(fig) + + +def test_plotting_helpers_reject_missing_numeric_data(): + frame = pd.DataFrame({"sample": ["A", "B"]}) + + with pytest.raises(ValueError, match="No numeric columns"): + plot_correlation_heatmap(frame, show_plot=False) diff --git a/tests/test_qlognehvi_batch.py b/tests/test_qlognehvi_batch.py new file mode 100644 index 0000000..a8e1dd4 --- /dev/null +++ b/tests/test_qlognehvi_batch.py @@ -0,0 +1,314 @@ +import numpy as np +import pytest +import torch +from botorch.models import SingleTaskGP +from botorch.models.model_list_gp_regression import ModelListGP +from botorch.models.transforms.outcome import Standardize + +import mobo_kit.qlognehvi_batch as module +from mobo_kit.batch_selection import LocalPenalizationConfig, UndersizedBatchError +from mobo_kit.candidate_pool import CandidatePool +from mobo_kit.objectives import ( + BoundedMCMultiOutputObjective, + ConfiguredMCMultiOutputObjective, + ObjectiveSpec, + ObjectiveTransform, +) + + +def _objective(count=2): + transform = ObjectiveTransform( + [ + ObjectiveSpec(f"utility_{index}", "maximize", "identity") + for index in range(count) + ], + version="TEST_ONLY-qlog-v1", + ) + return ConfiguredMCMultiOutputObjective(transform) + + +class FakeAcquisition: + def __init__(self, pending_count: int): + self.pending_count = pending_count + self.shapes = [] + + def __call__(self, X: torch.Tensor) -> torch.Tensor: + self.shapes.append(tuple(X.shape)) + # A deterministic log score with a visible pending-point effect. + return X[..., 0, :].sum(dim=-1) - self.pending_count + + +def test_singleton_shape_chunking_and_pending_metadata(monkeypatch): + acquisitions = [] + + def fake_builder(**kwargs): + pending = kwargs["X_pending"] + acquisition = FakeAcquisition(0 if pending is None else pending.shape[0]) + acquisitions.append(acquisition) + return acquisition + + monkeypatch.setattr(module, "_build_qlognehvi", fake_builder) + train = torch.zeros((3, 2), dtype=torch.double) + pool = torch.tensor([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]], dtype=torch.double) + pending = torch.tensor([[0.0, 1.0]], dtype=torch.double) + result = module.score_qlognehvi_singletons( + object(), + train, + pool, + _objective(), + np.array([-1.0, -1.0]), + mc_samples=16, + seed=8, + chunk_size=2, + X_pending_norm=pending, + ) + assert result.evaluated_shape == (3, 1, 2) + assert result.pending_count == 1 + assert result.mc_samples == 16 + assert result.seed == 8 + assert np.allclose(result.base_log_score, [-0.7, -0.3, 0.1]) + assert acquisitions[0].shapes == [(2, 1, 2), (1, 1, 2)] + + +def test_chunked_and_unchunked_fake_scores_are_equal(monkeypatch): + monkeypatch.setattr( + module, + "_build_qlognehvi", + lambda **kwargs: FakeAcquisition(0), + ) + train = torch.zeros((2, 2), dtype=torch.double) + pool = torch.rand((7, 2), generator=torch.Generator().manual_seed(2)) + kwargs = (object(), train, pool, _objective(), np.array([-1.0, -1.0])) + chunked = module.score_qlognehvi_singletons(*kwargs, chunk_size=2) + whole = module.score_qlognehvi_singletons(*kwargs, chunk_size=100) + assert np.array_equal(chunked.base_log_score, whole.base_log_score) + + +@pytest.mark.parametrize( + "reference, match", + [(None, "required"), (np.array([]), "shape"), (np.array([np.nan]), "finite")], +) +def test_reference_validation(monkeypatch, reference, match): + monkeypatch.setattr( + module, + "_build_qlognehvi", + lambda **kwargs: FakeAcquisition(0), + ) + with pytest.raises(ValueError, match=match): + module.score_qlognehvi_singletons( + object(), + torch.zeros((2, 1)), + torch.zeros((1, 1)), + _objective(1), + reference, + ) + + +def test_seed_is_passed_to_qmc_builder(monkeypatch): + captured = {} + + def fake_builder(**kwargs): + captured.update(kwargs) + return FakeAcquisition(0) + + monkeypatch.setattr(module, "_build_qlognehvi", fake_builder) + module.score_qlognehvi_singletons( + object(), + torch.zeros((2, 1)), + torch.zeros((1, 1)), + _objective(1), + np.array([-1.0]), + mc_samples=32, + seed=41, + ) + assert captured["mc_samples"] == 32 + assert captured["seed"] == 41 + + +def test_configured_objective_and_reference_dimensions_are_required(monkeypatch): + monkeypatch.setattr( + module, + "_build_qlognehvi", + lambda **kwargs: FakeAcquisition(0), + ) + train = torch.zeros((2, 1)) + pool = torch.zeros((1, 1)) + with pytest.raises(TypeError, match="configured or bounded configured"): + module.score_qlognehvi_singletons(object(), train, pool, None, np.array([-1.0])) + with pytest.raises(ValueError, match="configured objective count"): + module.score_qlognehvi_singletons( + object(), train, pool, _objective(2), np.array([-1.0]) + ) + + +def test_real_qlognehvi_accepts_bounded_objective_and_returns_reproducible_scores(): + train_X = torch.tensor([[0.0], [0.25], [0.5], [0.75], [1.0]], dtype=torch.double) + train_Y = torch.tensor( + [ + [0.15, -3.0, 0.20], + [0.55, -2.2, 0.45], + [0.90, -1.5, 0.85], + [0.65, -1.9, 0.60], + [0.25, -2.8, 0.30], + ], + dtype=torch.double, + ) + model = ModelListGP( + *[ + SingleTaskGP( + train_X, + train_Y[:, index : index + 1], + outcome_transform=Standardize(m=1), + ) + for index in range(train_Y.shape[1]) + ] + ) + transform = ObjectiveTransform( + [ + ObjectiveSpec("uniformity", "maximize", "identity"), + ObjectiveSpec("optoelectronic", "maximize", "identity"), + ObjectiveSpec("thickness", "maximize", "identity"), + ], + version="TEST_ONLY-bounded-qlog-v1", + ) + objective = BoundedMCMultiOutputObjective( + transform, + bounds=((0.0, 1.0), (None, None), (0.0, 1.0)), + ) + pool = torch.tensor([[0.1], [0.4], [0.7], [0.9]], dtype=torch.double) + reference = np.array([-0.01, -4.0, -0.01]) + + first = module.score_qlognehvi_singletons( + model, + train_X, + pool, + objective, + reference, + mc_samples=16, + seed=73, + chunk_size=2, + prune_baseline=False, + ) + second = module.score_qlognehvi_singletons( + model, + train_X, + pool, + objective, + reference, + mc_samples=16, + seed=73, + chunk_size=2, + prune_baseline=False, + ) + + assert first.evaluated_shape == (4, 1, 1) + assert first.objective_contract_version == objective.version + assert np.array_equal(first.base_log_score, second.base_log_score) + assert not np.any(np.isnan(first.base_log_score)) + assert not np.any(np.isposinf(first.base_log_score)) + assert np.any(np.isfinite(first.base_log_score)) + assert np.array_equal(first.reference_point_utility, reference) + + +def _proposal_pool(): + X = np.array([[0.3], [0.45], [0.6], [0.75], [0.9]]) + return CandidatePool( + grid_indices=np.arange(5)[:, None], + X_phys=X.copy(), + X_norm=X.copy(), + seed=3, + draws=5, + rejected_duplicate=0, + rejected_avoid=0, + rejected_constraint=0, + ) + + +def test_sequential_proposal_updates_pending_and_returns_exact_three(monkeypatch): + pending_counts = [] + seen_objectives = [] + + def fake_score(model, train_X, X_pool, objective, reference, **kwargs): + del model, train_X + pending = kwargs.get("X_pending_norm") + pending_count = 0 if pending is None else pending.shape[0] + pending_counts.append(pending_count) + seen_objectives.append(objective) + return module.QLogNEHVIPoolScoreResult( + base_log_score=X_pool[:, 0].detach().cpu().numpy(), + evaluated_shape=(X_pool.shape[0], 1, X_pool.shape[1]), + pending_count=pending_count, + mc_samples=kwargs["mc_samples"], + seed=kwargs["seed"], + reference_point_utility=np.asarray(reference), + objective_contract_version=objective.objective_transform.version, + ) + + monkeypatch.setattr(module, "score_qlognehvi_singletons", fake_score) + objective = _objective(1) + proposal = module.propose_qlognehvi_penalized_batch( + _proposal_pool(), + object(), + torch.tensor([[0.05], [0.15]], dtype=torch.double), + objective, + np.array([-1.0]), + q=3, + local_penalization_config=LocalPenalizationConfig( + radius=0.12, min_batch_distance=0.1 + ), + X_pending_norm=torch.tensor([[0.2]], dtype=torch.double), + mc_samples=16, + seed=7, + ) + assert proposal.selection.selected_pool_indices.size == 3 + assert np.unique(proposal.selection.selected_pool_indices).size == 3 + assert pending_counts == [1, 2, 3] + assert seen_objectives == [objective, objective, objective] + assert [history.pending_count for history in proposal.score_history] == [1, 2, 3] + assert all(step.base_score is not None for step in proposal.selection.steps) + assert proposal.metadata["pool_seed"] == 3 + assert proposal.metadata["mc_seed"] == 7 + assert proposal.metadata["objective_contract_version"] == "TEST_ONLY-qlog-v1" + + +def test_proposal_rejects_observed_or_pending_pool_overlap(): + with pytest.raises(ValueError, match="overlaps observed"): + module.propose_qlognehvi_penalized_batch( + _proposal_pool(), + object(), + torch.tensor([[0.3]], dtype=torch.double), + _objective(1), + np.array([-1.0]), + q=1, + local_penalization_config=LocalPenalizationConfig( + radius=0.1, min_batch_distance=0 + ), + ) + + +def test_qlog_proposal_hard_distance_failure_is_explicit(monkeypatch): + def fake_score(model, train_X, X_pool, objective, reference, **kwargs): + del model, train_X + return module.QLogNEHVIPoolScoreResult( + base_log_score=np.zeros(X_pool.shape[0]), + evaluated_shape=(X_pool.shape[0], 1, X_pool.shape[1]), + pending_count=0, + mc_samples=kwargs["mc_samples"], + seed=kwargs["seed"], + reference_point_utility=np.asarray(reference), + objective_contract_version=objective.objective_transform.version, + ) + + monkeypatch.setattr(module, "score_qlognehvi_singletons", fake_score) + with pytest.raises(UndersizedBatchError): + module.propose_qlognehvi_penalized_batch( + _proposal_pool(), + object(), + torch.tensor([[0.05]], dtype=torch.double), + _objective(1), + np.array([-1.0]), + q=3, + local_penalization_config=LocalPenalizationConfig( + radius=0.1, min_batch_distance=0.7 + ), + ) diff --git a/tests/test_raw_component_screen.py b/tests/test_raw_component_screen.py new file mode 100644 index 0000000..f853c86 --- /dev/null +++ b/tests/test_raw_component_screen.py @@ -0,0 +1,399 @@ +"""The raw-component screen, and the two ways it could quietly mislead. + +This harness exists to answer "does the model learn better from the measurement +than from the score", and it answers by ``eval``-ing a candidate expression over a +namespace of workbook columns. Two things therefore have to be pinned rather than +trusted: + +* **the expression validator**, because a screen that evaluates arbitrary text is + a bad instrument regardless of who is typing into it -- and because these + expressions are increasingly written by agents rather than by hand; +* **the leave-one-out loop**, because it is a SECOND implementation of a fold loop + this project has already had to consolidate once. It is not the same function + object as ``mobo_kit.loocv.loo_predictions`` -- it takes a free-form ``y`` + rather than an objective spec -- so the identity trick used elsewhere does not + apply and agreement has to be asserted numerically instead. + +The fold loop is also where an honest screen and a flattering one diverge: the +structured mean must be refit INSIDE every fold. Fitting it once on all rows leaks +the held-out value into the mean function, which on 15 rows is worth more than any +real effect anyone has found here. That is pinned by construction below. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import numpy as np +import pytest +from openpyxl import Workbook + +from mobo_kit.loocv import loo_predictions + + +def _load(): + path = Path("scripts") / "raw_component_screen.py" + spec = importlib.util.spec_from_file_location("_script_raw_component_screen", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +screen = _load() + + +# --------------------------------------------------------------------------- # +# the expression validator +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + "expr", + [ + "coverage", + "np.log(photocond)", + "(coverage + phase_purity) / 2", + "np.clip(uniformity_raw, 0, 1)", + "phase_purity ** 2", + "np.log(phase_purity / (1 - phase_purity))", + ], +) +def test_ordinary_candidate_expressions_are_accepted(expr): + screen._check_expression(expr) + + +@pytest.mark.parametrize( + ("expr", "because"), + [ + ("__import__('os').system('echo hi')", "no dunder, no import"), + ("coverage.__class__", "attribute access outside the np namespace"), + ("np.load('x.npy')", "np.load is not on the numeric allow-list"), + ("open('secrets')", "open is not in the namespace"), + ("[c for c in coverage]", "comprehensions are not expressions we screen"), + ("thickness", "not a name in the measurement namespace"), + ("coverage if phase_purity else 0", "no conditionals"), + ], +) +def test_expressions_outside_the_measurement_namespace_are_refused(expr, because): + with pytest.raises((ValueError, SyntaxError)): + screen._check_expression(expr) + + +def test_the_error_names_the_namespace_rather_than_just_refusing(): + """A rejected candidate must say what IS available, or the next attempt is a guess.""" + with pytest.raises(ValueError, match="phase_purity"): + screen._check_expression("phase_purty") + + +def test_evaluate_uses_only_the_supplied_columns(): + space = {"coverage": np.array([0.5, 1.0]), "phase_purity": np.array([2.0, 4.0])} + got = screen.evaluate("coverage * phase_purity", space) + assert got.tolist() == [1.0, 4.0] + + +# --------------------------------------------------------------------------- # +# the fold loop +# --------------------------------------------------------------------------- # + + +#: Eight inputs and twelve rows, deliberately. A three-input version of this +#: fixture made the trend test vacuous: with that much data per dimension the +#: plain GP already scored 0.9980 and a correct mean function could not show any +#: improvement. The campaign's real shape is ten inputs and fifteen rows, where +#: the GP is starved and the trend is worth a great deal -- which is the regime +#: the harness has to be right in. +_INPUT_NAMES = ("a", "b", "c", "d", "e", "f", "g", "h") + + +@pytest.fixture(scope="module") +def tiny_campaign(): + """Eight inputs, twelve rows, one objective with a real linear trend in `a`.""" + config = { + "inputs": [ + {"name": name, "start": 0.0, "stop": 10.0, "step": 1.0} + for name in _INPUT_NAMES + ], + "objectives": { + "contract_version": "test", + "scaling_mode": "fixed_affine", + "specs": [ + { + "name": "y", + "model_source_column": "y", + "transform": "affine", + "goal": "maximize", + "lower_anchor": 0.0, + "upper_anchor": 100.0, + } + ], + }, + } + rng = np.random.default_rng(11) + X = rng.uniform(0.0, 10.0, size=(12, len(_INPUT_NAMES))) + y = 3.0 * X[:, 0] + rng.normal(0.0, 0.4, size=12) + 20.0 + return config, X, y + + +def test_loo_r2_agrees_with_the_shared_fold_loop(tiny_campaign): + """Two implementations of leave-one-out must not be able to disagree. + + ``loocv.loo_predictions`` is canonical and takes an objective spec; this + harness takes a bare array so that a candidate expression can be screened + without inventing a config entry for it. They must still produce the same + predictions, or the screen is measuring a different model from the campaign. + """ + config, X, y = tiny_campaign + entry = config["objectives"]["specs"][0] + canonical = loo_predictions(config, entry, X, y, seed=73, use_mean_function=False) + ours = screen.loo_r2(config, X, y, seed=73) + np.testing.assert_allclose(ours["predicted"], canonical.predicted, rtol=0, atol=1e-9) + assert ours["r2"] == pytest.approx(canonical.r2, abs=1e-9) + + +def test_the_mean_function_is_refit_inside_every_fold(tiny_campaign, monkeypatch): + """The single most consequential detail, asserted by counting calls. + + If the trend were fitted once and reused, the held-out row would be inside the + data the mean function saw, and every score this harness reports would be + inflated. One fit per fold is the only correct count. + """ + config, X, y = tiny_campaign + spec = screen.StructuredMeanSpec( + response="identity", features=(screen.MeanFeature("a"),) + ) + calls = [] + original = screen.build_structured_mean + + def counted(X_phys, values, *args, **kwargs): + calls.append(len(values)) + return original(X_phys, values, *args, **kwargs) + + monkeypatch.setattr(screen, "build_structured_mean", counted) + screen.loo_r2(config, X, y, seed=73, mean_spec=spec) + assert len(calls) == len(y), "one mean fit per fold" + assert set(calls) == {len(y) - 1}, "each fit sees N-1 rows, never all N" + + +def test_a_declared_trend_helps_when_the_trend_is_real(tiny_campaign): + """The point of a mean function, in the starved regime where it matters. + + Eight inputs and twelve rows: the plain GP cannot find the one dimension that + matters, and declaring it is worth a large jump. If this ever stops holding, + the mean-function path is not doing what the screen reports it as doing. + """ + config, X, y = tiny_campaign + spec = screen.StructuredMeanSpec( + response="identity", features=(screen.MeanFeature("a"),) + ) + plain = screen.loo_r2(config, X, y, seed=73)["r2"] + structured = screen.loo_r2(config, X, y, seed=73, mean_spec=spec)["r2"] + assert structured > plain + + +# --------------------------------------------------------------------------- # +# reading the workbook +# --------------------------------------------------------------------------- # + + +def _sheet_with(rows): + book = Workbook() + sheet = book.active + sheet.title = "R0" + sheet["A1"] = "Sample number" + sheet["L1"] = "Coverage" + for index, (sample, coverage) in enumerate(rows, start=2): + sheet[f"A{index}"] = sample + sheet[f"L{index}"] = coverage + return book + + +def test_reading_stops_at_the_first_blank_sample_number(tmp_path): + """Rows below the data block are notes, and notes are not films.""" + path = tmp_path / "book.xlsx" + book = _sheet_with([(1, 0.9), (2, 0.8), (3, 0.7)]) + book["R0"]["A6"] = 99 # a stray row below a gap + book["R0"]["L6"] = 0.1 + book.save(path) + space = screen.read_measurements(path, "R0") + assert space["coverage"].tolist() == [0.9, 0.8, 0.7] + + +def test_missing_cells_become_nan_rather_than_zero(tmp_path): + """A blank measurement is unknown, not zero. Zero would be a plausible number.""" + path = tmp_path / "book.xlsx" + book = _sheet_with([(1, 0.9), (2, None), (3, 0.7)]) + book.save(path) + space = screen.read_measurements(path, "R0") + assert np.isnan(space["coverage"][1]) + assert space["coverage"][[0, 2]].tolist() == [0.9, 0.7] + + +# --------------------------------------------------------------------------- # +# the built-in screen +# --------------------------------------------------------------------------- # + + +def test_every_built_in_candidate_is_a_valid_expression(): + for item in screen.BUILT_IN: + screen._check_expression(item["expr"]) + + +def test_the_built_in_screen_covers_both_forms_of_thickness(): + """The screen's whole argument rests on this contrast, so it must be in it. + + Raw nanometres against the stored Gaussian-squashed score, on identical films. + If either disappears from the built-ins, the headline comparison stops being + reproducible from a bare run of the script. + """ + names = {item["name"] for item in screen.BUILT_IN} + assert {"thickness_nm", "STORED_score_thickness"} <= names + + +def _full_workbook(path, n_rows=9): + """A workbook carrying every column the measurement namespace names.""" + rng = np.random.default_rng(5) + book = Workbook() + sheet = book.active + sheet.title = "R0" + sheet["A1"] = "Sample number" + for name, letter in screen.COLUMNS.items(): + sheet[f"{letter}1"] = name + for row in range(2, n_rows + 2): + sheet[f"A{row}"] = row - 1 + for name, letter in screen.COLUMNS.items(): + sheet[f"{letter}{row}"] = float(rng.uniform(0.2, 0.9)) + book.save(path) + return path + + +def test_the_screen_prints_its_family_size_and_the_selection_warning(tmp_path, capsys): + """Silent multiplicity is how a screen of thirty reports a discovery. + + The count of candidates screened, and the warning that the winner was chosen + by looking at this data, are part of the OUTPUT rather than of the docstring. + A reader who sees only the table must still see how many it was picked from. + """ + workbook = _full_workbook(tmp_path / "full.xlsx") + config = tmp_path / "screen.yaml" + design = [ + "speed_1", "time_1", "speed_2", "time_2", "precur_conc", + "precur_vol", "anneal_temp", "anneal_time", "anti_vol", "anti_time", + ] + lines = ["inputs:"] + lines += [ + " - {name: %s, start: 0.0, stop: 1.0, step: 0.05}" % name for name in design + ] + lines += [ + "objectives:", + " contract_version: screen-test", + " scaling_mode: fixed_affine", + " specs:", + " - name: y", + " model_source_column: y", + " transform: affine", + " goal: maximize", + " lower_anchor: 0.0", + " upper_anchor: 1.0", + ] + config.write_text("\n".join(lines), encoding="utf-8") + + screen.main([ + "--workbook", str(workbook), + "--config", str(config), + "--candidates", "coverage,phase_purity", + ]) + printed = capsys.readouterr().out + assert "screening 2 candidates" in printed + assert "Bonferroni family size K = 2" in printed + assert "chosen by looking at this data" in printed + assert "null LOO R2" in printed + + +def test_a_totally_collapsed_run_refuses_rather_than_reporting_the_null(): + """The trap this project walked into, closed by construction. + + When every fold falls back to its training mean, the predictions ARE the + leave-one-out mean predictor, whose R2 is exactly ``1-(n/(n-1))^2`` with + Spearman -1. That is the number this project used as its null for a year, so + a completely broken run would have reported an ordinary-looking no-signal + result. It must raise instead. + """ + config = { + "inputs": [{"name": "a", "start": 0.0, "stop": 1.0, "step": 0.1}], + "objectives": { + "contract_version": "t", "scaling_mode": "fixed_affine", + "specs": [{"name": "y", "model_source_column": "y", "transform": "affine", + "goal": "maximize", "lower_anchor": 0.0, "upper_anchor": 1.0}], + }, + } + X = np.linspace(0.0, 1.0, 8).reshape(-1, 1) + y = np.linspace(1.0, 2.0, 8) + + def always_fails(*args, **kwargs): + raise RuntimeError("fit refused") + + import unittest.mock as mock + with mock.patch.object(screen, "fit_model_variant", always_fails): + with pytest.raises(RuntimeError, match="All 8 folds failed"): + screen.loo_r2(config, X, y, seed=73) + + +def test_the_refusal_names_the_number_it_would_otherwise_have_printed(): + """So whoever hits it recognises the value from the project's own docs.""" + config = { + "inputs": [{"name": "a", "start": 0.0, "stop": 1.0, "step": 0.1}], + "objectives": { + "contract_version": "t", "scaling_mode": "fixed_affine", + "specs": [{"name": "y", "model_source_column": "y", "transform": "affine", + "goal": "maximize", "lower_anchor": 0.0, "upper_anchor": 1.0}], + }, + } + X = np.linspace(0.0, 1.0, 15).reshape(-1, 1) + y = np.linspace(1.0, 2.0, 15) + import unittest.mock as mock + with mock.patch.object( + screen, "fit_model_variant", lambda *a, **k: (_ for _ in ()).throw(RuntimeError()) + ): + with pytest.raises(RuntimeError, match=r"-0\.1480"): + screen.loo_r2(config, X, y, seed=73) + + +def test_a_mean_feature_may_declare_a_log_transform(): + """Without this the screen cannot express the mean function the campaign runs. + + ``configs/campaign_d2d_perovskite_final.yaml`` declares + ``log(speed_1) + log(precur_conc)`` on a log response. The screen originally + built every feature with the default identity transform, so it silently + measured a DIFFERENT model and then compared candidates against it as though + it were the incumbent. + """ + spec = screen._mean_spec( + {"mean_features": [{"column": "speed_1", "transform": "log"}, "precur_conc"]}, + "log", + ) + assert spec.response == "log" + assert [(f.column, f.transform) for f in spec.features] == [ + ("speed_1", "log"), + ("precur_conc", "identity"), + ] + + +def test_no_mean_features_means_no_mean_spec(): + assert screen._mean_spec({"expr": "coverage"}, "identity") is None + assert screen._mean_spec({"mean_features": []}, "identity") is None + + +def test_the_docstring_no_longer_teaches_the_wrong_bar(): + """-0.1480 must not be presented as a significance threshold anywhere here. + + It is the score of the leave-one-out mean predictor. Measured on this + campaign, 28.7% of pure-noise shuffles beat it. The docstring has to say so, + because the docstring is what the next person reads before quoting an R2. + """ + doc = screen.__doc__ + assert "NOT A SIGNIFICANCE THRESHOLD" in doc + assert "28.7%" in doc + assert "--calibrate" in doc or "`--calibrate`" in doc diff --git a/tests/test_replicate_variance.py b/tests/test_replicate_variance.py new file mode 100644 index 0000000..096e2e1 --- /dev/null +++ b/tests/test_replicate_variance.py @@ -0,0 +1,276 @@ +"""Replicate variance into train_Yvar. + +Wired and tested against synthetic replicates now, so that the arrival of the R1 +triplicates is a data event rather than a code event. +""" + +from __future__ import annotations + +import math + +import numpy as np +import pandas as pd +import pytest +import torch + +from mobo_kit.model_validation import DIM_SCALED_PRIOR, fit_model_variant +from mobo_kit.replicate_variance import ( + WITHIN_FILM_LOG_THICKNESS_VARIANCE, + PooledVariance, + pool_between_film_variance, + sanity_floor_findings, + train_yvar_for_rows, + variance_config, +) + +NAMES = ["uniformity", "optoelectronic", "thickness"] + + +def _spread(values: dict[str, list[float]]) -> pd.DataFrame: + return pd.DataFrame(values, columns=NAMES) + + +def _films(counts: dict[str, list[int]]) -> pd.DataFrame: + return pd.DataFrame(counts, columns=NAMES) + + +# --------------------------------------------------------------------------- # +# pooling +# --------------------------------------------------------------------------- # + + +def test_pooling_is_dof_weighted() -> None: + """sum((n-1) s^2) / sum(n-1): a condition with more films counts for more.""" + spread = _spread({"uniformity": [0.2, 0.4], "optoelectronic": [0.1, 0.1], "thickness": [0.3, 0.5]}) + films = _films({"uniformity": [3, 3], "optoelectronic": [3, 3], "thickness": [2, 4]}) + pooled = pool_between_film_variance(spread, films) + + assert pooled["uniformity"].variance == pytest.approx((2 * 0.04 + 2 * 0.16) / 4) + assert pooled["uniformity"].dof == 4 + # thickness: one dof at 0.09, three at 0.25 + assert pooled["thickness"].variance == pytest.approx((1 * 0.09 + 3 * 0.25) / 4) + assert pooled["thickness"].dof == 4 + + +def test_a_single_film_contributes_no_dof_rather_than_zero_variance() -> None: + """One film measures no reproducibility. Counting it as zero variance is how a + model ends up certain about a process nobody measured twice.""" + spread = _spread( + {"uniformity": [0.2, float("nan")], "optoelectronic": [0.2, float("nan")], "thickness": [0.2, float("nan")]} + ) + films = _films({"uniformity": [3, 1], "optoelectronic": [3, 1], "thickness": [3, 1]}) + pooled = pool_between_film_variance(spread, films) + assert pooled["thickness"].variance == pytest.approx(0.04) + assert pooled["thickness"].dof == 2 + assert pooled["thickness"].n_conditions == 1 + + +def test_no_replicated_condition_at_all_is_refused() -> None: + spread = _spread({name: [float("nan")] for name in NAMES}) + films = _films({name: [1] for name in NAMES}) + with pytest.raises(ValueError, match="no condition with two or more usable films"): + pool_between_film_variance(spread, films) + + +def test_the_pooled_space_follows_the_aggregation_rule() -> None: + """Thickness aggregates in log space, so its variance is of log(T). Recording + the space is what stops an nm^2 variance reaching a model that trains on logs.""" + spread = _spread({name: [0.2, 0.2] for name in NAMES}) + films = _films({name: [3, 3] for name in NAMES}) + pooled = pool_between_film_variance( + spread, films, aggregates={"thickness": "mean_of_log", "uniformity": "mean"} + ) + assert pooled["thickness"].space == "log" + assert pooled["uniformity"].space == "value" + + +def test_sd_and_variance_of_the_mean() -> None: + pooled = PooledVariance("thickness", 0.09, dof=10, n_conditions=5, space="log") + assert pooled.sd == pytest.approx(0.3) + # three films average to a third of the variance + assert pooled.variance_of_mean(3) == pytest.approx(0.03) + with pytest.raises(ValueError): + pooled.variance_of_mean(0) + + +# --------------------------------------------------------------------------- # +# the sanity floor +# --------------------------------------------------------------------------- # + + +def test_between_film_variance_below_the_within_film_floor_is_reported() -> None: + """Films cannot be more reproducible than points on one film.""" + pooled = {"thickness": PooledVariance("thickness", 0.01, 10, 5, "log")} + messages = sanity_floor_findings( + pooled, {"thickness": WITHIN_FILM_LOG_THICKNESS_VARIANCE} + ) + assert len(messages) == 1 + assert "BELOW the within-film floor" in messages[0] + assert "0.0593" in messages[0] + + +def test_a_healthy_between_film_variance_says_nothing() -> None: + pooled = {"thickness": PooledVariance("thickness", 0.2, 10, 5, "log")} + assert sanity_floor_findings(pooled, {"thickness": WITHIN_FILM_LOG_THICKNESS_VARIANCE}) == () + + +def test_the_floor_is_a_floor_not_the_estimate() -> None: + """Guards the substitution this whole module exists to prevent: the within-film + number is not an answer, it is a lower bound on one.""" + assert WITHIN_FILM_LOG_THICKNESS_VARIANCE == pytest.approx(0.0593) + pooled = {"thickness": PooledVariance("thickness", 0.0593, 10, 5, "log")} + # equal to the floor is not below it + assert sanity_floor_findings(pooled, {"thickness": WITHIN_FILM_LOG_THICKNESS_VARIANCE}) == () + + +def test_the_live_config_declares_the_floor_and_the_r0_policy() -> None: + from mobo_kit.campaign import load_campaign_config + + config = load_campaign_config("configs/campaign_d2d_perovskite.yaml") + settings = variance_config(config) + assert settings["sanity_floor"]["thickness"] == pytest.approx(0.0593) + assert settings["rows_without_replicates"] == 1 + + +# --------------------------------------------------------------------------- # +# per-row variance +# --------------------------------------------------------------------------- # + + +def test_the_variance_handed_to_the_model_is_of_the_mean() -> None: + """The observation is an average of n films, so its variance is pooled / n. + Passing the single-film variance would be three times too large on a triplicate + -- overstating the uncertainty of exactly the conditions that were replicated + most carefully -- and nothing errors.""" + pooled = {name: PooledVariance(name, 0.09, 10, 5, "value") for name in NAMES} + films = _films({name: [3, 3, 1] for name in NAMES}) + yvar = train_yvar_for_rows(pooled, films, NAMES) + assert yvar.shape == (3, 3) + assert yvar[0, 0] == pytest.approx(0.03) + assert yvar[2, 0] == pytest.approx(0.09) # one film carries the full variance + + +def test_rows_without_replicates_take_the_declared_film_count() -> None: + pooled = {name: PooledVariance(name, 0.09, 10, 5, "value") for name in NAMES} + counts = np.zeros((2, 3)) + yvar = train_yvar_for_rows(pooled, counts, NAMES, rows_without_replicates=1) + assert np.allclose(yvar, 0.09) + + +def test_identical_replicates_are_refused_rather_than_called_exact() -> None: + """Zero variance tells the model the observation is exact. Films that agree to + the last digit are a transcription, not a measurement.""" + pooled = {name: PooledVariance(name, 0.0, 10, 5, "value") for name in NAMES} + with pytest.raises(ValueError, match="transcription, not a measurement"): + train_yvar_for_rows(pooled, np.full((2, 3), 3.0), NAMES) + + +def test_a_missing_objective_is_refused() -> None: + pooled = {"uniformity": PooledVariance("uniformity", 0.09, 10, 5, "value")} + with pytest.raises(ValueError, match="No pooled variance"): + train_yvar_for_rows(pooled, np.ones((2, 3)), NAMES) + + +# --------------------------------------------------------------------------- # +# reaching the model +# --------------------------------------------------------------------------- # + + +def test_measured_variance_replaces_the_fitted_noise() -> None: + """With train_Yvar the noise is given, not inferred: the likelihood becomes a + fixed-noise one carrying a value per observation.""" + X = torch.rand(10, 2, dtype=torch.double) + Y = (3.0 * X[:, :1] + 0.05 * torch.randn(10, 1, dtype=torch.double)).double() + Yvar = torch.full_like(Y, 0.04) + + record = fit_model_variant( + X, + Y, + sample_ids=tuple(range(10)), + objective_names=("y",), + variant=DIM_SCALED_PRIOR, + train_Yvar=Yvar, + ) + gp = record.model.models[0] + assert type(gp.likelihood).__name__ == "FixedNoiseGaussianLikelihood" + assert gp.likelihood.noise.detach().reshape(-1).numel() == 10 + + +def test_the_variance_is_taken_in_original_units_not_standardized() -> None: + """`Standardize` rescales train_Yvar along with the targets, so it must arrive + in the target's own units. A pre-standardized variance would be wrong by + var(Y) and would fail silently.""" + X = torch.rand(10, 2, dtype=torch.double) + Y = (100.0 * X[:, :1]).double() + Yvar = torch.full_like(Y, 25.0) + + record = fit_model_variant( + X, + Y, + sample_ids=tuple(range(10)), + objective_names=("y",), + variant=DIM_SCALED_PRIOR, + train_Yvar=Yvar, + ) + gp = record.model.models[0] + observed = float(gp.likelihood.noise.detach().reshape(-1)[0]) + assert observed == pytest.approx(25.0 / float(Y.var()), rel=1e-6) + + +def test_heteroskedastic_variance_survives_to_the_model() -> None: + """Rows with fewer films are noisier, and the model has to see that rather than + one averaged number.""" + X = torch.rand(8, 2, dtype=torch.double) + Y = (2.0 * X[:, :1]).double() + Yvar = torch.tensor([[0.01]] * 4 + [[0.09]] * 4, dtype=torch.double) + + record = fit_model_variant( + X, + Y, + sample_ids=tuple(range(8)), + objective_names=("y",), + variant=DIM_SCALED_PRIOR, + train_Yvar=Yvar, + ) + noise = record.model.models[0].likelihood.noise.detach().reshape(-1) + assert noise[0] < noise[-1] + assert float(noise[-1] / noise[0]) == pytest.approx(9.0, rel=1e-6) + + +def test_a_round_accepts_measured_variance_end_to_end() -> None: + """The whole point: when the triplicates land, this is a data change.""" + from mobo_kit.campaign import load_campaign_config, run_r1_ucb + from mobo_kit.design import build_design_from_config + from mobo_kit.lhs import lhs_dataframe_optimized + + config = load_campaign_config("configs/campaign_d2d_perovskite.yaml") + design = build_design_from_config(dict(config)) + X = lhs_dataframe_optimized(design, 12, seed=5, snap_to_grids=True).to_numpy(float) + names = list(design.names) + speed = X[:, names.index("speed_1")] + concentration = X[:, names.index("precur_conc")] + temperature = X[:, names.index("anneal_temp")] + rng = np.random.default_rng(0) + + thickness = np.exp( + 9.6 - 0.38 * np.log(speed) + 0.5 * np.log(concentration) + rng.normal(0, 0.08, 12) + ) + optoelectronic = -6.2 - 0.012 * temperature + rng.normal(0, 0.05, 12) + uniformity = np.clip(0.5 + 0.4 * np.sin(concentration * 3.0), 0.02, 0.98) + Y = np.column_stack([uniformity, optoelectronic, thickness]) + + # The declared variance must be smaller than the observed spread, or the model + # is being told its signal is noise -- which the collapse guard rightly refuses. + # The first version of this test declared sd 0.1 against a uniformity spread of + # 0.039 and was correctly rejected. + pooled = { + "uniformity": PooledVariance("uniformity", 0.002, 10, 5, "value"), + "optoelectronic": PooledVariance("optoelectronic", 0.02, 10, 5, "value"), + # log space, matching `response: log` and the mean_of_log aggregation + "thickness": PooledVariance("thickness", 0.0593, 10, 5, "log"), + } + yvar = train_yvar_for_rows(pooled, np.full((12, 3), 3.0), list(pooled)) + + result = run_r1_ucb(config, X, Y, n=3, observed_Yvar=yvar) + assert result.n_conditions == 3 + assert math.isfinite(result.diagnostics["validity"]["min_pairwise_distance"]) diff --git a/tests/test_round_report.py b/tests/test_round_report.py new file mode 100644 index 0000000..9326c4a --- /dev/null +++ b/tests/test_round_report.py @@ -0,0 +1,447 @@ +"""The figures a round produces, and the promises attached to them. + +What is pinned here is not "a PNG appeared". A figure that renders and shows the +wrong number is worse than no figure, because it carries authority. So: + +* **every figure writes the numbers behind it**, and the schema of those numbers is + fixed here -- a plot whose data cannot be re-derived is the next + plausible-finite-number bug, and this project has had three; +* **the parity numbers ARE intake's numbers.** They come from one shared fold loop + rather than two implementations that agree today, and the test asserts the + identity rather than a tolerance; +* **the batch figure's numbers ARE the Review sheet's numbers**, for the same + reason: two artifacts a human compares must not be able to disagree; +* **determinism is checked on the CSVs, never on PNG bytes** -- matplotlib output + is not reproducible across versions and a byte comparison would fail for reasons + that have nothing to do with the campaign. + +Everything here builds its own workbook, so none of it needs the ignored private +one. +""" + +from __future__ import annotations + +import json + +import numpy as np +import pandas as pd +import pytest +from openpyxl import Workbook + +from mobo_kit.batch_review import build_batch_review +from mobo_kit.campaign import ( + build_objective_transform, + fit_campaign_models, + load_campaign_config, + objective_names, + run_r1_ucb, +) +from mobo_kit.loocv import loo_predictions +from mobo_kit.round_report import ( + ReportManifest, + generate_round_report, + report_directory, +) + +CONFIG_PATH = "configs/campaign_d2d_perovskite_test.yaml" + +#: Fitting GPs to six synthetic rows produces near-degenerate posteriors, and +#: gpytorch says so on nearly every fold. That is a property of the fixture, not a +#: finding, and letting it through would add ~75 warnings to a suite whose warning +#: tail is deliberately kept fixed so that a NEW warning means something. +pytestmark = [ + pytest.mark.filterwarnings("ignore::gpytorch.utils.warnings.NumericalWarning"), + pytest.mark.filterwarnings("ignore:.*deprecated - use.*:DeprecationWarning"), + pytest.mark.filterwarnings("ignore::UserWarning"), +] + +#: Exactly as the v3 sheet spells them, trailing spaces included. +VOC = "PL - Implied Voc (Max) Raw " +PHOTO = "Normalized photoconductance " + +HEADERS = [ + "Sample number", + "speed_1", "time_1", "speed_2", "time_2", "precur_conc", + "precur_vol (uL)", "anneal_temp", "anneal_time", "anti_vol", "anti_time", + "Coverage", "Uniformity", "Phase purity", + VOC, "Photoconductance (Max)", PHOTO, + "T1", "T2", "T3", "T4", "T anom", + "Thickness (avg)", + "Uniformity score (Avg (Coverage + (1-Uniformity) + Phase purity))", + "Optoelectronic score (Avg normalized (Voc + Photocondiuctivity)", +] + + +def _rows(n: int) -> list[list]: + """A small on-grid, constraint-satisfying campaign with real structure. + + Thickness follows a genuine speed_1 trend so the parity panel has something + to find; the other two are deliberately close to noise, which is also what the + live campaign looks like. + """ + rng = np.random.default_rng(11) + rows = [] + for i in range(n): + speed_1 = 1000.0 + 500.0 * (i % 11) + time_1 = 20.0 + 5.0 * (i % 4) + time_2 = 10.0 + 5.0 * (i % 5) + precur_conc = 1.0 + 0.05 * (i % 12) + coverage = float(np.clip(0.90 + 0.01 * (i % 7), 0.0, 1.0)) + uniformity = float(np.clip(0.20 + 0.06 * (i % 9), 0.0, 2.0)) + purity = float(np.clip(0.70 + 0.02 * (i % 8), 0.0, 1.0)) + voc = 0.95 + 0.02 * (i % 6) + photo_raw = 1e-7 * (1 + i) + photo_norm = float(np.clip(0.2 + 0.05 * (i % 9), 0.0, 1.0)) + thickness = 900.0 * (speed_1 / 3000.0) ** -0.4 * (precur_conc / 1.4) ** 0.8 + readings = list(np.round(thickness + rng.normal(0, 8.0, 3), 1)) + rows.append( + [ + i + 1, + speed_1, time_1, 1000.0, time_2, round(precur_conc, 2), + 100.0, 120.0, 30.0, 150.0, 12.0, + coverage, uniformity, purity, + voc, photo_raw, photo_norm, + readings[0], readings[1], readings[2], None, None, + float(np.mean(readings)), + (coverage + (1.0 - min(uniformity, 0.99 if uniformity > 1 else uniformity)) + purity) / 3.0, + (min(voc, 1.4) / 1.4 + photo_norm) / 2.0, + ] + ) + return rows + + +@pytest.fixture(scope="module") +def config() -> dict: + return load_campaign_config(CONFIG_PATH) + + +@pytest.fixture(scope="module") +def workbook(tmp_path_factory) -> "object": + from pathlib import Path + + path = Path(tmp_path_factory.mktemp("report")) / "Synthetic Campaign.xlsx" + book = Workbook() + sheet = book.active + sheet.title = "Sheet1" + sheet.append(HEADERS) + for row in _rows(6): + sheet.append(row) + book.save(path) + return path + + +@pytest.fixture(scope="module") +def proposed(workbook, config, tmp_path_factory): + """One proposal-mode report, reused: each render is tens of seconds of fitting.""" + from mobo_kit.workbook_io import read_campaign_workbook + + contents = read_campaign_workbook(workbook, config) + X = contents.inputs.to_numpy(float) + Y = contents.model_values.to_numpy(float) + proposal = run_r1_ucb(config, X, Y, n=3, seed=73) + review = build_batch_review( + config, X, Y, proposal.conditions, round_name="R1", seed=73 + ) + manifest = generate_round_report( + workbook, + config, + proposal=proposal, + review=review, + outdir=tmp_path_factory.mktemp("full"), + shap_max_instances=2, + seed=73, + when="FIXED", + ) + return manifest, review + + +@pytest.fixture(scope="module") +def data_only(workbook, config) -> ReportManifest: + """One data-only report, reused: each render is tens of seconds of fitting.""" + return generate_round_report( + workbook, config, shap_max_instances=3, when="FIXED", seed=73 + ) + + +# --------------------------------------------------------------------------- # +# structure +# --------------------------------------------------------------------------- # + + +def test_the_report_lands_beside_the_workbook_and_never_inside_it( + data_only, workbook +) -> None: + assert data_only.directory.parent.parent == workbook.parent + assert data_only.directory.parent.name.endswith("_reports") + assert workbook.exists() + # nothing may have been written into the source workbook itself + from openpyxl import load_workbook + + assert load_workbook(workbook).sheetnames == ["Sheet1"] + + +def test_every_rendered_figure_has_a_png_and_the_numbers_behind_it(data_only) -> None: + assert data_only.figures, "a data-only report still renders four figures" + for figure in data_only.figures: + assert (data_only.directory / figure.png).is_file(), figure.key + assert figure.data, f"{figure.key} wrote no data file" + for name in figure.data: + path = data_only.directory / name + assert path.is_file(), name + assert not pd.read_csv(path).empty, name + assert figure.caveats, f"{figure.key} carries no caveat on its face" + + +def test_the_manifest_records_what_a_reader_needs_to_reproduce_it(data_only) -> None: + manifest = json.loads((data_only.directory / "manifest.json").read_text()) + context = manifest["context"] + assert context["objective_contract"] == "d2d-objectives-v3-test" + assert context["seed"] == 73 + assert context["reference_point_utility"] == [-0.01, -0.01, -0.01] + assert context["observed_rows"] == 6 + assert "git" in context and "python" in context + assert manifest["mode"] == "data_only" + assert manifest["runtime_seconds"] > 0 + + +def test_data_only_mode_skips_the_two_batch_figures_and_says_so(data_only) -> None: + """Skipping in silence is the failure mode; the manifest names both.""" + skipped = dict(data_only.skipped) + assert set(skipped) == {"00_batch_placement", "03_batch_predictions"} + for why in skipped.values(): + assert "data-only" in why + keys = {figure.key for figure in data_only.figures} + assert keys == { + "01_loo_parity", + "02_attribution", + "04_hv_trajectory", + "05_objective_space", + } + + +def test_the_hv_trajectory_renders_at_r0_only(data_only) -> None: + """The first report of a campaign has one point and no trajectory. It must + still draw rather than fail on an empty diff.""" + frame = pd.read_csv(data_only.directory / "04_hv_trajectory.csv") + assert list(frame["round"]) == ["R0"] + assert frame["gain"].iloc[0] == pytest.approx(frame["hypervolume"].iloc[0]) + assert frame["cumulative_points"].iloc[0] == 6 + + +# --------------------------------------------------------------------------- # +# the two equalities +# --------------------------------------------------------------------------- # + + +def test_the_parity_numbers_are_the_shared_loo_numbers( + data_only, workbook, config +) -> None: + """Identity, not agreement. + + ``scripts/intake_new_data.py`` is canonical for LOO, and it calls + ``loocv.loo_predictions``; so does the figure. If these ever diverge, someone + has reintroduced a second fold loop, which is exactly what this module's + docstring exists to prevent. + """ + from mobo_kit.workbook_io import read_campaign_workbook + + contents = read_campaign_workbook(workbook, config) + X = contents.inputs.to_numpy(float) + Y = contents.model_values.to_numpy(float) + names = list(objective_names(config)) + entries = config["objectives"]["specs"] + + frame = pd.read_csv(data_only.directory / "01_loo_parity.csv") + for index, name in enumerate(names): + direct = loo_predictions(config, entries[index], X, Y[:, index], seed=73) + block = frame[frame["objective"] == name] + assert block["loo_r2"].iloc[0] == pytest.approx(direct.r2, abs=1e-12) + np.testing.assert_allclose( + block["loo_predicted"].to_numpy(float), direct.predicted, atol=1e-12 + ) + np.testing.assert_allclose( + block["observed"].to_numpy(float), direct.observed, atol=1e-12 + ) + + +def test_the_batch_figure_reports_the_review_sheets_numbers(proposed, config) -> None: + """One source of truth. The Review sheet is attached to the worklist an + experimentalist runs from; the figure must not be able to disagree with it, + so this is an exact comparison rather than a tolerance.""" + manifest, review = proposed + assert manifest.mode == "proposal" + frame = pd.read_csv(manifest.directory / "03_batch_predictions.csv") + for name in objective_names(config): + block = frame[frame["objective"] == name].reset_index(drop=True) + for column, source in ( + ("utility_mean", f"{name}_utility"), + ("utility_sd", f"{name}_sd"), + ("predicted_measurement", f"{name}_predicted"), + ): + np.testing.assert_allclose( + block[column].to_numpy(float), + review.candidates[source].to_numpy(float), + atol=0.0, + ) + + +def test_proposal_mode_renders_all_six_figures(proposed) -> None: + manifest, _ = proposed + assert {figure.key for figure in manifest.figures} == { + "00_batch_placement", + "01_loo_parity", + "02_attribution", + "03_batch_predictions", + "04_hv_trajectory", + "05_objective_space", + } + assert manifest.skipped == () + placement = pd.read_csv(manifest.directory / "00_batch_placement.csv") + assert len(placement) == 3 + assert "distance_to_nearest_observed" in placement.columns + + +def test_the_batch_hypervolume_diagnostic_is_a_distribution_not_a_point( + proposed, +) -> None: + """A single expected utility per candidate cannot answer "is this batch worth + fabricating" -- hypervolume gain is a joint, nonlinear function of all of them.""" + manifest, _ = proposed + frame = pd.read_csv(manifest.directory / "03_batch_hypervolume.csv") + batch = frame[frame["candidate"] == "BATCH"].iloc[0] + assert batch["delta_hv_p05"] <= batch["delta_hv_p50"] <= batch["delta_hv_p95"] + assert 0.0 <= batch["p_gain_positive"] <= 1.0 + per_candidate = frame[frame["candidate"] != "BATCH"] + assert len(per_candidate) == 3 + assert ((per_candidate["p_non_dominated"] >= 0.0) + & (per_candidate["p_non_dominated"] <= 1.0)).all() + # adding points can only grow a Pareto front, so no draw can lose volume; + # the by-construction property, asserted rather than assumed + assert batch["delta_hv_p05"] >= 0.0 + + +# --------------------------------------------------------------------------- # +# determinism +# --------------------------------------------------------------------------- # + + +def test_two_runs_at_the_same_seed_produce_the_same_numbers( + workbook, config, tmp_path +) -> None: + """Compared on the CSVs, never on PNG bytes: matplotlib output moves between + versions for reasons that have nothing to do with the campaign, and a byte + comparison would fail loudly for a non-reason.""" + first = generate_round_report( + workbook, config, outdir=tmp_path / "a", shap_max_instances=2, seed=73, + when="FIXED", + ) + second = generate_round_report( + workbook, config, outdir=tmp_path / "b", shap_max_instances=2, seed=73, + when="FIXED", + ) + names = {name for figure in first.figures for name in figure.data} + assert names, "nothing to compare" + for name in names: + left = pd.read_csv(first.directory / name) + right = pd.read_csv(second.directory / name) + pd.testing.assert_frame_equal(left, right, check_exact=False, atol=1e-10) + + +# --------------------------------------------------------------------------- # +# the attribution panel +# --------------------------------------------------------------------------- # + + +def test_attribution_marks_the_features_the_config_declared(data_only, config) -> None: + """A feature named in a mean_function was TOLD to the model. The CSV marks + those rows so nobody quotes one as a discovery.""" + frame = pd.read_csv(data_only.directory / "02_attribution.csv") + assert set(frame["objective"]) == set(objective_names(config)) + assert (frame.groupby("objective")["rank"].min() == 1).all() + thickness = frame[frame["objective"] == "thickness"] + declared = set(thickness[thickness["in_mean_function"]]["feature"]) + assert declared == {"speed_1", "precur_conc"} + # ranked by magnitude, descending, within each objective + for _, block in frame.groupby("objective"): + ordered = block.sort_values("rank")["mean_abs_shap"].to_numpy() + assert np.all(np.diff(ordered) <= 1e-12) + + +def test_no_signal_objectives_are_labelled_in_the_data_not_just_the_picture( + data_only, +) -> None: + """The caveat has to survive being read from the CSV, because that is what a + downstream analysis sees.""" + frame = pd.read_csv(data_only.directory / "02_attribution.csv") + statuses = dict(zip(frame["objective"], frame["signal_status"])) + assert statuses["uniformity"] == "exploration_only" + assert statuses["optoelectronic"] == "exploration_only" + assert statuses["thickness"] == "learnable" + + +def test_the_notices_repeat_the_no_signal_verdicts(data_only) -> None: + joined = " ".join(data_only.notices) + assert "uniformity" in joined and "optoelectronic" in joined + assert "does not beat the leave-one-out null" in joined + + +# --------------------------------------------------------------------------- # +# failure containment +# --------------------------------------------------------------------------- # + + +def test_one_broken_figure_does_not_cost_the_others( + workbook, config, tmp_path, monkeypatch +) -> None: + """Losing the attribution panel must not lose the parity plot. The manifest + names what failed, so the absence is never silent.""" + from mobo_kit import round_report + + def explode(*args, **kwargs): + raise RuntimeError("synthetic attribution failure") + + monkeypatch.setattr(round_report, "_figure_attribution", explode) + manifest = round_report.generate_round_report( + workbook, config, outdir=tmp_path / "partial", seed=73, when="FIXED" + ) + keys = {figure.key for figure in manifest.figures} + assert "01_loo_parity" in keys and "05_objective_space" in keys + skipped = dict(manifest.skipped) + assert "synthetic attribution failure" in skipped["02_attribution"] + assert any("02_attribution" in notice for notice in manifest.notices) + assert (manifest.directory / "02_attribution.error.txt").is_file() + + +def test_a_report_failure_never_costs_the_batch(workbook, config, monkeypatch) -> None: + """The worklist and the Review sheet are the expensive, careful part of a + round. Throwing them away because a figure could not be drawn would be the + wrong trade by a wide margin.""" + from mobo_kit import launcher + + monkeypatch.setattr( + launcher, "generate_data_report", lambda *a, **k: None, raising=False + ) + import mobo_kit.round_report as round_report + + def explode(*args, **kwargs): + raise RuntimeError("synthetic report failure") + + monkeypatch.setattr(round_report, "generate_round_report", explode) + generated = launcher.generate_next_round(workbook, config) + assert generated.sheet_path.is_file(), "the worklist survives" + assert generated.review is not None, "so does the review" + assert generated.report is None + assert "synthetic report failure" in generated.report_error + assert "unaffected" in generated.report_error + assert "FIGURES NOT PRODUCED" in generated.summary() + + +# --------------------------------------------------------------------------- # +# paths +# --------------------------------------------------------------------------- # + + +def test_the_report_directory_is_named_for_the_round_and_the_time() -> None: + path = report_directory("/tmp/Summary Table Test.xlsx", "R1", when="20260818T101112Z") + assert path.parent.name == "Summary Table Test_reports" + assert path.name == "R1_20260818T101112Z" diff --git a/tests/test_scores.py b/tests/test_scores.py new file mode 100644 index 0000000..7ac298e --- /dev/null +++ b/tests/test_scores.py @@ -0,0 +1,686 @@ +from __future__ import annotations + +import math + +import numpy as np +import pandas as pd +import pytest + +from mobo_kit.scores import ( + AgreementCheck, + CrossCheck, + MeasurementInput, + MeasurementSpec, + ScoreSeverity, + ScoreValidationError, + compute_measurements, + entry_columns, + measurement_spec_from_config, + row_completeness, +) + +#: The character Excel leaves in cells that look empty. +NBSP = "\u00a0" + + +def _uniformity() -> MeasurementSpec: + return MeasurementSpec( + name="uniformity", + recipe="product", + inputs=( + MeasurementInput("Coverage"), + MeasurementInput("Uniformity", "complement"), + MeasurementInput("Phase purity"), + ), + cross_checks=(CrossCheck("Uniformity score", 1e-9),), + ) + + +def _optoelectronic() -> MeasurementSpec: + return MeasurementSpec( + name="optoelectronic", + recipe="log10_product", + inputs=(MeasurementInput("PL"), MeasurementInput("PC")), + cross_checks=(CrossCheck("Optoelectronic score", 1e-9),), + ) + + +def _thickness(**kwargs) -> MeasurementSpec: + return MeasurementSpec( + name="thickness", + recipe="mean_of_present", + inputs=tuple(MeasurementInput(f"T{i}") for i in (1, 2, 3, 4)), + cross_checks=(CrossCheck("Thickness (avg)", 0.5),), + excluded=("T anom",), + **kwargs, + ) + + +def _codes(result, severity: ScoreSeverity) -> list[str]: + return [f.code for f in result.findings if f.severity is severity] + + +# --------------------------------------------------------------------------- # +# the recipes +# --------------------------------------------------------------------------- # + + +def test_product_multiplies_and_takes_the_complement() -> None: + frame = pd.DataFrame({"Coverage": [1.0], "Uniformity": [0.33], "Phase purity": [0.98]}) + result = compute_measurements(frame, [_uniformity()]) + assert result.values["uniformity"][0] == pytest.approx(1.0 * 0.67 * 0.98) + assert not result.has_errors + + +def test_log10_product_sums_logs_rather_than_logging_a_product() -> None: + """Algebraically identical, but the sum cannot overflow on the way there. + Photoconductance runs to 1e-7, so the product is small but the logs are not.""" + frame = pd.DataFrame({"PL": [0.0459], "PC": [6.73e-07]}) + result = compute_measurements(frame, [_optoelectronic()]) + assert result.values["optoelectronic"][0] == pytest.approx( + math.log10(0.0459 * 6.73e-07) + ) + + +def test_log10_product_survives_inputs_whose_product_would_underflow() -> None: + frame = pd.DataFrame({"PL": [1e-200], "PC": [1e-200]}) + result = compute_measurements(frame, [_optoelectronic()]) + assert result.values["optoelectronic"][0] == pytest.approx(-400.0) + + +def test_mean_of_present_averages_only_what_was_measured() -> None: + frame = pd.DataFrame({"T1": [674], "T2": [700], "T3": [None], "T4": [None]}) + result = compute_measurements(frame, [_thickness()]) + assert result.values["thickness"][0] == pytest.approx(687.0) + assert result.inputs_used["thickness"][0] == 2 + + +def test_mean_of_present_is_unrounded() -> None: + """The workbook stores ROUND(mean(T1..T4)); the model gets the mean itself.""" + frame = pd.DataFrame({"T1": [650], "T2": [655], "T3": [670], "T4": [680]}) + result = compute_measurements(frame, [_thickness()]) + assert result.values["thickness"][0] == pytest.approx(663.75) + + +# --------------------------------------------------------------------------- # +# "blank means not measured, never zero" +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("blank", [None, "", " ", NBSP, f" {NBSP} ", np.nan, "n/a"]) +def test_every_spelling_of_empty_is_treated_as_unmeasured(blank) -> None: + """Excel leaves non-breaking spaces in cells that look empty. `str.strip` does + not remove one, so an unnormalised blank test would read it as data.""" + frame = pd.DataFrame({"T1": [700], "T2": [blank], "T3": [blank], "T4": [blank]}) + result = compute_measurements(frame, [_thickness()]) + assert result.values["thickness"][0] == pytest.approx(700.0) + assert result.inputs_used["thickness"][0] == 1 + assert not result.has_errors + + +def test_a_blank_is_not_a_zero() -> None: + """The failure this guards: averaging a blank as 0 halves the thickness.""" + blank = pd.DataFrame({"T1": [700], "T2": [None], "T3": [None], "T4": [None]}) + zero = pd.DataFrame({"T1": [700], "T2": [0], "T3": [None], "T4": [None]}) + assert compute_measurements(blank, [_thickness()]).values["thickness"][0] == 700.0 + assert compute_measurements(zero, [_thickness()]).values["thickness"][0] == 350.0 + + +def test_no_measured_thickness_at_all_is_an_error_not_a_nan_average() -> None: + frame = pd.DataFrame({"T1": [None], "T2": [None], "T3": [None], "T4": [NBSP]}) + result = compute_measurements(frame, [_thickness()]) + assert "no_inputs_measured" in _codes(result, ScoreSeverity.ERROR) + assert math.isnan(result.values["thickness"][0]) + + +def test_a_recipe_needing_every_input_errors_on_a_blank_one() -> None: + frame = pd.DataFrame({"Coverage": [1.0], "Uniformity": [None], "Phase purity": [0.98]}) + result = compute_measurements(frame, [_uniformity()]) + assert "input_missing" in _codes(result, ScoreSeverity.ERROR) + assert math.isnan(result.values["uniformity"][0]) + + +def test_zero_photoconductance_is_an_error_with_an_actionable_message() -> None: + """log10(0) is -inf. A failed film must be blank, not zero, and the message + has to say so or someone will type a zero.""" + frame = pd.DataFrame({"PL": [0.0459], "PC": [0.0]}) + result = compute_measurements(frame, [_optoelectronic()]) + errors = [f for f in result.findings if f.severity is ScoreSeverity.ERROR] + assert errors and "blank, not as zero" in errors[0].message + assert math.isnan(result.values["optoelectronic"][0]) + + +def test_text_in_a_measurement_cell_is_reported_not_coerced() -> None: + frame = pd.DataFrame({"T1": ["about 700"], "T2": [None], "T3": [None], "T4": [None]}) + result = compute_measurements(frame, [_thickness()]) + assert "input_not_numeric" in _codes(result, ScoreSeverity.ERROR) + + +def test_a_numeric_string_is_accepted() -> None: + frame = pd.DataFrame({"T1": ["700"], "T2": [f"710{NBSP}"], "T3": [None], "T4": [None]}) + result = compute_measurements(frame, [_thickness()]) + assert result.values["thickness"][0] == pytest.approx(705.0) + + +def test_one_bad_row_does_not_hide_the_others() -> None: + frame = pd.DataFrame( + {"T1": [700, None, 500], "T2": [None, None, None], "T3": [None] * 3, "T4": [None] * 3} + ) + result = compute_measurements(frame, [_thickness()], sample_ids=[1, 2, 3]) + assert result.values["thickness"].tolist()[0] == 700.0 + assert math.isnan(result.values["thickness"][1]) + assert result.values["thickness"].tolist()[2] == 500.0 + assert [f.sample_id for f in result.errors] == [2] + + +def test_raise_for_errors_fails_closed() -> None: + frame = pd.DataFrame({"T1": [None], "T2": [None], "T3": [None], "T4": [None]}) + result = compute_measurements(frame, [_thickness()]) + with pytest.raises(ScoreValidationError, match="cannot be computed"): + result.raise_for_errors() + + +# --------------------------------------------------------------------------- # +# cross-checks +# --------------------------------------------------------------------------- # + + +def test_a_matching_stored_cell_says_nothing() -> None: + frame = pd.DataFrame( + { + "Coverage": [1.0], + "Uniformity": [0.33], + "Phase purity": [0.98], + "Uniformity score": [1.0 * 0.67 * 0.98], + } + ) + result = compute_measurements(frame, [_uniformity()]) + assert result.findings == () + + +def test_a_stale_paste_is_caught() -> None: + """The whole point: a literal that no longer matches its inputs.""" + frame = pd.DataFrame( + { + "Coverage": [1.0], + "Uniformity": [0.33], + "Phase purity": [0.98], + "Uniformity score": [0.5], + } + ) + result = compute_measurements(frame, [_uniformity()], sample_ids=[7]) + mismatch = [f for f in result.warnings if f.code == "cross_check_mismatch"] + assert len(mismatch) == 1 + assert mismatch[0].sample_id == 7 + assert "the model uses" in mismatch[0].message + # and the computed value is what comes out + assert result.values["uniformity"][0] == pytest.approx(0.6566) + + +def test_a_rounded_stored_cell_within_tolerance_is_accepted() -> None: + """`Thickness (avg)` is ROUND(mean), so half a nanometre is not a mismatch.""" + frame = pd.DataFrame( + {"T1": [650], "T2": [655], "T3": [670], "T4": [680], "Thickness (avg)": [664]} + ) + result = compute_measurements(frame, [_thickness()]) + assert [f.code for f in result.warnings] == [] + + +def test_a_rounded_stored_cell_beyond_tolerance_is_not() -> None: + frame = pd.DataFrame( + {"T1": [650], "T2": [655], "T3": [670], "T4": [680], "Thickness (avg)": [700]} + ) + result = compute_measurements(frame, [_thickness()]) + assert "cross_check_mismatch" in _codes(result, ScoreSeverity.WARNING) + + +def test_an_emptied_formula_column_names_the_cause() -> None: + """openpyxl discards cached formula values on save. If a cross-check column + reads empty, that is the likely reason and the message should say it.""" + frame = pd.DataFrame( + { + "Coverage": [1.0], + "Uniformity": [0.33], + "Phase purity": [0.98], + "Uniformity score": [None], + } + ) + result = compute_measurements(frame, [_uniformity()]) + empty = [f for f in result.warnings if f.code == "cross_check_empty"] + assert empty and "non-Excel tool" in empty[0].message + + +def test_a_missing_cross_check_column_is_a_note_not_a_failure() -> None: + """A replacement dataset may not carry the score columns at all.""" + frame = pd.DataFrame({"Coverage": [1.0], "Uniformity": [0.33], "Phase purity": [0.98]}) + result = compute_measurements(frame, [_uniformity()]) + assert "cross_check_absent" in _codes(result, ScoreSeverity.NOTE) + assert not result.has_errors + + +# --------------------------------------------------------------------------- # +# excluded readings and disagreement +# --------------------------------------------------------------------------- # + + +def test_an_excluded_reading_is_recorded_rather_than_averaged() -> None: + frame = pd.DataFrame( + {"T1": [650], "T2": [655], "T3": [670], "T4": [680], "T anom": [1618]} + ) + result = compute_measurements(frame, [_thickness()], sample_ids=[4]) + assert result.values["thickness"][0] == pytest.approx(663.75) + notes = [f for f in result.notes if f.code == "reading_excluded"] + assert notes and notes[0].sample_id == 4 and "1618" in notes[0].message + + +def test_an_empty_anomaly_column_says_nothing() -> None: + frame = pd.DataFrame({"T1": [700], "T2": [710], "T3": [None], "T4": [None], "T anom": [NBSP]}) + result = compute_measurements(frame, [_thickness()]) + assert [f.code for f in result.notes if f.code == "reading_excluded"] == [] + + +def test_readings_that_split_into_two_clusters_warn() -> None: + """Sample 12's recorded 1155 nm is the midpoint of 1600 and 709. The mean is + computed either way, but nobody should act on it without knowing.""" + frame = pd.DataFrame({"T1": [1600], "T2": [709], "T3": [None], "T4": [None]}) + result = compute_measurements( + frame, [_thickness(spread_warning_ratio=0.25)], sample_ids=[12] + ) + warned = [f for f in result.warnings if f.code == "readings_disagree"] + assert warned and warned[0].sample_id == 12 + assert "1600" in warned[0].message and "709" in warned[0].message + assert result.values["thickness"][0] == pytest.approx(1154.5) + + +def test_ordinary_scatter_does_not_warn() -> None: + frame = pd.DataFrame({"T1": [751], "T2": [754], "T3": [752], "T4": [None]}) + result = compute_measurements(frame, [_thickness(spread_warning_ratio=0.25)]) + assert [f.code for f in result.warnings] == [] + + +def test_the_spread_warning_is_off_unless_configured() -> None: + frame = pd.DataFrame({"T1": [1600], "T2": [709], "T3": [None], "T4": [None]}) + result = compute_measurements(frame, [_thickness()]) + assert "readings_disagree" not in _codes(result, ScoreSeverity.WARNING) + + +# --------------------------------------------------------------------------- # +# completeness and entry columns +# --------------------------------------------------------------------------- # + + +def test_row_completeness_needs_every_input_for_a_product() -> None: + frame = pd.DataFrame( + { + "Coverage": [1.0, 1.0], + "Uniformity": [0.33, None], + "Phase purity": [0.98, 0.98], + } + ) + assert row_completeness(frame, [_uniformity()]).tolist() == [True, False] + + +def test_row_completeness_needs_only_one_thickness_reading() -> None: + """Nine of the fifteen R0 rows have two readings. Demanding all four would + report a finished sheet as half-filled and block the next round.""" + frame = pd.DataFrame( + { + "T1": [674, None], + "T2": [700, None], + "T3": [None, None], + "T4": [None, None], + } + ) + assert row_completeness(frame, [_thickness()]).tolist() == [True, False] + + +def test_entry_columns_split_required_from_optional() -> None: + required, optional = entry_columns([_uniformity(), _optoelectronic(), _thickness()]) + assert required == ( + "Coverage", + "Uniformity", + "Phase purity", + "PL", + "PC", + ) + assert optional == ("T1", "T2", "T3", "T4", "T anom") + + +def test_an_absent_required_column_is_refused_up_front() -> None: + frame = pd.DataFrame({"Coverage": [1.0], "Phase purity": [0.98]}) + with pytest.raises(ValueError, match="missing from the sheet"): + compute_measurements(frame, [_uniformity()]) + + +def test_an_absent_optional_column_is_reported_once_not_per_row() -> None: + frame = pd.DataFrame({"T1": [700, 800], "T2": [710, 810]}) + result = compute_measurements(frame, [_thickness()]) + absent = [f for f in result.notes if f.code == "input_column_absent"] + assert {f.column for f in absent} == {"T3", "T4"} + assert len(absent) == 2 + assert result.values["thickness"].tolist() == [705.0, 805.0] + + +# --------------------------------------------------------------------------- # +# config parsing +# --------------------------------------------------------------------------- # + + +def test_no_measurement_block_means_no_spec() -> None: + assert measurement_spec_from_config({"name": "uniformity"}) is None + + +def test_inputs_accept_bare_strings_and_mappings() -> None: + spec = measurement_spec_from_config( + { + "name": "uniformity", + "measurement": { + "recipe": "product", + "inputs": ["Coverage", {"column": "Uniformity", "transform": "complement"}], + "cross_check": "Uniformity score", + }, + } + ) + assert spec is not None + assert [i.column for i in spec.inputs] == ["Coverage", "Uniformity"] + assert [i.transform for i in spec.inputs] == ["identity", "complement"] + assert spec.cross_checks[0].column == "Uniformity score" + assert spec.cross_checks[0].atol == pytest.approx(0.005) + + +def test_a_single_cross_check_mapping_is_accepted() -> None: + spec = measurement_spec_from_config( + { + "name": "thickness", + "measurement": { + "recipe": "mean_of_present", + "inputs": ["T1"], + "cross_check": {"column": "Thickness (avg)", "atol": 0.5}, + }, + } + ) + assert spec.cross_checks == (CrossCheck("Thickness (avg)", 0.5),) + + +@pytest.mark.parametrize( + "block, match", + [ + ({"recipe": "nonsense", "inputs": ["a"]}, "unknown recipe"), + ({"recipe": "product", "inputs": []}, "non-empty list"), + ({"recipe": "product", "inputs": ["a", "a"]}, "repeats"), + ( + {"recipe": "product", "inputs": [{"column": "a", "transform": "sqrt"}]}, + "Unsupported measurement transform", + ), + ( + {"recipe": "mean_of_present", "inputs": ["a"], "spread_warning_ratio": 0}, + "positive finite", + ), + ], +) +def test_a_malformed_measurement_block_is_refused(block, match) -> None: + with pytest.raises(ValueError, match=match): + measurement_spec_from_config({"name": "x", "measurement": block}) + + +# --------------------------------------------------------------------------- # +# the v3 contract: `mean`, the two threshold transforms, and the agreement check +# --------------------------------------------------------------------------- # + + +def _v3_uniformity(**kwargs) -> MeasurementSpec: + return MeasurementSpec( + name="uniformity", + recipe="mean", + inputs=( + MeasurementInput("Coverage"), + MeasurementInput( + "Uniformity", "clamped_complement", clamp_above=1.0, clamp_to=0.99 + ), + MeasurementInput("Phase purity"), + ), + **kwargs, + ) + + +def _v3_optoelectronic(**kwargs) -> MeasurementSpec: + return MeasurementSpec( + name="optoelectronic", + recipe="mean", + inputs=( + MeasurementInput("Voc raw", "capped_ratio", cap=1.4), + MeasurementInput("Normalized photoconductance"), + ), + **kwargs, + ) + + +def test_the_mean_recipe_averages_every_input() -> None: + frame = pd.DataFrame( + {"Coverage": [0.989], "Uniformity": [0.324584], "Phase purity": [0.9685]} + ) + result = compute_measurements(frame, [_v3_uniformity()], sample_ids=[1]) + # the workbook's own (L + O + P) / 3 for sample 1 + assert result.values["uniformity"][0] == pytest.approx(0.8776386666666668, abs=1e-15) + assert result.inputs_used["uniformity"][0] == 3 + + +def test_mean_refuses_a_blank_where_mean_of_present_would_accept_one() -> None: + """The two recipes do the same arithmetic and differ only here, which is the + entire reason `mean` exists rather than reusing `mean_of_present`: a missing + Coverage is a hole in the row, not a film with fewer readings.""" + frame = pd.DataFrame( + {"Coverage": [None], "Uniformity": [0.3], "Phase purity": [0.9]} + ) + result = compute_measurements(frame, [_v3_uniformity()], sample_ids=[1]) + assert "input_missing" in _codes(result, ScoreSeverity.ERROR) + assert math.isnan(result.values["uniformity"][0]) + + +@pytest.mark.parametrize( + "uniformity, expected_complement, note", + [ + (1.658775, 0.010000000000000009, "sample 4 of the v3 workbook"), + (1.277, 0.010000000000000009, "sample 8 of the v3 workbook"), + (1.0000001, 0.010000000000000009, "just above the threshold"), + (1.0, 0.0, "EXACTLY 1.0 keeps its own value: the clamp is strict"), + (0.324584, 0.675416, "an ordinary reading is untouched"), + (0.0, 1.0, "the bottom of the range"), + ], +) +def test_the_uniformity_clamp_including_its_boundary( + uniformity, expected_complement, note +) -> None: + frame = pd.DataFrame( + {"Coverage": [0.0], "Uniformity": [uniformity], "Phase purity": [0.0]} + ) + result = compute_measurements(frame, [_v3_uniformity()], sample_ids=[1]) + assert result.values["uniformity"][0] == pytest.approx( + expected_complement / 3.0, abs=1e-12 + ), note + + +def test_the_voc_cap_is_dormant_on_readings_below_it() -> None: + """Every observed reading is under 1.4, so the cap changes nothing today and + the recipe reproduces the sheet's uncapped Q / 1.4 exactly.""" + frame = pd.DataFrame( + {"Voc raw": [1.02683981553478], "Normalized photoconductance": [0.763425]} + ) + result = compute_measurements(frame, [_v3_optoelectronic()], sample_ids=[1]) + assert result.values["optoelectronic"][0] == pytest.approx( + 0.7484410055481358, abs=1e-15 + ) + + +def test_the_voc_cap_binds_above_1_4_where_the_sheet_would_not() -> None: + """The declared divergence, exercised. The workbook has no ceiling, so this + row is where the two would part company -- and the cross-check is what would + say so on real data.""" + frame = pd.DataFrame({"Voc raw": [2.8], "Normalized photoconductance": [0.0]}) + result = compute_measurements(frame, [_v3_optoelectronic()], sample_ids=[1]) + assert result.values["optoelectronic"][0] == pytest.approx(0.5) # (1.0 + 0.0) / 2 + + +def test_normalized_photoconductance_passes_straight_through() -> None: + frame = pd.DataFrame({"Voc raw": [0.0], "Normalized photoconductance": [0.42]}) + result = compute_measurements(frame, [_v3_optoelectronic()], sample_ids=[1]) + assert result.values["optoelectronic"][0] == pytest.approx(0.21) + + +@pytest.mark.parametrize( + "kwargs, match", + [ + ({"transform": "clamped_complement", "clamp_above": 1.0}, "clamp_to"), + ({"transform": "clamped_complement", "clamp_to": 0.99}, "clamp_above"), + ({"transform": "capped_ratio"}, "cap"), + ({"transform": "capped_ratio", "cap": 0.0}, "positive"), + ({"transform": "identity", "cap": 1.4}, "ignores"), + ({"transform": "complement", "clamp_to": 0.99}, "ignores"), + ], +) +def test_a_threshold_that_would_do_nothing_is_an_error(kwargs, match) -> None: + """A clamp everyone believes is configured while nothing applies it is the + same class of failure as the three finite-but-wrong numbers this project has + already found.""" + with pytest.raises(ValueError, match=match): + MeasurementInput("x", **kwargs) + + +def test_an_unknown_key_on_a_measurement_input_is_refused() -> None: + with pytest.raises(ValueError, match="unknown key"): + measurement_spec_from_config( + { + "name": "x", + "measurement": { + "recipe": "mean", + "inputs": [{"column": "a", "clamp_at": 1.0}], + }, + } + ) + + +def test_thresholds_survive_the_config_round_trip() -> None: + spec = measurement_spec_from_config( + { + "name": "optoelectronic", + "measurement": { + "recipe": "mean", + "inputs": [ + {"column": "Voc ", "transform": "capped_ratio", "cap": 1.4}, + { + "column": " Uniformity", + "transform": "clamped_complement", + "clamp_above": 1.0, + "clamp_to": 0.99, + }, + ], + }, + } + ) + # column names are stripped on BOTH sides: the v3 sheet's headers carry + # trailing spaces ('PL - Implied Voc (Max) Raw ') and the config quotes them + # verbatim, so resolution must not depend on which spelling was written + assert [item.column for item in spec.inputs] == ["Voc", "Uniformity"] + assert spec.inputs[0].cap == 1.4 + assert spec.inputs[1].clamp_above == 1.0 + assert spec.inputs[1].clamp_to == 0.99 + + +def _agreement(raw_values, normalized_values, **kwargs): + spec = MeasurementSpec( + name="optoelectronic", + recipe="mean", + inputs=(MeasurementInput("Normalized photoconductance"),), + agreement_check=AgreementCheck( + raw="Photoconductance (Max)", + normalized="Normalized photoconductance", + **kwargs, + ), + ) + frame = pd.DataFrame( + { + "Photoconductance (Max)": raw_values, + "Normalized photoconductance": normalized_values, + } + ) + return compute_measurements( + frame, [spec], sample_ids=list(range(1, len(raw_values) + 1)) + ) + + +def test_a_normalization_that_ranks_backwards_warns() -> None: + """The live case: the strongest film carries the lowest normalised value.""" + result = _agreement([1e-8, 1e-7, 1e-6], [0.9, 0.5, 0.01]) + assert "agreement_not_monotonic" in _codes(result, ScoreSeverity.WARNING) + message = next( + f.message for f in result.findings if f.code == "agreement_not_monotonic" + ) + assert "-1.0000" in message + assert "sample 3" in message # names the highest-raw film, not just the rho + + +def test_a_normalization_that_preserves_order_is_only_a_note() -> None: + result = _agreement([1e-8, 1e-7, 1e-6], [0.01, 0.5, 0.9]) + assert "agreement_monotonic" in _codes(result, ScoreSeverity.NOTE) + assert not _codes(result, ScoreSeverity.WARNING) + + +def test_the_agreement_check_never_blocks_a_round() -> None: + """It is a finding by design: which column the model trains on is the group's + decision, and a diagnostic that refused to run would make it by refusing.""" + result = _agreement([1e-8, 1e-7, 1e-6], [0.9, 0.5, 0.01]) + assert not result.has_errors + assert result.values["optoelectronic"].notna().all() + + +def test_the_agreement_check_says_so_when_it_cannot_run() -> None: + spec = MeasurementSpec( + name="optoelectronic", + recipe="mean", + inputs=(MeasurementInput("Normalized photoconductance"),), + agreement_check=AgreementCheck( + raw="Photoconductance (Max)", normalized="Normalized photoconductance" + ), + ) + frame = pd.DataFrame({"Normalized photoconductance": [0.1, 0.2, 0.3]}) + result = compute_measurements(frame, [spec], sample_ids=[1, 2, 3]) + assert "agreement_check_absent" in _codes(result, ScoreSeverity.NOTE) + + too_few = _agreement([1e-8, 1e-7], [0.9, 0.5]) + assert "agreement_check_too_few_rows" in _codes(too_few, ScoreSeverity.NOTE) + + constant = _agreement([1e-7, 1e-7, 1e-7], [0.9, 0.5, 0.01]) + assert "agreement_check_undefined" in _codes(constant, ScoreSeverity.NOTE) + + +def test_the_agreement_raw_column_is_offered_but_never_required() -> None: + """A row without it simply does not join the rank comparison; the objective is + computed from the normalised column either way.""" + spec = MeasurementSpec( + name="optoelectronic", + recipe="mean", + inputs=(MeasurementInput("Normalized photoconductance"),), + agreement_check=AgreementCheck( + raw="Photoconductance (Max)", normalized="Normalized photoconductance" + ), + ) + required, optional = entry_columns([spec]) + assert "Photoconductance (Max)" not in required + assert "Photoconductance (Max)" in optional + + +def test_findings_frame_is_exportable() -> None: + frame = pd.DataFrame({"T1": [1600], "T2": [709], "T3": [None], "T4": [None]}) + result = compute_measurements( + frame, [_thickness(spread_warning_ratio=0.25)], sample_ids=[12] + ) + exported = result.findings_frame() + assert list(exported.columns) == [ + "severity", + "code", + "objective", + "sample_id", + "column", + "message", + ] + assert (exported["objective"] == "thickness").all() diff --git a/tests/test_scripts.py b/tests/test_scripts.py new file mode 100644 index 0000000..47dd745 --- /dev/null +++ b/tests/test_scripts.py @@ -0,0 +1,114 @@ +"""The two operator-facing scripts. + +Neither is a test — one sweeps parameters, one is what you run when new data +arrives — but both encode commitments that should not drift silently: the sweep's +pre-committed decision rule, and the intake's floors. Those are pinned here. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +SCRIPTS = Path("scripts") + + +def _load(name: str): + """Import a script by path. Both guard their entry point with __main__, so + importing runs no work.""" + path = SCRIPTS / f"{name}.py" + spec = importlib.util.spec_from_file_location(f"_script_{name}", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_both_scripts_import_cleanly() -> None: + assert _load("dtlz2_parameter_sweep") is not None + assert _load("intake_new_data") is not None + + +# --------------------------------------------------------------------------- # +# the sweep's pre-committed rule +# --------------------------------------------------------------------------- # + + +def test_the_sweep_grid_and_current_default_are_what_was_agreed() -> None: + sweep = _load("dtlz2_parameter_sweep") + assert sweep.BETAS == (2.0, 4.0, 8.0) + assert sweep.RADII == (0.15, 0.25, 0.35) + assert sweep.CURRENT_BETA == 4.0 + assert sweep.CURRENT_RADIUS == 0.25 + # a hard floor on spacing, held fixed so "spacing" means the same thing in + # every cell + assert sweep.MIN_BATCH_DISTANCE == 0.15 + + +def test_the_sweep_scores_the_added_budget_not_the_whole_campaign() -> None: + """The comparison is BO against random at EQUAL budget: the 8 points R1 and R2 + add. Scoring the whole campaign would credit BO with the shared R0 start.""" + sweep = _load("dtlz2_parameter_sweep") + assert sweep.ADDED == sweep.R1_SIZE + sweep.R2_SIZE == 8 + + +# --------------------------------------------------------------------------- # +# the intake's floors +# --------------------------------------------------------------------------- # + + +def test_the_null_moves_with_n() -> None: + """1 - (N/(N-1))^2, independent of the data. Reusing the N=15 value on a bigger + dataset would hold the model to the wrong bar.""" + intake = _load("intake_new_data") + assert intake.null_loo_r2(15) == pytest.approx(-0.1480, abs=1e-4) + assert intake.null_loo_r2(21) == pytest.approx(-0.1025, abs=1e-4) + assert intake.null_loo_r2(31) == pytest.approx(-0.0678, abs=1e-4) + # it approaches zero from below as N grows, never crossing it + assert intake.null_loo_r2(1000) < 0.0 + + +def test_the_resolution_floor_shrinks_with_n() -> None: + intake = _load("intake_new_data") + assert intake.resolution_sd(15) == pytest.approx(0.236) + assert intake.resolution_sd(60) == pytest.approx(0.118) + assert intake.resolution_sd(15) > intake.resolution_sd(30) + + +def test_the_bootstrap_reference_is_the_measured_one() -> None: + """0.236 was measured by parametric bootstrap at N=15, 4000 resamples. The + sqrt(15/N) rescaling is an approximation and the script says so. + + The constants live in ``mobo_kit.loocv`` rather than in the script, because + the round report and the permutation test need the same ones. The script + re-exports them by importing, and this asserts they are the same objects + rather than two copies drifting apart. + """ + from mobo_kit import loocv + + intake = _load("intake_new_data") + assert loocv.RESOLUTION_SD_AT_15 == 0.236 + assert loocv.RESOLUTION_REFERENCE_N == 15 + assert intake.RESOLUTION_SD_AT_15 is loocv.RESOLUTION_SD_AT_15 + assert intake.resolution_sd is loocv.resolution_sd + assert intake.null_loo_r2 is loocv.null_loo_r2 + assert "estimate" in intake.__doc__ or "approximation" in intake.__doc__ + + +def test_the_report_and_the_intake_share_one_fold_loop() -> None: + """Not "they agree" -- they are the same function. + + This project's canonical LOO numbers briefly had three implementations: the + intake script, the round report and the permutation test. Two of them agreeing + today is exactly the situation that produced its three silent-failure bugs. + """ + from mobo_kit import loocv, round_report + + intake = _load("intake_new_data") + permutation = _load("permutation_rank_test") + assert intake.loo_predictions is loocv.loo_predictions + assert permutation.loo_predictions is loocv.loo_predictions + assert round_report.loo_predictions is loocv.loo_predictions diff --git a/tests/test_second_campaign.py b/tests/test_second_campaign.py new file mode 100644 index 0000000..5cf10e2 --- /dev/null +++ b/tests/test_second_campaign.py @@ -0,0 +1,539 @@ +"""The second campaign's objective contract, end to end. + +`configs/campaign_d2d_perovskite_test.yaml` is a new contract on a new workbook: +uniformity and optoelectronic are computed differently from the first campaign, +the column layout moved, two grids changed and three constraints are active for +the first time in this project. + +The synthetic half builds a sheet with the v3 headers -- INCLUDING their trailing +spaces -- so the contract is exercised without the ignored workbook. The real- +workbook half is marked `local_input` and skips without it. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from mobo_kit.campaign import ( + BatchValidityError, + build_design_from_config, + build_objective_transform, + load_campaign_config, + measurement_specs, + objective_names, + run_r1_ucb, + validate_batch, +) +from mobo_kit.constraints import constraint_violations, constraints_from_config +from mobo_kit.scores import ScoreSeverity, compute_measurements + +CONFIG_PATH = "configs/campaign_d2d_perovskite_test.yaml" +ARCHIVED_CONFIG_PATH = "configs/campaign_d2d_perovskite.yaml" +SOURCE = "local_inputs/Summary Table Test.xlsx" + +#: Exactly as Sheet1 spells them. Two carry a trailing space, which is not a typo +#: in this file -- it is what the header cell contains, and resolution has to cope +#: with it from both directions. +VOC_HEADER = "PL - Implied Voc (Max) Raw " +PHOTOCONDUCTANCE_HEADER = "Normalized photoconductance " + + +@pytest.fixture(scope="module") +def config() -> dict: + return load_campaign_config(CONFIG_PATH) + + +# --------------------------------------------------------------------------- # +# the contract itself +# --------------------------------------------------------------------------- # + + +def test_the_new_contract_is_distinct_from_the_archived_one(config) -> None: + """Every contract's utility space is its own, and they must not be confused. + + Uniformity is a three-term mean here and a three-term product in v2; + optoelectronic is a mean of normalised values here and a log10 product there. + A shared contract_version would make hypervolumes look comparable when they + measure different spaces. v3 is itself archived now -- superseded by v4 -- so + all that is asserted here is that the three versions are distinct. + """ + archived = load_campaign_config(ARCHIVED_CONFIG_PATH) + assert config["objectives"]["contract_version"] == "d2d-objectives-v3-test" + assert ( + config["objectives"]["contract_version"] + != archived["objectives"]["contract_version"] + ) + assert archived["campaign"]["status"] == "archived" + assert config["campaign"]["status"] == "archived" + # objective ORDER is part of the contract: Y columns are positional + assert objective_names(config) == ("uniformity", "optoelectronic", "thickness") + + +def test_both_new_scores_live_in_zero_to_one(config) -> None: + """A mean of terms that are each in [0, 1] is in [0, 1] by construction, so + these anchors are the objective's range and not a guess about the data.""" + specs = {spec["name"]: spec for spec in config["objectives"]["specs"]} + for name in ("uniformity", "optoelectronic"): + assert specs[name]["lower_anchor"] == 0.0 + assert specs[name]["upper_anchor"] == 1.0 + build_objective_transform(config) # runs assert_scaling_is_campaign_fixed + + +def test_the_signal_verdicts_are_the_ones_measured_on_these_rows(config) -> None: + """Nothing was inherited: every verdict here came from an intake run on this + workbook, and two of the three came out differently from the first campaign's. + + Both non-thickness objectives sit below the leave-one-out null, so a batch is + chosen on one informative axis and two uninformative ones. + """ + status = { + spec["name"]: spec["signal_status"] for spec in config["objectives"]["specs"] + } + assert status == { + "uniformity": "exploration_only", + "optoelectronic": "exploration_only", + "thickness": "learnable", + } + + +def test_the_optoelectronic_mean_function_stays_deleted(config) -> None: + """The intake verdict was DELETE: the first campaign's linear anneal_temp trend + made this objective's fit WORSE here (-0.5842 -> -0.6977), because the target + was redefined underneath it. Reinstating it from the archived config is the + obvious mistake, so it is pinned.""" + specs = {spec["name"]: spec for spec in config["objectives"]["specs"]} + assert "mean_function" not in specs["optoelectronic"] + # thickness keeps its block: inconclusive, but it clears the null either way + assert specs["thickness"]["mean_function"]["response"] == "log" + assert [ + feature["column"] for feature in specs["thickness"]["mean_function"]["features"] + ] == ["speed_1", "precur_conc"] + + +def test_the_thickness_cross_check_is_tight_now(config) -> None: + """The first campaign's `Thickness (avg)` was ROUND(mean(T1..T4)), so half a + nanometre of disagreement was legitimate. This sheet's is a live unrounded + AVERAGE, so anything above floating-point noise is real.""" + thickness = next( + spec + for spec in config["objectives"]["specs"] + if spec["name"] == "thickness" + ) + (check,) = thickness["measurement"]["cross_check"] + assert check["atol"] == pytest.approx(1e-9) + + +# --------------------------------------------------------------------------- # +# the grid edits +# --------------------------------------------------------------------------- # + + +def test_the_two_grid_edits_and_nothing_else(config) -> None: + archived = load_campaign_config(ARCHIVED_CONFIG_PATH) + before = {item["name"]: item for item in archived["inputs"]} + after = {item["name"]: item for item in config["inputs"]} + assert list(before) == list(after), "input order is positional; it must not move" + + changed = { + name + for name in after + if (after[name]["start"], after[name]["stop"], after[name]["step"]) + != (before[name]["start"], before[name]["stop"], before[name]["step"]) + } + assert changed == {"time_2", "anti_time"} + # time_2 reaches 0 so a one-step film is on-grid; anti_time steps by 1 so + # sample 1's anti_time = 12 is an ordinary observation rather than a declared + # off-grid exception + assert (after["time_2"]["start"], after["time_2"]["step"]) == (0, 5) + assert (after["anti_time"]["start"], after["anti_time"]["step"]) == (9, 1) + + +def test_the_grid_hole_at_time_2_equals_5_is_declared_not_silent(config) -> None: + """Reaching 0 with step 5 also reaches 5, which the first campaign's grid + excluded and no film has run. The constraint is what keeps it out.""" + design = build_design_from_config(dict(config)) + time_2_grid = design.var_array[design.names.index("time_2")] + assert 5.0 in set(time_2_grid), "the arithmetic grid does contain it" + + constraints = constraints_from_config(dict(config), design) + row = {name: design.var_array[i][1] for i, name in enumerate(design.names)} + row.update({"speed_2": 1000.0, "time_1": 50.0, "time_2": 5.0, "anti_time": 9.0}) + values = np.asarray([[row[name] for name in design.names]], dtype=float) + assert constraint_violations(values, design, constraints) == [ + ["second_stage_runs_at_least_10s"] + ], "and the constraint is what excludes it" + + +# --------------------------------------------------------------------------- # +# recipes, on a synthetic sheet with the v3 headers +# --------------------------------------------------------------------------- # + + +def _sheet(rows: list[dict]) -> pd.DataFrame: + """A frame keyed by the v3 headers, trailing spaces and all.""" + return pd.DataFrame(rows, dtype=object) + + +def test_the_recipes_reproduce_the_stored_scores_on_a_synthetic_sheet(config) -> None: + """Sample 1 and sample 4 of the real sheet, transcribed. Sample 4 is one of + the two clamped rows, so this covers the clamp on the way through as well.""" + specs = [spec for spec in measurement_specs(config) if spec is not None] + frame = _sheet( + [ + { + "Coverage": 0.989, + "Uniformity": 0.324584, + "Phase purity": 0.9685, + VOC_HEADER: 1.02683981553478, + PHOTOCONDUCTANCE_HEADER: 0.763425, + "Photoconductance (Max)": 5e-07, + "T1": 584.4, + "T2": 418.5, + "T3": 692.0, + "T4": 624.6, + "T anom": None, + }, + { + "Coverage": 0.992, + "Uniformity": 1.658775, # clamped to 0.99 + "Phase purity": 0.786, + VOC_HEADER: 1.13513544854332, + PHOTOCONDUCTANCE_HEADER: 1.0, + "Photoconductance (Max)": 3.42e-08, + "T1": 657.7, + "T2": 586.4, + "T3": 693.2, + "T4": 667.1, + "T anom": 832.1, + }, + ] + ) + result = compute_measurements(frame, specs, sample_ids=[1, 4]) + + # the workbook's own AB, AC and Z for those two rows + assert result.values["uniformity"].tolist() == pytest.approx( + [0.8776386666666668, 0.596], abs=1e-15 + ) + assert result.values["optoelectronic"].tolist() == pytest.approx( + [0.7484410055481358, 0.9054055173369], abs=1e-15 + ) + assert result.values["thickness"].tolist() == pytest.approx( + [579.875, 651.1], abs=1e-12 + ) + assert not result.has_errors + + +def _filler(**overrides) -> dict: + """A row that satisfies every objective, so one can be varied at a time.""" + row = { + "Coverage": 1.0, + "Uniformity": 0.0, + "Phase purity": 1.0, + VOC_HEADER: 1.4, + PHOTOCONDUCTANCE_HEADER: 1.0, + "T1": 650.0, + "T2": 650.0, + "T3": 650.0, + "T4": 650.0, + "T anom": None, + } + row.update(overrides) + return row + + +def test_a_variable_number_of_thickness_readings_is_normal(config) -> None: + """Eleven of the fifteen rows carry three readings and four carry four, so a + recipe demanding all four would reject two thirds of the campaign.""" + specs = [spec for spec in measurement_specs(config) if spec is not None] + frame = _sheet( + [ + _filler(T1=413.0, T2=430.2, T3=439.4, T4=None), + _filler(T1=962.6, T2=961.1, T3=947.7, T4=942.8), + ] + ) + result = compute_measurements(frame, specs, sample_ids=[2, 3]) + assert result.inputs_used["thickness"].tolist() == [3, 4] + assert result.values["thickness"].tolist() == pytest.approx( + [427.5333333333333, 953.55], abs=1e-12 + ) + assert not result.has_errors + + +def test_the_v3_headers_resolve_despite_their_trailing_spaces(config) -> None: + """Two of the sheet's headers end in a space, and the config quotes them + verbatim. Names are compared stripped on BOTH sides, so either spelling + resolves and neither silently reports a present column as missing.""" + specs = [spec for spec in measurement_specs(config) if spec is not None] + declared = [item.column for spec in specs for item in spec.inputs] + assert VOC_HEADER.rstrip() in declared, "the config side is stripped" + assert VOC_HEADER not in declared + + with_spaces = _sheet([_filler()]) + without_spaces = with_spaces.rename(columns=lambda name: name.strip()) + assert list(with_spaces.columns) != list(without_spaces.columns) + + from_spaced = compute_measurements(with_spaces, specs, sample_ids=[1]) + from_stripped = compute_measurements(without_spaces, specs, sample_ids=[1]) + assert not from_spaced.has_errors + pd.testing.assert_frame_equal(from_spaced.values, from_stripped.values) + + +def test_t_anom_is_excluded_from_the_mean_and_still_reported(config) -> None: + specs = [spec for spec in measurement_specs(config) if spec is not None] + frame = _sheet( + [_filler(T1=657.7, T2=586.4, T3=693.2, T4=667.1, **{"T anom": 832.1})] + ) + result = compute_measurements(frame, specs, sample_ids=[4]) + assert result.values["thickness"][0] == pytest.approx(651.1) + codes = [f.code for f in result.findings if f.severity is ScoreSeverity.NOTE] + assert "reading_excluded" in codes + + +# --------------------------------------------------------------------------- # +# constraints reach the batch gate +# --------------------------------------------------------------------------- # + + +def test_validate_batch_refuses_a_condition_that_breaks_a_constraint(config) -> None: + """Deliberately redundant with the pool filter. The pool is the mechanism; + this is the independent second route to the same answer, which is the check + this project's three finite-but-wrong-number bugs all lacked.""" + design = build_design_from_config(dict(config)) + constraints = constraints_from_config(dict(config), design) + good = { + "speed_1": 2500.0, + "time_1": 30.0, + "speed_2": 1000.0, + "time_2": 20.0, + "precur_conc": 1.4, + "precur_vol": 100.0, + "anneal_temp": 120.0, + "anneal_time": 30.0, + "anti_vol": 150.0, + "anti_time": 12.0, + } + frame = pd.DataFrame([good], columns=design.names) + report = validate_batch(frame, design, expected_count=1, constraints=constraints) + assert report["constraints_satisfied"] is True + assert report["constraint_violations_per_condition"] == [[]] + + broken = dict(good, speed_2=0.0) # time_2 still 20: exactly one of the pair is 0 + with pytest.raises(BatchValidityError, match="second_stage_all_or_nothing"): + validate_batch( + pd.DataFrame([broken], columns=design.names), + design, + expected_count=1, + constraints=constraints, + ) + + +def test_constraints_are_inert_when_unconfigured(config) -> None: + """DTLZ2 declares none, and its acceptance suite must be unaffected.""" + design = build_design_from_config(dict(config)) + frame = pd.DataFrame( + [ + { + "speed_1": 2500.0, + "time_1": 30.0, + "speed_2": 0.0, + "time_2": 20.0, # would break second_stage_all_or_nothing + "precur_conc": 1.4, + "precur_vol": 100.0, + "anneal_temp": 120.0, + "anneal_time": 30.0, + "anti_vol": 150.0, + "anti_time": 12.0, + } + ], + columns=design.names, + ) + report = validate_batch(frame, design, expected_count=1) + assert report["constraint_violations_per_condition"] == [[]] + assert report["constraints_declared"] == [] + + +# --------------------------------------------------------------------------- # +# the launcher points at the campaign that is actually running +# --------------------------------------------------------------------------- # + + +def test_this_contract_is_archived_and_the_launcher_has_moved_on() -> None: + """v3 was the DRY RUN -- it rehearsed this contract's shape on a workbook + literally called "Test". The live campaign is v4, and the launcher points + there; the pinning of that default lives in `test_final_campaign.py`. + + The 2026-08-18 regression this guards against is unchanged in kind: archiving + a config without moving the launcher's default leaves the double-click path + reading a new workbook against a retired contract, which surfaces as a + missing-column error on an intact workbook. + """ + from mobo_kit.launcher import DEFAULT_CONFIG + + assert load_campaign_config(CONFIG_PATH)["campaign"]["status"] == "archived" + assert DEFAULT_CONFIG != CONFIG_PATH + assert load_campaign_config(DEFAULT_CONFIG)["campaign"]["status"] == "active" + + +def test_reading_a_workbook_against_the_wrong_contract_says_which_contract() -> None: + """A column mismatch is almost never a broken workbook; it is a config + describing a different campaign. The message has to say so, because the + obvious reading of "missing column" sends someone to edit the sheet.""" + from openpyxl import Workbook + + from mobo_kit.workbook_io import CandidateSheetError, read_campaign_workbook + + archived = load_campaign_config(ARCHIVED_CONFIG_PATH) + book = Workbook() + sheet = book.active + sheet.title = "Sheet1" + # a v3-shaped sheet: the archived config wants "PL - Implied Voc (Max)" + sheet.append(["Sample number", VOC_HEADER]) + sheet.append([1, 1.0]) + import tempfile + from pathlib import Path as _Path + + with tempfile.TemporaryDirectory() as tmp: + path = _Path(tmp) / "Summary Table Test.xlsx" + book.save(path) + with pytest.raises(CandidateSheetError) as caught: + read_campaign_workbook(path, archived) + + message = str(caught.value) + assert "archived" in message.lower() + assert "d2d-objectives-v2-nm-thickness" in message + # and it points at the column that is almost certainly the same measurement + assert "PL - Implied Voc (Max) Raw" in message + + +def test_a_column_level_finding_does_not_pretend_to_have_a_row(config) -> None: + """`sample ?` reads as a row whose identity was lost. The rank-agreement + finding is about a column and says so.""" + from mobo_kit.scores import ScoreFinding, ScoreSeverity + + finding = ScoreFinding( + severity=ScoreSeverity.WARNING, + code="agreement_not_monotonic", + objective="optoelectronic", + row_position=-1, + sample_id=None, + message="ranks backwards", + ) + assert finding.is_column_level + assert "sample ?" not in str(finding) + assert "all rows, optoelectronic" in str(finding) + + per_row = ScoreFinding( + severity=ScoreSeverity.WARNING, + code="readings_disagree", + objective="thickness", + row_position=0, + sample_id=1, + message="readings disagree", + ) + assert not per_row.is_column_level + assert "sample 1, thickness" in str(per_row) + + +# --------------------------------------------------------------------------- # +# the real workbook +# --------------------------------------------------------------------------- # + +requires_workbook = pytest.mark.skipif( + not Path(SOURCE).is_file(), reason=f"{SOURCE} is not present in this checkout" +) + + +@pytest.mark.local_input +@requires_workbook +def test_every_measured_row_is_on_grid_and_satisfies_every_constraint(config) -> None: + """Both halves matter. Off-grid observations drop out of pool bookkeeping, and + a constraint that rejects a film the group actually ran is far more likely to + be wrong than the film is.""" + from mobo_kit.workbook_io import read_campaign_workbook + + contents = read_campaign_workbook(SOURCE, config) + assert contents.n_rows == 15 + assert contents.errors == () + + design = build_design_from_config(dict(config)) + X = contents.inputs.to_numpy(float) + off_grid = [ + (contents.sample_ids[i], name, value) + for j, name in enumerate(design.names) + for i, value in enumerate(X[:, j]) + if not np.any(np.isclose(design.var_array[j], value, rtol=0.0, atol=1e-9)) + ] + assert off_grid == [] + + constraints = constraints_from_config(dict(config), design) + assert constraint_violations(X, design, constraints) == [[] for _ in range(15)] + + +@pytest.mark.local_input +@requires_workbook +def test_the_computed_objectives_match_the_stored_score_columns(config) -> None: + """The policy for this workbook is that the stored scores are authoritative and + the recompute is the cross-check, so the two agreeing is the whole claim.""" + from mobo_kit.workbook_io import read_campaign_workbook + + contents = read_campaign_workbook(SOURCE, config) + computed = contents.model_values.to_numpy(float) + stored = contents.workbook_values.to_numpy(float) + assert computed.shape == stored.shape == (15, 3) + assert np.abs(computed - stored).max() < 1e-9 + assert not [ + f for f in contents.findings if f.code == "cross_check_mismatch" + ] + + +@pytest.mark.local_input +@requires_workbook +def test_the_photoconductance_normalization_warns_on_the_real_rows(config) -> None: + """The live defect, pinned so it cannot be quietly resolved by editing config. + + When the group supplies the real formula this test should start failing, and + that failure is the signal to update the recorded number rather than to + loosen the check. + """ + from mobo_kit.workbook_io import read_campaign_workbook + + contents = read_campaign_workbook(SOURCE, config) + warning = next( + f for f in contents.findings if f.code == "agreement_not_monotonic" + ) + assert warning.severity is ScoreSeverity.WARNING + assert "-0.5484" in warning.message + assert contents.errors == (), "a finding, never a gate" + + +@pytest.mark.local_input +@pytest.mark.slow +@requires_workbook +def test_r1_on_the_real_workbook_is_valid_and_deterministic(config) -> None: + """One full R1 at production settings: five conditions, on grid, constraint + satisfying, and identical on a second run at the same seed.""" + from mobo_kit.workbook_io import read_campaign_workbook + + contents = read_campaign_workbook(SOURCE, config) + X = contents.inputs.to_numpy(float) + Y = contents.model_values.to_numpy(float) + + first = run_r1_ucb(config, X, Y, n=5, seed=73) + assert len(first.conditions) == 5 + assert first.diagnostics["validity"]["constraints_satisfied"] is True + assert first.diagnostics["validity"]["constraint_violations_per_condition"] == [ + [] for _ in range(5) + ] + assert first.diagnostics["observed_rows_violating_constraints"] == [] + assert len(first.diagnostics["constraints_declared"]) == 3 + # the sampler draws until the pool is full, so a constraint that gutted the + # space would still yield a normal-looking pool; the survival rate is the only + # place that shows + assert 0.0 < first.diagnostics["constraint_pool_survival_rate"] <= 1.0 + + second = run_r1_ucb(config, X, Y, n=5, seed=73) + pd.testing.assert_frame_equal(first.conditions, second.conditions) diff --git a/tests/test_shap_attribution.py b/tests/test_shap_attribution.py new file mode 100644 index 0000000..e2810d9 --- /dev/null +++ b/tests/test_shap_attribution.py @@ -0,0 +1,391 @@ +"""SHAP attribution over the campaign's own models. + +What is pinned here is not "the numbers look plausible" -- that is how the last +three silent-failure bugs survived -- but properties that fail loudly if the +attribution stops meaning what the figures claim: + +* **additivity.** Shapley values must reconstruct the model output exactly: + ``base_value + sum(shap) == f(x)``. At 10 features ``KernelExplainer`` + enumerates all ``2**10`` coalitions, so this holds to machine precision and is a + genuine comparator rather than a plausibility check. +* **determinism.** The figures and the summary CSV are compared across model + states, which is meaningless if two runs of the same input disagree. +* **the mean function shows up where it must.** An objective carrying a declared + physics trend on a feature had better attribute to that feature; if it does not, + either the mean module is not reaching the posterior or the explained function + is the wrong one. + +The synthetic campaign needs no workbook. One test does and skips without it. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from mobo_kit.campaign import ( + build_objective_transform, + fit_campaign_models, + run_r0_lhs, + run_r2_qlognehvi, +) +from mobo_kit.candidate_pool import sample_discrete_candidate_pool +from mobo_kit.design import build_design_from_config +from mobo_kit.research_qnehvi import R2_ACQUISITIONS, run_r2_qnehvi_research + +SOURCE = "local_inputs/Summary Table.xlsx" +SEED = 73 +INPUT_DIM = 10 + + +def _load(): + path = Path("scripts") / "plot_shap_attribution.py" + spec = importlib.util.spec_from_file_location("_script_plot_shap", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +psa = _load() + + +def _config(pool: int = 256) -> dict: + """Synthetic campaign with a log-link objective whose trend is on x0.""" + return { + "inputs": [ + {"name": f"x{i}", "unit": "u", "start": 1.0, "stop": 2.0, "step": 0.05} + for i in range(INPUT_DIM) + ], + "objectives": { + "contract_version": "TEST_ONLY-shap-v1", + "scaling_mode": "fixed_affine", + "specs": [ + { + "name": "plain", + "goal": "maximize", + "transform": "affine", + "model_source_column": "plain", + "lower_anchor": 0.0, + "upper_anchor": 3.0, + }, + { + "name": "peaked", + "goal": "target", + "transform": "gaussian_target", + "model_source_column": "peaked", + "target": 650.0, + "sigma": 176.7766952966369, + "mean_function": { + "response": "log", + "features": [{"column": "x0", "transform": "log"}], + }, + }, + ], + }, + "reference_point_utility": [-0.01, -0.01], + "rounds": { + "r1": { + "method": "ucb_hvi", "batch_size": 5, "replicates_per_condition": 3, + "beta": 4.0, "candidate_pool_size": pool, "posterior_samples": 16, + "moment_method": "monte_carlo", + }, + "r2": { + "method": "qlognehvi", "batch_size": 3, + "replicates_per_condition": 3, + "candidate_pool_size": pool, "mc_samples": 8, + }, + }, + "local_penalization": { + "radius": 0.25, "min_batch_distance": 0.15, + "min_observed_distance": 0.0, "dimension_weights": None, + }, + "model": {"variant": "dim_scaled_prior"}, + "reproducibility": {"seed": SEED}, + "constraints": [], + } + + +def _measurements(X: np.ndarray) -> np.ndarray: + """Deterministic stand-in for measured columns. + + ``peaked`` deliberately carries structure the declared mean function CANNOT + absorb (the ``x1`` term), so the residual GP has non-zero posterior variance. + Without it the trend fits exactly, the variance collapses, and + ``expected_transform`` becomes indistinguishable from transforming the mean -- + which would make the quadrature test vacuous rather than passing. + """ + X = np.asarray(X, dtype=float) + return np.column_stack([ + X.mean(axis=1), + 650.0 * X[:, 0] ** -0.6 * X[:, 1] ** 0.3, + ]) + + +@pytest.fixture(scope="module") +def fitted(): + config = _config() + transform = build_objective_transform(config) + X = run_r0_lhs(config, n=15, seed=SEED).conditions.to_numpy(float) + Y = _measurements(X) + model, warnings = fit_campaign_models(config, X, Y, seed=SEED) + assert not warnings + design = build_design_from_config(dict(config)) + instances = np.asarray( + sample_discrete_candidate_pool(design, 12, seed=SEED).X_phys, dtype=float + ) + return { + "config": config, "transform": transform, "X": X, "Y": Y, + "model": model, "instances": instances, "design": design, + } + + +# --------------------------------------------------------------------------- # +# the attribution itself +# --------------------------------------------------------------------------- # + + +def test_shap_values_have_one_column_per_campaign_input(fitted) -> None: + values = psa.shap_values_for( + fitted["model"], fitted["config"], fitted["transform"], 1, + background=fitted["X"], instances=fitted["instances"], seed=SEED, + ) + assert values.shape == (len(fitted["instances"]), INPUT_DIM) + assert np.isfinite(values).all() + + +def test_attributions_reconstruct_the_model_output_exactly(fitted) -> None: + """Additivity. The comparator that makes the rest of this meaningful. + + Shapley values are defined by summing to the difference between the model + output and its expectation over the background. If that fails, the beeswarm is + a picture of something other than the model. + """ + import shap + + f = psa.expected_utility_fn( + fitted["model"], fitted["config"], fitted["transform"], 1 + ) + explainer = shap.KernelExplainer(f, fitted["X"]) + values = np.asarray(explainer.shap_values(fitted["instances"], silent=True)) + reconstructed = float(explainer.expected_value) + values.sum(axis=1) + np.testing.assert_allclose( + reconstructed, f(fitted["instances"]), rtol=0, atol=1e-9 + ) + + +def test_attributions_are_deterministic(fitted) -> None: + kwargs = dict( + background=fitted["X"], instances=fitted["instances"], seed=SEED + ) + first = psa.shap_values_for( + fitted["model"], fitted["config"], fitted["transform"], 1, **kwargs + ) + second = psa.shap_values_for( + fitted["model"], fitted["config"], fitted["transform"], 1, **kwargs + ) + assert np.array_equal(first, second) + # and the ranking a figure would draw is stable, not just the raw array + assert np.array_equal( + np.argsort(-np.abs(first).mean(axis=0)), + np.argsort(-np.abs(second).mean(axis=0)), + ) + + +def test_the_declared_mean_function_feature_dominates(fitted) -> None: + """`peaked` carries a log trend on x0 and nothing else; x0 must lead. + + This is the property the figures' construction caveat warns about, asserted + rather than assumed -- and it doubles as a check that the structured mean + reaches ``posterior()`` at all. + """ + values = psa.shap_values_for( + fitted["model"], fitted["config"], fitted["transform"], 1, + background=fitted["X"], instances=fitted["instances"], seed=SEED, + ) + mean_abs = np.abs(values).mean(axis=0) + assert int(np.argmax(mean_abs)) == 0, "x0 carries the declared trend" + assert mean_abs[0] > 2.0 * np.median(mean_abs) + + +def test_expected_utility_uses_the_lognormal_quadrature_not_the_mean(fitted) -> None: + """The explained function must be E[utility], not utility(E[.]). + + For a peaked target on a lognormal posterior the two differ, and the second is + biased by Jensen's inequality and blind to variance. + """ + import torch + + from mobo_kit.campaign import normalise_inputs + + config, transform = fitted["config"], fitted["transform"] + f = psa.expected_utility_fn(fitted["model"], config, transform, 1) + expected = f(fitted["instances"]) + + model = fitted["model"] + model.eval() + with torch.no_grad(): + posterior = model.posterior( + torch.tensor( + normalise_inputs(config, fitted["instances"]), dtype=torch.double + ), + observation_noise=False, + ) + naive = transform.transform(posterior.mean)[:, 1].numpy() + assert np.isfinite(expected).all() + assert not np.allclose(expected, naive, atol=1e-6), ( + "expected utility must differ from the transformed mean, or the " + "quadrature is not being used" + ) + + +# --------------------------------------------------------------------------- # +# the qNEHVI research variant +# --------------------------------------------------------------------------- # + + +def test_qnehvi_proposes_a_valid_batch(fitted) -> None: + config = fitted["config"] + X = fitted["X"] + Y = fitted["Y"] + X1 = run_r0_lhs(config, n=5, seed=SEED + 1).conditions.to_numpy(float) + X01 = np.vstack([X, X1]) + Y01 = np.vstack([Y, _measurements(X1)]) + + result = run_r2_qnehvi_research(config, X01, Y01, seed=SEED) + assert len(result.conditions) == 3 + report = result.diagnostics["validity"] + assert report["unique"] and report["on_grid"] and report["in_bounds"] + assert report["min_pairwise_distance"] >= 0.15 + assert result.diagnostics["method"] == "qnehvi" + assert result.diagnostics["research_only"] is True + + +def test_both_acquisitions_are_selectable_and_named() -> None: + assert R2_ACQUISITIONS == ("qlognehvi", "qnehvi") + + +def test_identical_batch_detection(fitted) -> None: + """The detection logic, exercised on both outcomes. + + Whether the two acquisitions actually agree is a property of the data, not + something a test should assert; what must work is noticing either way. + """ + frame = pd.DataFrame( + [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], columns=["a", "b"] + ) + same = frame.iloc[[2, 0, 1]].reset_index(drop=True) + different = frame.copy() + different.iloc[0, 0] = 9.0 + assert psa.batch_hash(frame) == psa.batch_hash(same) + assert psa.batch_hash(frame) != psa.batch_hash(different) + + +def test_the_two_acquisitions_run_on_the_same_inputs(fitted) -> None: + """Both runners accept the same contract, so a comparison is apples to apples.""" + config, X, Y = fitted["config"], fitted["X"], fitted["Y"] + X1 = run_r0_lhs(config, n=5, seed=SEED + 1).conditions.to_numpy(float) + X01, Y01 = np.vstack([X, X1]), np.vstack([Y, _measurements(X1)]) + + a = run_r2_qlognehvi(config, X01, Y01, seed=SEED) + b = run_r2_qnehvi_research(config, X01, Y01, seed=SEED) + assert list(a.conditions.columns) == list(b.conditions.columns) + assert len(a.conditions) == len(b.conditions) == 3 + # both are valid batches whether or not they agree + for result in (a, b): + assert result.diagnostics["validity"]["on_grid"] + + +# --------------------------------------------------------------------------- # +# figures and captions +# --------------------------------------------------------------------------- # + + +def test_beeswarm_renders_headlessly(fitted, tmp_path) -> None: + values = psa.shap_values_for( + fitted["model"], fitted["config"], fitted["transform"], 1, + background=fitted["X"], instances=fitted["instances"], seed=SEED, + ) + path = tmp_path / "beeswarm.png" + psa.plot_beeswarm( + path, values, fitted["instances"], fitted["config"], "peaked", + "test state", seed=SEED, + caveats=psa.caveats_for("peaked", "final", True), + ) + assert path.is_file() and path.stat().st_size > 0 + + +def test_feature_labels_carry_the_physical_range(fitted) -> None: + """The colorbar is per-feature normalised, so the units live in the labels.""" + labels = psa._feature_labels(fitted["config"], fitted["instances"]) + assert len(labels) == INPUT_DIM + assert all("\n" in label for label in labels) + assert labels[0].startswith("x0") + assert "u" in labels[0], "unit must appear in the label" + + +def test_captions_state_only_what_is_true_of_that_figure() -> None: + r0 = psa.caveats_for("thickness", "r0_only", identical=False) + assert any("15 real" in line for line in r0) + assert not any("Oracle:" in line for line in r0) + + final = psa.caveats_for("thickness", "final", identical=False) + assert any("Oracle:" in line for line in final) + assert any("mean function" in line for line in final) + + uniformity = psa.caveats_for("uniformity", "final", identical=False) + assert any("fitted noise" in line for line in uniformity) + + with_note = psa.caveats_for("thickness", "final", identical=True) + assert any("IDENTICAL" in line for line in with_note) + # the R0 anchor never carries the acquisition note: it predates R2 + assert not any( + "IDENTICAL" in line + for line in psa.caveats_for("thickness", "r0_only", identical=True) + ) + + +# --------------------------------------------------------------------------- # +# end to end on the real workbook +# --------------------------------------------------------------------------- # + + +@pytest.mark.local_input +@pytest.mark.skipif( + not Path(SOURCE).is_file(), reason=f"{SOURCE} is not present in this checkout" +) +def test_headless_smoke_on_the_real_workbook(tmp_path) -> None: + import yaml + + from mobo_kit.campaign import load_campaign_config + + config = load_campaign_config("configs/campaign_d2d_perovskite.yaml") + config["rounds"]["r1"]["candidate_pool_size"] = 128 + config["rounds"]["r1"]["posterior_samples"] = 8 + config["rounds"]["r2"]["candidate_pool_size"] = 128 + config["rounds"]["r2"]["mc_samples"] = 4 + scratch = tmp_path / "campaign.yaml" + scratch.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8") + + output = tmp_path / "out" + code = psa.main([ + "--workbook", SOURCE, + "--config", str(scratch), + "--output-dir", str(output), + "--instances", "6", + "--objectives", "thickness", + ]) + assert code == 0 + + summary = pd.read_csv(output / "shap_summary.csv") + assert set(summary["objective"]) == {"thickness"} + assert summary["rank"].min() == 1 + # one row per feature per model state + assert len(summary) % INPUT_DIM == 0 + assert (output / "figures").is_dir() + assert list((output / "figures").glob("*.png")) diff --git a/tests/test_sobol_pool.py b/tests/test_sobol_pool.py new file mode 100644 index 0000000..c43f022 --- /dev/null +++ b/tests/test_sobol_pool.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import itertools + +import numpy as np +import pytest + +from mobo_kit.candidate_pool import ( + CandidatePoolSamplingError, + physical_rows_to_grid_indices, +) +from mobo_kit.design import InputSpec, build_design +from mobo_kit.sobol_pool import ( + build_nested_sobol_discrete_pool, + hash_grid_index_prefix, + map_unit_points_to_grid_indices, +) + + +def _design(): + return build_design( + [ + InputSpec("a", 0.0, 8.0, 1.0), + InputSpec("b", 10.0, 22.0, 2.0), + InputSpec("c", -2.0, 2.0, 1.0), + ] + ) + + +def test_unit_points_map_by_floor_to_exact_grid_indices(): + design = build_design( + [InputSpec("x", 0.0, 3.0, 1.0), InputSpec("y", 10.0, 20.0, 10.0)] + ) + points = np.array( + [ + [0.0, 0.0], + [0.249999, 0.499999], + [0.25, 0.5], + [np.nextafter(1.0, 0.0), np.nextafter(1.0, 0.0)], + ] + ) + np.testing.assert_array_equal( + map_unit_points_to_grid_indices(points, design), + np.array([[0, 0], [0, 0], [1, 1], [3, 1]]), + ) + + +@pytest.mark.parametrize( + "points, message", + [ + (np.array([[1.0, 0.0]]), "half-open"), + (np.array([[-1e-12, 0.0]]), "half-open"), + (np.array([[np.nan, 0.0]]), "finite"), + (np.array([0.2, 0.3]), "shape"), + ], +) +def test_unit_point_mapping_fails_closed(points, message): + design = build_design( + [InputSpec("x", 0.0, 3.0, 1.0), InputSpec("y", 0.0, 1.0, 1.0)] + ) + with pytest.raises(ValueError, match=message): + map_unit_points_to_grid_indices(points, design) + + +def test_same_scramble_is_deterministic_nested_and_has_locked_prefix_hashes(): + sizes = (8, 16, 32, 64) + first = build_nested_sobol_discrete_pool(_design(), sizes, scramble_seed=73) + repeat = build_nested_sobol_discrete_pool( + _design(), tuple(reversed(sizes)), scramble_seed=73 + ) + expected_hashes = { + 8: "E36D0663991DFBF5A53C278C3F78DD66AB7D5245B8B7306D7422960F506F0361", + 16: "9E9AC7931E14CB3E4BE992D4B17D0670BEF0E6DB6AF467E059CE39A89D8A55F6", + 32: "79DBFBEEC3B6C7D1AA2E2D685E6DF19D5CA546BAD84F575A376FC83305B6A65B", + 64: "22AFBA550FA4A2F39104A9B565932225E374C0B675ABB4685FB19A38D6A748B7", + } + + assert first.accepted_sizes == sizes + assert first.accepted_count == 64 + assert dict(first.prefix_hashes) == expected_hashes + assert dict(repeat.prefix_hashes) == expected_hashes + assert first.scipy_version + for smaller, larger in zip(sizes, sizes[1:]): + np.testing.assert_array_equal( + first.pools[smaller].grid_indices, + first.pools[larger].grid_indices[:smaller], + ) + for size in sizes: + pool = first.pools[size] + assert pool.size == size + assert np.unique(pool.grid_indices, axis=0).shape[0] == size + np.testing.assert_array_equal( + physical_rows_to_grid_indices(pool.X_phys, _design()), pool.grid_indices + ) + assert np.all((pool.X_norm >= 0.0) & (pool.X_norm <= 1.0)) + assert first.prefix_hashes[size] == hash_grid_index_prefix(pool.grid_indices) + np.testing.assert_array_equal( + pool.grid_indices, repeat.pools[size].grid_indices + ) + + +def test_different_scramble_changes_the_accepted_order_and_hash(): + primary = build_nested_sobol_discrete_pool(_design(), [64], scramble_seed=73) + secondary = build_nested_sobol_discrete_pool(_design(), [64], scramble_seed=137) + assert not np.array_equal( + primary.largest_pool.grid_indices, secondary.largest_pool.grid_indices + ) + assert primary.prefix_hashes[64] != secondary.prefix_hashes[64] + + +def test_exclusions_constraints_and_off_grid_observed_partition_are_stable(): + design = build_design( + [ + InputSpec("a", 0.0, 4.0, 1.0), + InputSpec("b", 0.0, 2.0, 1.0), + InputSpec("c", 0.0, 1.0, 1.0), + ] + ) + observed = np.array([[0.0, 0.0, 0.0], [0.5, 1.0, 1.0]]) + pending = np.array([[2.0, 1.0, 1.0]]) + avoid = np.array([[4.0, 2.0, 1.0]]) + + def require_even_a(X_phys, supplied_design): + assert supplied_design is design + return (X_phys[:, 0] % 2.0) == 0.0 + + result = build_nested_sobol_discrete_pool( + design, + [5, 15], + scramble_seed=11, + observed_phys=observed, + pending_phys=pending, + avoid_phys=avoid, + row_constraints=[require_even_a], + max_raw_draws=4096, + ) + excluded = { + tuple(row) + for row in physical_rows_to_grid_indices( + np.vstack([observed[:1], pending, avoid]), design + ) + } + final = result.largest_pool + assert result.ignored_off_grid_observed == 1 + assert final.size == 15 + assert np.all(final.X_phys[:, 0] % 2.0 == 0.0) + assert not ({tuple(row) for row in final.grid_indices} & excluded) + assert final.rejected_avoid > 0 + assert final.rejected_constraint > 0 + np.testing.assert_array_equal(result.pools[5].grid_indices, final.grid_indices[:5]) + + +def test_pending_and_explicit_avoid_rows_must_be_exactly_on_grid(): + off_grid = np.array([[0.25, 10.0, 0.0]]) + with pytest.raises(ValueError, match="off-grid"): + build_nested_sobol_discrete_pool( + _design(), [4], scramble_seed=1, pending_phys=off_grid + ) + with pytest.raises(ValueError, match="off-grid"): + build_nested_sobol_discrete_pool( + _design(), [4], scramble_seed=1, avoid_phys=off_grid + ) + + with pytest.raises(ValueError, match="within the design bounds"): + build_nested_sobol_discrete_pool( + _design(), + [4], + scramble_seed=1, + observed_phys=np.array([[9.0, 10.0, 0.0]]), + ) + + +def test_sampler_never_materializes_the_cartesian_product(monkeypatch): + def forbidden(*args, **kwargs): + del args, kwargs + raise AssertionError("full Cartesian materialization was attempted") + + monkeypatch.setattr(np, "meshgrid", forbidden) + monkeypatch.setattr(np, "indices", forbidden) + monkeypatch.setattr(itertools, "product", forbidden) + result = build_nested_sobol_discrete_pool(_design(), [16, 32], scramble_seed=9) + assert result.largest_pool.size == 32 + + +def test_impossible_capacity_and_draw_limit_fail_with_structured_errors(): + tiny = build_design([InputSpec("x", 0.0, 1.0, 1.0)]) + with pytest.raises(CandidatePoolSamplingError) as capacity: + build_nested_sobol_discrete_pool( + tiny, + [2], + scramble_seed=1, + observed_phys=np.array([[0.0]]), + ) + assert capacity.value.draws == 0 + assert "exceeds" in capacity.value.reason + + larger = build_design([InputSpec("x", 0.0, 7.0, 1.0)]) + + def reject_all(X_phys, supplied_design): + del supplied_design + return np.zeros(X_phys.shape[0], dtype=bool) + + with pytest.raises(CandidatePoolSamplingError) as limited: + build_nested_sobol_discrete_pool( + larger, + [1], + scramble_seed=2, + row_constraints=[reject_all], + max_raw_draws=8, + ) + assert limited.value.draws == 8 + assert limited.value.accepted == 0 + assert limited.value.rejected_constraint > 0 + + +@pytest.mark.parametrize( + "sizes, seed, max_draws, message", + [ + ([], 1, None, "must not be empty"), + ([0], 1, None, "positive integers"), + ([2, 2], 1, None, "duplicates"), + ([2], True, None, "scramble_seed"), + ([2], 1, 0, "max_raw_draws"), + ], +) +def test_sampler_configuration_validation(sizes, seed, max_draws, message): + kwargs = {} if max_draws is None else {"max_raw_draws": max_draws} + with pytest.raises(ValueError, match=message): + build_nested_sobol_discrete_pool(_design(), sizes, scramble_seed=seed, **kwargs) + + +def test_prefix_hash_requires_an_integer_matrix(): + with pytest.raises(TypeError, match="integer dtype"): + hash_grid_index_prefix(np.array([[0.0, 1.0]])) + with pytest.raises(ValueError, match="two-dimensional"): + hash_grid_index_prefix(np.array([0, 1], dtype=np.int64)) diff --git a/tests/test_structured_mean.py b/tests/test_structured_mean.py new file mode 100644 index 0000000..4aad381 --- /dev/null +++ b/tests/test_structured_mean.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from mobo_kit.campaign import load_campaign_config +from mobo_kit.structured_mean import ( + MeanFeature, + StructuredMeanSpec, + apply_structured_mean, + fit_structured_mean, + mean_spec_from_config, +) + +NAMES = ["speed_1", "time_1", "precur_conc", "anneal_temp"] + + +def _X(n: int = 12) -> np.ndarray: + rng = np.random.default_rng(0) + return np.column_stack( + [ + rng.uniform(1000, 6000, n), + rng.uniform(5, 50, n), + rng.uniform(1.0, 2.0, n), + rng.uniform(100, 185, n), + ] + ) + + +def test_log_response_recovers_a_power_law_exactly() -> None: + """T = c * speed^a * conc^b is linear in log-log, so the mean should absorb + it completely and leave zero residual.""" + X = _X() + T = 5.0e5 * X[:, 0] ** -0.4 * X[:, 2] ** 1.3 + spec = StructuredMeanSpec( + response="log", + features=(MeanFeature("speed_1", "log"), MeanFeature("precur_conc", "log")), + ) + coefficients, residual = fit_structured_mean(X, T, spec, NAMES) + assert np.abs(residual).max() < 1e-9 + assert coefficients[1] == pytest.approx(-0.4, abs=1e-6) + assert coefficients[2] == pytest.approx(1.3, abs=1e-6) + + +def test_identity_response_recovers_a_linear_trend() -> None: + X = _X() + y = 3.0 - 0.05 * X[:, 3] + spec = StructuredMeanSpec("identity", (MeanFeature("anneal_temp"),)) + coefficients, residual = fit_structured_mean(X, y, spec, NAMES) + assert np.abs(residual).max() < 1e-9 + assert coefficients[1] == pytest.approx(-0.05, abs=1e-9) + + +def test_trend_round_trips_through_apply() -> None: + X = _X() + y = 2.0 + 0.01 * X[:, 3] + spec = StructuredMeanSpec("identity", (MeanFeature("anneal_temp"),)) + coefficients, residual = fit_structured_mean(X, y, spec, NAMES) + post = apply_structured_mean( + coefficients, X, spec, NAMES, residual, np.zeros(len(X)) + ) + np.testing.assert_allclose(post.mean, y, atol=1e-9) + assert post.link == "identity" + + +def test_log_response_reports_its_link() -> None: + """The link is what tells the utility layer to integrate a lognormal instead + of using the Gaussian closed form.""" + X = _X() + spec = StructuredMeanSpec("log", (MeanFeature("speed_1", "log"),)) + coefficients, residual = fit_structured_mean(X, np.full(len(X), 700.0), spec, NAMES) + post = apply_structured_mean( + coefficients, X, spec, NAMES, residual, np.zeros(len(X)) + ) + assert post.link == "log" + np.testing.assert_allclose(np.exp(post.mean), 700.0, atol=1e-8) + + +def test_coefficients_come_only_from_the_rows_passed_in() -> None: + """Guards the leakage property: fitting on a subset must not see the rest.""" + X = _X(14) + y = 2.0 + 0.01 * X[:, 3] + spec = StructuredMeanSpec("identity", (MeanFeature("anneal_temp"),)) + keep = list(range(13)) + a, _ = fit_structured_mean(X[keep], y[keep], spec, NAMES) + b, _ = fit_structured_mean(X[keep], y[keep] * 1.0, spec, NAMES) + np.testing.assert_allclose(a, b) + # perturbing the held-out row must not change the fitted trend + y_perturbed = y.copy() + y_perturbed[13] += 1000.0 + c, _ = fit_structured_mean(X[keep], y_perturbed[keep], spec, NAMES) + np.testing.assert_allclose(a, c) + + +def test_log_response_rejects_non_positive_observations() -> None: + X = _X() + spec = StructuredMeanSpec("log", (MeanFeature("speed_1", "log"),)) + y = np.full(len(X), 1.0) + y[0] = -1.0 + with pytest.raises(ValueError, match="strictly positive"): + fit_structured_mean(X, y, spec, NAMES) + + +def test_unknown_feature_column_is_rejected() -> None: + spec = StructuredMeanSpec("identity", (MeanFeature("not_an_input"),)) + with pytest.raises(ValueError, match="not a declared input"): + fit_structured_mean(_X(), np.ones(12), spec, NAMES) + + +@pytest.mark.parametrize( + "kwargs, match", + [ + ({"response": "sqrt", "features": (MeanFeature("speed_1"),)}, "response link"), + ({"response": "identity", "features": ()}, "at least one feature"), + ( + { + "response": "identity", + "features": (MeanFeature("speed_1"), MeanFeature("speed_1")), + }, + "unique", + ), + ], +) +def test_invalid_specs_are_rejected(kwargs, match) -> None: + with pytest.raises(ValueError, match=match): + StructuredMeanSpec(**kwargs) + + +# --------------------------------------------------------------------------- # +# the campaign's declared shapes +# --------------------------------------------------------------------------- # + + +def test_campaign_declares_the_two_measured_mean_functions() -> None: + """Opposite shapes by design: thickness needs a pair of log terms, + optoelectronic needs exactly one linear term. Neither generalises.""" + config = load_campaign_config("configs/campaign_d2d_perovskite.yaml") + by_name = {s["name"]: s for s in config["objectives"]["specs"]} + + assert mean_spec_from_config(by_name["uniformity"]) is None + + opto = mean_spec_from_config(by_name["optoelectronic"]) + assert opto.response == "identity" + assert [f.column for f in opto.features] == ["anneal_temp"] + assert [f.transform for f in opto.features] == ["identity"] + + thickness = mean_spec_from_config(by_name["thickness"]) + assert thickness.response == "log" + assert [f.column for f in thickness.features] == ["speed_1", "precur_conc"] + assert [f.transform for f in thickness.features] == ["log", "log"] diff --git a/tests/test_ucb_hvi.py b/tests/test_ucb_hvi.py new file mode 100644 index 0000000..4b59b59 --- /dev/null +++ b/tests/test_ucb_hvi.py @@ -0,0 +1,447 @@ +import numpy as np +import pytest +import torch +from botorch.models import ModelListGP, SingleTaskGP +from botorch.models.transforms.outcome import Standardize + +from mobo_kit.ucb_hvi import ( + UCBHVIScoreResult, + apply_ucb_bound_policy, + hypervolume_improvement_scores, + posterior_identity_moments, + posterior_utility_moments, + propose_ucb_hvi_batch, + score_ucb_hvi_from_moments, + score_ucb_hvi_pool, +) +from mobo_kit.batch_selection import LocalPenalizationConfig, UndersizedBatchError +from mobo_kit.candidate_pool import CandidatePool +from mobo_kit.objectives import ObjectiveSpec, ObjectiveTransform +import mobo_kit.ucb_hvi as module + + +class IdentityTransform: + def transform(self, Y: torch.Tensor) -> torch.Tensor: + return Y + + +def _identity_contract(count=1): + return ObjectiveTransform( + [ + ObjectiveSpec(f"objective_{index}", "maximize", "identity") + for index in range(count) + ], + version="TEST-IDENTITY-v1", + ) + + +class _ExactPosteriorModel: + def posterior(self, X, *, observation_noise): + mean = torch.stack((X[..., 0] + 0.25, 1.5 - X[..., 0]), dim=-1) + variance = torch.full_like(mean, 0.09 if not observation_noise else 0.16) + return type("Posterior", (), {"mean": mean, "variance": variance})() + + +def _observed_2d(): + return np.array([[0.4, 0.8], [0.8, 0.4], [0.2, 0.2]]) + + +def test_beta_zero_and_uncertainty_definition(): + means = np.array([[0.7, 0.7], [0.6, 0.6]]) + std = np.array([[0.2, 0.1], [0.0, 0.0]]) + zero = score_ucb_hvi_from_moments( + means, std, _observed_2d(), np.array([0.0, 0.0]), beta=0.0 + ) + assert np.allclose(zero.utility_ucb, means) + positive = score_ucb_hvi_from_moments( + means, std, _observed_2d(), np.array([0.0, 0.0]), beta=4.0 + ) + assert positive.kappa == pytest.approx(2.0) + assert np.allclose(positive.utility_ucb, means + 2.0 * std) + assert positive.base_score[0] > zero.base_score[0] + with pytest.raises(ValueError, match="beta"): + score_ucb_hvi_from_moments( + means, std, _observed_2d(), np.array([0.0, 0.0]), beta=-0.1 + ) + with pytest.raises(ValueError, match="non-boolean"): + score_ucb_hvi_from_moments( + means, std, _observed_2d(), np.array([0.0, 0.0]), beta=True + ) + + +def test_hvi_matches_hand_calculation_and_dominated_is_true_zero(): + scores, baseline, pareto, _ = hypervolume_improvement_scores( + np.array([[0.7, 0.7], [0.3, 0.3]]), + _observed_2d(), + np.array([0.0, 0.0]), + ) + assert baseline == pytest.approx(0.48) + # Added area: (0.7-0.4)*(0.7-0.4) = 0.09. + assert scores[0] == pytest.approx(0.09) + assert scores[1] == 0.0 + assert pareto.shape == (2, 2) + + +def test_three_objective_hvi_and_chunk_invariance(): + observed = np.array([[0.5, 0.5, 0.5]]) + candidates = np.array([[0.6, 0.6, 0.6], [0.4, 0.4, 0.4]]) + chunked = hypervolume_improvement_scores( + candidates, observed, np.zeros(3), chunk_size=1 + ) + whole = hypervolume_improvement_scores( + candidates, observed, np.zeros(3), chunk_size=20 + ) + assert np.allclose(chunked[0], whole[0]) + assert chunked[0][0] == pytest.approx(0.6**3 - 0.5**3) + assert chunked[0][1] == 0.0 + + +def test_dominated_observed_rows_do_not_change_scores(): + candidates = np.array([[0.7, 0.7]]) + with_dominated = hypervolume_improvement_scores( + candidates, _observed_2d(), np.zeros(2) + )[0] + without_dominated = hypervolume_improvement_scores( + candidates, _observed_2d()[:2], np.zeros(2) + )[0] + assert np.allclose(with_dominated, without_dominated) + + +@pytest.mark.parametrize( + "reference, message", + [(None, "required"), (np.zeros(3), "shape"), (np.array([0.0, np.nan]), "finite")], +) +def test_reference_point_validation(reference, message): + with pytest.raises(ValueError, match=message): + hypervolume_improvement_scores( + np.array([[0.7, 0.7]]), _observed_2d(), reference + ) + + +def test_posterior_utility_moments_are_seeded_and_chunk_invariant(): + train_X = torch.tensor([[0.0], [0.5], [1.0]], dtype=torch.double) + train_Y = torch.tensor([[0.0], [1.0], [0.0]], dtype=torch.double) + model = SingleTaskGP(train_X, train_Y, outcome_transform=Standardize(m=1)) + model.eval() + pool = torch.linspace(0.1, 0.9, 6, dtype=torch.double).unsqueeze(-1) + one = posterior_utility_moments( + model, + pool, + IdentityTransform(), + mc_samples=16, + seed=13, + chunk_size=1, + ) + all_at_once = posterior_utility_moments( + model, + pool, + IdentityTransform(), + mc_samples=16, + seed=13, + chunk_size=100, + ) + repeat = posterior_utility_moments( + model, + pool, + IdentityTransform(), + mc_samples=16, + seed=13, + chunk_size=2, + ) + assert np.allclose(one.utility_mean, all_at_once.utility_mean) + assert np.allclose(one.utility_std, all_at_once.utility_std) + assert np.allclose(one.utility_mean, repeat.utility_mean) + assert np.allclose(one.utility_std, repeat.utility_std) + assert np.all(one.utility_std > 0) + + +def test_analytic_identity_moments_are_exact_chunk_dtype_and_device_invariant(): + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + pool = torch.linspace(0.1, 0.9, 7, dtype=torch.float32, device=device).unsqueeze(-1) + model = _ExactPosteriorModel() + contract = _identity_contract(2) + + one = posterior_identity_moments( + model, pool, contract, chunk_size=1, observation_noise=False + ) + whole = posterior_identity_moments( + model, pool, contract, chunk_size=100, observation_noise=False + ) + expected = model.posterior(pool, observation_noise=False) + + assert one.moment_method == "analytic_identity" + assert one.objective_contract_version == "TEST-IDENTITY-v1" + assert one.utility_mean.dtype == pool.dtype + assert one.utility_mean.device == pool.device + assert one.utility_std.dtype == pool.dtype + assert one.utility_std.device == pool.device + assert torch.equal(one.utility_mean, expected.mean) + assert torch.equal(one.utility_std, expected.variance.sqrt()) + assert torch.equal(one.utility_mean, whole.utility_mean) + assert torch.equal(one.utility_std, whole.utility_std) + + +def test_analytic_identity_moments_reject_nonidentity_or_implicit_contracts(): + pool = torch.tensor([[0.2], [0.8]], dtype=torch.double) + nonidentity = ObjectiveTransform( + [ + ObjectiveSpec( + "scaled", + "maximize", + "affine", + lower_anchor=0.0, + upper_anchor=1.0, + ) + ], + version="TEST-NONLINEAR-v1", + ) + with pytest.raises(ValueError, match="every objective.*identity"): + posterior_identity_moments( + _ExactPosteriorModel(), pool, nonidentity, chunk_size=2 + ) + with pytest.raises(ValueError, match="explicit versioned objective contract"): + posterior_identity_moments( + _ExactPosteriorModel(), pool, IdentityTransform(), chunk_size=2 + ) + + +def test_analytic_moments_agree_with_high_sample_mc_and_stable_hvi_selection(): + train_X = torch.tensor([[0.0], [0.5], [1.0]], dtype=torch.double) + first_Y = torch.tensor([[0.1], [0.9], [0.4]], dtype=torch.double) + second_Y = torch.tensor([[0.8], [0.2], [0.7]], dtype=torch.double) + model = ModelListGP( + SingleTaskGP(train_X, first_Y, outcome_transform=Standardize(m=1)), + SingleTaskGP(train_X, second_Y, outcome_transform=Standardize(m=1)), + ) + model.eval() + pool = torch.linspace(0.05, 0.95, 9, dtype=torch.double).unsqueeze(-1) + contract = _identity_contract(2) + + analytic_moments = posterior_identity_moments(model, pool, contract, chunk_size=4) + mc_moments = posterior_utility_moments( + model, + pool, + contract, + mc_samples=4096, + seed=17, + chunk_size=3, + ) + np.testing.assert_allclose( + mc_moments.utility_mean, + analytic_moments.utility_mean.cpu().numpy(), + atol=0.02, + rtol=0.02, + ) + np.testing.assert_allclose( + mc_moments.utility_std, + analytic_moments.utility_std.cpu().numpy(), + atol=0.02, + rtol=0.05, + ) + + observed = torch.cat((first_Y, second_Y), dim=1) + reference = np.array([-0.1, -0.1]) + analytic_scores = score_ucb_hvi_pool( + model, + pool, + observed, + contract, + reference, + beta=4.0, + moment_method="analytic_identity", + posterior_chunk_size=4, + ) + mc_scores = score_ucb_hvi_pool( + model, + pool, + observed, + contract, + reference, + beta=4.0, + moment_method="monte_carlo", + mc_samples=4096, + seed=17, + posterior_chunk_size=3, + ) + assert analytic_scores.moment_method == "analytic_identity" + assert analytic_scores.mc_samples is None + assert analytic_scores.seed is None + assert int(np.argmax(analytic_scores.base_score)) == int( + np.argmax(mc_scores.base_score) + ) + + +def test_ucb_bound_policy_retains_raw_effective_and_clip_amounts(): + raw = np.array([[1.2, -3.0, -0.2], [0.8, -2.0, 1.1]]) + original = raw.copy() + bounds = [(0.0, 1.0), (None, None), (0.0, 1.0)] + + unchanged = apply_ucb_bound_policy(raw, bounds, "none") + clipped = apply_ucb_bound_policy(raw, bounds, "clip_ucb") + + np.testing.assert_array_equal(raw, original) + np.testing.assert_array_equal(unchanged.utility_ucb_raw, raw) + np.testing.assert_array_equal(unchanged.utility_ucb_effective, raw) + np.testing.assert_array_equal(unchanged.utility_ucb_clip_amount, 0.0) + np.testing.assert_allclose( + clipped.utility_ucb_effective, + np.array([[1.0, -3.0, 0.0], [0.8, -2.0, 1.0]]), + ) + np.testing.assert_allclose( + clipped.utility_ucb_clip_amount, + np.array([[0.2, 0.0, 0.2], [0.0, 0.0, 0.1]]), + ) + + +def test_score_uses_effective_bounded_ucb_for_hvi_and_keeps_compatibility_alias(): + means = np.array([[1.2, 0.8, 1.2]]) + observed = np.array([[0.5, 0.5, 0.5]]) + bounds = [(0.0, 1.0), (None, None), (0.0, 1.0)] + result = score_ucb_hvi_from_moments( + means, + np.zeros_like(means), + observed, + np.zeros(3), + beta=0.0, + bound_policy="clip_ucb", + utility_bounds=bounds, + moment_method="analytic_identity", + ) + expected_scores = hypervolume_improvement_scores( + np.array([[1.0, 0.8, 1.0]]), observed, np.zeros(3) + )[0] + np.testing.assert_allclose(result.base_score, expected_scores) + np.testing.assert_allclose(result.utility_ucb_raw, means) + np.testing.assert_allclose(result.utility_ucb_effective, [[1.0, 0.8, 1.0]]) + np.testing.assert_array_equal(result.utility_ucb, result.utility_ucb_effective) + assert result.bound_policy == "clip_ucb" + + +@pytest.mark.parametrize( + "bounds, policy, message", + [ + (None, "clip_ucb", "requires explicit"), + ([(0.0, 1.0)], "clip_ucb", "one.*per objective"), + ([(1.0, 0.0), (None, None)], "clip_ucb", "must not exceed"), + ([(None, None), (None, None)], "clip_ucb", "at least one"), + (None, "invalid", "none.*clip_ucb"), + ], +) +def test_ucb_bound_policy_rejects_invalid_contract(bounds, policy, message): + with pytest.raises(ValueError, match=message): + apply_ucb_bound_policy(np.ones((2, 2)), bounds, policy) + + +def test_zero_scores_remain_zero_while_log_scores_are_stabilized(): + result = score_ucb_hvi_from_moments( + np.array([[0.1, 0.1]]), + np.zeros((1, 2)), + _observed_2d(), + np.zeros(2), + beta=0.0, + log_epsilon=1e-9, + ) + assert result.base_score[0] == 0.0 + assert result.base_log_score[0] == pytest.approx(np.log(1e-9)) + + +def test_invalid_standard_deviation_and_shapes_fail(): + with pytest.raises(ValueError, match="negative"): + score_ucb_hvi_from_moments( + np.ones((1, 2)), + np.array([[-1.0, 0.0]]), + _observed_2d(), + np.zeros(2), + beta=1.0, + ) + with pytest.raises(ValueError, match="same shape"): + score_ucb_hvi_from_moments( + np.ones((1, 2)), + np.ones((2, 2)), + _observed_2d(), + np.zeros(2), + beta=1.0, + ) + + +def _proposal_pool(): + X = np.array([[0.1], [0.3], [0.5], [0.7], [0.9]]) + return CandidatePool( + grid_indices=np.arange(5)[:, None], + X_phys=X.copy(), + X_norm=X.copy(), + seed=9, + draws=5, + rejected_duplicate=0, + rejected_avoid=0, + rejected_constraint=0, + ) + + +def _static_ucb_result(scores): + scores = np.asarray(scores, dtype=float) + return UCBHVIScoreResult( + base_score=scores, + base_log_score=np.log(np.maximum(scores, 1e-12)), + utility_mean=np.column_stack([scores, scores]), + utility_std=np.zeros((scores.size, 2)), + utility_ucb=np.column_stack([scores, scores]), + baseline_hypervolume=0.1, + pareto_utility=np.array([[0.5, 0.5]]), + reference_point_utility=np.zeros(2), + beta=1.0, + kappa=1.0, + mc_samples=8, + seed=4, + observation_noise=False, + objective_contract_version="TEST_ONLY-v1", + ) + + +def test_ucb_proposal_selects_only_positive_hvi_and_exact_q(monkeypatch): + monkeypatch.setattr( + module, + "score_ucb_hvi_pool", + lambda *args, **kwargs: _static_ucb_result([0.0, 0.8, 0.7, 0.0, 0.6]), + ) + proposal = propose_ucb_hvi_batch( + _proposal_pool(), + torch.nn.Linear(1, 1).double(), + np.ones((2, 2)), + object(), + np.zeros(2), + q=2, + beta=1.0, + local_penalization_config=LocalPenalizationConfig( + radius=0.15, min_batch_distance=0.1 + ), + positive_score_tolerance=1e-6, + ) + assert proposal.selection.selected_pool_indices.size == 2 + assert np.all( + proposal.scoring.base_score[proposal.selection.selected_pool_indices] > 0 + ) + assert proposal.metadata["pool_seed"] == 9 + assert proposal.metadata["objective_contract_version"] == "TEST_ONLY-v1" + assert proposal.metadata["beta"] == 1.0 + + +def test_ucb_proposal_refuses_to_fill_with_zero_hvi(monkeypatch): + monkeypatch.setattr( + module, + "score_ucb_hvi_pool", + lambda *args, **kwargs: _static_ucb_result([0.0, 0.8, 0.0, 0.0, 0.0]), + ) + with pytest.raises(UndersizedBatchError): + propose_ucb_hvi_batch( + _proposal_pool(), + torch.nn.Linear(1, 1).double(), + np.ones((2, 2)), + object(), + np.zeros(2), + q=2, + beta=1.0, + local_penalization_config=LocalPenalizationConfig( + radius=0.15, min_batch_distance=0 + ), + ) diff --git a/tests/test_workbook_io.py b/tests/test_workbook_io.py new file mode 100644 index 0000000..36b94e0 --- /dev/null +++ b/tests/test_workbook_io.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import shutil + +import pandas as pd +import pytest +from openpyxl import load_workbook + +from mobo_kit.campaign import ( + load_campaign_config, + measurement_entry_columns, + model_source_columns, + objective_names, +) +from mobo_kit.workbook_io import ( + candidate_workbook_path, + CandidateSheetError, + detect_round, + read_campaign_workbook, + sheet_name_for_round, + write_candidate_sheet, +) + +CONFIG_PATH = "configs/campaign_d2d_perovskite.yaml" +SOURCE = "local_inputs/Summary Table.xlsx" + +pytestmark = pytest.mark.skipif( + not __import__("pathlib").Path(SOURCE).exists(), + reason="requires the ignored private campaign workbook", +) + + +@pytest.fixture(scope="module") +def config() -> dict: + return load_campaign_config(CONFIG_PATH) + + +@pytest.fixture +def workbook(tmp_path): + destination = tmp_path / "Summary Table.xlsx" + shutil.copy2(SOURCE, destination) + return destination + + +def _conditions(config: dict, n: int = 5) -> pd.DataFrame: + names = [item["name"] for item in config["inputs"]] + rows = [ + [float(item["start"]) + i * float(item["step"]) for item in config["inputs"]] + for i in range(n) + ] + return pd.DataFrame(rows, columns=names) + + +def test_reads_inputs_and_computes_one_value_per_objective(workbook, config) -> None: + contents = read_campaign_workbook(workbook, config) + assert contents.n_rows == 15 + assert list(contents.inputs.columns) == [i["name"] for i in config["inputs"]] + assert list(contents.model_values.columns) == list(objective_names(config)) + # thickness must arrive in nanometres, not as its score + assert contents.model_values["thickness"].max() > 100.0 + assert contents.model_values.notna().all().all() + + +def test_computed_values_agree_with_the_workbook_within_tolerance( + workbook, config +) -> None: + """The recomputation is not a different quantity: on the R0 rows it reproduces + the stored cells to floating-point noise, and thickness only to half a + nanometre because `Thickness (avg)` is ROUND(mean(T1..T4)).""" + contents = read_campaign_workbook(workbook, config) + stored = contents.workbook_values + assert ( + (contents.model_values["uniformity"] - stored["Uniformity score"]).abs().max() + < 1e-12 + ) + assert ( + (contents.model_values["optoelectronic"] - stored["Optoelectronic score"]) + .abs() + .max() + < 1e-12 + ) + thickness_gap = ( + (contents.model_values["thickness"] - stored["Thickness (avg)"]).abs().max() + ) + assert thickness_gap <= 0.5 + # and it is genuinely unrounded, or the gap would be zero + assert thickness_gap > 0.0 + + +def test_the_r0_rows_produce_no_errors_and_flag_the_disagreeing_films( + workbook, config +) -> None: + contents = read_campaign_workbook(workbook, config) + assert contents.errors == () + disagreeing = { + finding.sample_id + for finding in contents.warnings + if finding.code == "readings_disagree" + } + # samples 8, 12 and 15 hold thickness readings that split into two clusters + assert disagreeing == {8, 12, 15} + excluded = { + finding.sample_id + for finding in contents.findings + if finding.code == "reading_excluded" + } + assert excluded == {4, 14} + + +def test_thickness_records_how_many_readings_each_row_used(workbook, config) -> None: + """Two readings and four readings do not carry the same weight; Phase 4 needs + the count to turn a spread into an observation variance.""" + contents = read_campaign_workbook(workbook, config) + counts = contents.inputs_used["thickness"] + assert counts.min() == 2 and counts.max() == 4 + assert counts.value_counts().to_dict() == {2: 9, 3: 3, 4: 3} + + +def test_stops_at_the_first_blank_sample_number(workbook, config) -> None: + """Rows below the data block are notes, not observations.""" + contents = read_campaign_workbook(workbook, config) + assert contents.sample_ids == tuple(range(1, 16)) + + +def test_candidate_sheet_asks_for_raw_measurements_not_derived_scores( + workbook, config +) -> None: + """The objectives are computed now, so the sheet must collect what they are + computed from. Offering a `Thickness (avg)` cell would invite someone to fill + in a value that nothing reads.""" + write_candidate_sheet(workbook, config, _conditions(config), round_name="R1") + sheet = load_workbook(candidate_workbook_path(workbook, "R1"))[ + sheet_name_for_round("R1") + ] + headers = [c.value for c in sheet[1]] + required, optional = measurement_entry_columns(config) + for column in (*required, *optional): + assert column in headers + assert "T1" in headers and "T4" in headers and "T anom" in headers + for derived in model_source_columns(config): + assert derived not in headers + + +def test_writing_leaves_the_source_byte_identical(workbook, config) -> None: + """openpyxl discards cached formula values on save, and Uniformity score is a + formula column. Writing beside the workbook makes that impossible rather than + merely unlikely.""" + import hashlib + + before = hashlib.sha256(workbook.read_bytes()).hexdigest() + out = write_candidate_sheet(workbook, config, _conditions(config), round_name="R1") + assert out != workbook + assert out.exists() + assert hashlib.sha256(workbook.read_bytes()).hexdigest() == before + + +def test_formula_columns_survive_because_the_source_is_not_rewritten( + workbook, config +) -> None: + """The regression this design exists to prevent.""" + write_candidate_sheet(workbook, config, _conditions(config), round_name="R1") + contents = read_campaign_workbook(workbook, config) + # Uniformity score is =L*N*O; a rewritten workbook reads it as NaN, which + # would now surface as a cross_check_empty warning rather than as bad training + # data -- but the invariant worth holding is still that it survives + assert contents.workbook_values["Uniformity score"].notna().all() + assert contents.workbook_values["Uniformity score"].max() > 0.0 + assert [f for f in contents.warnings if f.code == "cross_check_empty"] == [] + + +def test_three_replicate_rows_per_condition(workbook, config) -> None: + write_candidate_sheet( + workbook, config, _conditions(config, 5), round_name="R1", replicates=3 + ) + sheet = load_workbook(candidate_workbook_path(workbook, "R1"))[ + sheet_name_for_round("R1") + ] + rows = [r for r in sheet.iter_rows(min_row=2, values_only=True) if r[0]] + assert len(rows) == 15 + assert len({r[1] for r in rows}) == 5 + + +def test_refuses_to_overwrite_an_existing_sheet(workbook, config) -> None: + write_candidate_sheet(workbook, config, _conditions(config), round_name="R1") + with pytest.raises(CandidateSheetError, match="already exists"): + write_candidate_sheet( + workbook, config, _conditions(config), round_name="R1", make_backup=False + ) + + +def test_round_detection_walks_r1_then_r2(workbook, config) -> None: + assert detect_round(workbook, config).next_round == "R1" + write_candidate_sheet(workbook, config, _conditions(config), round_name="R1") + state = detect_round(workbook, config) + assert state.next_round is None + assert "no results have been entered" in state.reason + + +def test_partially_scored_sheet_is_refused_with_a_readable_message( + workbook, config +) -> None: + """Fail closed: guessing at a half-filled sheet is how a round gets built on + data the experimentalist had not finished entering.""" + out = write_candidate_sheet(workbook, config, _conditions(config), round_name="R1") + book = load_workbook(out) + sheet = book[sheet_name_for_round("R1")] + headers = [c.value for c in sheet[1]] + required, _ = measurement_entry_columns(config) + for column in (*required, "T1"): + sheet.cell(row=2, column=headers.index(column) + 1).value = 1.0 + book.save(out) + + state = detect_round(workbook, config) + assert state.next_round is None + assert state.scored_rows == 1 and state.total_rows == 15 + assert "partly filled in" in state.reason + assert "1 of 15" in state.reason + + +def test_a_row_with_two_thickness_readings_counts_as_complete(workbook, config) -> None: + """Nine of the fifteen R0 rows have only T1 and T2. Demanding all four would + hold a finished round hostage to measurements nobody intended to take.""" + out = write_candidate_sheet(workbook, config, _conditions(config, 1), round_name="R1") + book = load_workbook(out) + sheet = book[sheet_name_for_round("R1")] + headers = [c.value for c in sheet[1]] + required, _ = measurement_entry_columns(config) + for row in (2, 3, 4): + for column in (*required, "T1", "T2"): + sheet.cell(row=row, column=headers.index(column) + 1).value = 1.0 + book.save(out) + + # accepting R1 as complete is what advances the campaign to R2; a stricter + # rule would leave it stuck reporting "partly filled in" forever + state = detect_round(workbook, config) + assert state.next_round == "R2" + + +def test_missing_source_sheet_is_a_plain_sentence(tmp_path, config) -> None: + from openpyxl import Workbook + + path = tmp_path / "wrong.xlsx" + book = Workbook() + book.active.title = "Renamed" + book.save(path) + # The message must name the sheet the CONFIG asked for, the key that decides + # it, and what the workbook actually has. "Rename it back" was the old advice + # and stopped being right once the sheet became configuration: the likelier + # cause is now a workbook belonging to a different campaign. + with pytest.raises(CandidateSheetError, match="campaign.source_sheet"): + read_campaign_workbook(path, config) diff --git a/unsorted/Code/20211101_PerovScaleup_Round3_Third_BatchBO_TwoConstraints_20201019_TH.ipynb b/unsorted/Code/20211101_PerovScaleup_Round3_Third_BatchBO_TwoConstraints_20201019_TH.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305141905.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305141905.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305142405.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305142405.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305143906.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305143906.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305145906.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305145906.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305150406.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305150406.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305151907.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305151907.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305152407.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305152407.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305153907.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305153907.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305154908.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305154908.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305155408.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305155408.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305160908.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305160908.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305161408.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305161408.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305162909.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305162909.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305163409.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305163409.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305164909.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305164909.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305165910.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305165910.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305170410.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305170410.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305173911.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305173911.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305174411.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305174411.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305175911.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305175911.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305180912.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305180912.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305183412.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305183412.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305184913.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305184913.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305185413.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305185413.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305191413.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305191413.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305193414.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305193414.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305194914.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305194914.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305195414.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305195414.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305200415.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305200415.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305203916.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305203916.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305204416.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305204416.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305205916.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305205916.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305211917.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305211917.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305212417.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305212417.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305214417.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305214417.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305221418.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305221418.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305222918.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305222918.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305224919.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305224919.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305225419.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305225419.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305230919.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305230919.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305232920.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305232920.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305234921.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305234921.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305235421.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210305235421.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306000921.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306000921.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306003422.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306003422.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306004922.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306004922.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306005422.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306005422.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306011423.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306011423.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306012923.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306012923.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306013424.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306013424.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306014924.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306014924.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306015424.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306015424.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306020924.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306020924.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306021425.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306021425.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306022925.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306022925.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306023425.bmp b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/BMP/20210306023425.bmp deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/Color_operations.py b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/Color_operations.py deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/Crop_my_video.py b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/Crop_my_video.py deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/RGB_extractor.py b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/RGB_extractor.py deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/RGB_extractor_Xrite_CC.py b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/RGB_extractor_Xrite_CC.py deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/RGB_extractor_savefigs.py b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/RGB_extractor_savefigs.py deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/Test_crop_box.py b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/Test_crop_box.py deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/input_extractor_2.ipynb b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/input_extractor_2.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/start and end values.txt b/unsorted/Code/DegradationChamber/DegradationAnalysis/Example/start and end values.txt deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/Emukit BO Example/Emukit_test_builtin_unknown_constraint_function.ipynb b/unsorted/Code/Emukit BO Example/Emukit_test_builtin_unknown_constraint_function.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/Emukit BO Example/Emukit_test_modified_unknown_constraint_function.ipynb b/unsorted/Code/Emukit BO Example/Emukit_test_modified_unknown_constraint_function.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/ML Perov Data/Data Summary for New Plasma Cleaned.csv b/unsorted/Code/ML Perov Data/Data Summary for New Plasma Cleaned.csv deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/ML Perov Data/ML Perov Data Stanford.xlsx b/unsorted/Code/ML Perov Data/ML Perov Data Stanford.xlsx deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/ML Perov Data/new_plamsa_previous_selected_20200927.xlsx b/unsorted/Code/ML Perov Data/new_plamsa_previous_selected_20200927.xlsx deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/PerovScaleup_Check_All_Experimental_Data.ipynb b/unsorted/Code/PerovScaleup_Check_All_Experimental_Data.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/PerovScaleup_Round1_Check_Experimental_Data_20200922.ipynb b/unsorted/Code/PerovScaleup_Round1_Check_Experimental_Data_20200922.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/PerovScaleup_Round1_First_BatchBO_TwoConstraints_20200927.ipynb b/unsorted/Code/PerovScaleup_Round1_First_BatchBO_TwoConstraints_20200927.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/PerovScaleup_Round2_Check_Experimental_Data_20200930.ipynb b/unsorted/Code/PerovScaleup_Round2_Check_Experimental_Data_20200930.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/PerovScaleup_Round2_Second_BatchBO_TwoConstraints_20200930.ipynb b/unsorted/Code/PerovScaleup_Round2_Second_BatchBO_TwoConstraints_20200930.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/PerovScaleup_Round3_Check_Experimental_Data_20201019.ipynb b/unsorted/Code/PerovScaleup_Round3_Check_Experimental_Data_20201019.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/PerovScaleup_Round3_Third_BatchBO_TwoConstraints_20201019.ipynb b/unsorted/Code/PerovScaleup_Round3_Third_BatchBO_TwoConstraints_20201019.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/PerovScaleup_Round4_Fourth_BatchBO_TwoConstraints_20201026.ipynb b/unsorted/Code/PerovScaleup_Round4_Fourth_BatchBO_TwoConstraints_20201026.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/PerovScaleup_Round5_Fifth_BatchBO_TwoConstraints_20201108.ipynb b/unsorted/Code/PerovScaleup_Round5_Fifth_BatchBO_TwoConstraints_20201108.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/PerovScaleup_Round6_Sixth_BatchBO_Entropy_Search_TwoConstraints_20201220.ipynb b/unsorted/Code/PerovScaleup_Round6_Sixth_BatchBO_Entropy_Search_TwoConstraints_20201220.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/Process Optimization for Perovskite Solar Cells v1.ipynb b/unsorted/Code/Process Optimization for Perovskite Solar Cells v1.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/Process Optimization for Perovskite Solar Cells v2.ipynb b/unsorted/Code/Process Optimization for Perovskite Solar Cells v2.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/ReadMe.txt b/unsorted/Code/ReadMe.txt deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Code/Salinan PerovScaleup_Round1_Check_Experimental_Data_20200922.ipynb b/unsorted/Code/Salinan PerovScaleup_Round1_Check_Experimental_Data_20200922.ipynb deleted file mode 100644 index 9b421da..0000000 --- a/unsorted/Code/Salinan PerovScaleup_Round1_Check_Experimental_Data_20200922.ipynb +++ /dev/null @@ -1,1577 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Check the pervoskite experimental data produced on Sep 22, 2020\n", - "\n", - "- Experiments are prepared by Nick Rolston and Thomas Colburn (Stanfrod University) \n", - "- Jupyter Notebook is prepared by Zhe Liu (Massachusetts Insititute of Technology)\n" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "import pandas as pd\n", - "import emukit\n", - "import GPy\n", - "import sklearn\n", - "import matplotlib.pyplot as plt\n", - "import seaborn as sns" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Index(['ML Condition', 'Temp [degC]', 'speed [mm/s]', 'sprayFL [uL/min]',\n", - " 'plamsaH [cm]', 'gasFL [L/min]', 'plasmaDC [%]', ' Success or Fail',\n", - " 'Unnamed: 8'],\n", - " dtype='object')\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
ML ConditionTemp [degC]speed [mm/s]sprayFL [uL/min]plamsaH [cm]gasFL [L/min]plasmaDC [%]Success or Fail
0015520050001.235501
1116022530000.830750
2213520025001.025751
3315017520001.0201000
4417027545001.216500
5517525035001.225750
6614015040001.020751
7715522540000.830250
8813017535000.825751
9913512525001.220251
101013025045001.230501
111114512535001.025501
121214515045001.0161001
131316027535000.8201000
141416522540001.025251
151512527530000.820501
161617017550001.016750
171715010025001.035501
181814030020001.030500
191916512530001.030750
\n", - "
" - ], - "text/plain": [ - " ML Condition Temp [degC] speed [mm/s] sprayFL [uL/min] plamsaH [cm] \\\n", - "0 0 155 200 5000 1.2 \n", - "1 1 160 225 3000 0.8 \n", - "2 2 135 200 2500 1.0 \n", - "3 3 150 175 2000 1.0 \n", - "4 4 170 275 4500 1.2 \n", - "5 5 175 250 3500 1.2 \n", - "6 6 140 150 4000 1.0 \n", - "7 7 155 225 4000 0.8 \n", - "8 8 130 175 3500 0.8 \n", - "9 9 135 125 2500 1.2 \n", - "10 10 130 250 4500 1.2 \n", - "11 11 145 125 3500 1.0 \n", - "12 12 145 150 4500 1.0 \n", - "13 13 160 275 3500 0.8 \n", - "14 14 165 225 4000 1.0 \n", - "15 15 125 275 3000 0.8 \n", - "16 16 170 175 5000 1.0 \n", - "17 17 150 100 2500 1.0 \n", - "18 18 140 300 2000 1.0 \n", - "19 19 165 125 3000 1.0 \n", - "\n", - " gasFL [L/min] plasmaDC [%] Success or Fail \n", - "0 35 50 1 \n", - "1 30 75 0 \n", - "2 25 75 1 \n", - "3 20 100 0 \n", - "4 16 50 0 \n", - "5 25 75 0 \n", - "6 20 75 1 \n", - "7 30 25 0 \n", - "8 25 75 1 \n", - "9 20 25 1 \n", - "10 30 50 1 \n", - "11 25 50 1 \n", - "12 16 100 1 \n", - "13 20 100 0 \n", - "14 25 25 1 \n", - "15 20 50 1 \n", - "16 16 75 0 \n", - "17 35 50 1 \n", - "18 30 50 0 \n", - "19 30 75 0 " - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df_film = pd.read_excel('./ML Perov Data/ML perov data stanford.xlsx', sheet_name='2020_09_22_film')\n", - "print(df_film.columns)\n", - "df_film.iloc[:,:-1]" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Index(['ML Condition', 'Sample', 'Temp [degC]', 'speed [mm/s]',\n", - " 'sprayFL [uL/min]', 'plamsaH [cm]', 'gasFL [L/min]', 'plasmaDC [%]',\n", - " 'Isc', 'Jsc', 'Voc', 'FF', 'PCE'],\n", - " dtype='object')\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
ML ConditionSampleTemp [degC]speed [mm/s]sprayFL [uL/min]plamsaH [cm]gasFL [L/min]plasmaDC [%]Isc [mA]Jsc [mA/cm2]Voc [V]FF [-]Efficiency [%]
06B9R_14-FR0.dat14015040001.020753.99619.02801.0460000.5911.743293
16B9L_4-FR0.dat14015040001.020753.55316.91901.0440000.5810.244822
26B10L_7-FR0.dat14015040001.020753.82018.19001.0400000.6712.675124
36B10R_AFTERUP2-FR0.dat14015040001.020754.18119.90901.0230000.6914.053536
415B11L_YELLOWISH2-FR0.dat12527530000.820504.77322.72850.9813030.6113.605205
515B11R_YELLOWISH2-FR0.dat12527530000.820504.84423.06600.9213320.5812.326194
615B12L-FR0.dat12527530000.820504.45821.22800.8325190.5910.427182
715B12RPALE_2-FR0.dat12527530000.820504.50421.44760.8188770.6210.889036
810B13R_5-FR0.dat13025045001.230504.64422.11420.9457750.5912.339932
910B14L_YELLOWISH4-FR0.dat13025045001.230504.26720.31900.9666750.6212.177988
1010B14R_YELLOWISH10-FR0.dat13025045001.230504.55721.70000.8510560.6411.819466
118B15L-FR0.dat13017535000.825754.65122.14700.9025300.5510.993890
128B15R_AFTERUP-FR0.dat13017535000.825754.59521.88001.0440000.7416.904349
138B16R_AFTERUP-FR0.dat13017535000.825754.89723.31900.9959980.6414.864464
148B16L_5-FR0.dat13017535000.825754.60121.90901.0110000.6213.733328
152B17LPALE_4-FR0.dat13520025001.025754.72822.51420.9451260.6012.767302
162B18RPALE_4-FR0.dat13520025001.025754.86723.17610.9269200.6012.889485
172B18LPALE_2-FR0.dat13520025001.025754.50821.46600.8955740.6211.919493
189B20RGAPPY_AFTER2UP2-FR0.dat13512525001.220254.63022.04761.0250000.7116.045155
199B20LGAPPY_6-FR0.dat13512525001.220254.57521.78501.0100000.6714.742393
209B21LGAPPY_AFTERUP-FR0.dat13512525001.220254.65922.18570.9708170.6413.784492
219B21RGAPPY_AFTERUP3-FR0.dat13512525001.220254.72322.49000.9912910.6915.383279
2211B22L_4-FR0.dat14512535001.025504.98823.75231.0090000.5713.660707
2311B22R_AGAIN_5MINSOAKAFTERUP3-FR0.dat14512535001.025505.01023.85711.0470000.6816.985331
2411B23L_AFTERUP-FR0.dat14512535001.025504.43821.13331.0450000.7616.784093
2511B23R_AFTER2UP2-FR0.dat14512535001.025504.68222.29521.0310000.7717.699521
2612B24L_3-FR0.dat14515045001.0161004.16119.81420.9930360.499.641387
2712B24R_8-FR0.dat14515045001.0161004.78222.77140.9710780.5411.940930
2812B25L-FR0.dat14515045001.0161003.61717.22380.9675340.518.498957
2912B25R_7-FR0.dat14515045001.0161004.44021.14280.9967750.5912.434056
3017B28R-FR0.dat15010025001.035501.6597.90001.0050000.342.699430
3117B28L_2-FR0.dat15010025001.035501.3296.32850.9382140.342.018768
320B29L-FR0.dat15520050001.235502.35011.19040.9690770.363.903996
330B29R_3-FR0.dat15520050001.235503.41516.26190.9325140.395.914137
347B30L-FR0.dat15522540000.830253.83918.28000.8988060.538.708446
357B30R-FR0.dat15522540000.830253.81718.17611.0070000.549.883849
3614B36LPALE_3-FR0.dat16522540001.025254.59921.90000.9744280.6413.657583
3714B36RPALE_8-FR0.dat16522540001.025254.74922.61420.9688570.6213.584206
\n", - "
" - ], - "text/plain": [ - " ML Condition Sample Temp [degC] \\\n", - "0 6 B9R_14-FR0.dat 140 \n", - "1 6 B9L_4-FR0.dat 140 \n", - "2 6 B10L_7-FR0.dat 140 \n", - "3 6 B10R_AFTERUP2-FR0.dat 140 \n", - "4 15 B11L_YELLOWISH2-FR0.dat 125 \n", - "5 15 B11R_YELLOWISH2-FR0.dat 125 \n", - "6 15 B12L-FR0.dat 125 \n", - "7 15 B12RPALE_2-FR0.dat 125 \n", - "8 10 B13R_5-FR0.dat 130 \n", - "9 10 B14L_YELLOWISH4-FR0.dat 130 \n", - "10 10 B14R_YELLOWISH10-FR0.dat 130 \n", - "11 8 B15L-FR0.dat 130 \n", - "12 8 B15R_AFTERUP-FR0.dat 130 \n", - "13 8 B16R_AFTERUP-FR0.dat 130 \n", - "14 8 B16L_5-FR0.dat 130 \n", - "15 2 B17LPALE_4-FR0.dat 135 \n", - "16 2 B18RPALE_4-FR0.dat 135 \n", - "17 2 B18LPALE_2-FR0.dat 135 \n", - "18 9 B20RGAPPY_AFTER2UP2-FR0.dat 135 \n", - "19 9 B20LGAPPY_6-FR0.dat 135 \n", - "20 9 B21LGAPPY_AFTERUP-FR0.dat 135 \n", - "21 9 B21RGAPPY_AFTERUP3-FR0.dat 135 \n", - "22 11 B22L_4-FR0.dat 145 \n", - "23 11 B22R_AGAIN_5MINSOAKAFTERUP3-FR0.dat 145 \n", - "24 11 B23L_AFTERUP-FR0.dat 145 \n", - "25 11 B23R_AFTER2UP2-FR0.dat 145 \n", - "26 12 B24L_3-FR0.dat 145 \n", - "27 12 B24R_8-FR0.dat 145 \n", - "28 12 B25L-FR0.dat 145 \n", - "29 12 B25R_7-FR0.dat 145 \n", - "30 17 B28R-FR0.dat 150 \n", - "31 17 B28L_2-FR0.dat 150 \n", - "32 0 B29L-FR0.dat 155 \n", - "33 0 B29R_3-FR0.dat 155 \n", - "34 7 B30L-FR0.dat 155 \n", - "35 7 B30R-FR0.dat 155 \n", - "36 14 B36LPALE_3-FR0.dat 165 \n", - "37 14 B36RPALE_8-FR0.dat 165 \n", - "\n", - " speed [mm/s] sprayFL [uL/min] plamsaH [cm] gasFL [L/min] plasmaDC [%] \\\n", - "0 150 4000 1.0 20 75 \n", - "1 150 4000 1.0 20 75 \n", - "2 150 4000 1.0 20 75 \n", - "3 150 4000 1.0 20 75 \n", - "4 275 3000 0.8 20 50 \n", - "5 275 3000 0.8 20 50 \n", - "6 275 3000 0.8 20 50 \n", - "7 275 3000 0.8 20 50 \n", - "8 250 4500 1.2 30 50 \n", - "9 250 4500 1.2 30 50 \n", - "10 250 4500 1.2 30 50 \n", - "11 175 3500 0.8 25 75 \n", - "12 175 3500 0.8 25 75 \n", - "13 175 3500 0.8 25 75 \n", - "14 175 3500 0.8 25 75 \n", - "15 200 2500 1.0 25 75 \n", - "16 200 2500 1.0 25 75 \n", - "17 200 2500 1.0 25 75 \n", - "18 125 2500 1.2 20 25 \n", - "19 125 2500 1.2 20 25 \n", - "20 125 2500 1.2 20 25 \n", - "21 125 2500 1.2 20 25 \n", - "22 125 3500 1.0 25 50 \n", - "23 125 3500 1.0 25 50 \n", - "24 125 3500 1.0 25 50 \n", - "25 125 3500 1.0 25 50 \n", - "26 150 4500 1.0 16 100 \n", - "27 150 4500 1.0 16 100 \n", - "28 150 4500 1.0 16 100 \n", - "29 150 4500 1.0 16 100 \n", - "30 100 2500 1.0 35 50 \n", - "31 100 2500 1.0 35 50 \n", - "32 200 5000 1.2 35 50 \n", - "33 200 5000 1.2 35 50 \n", - "34 225 4000 0.8 30 25 \n", - "35 225 4000 0.8 30 25 \n", - "36 225 4000 1.0 25 25 \n", - "37 225 4000 1.0 25 25 \n", - "\n", - " Isc [mA] Jsc [mA/cm2] Voc [V] FF [-] Efficiency [%] \n", - "0 3.996 19.0280 1.046000 0.59 11.743293 \n", - "1 3.553 16.9190 1.044000 0.58 10.244822 \n", - "2 3.820 18.1900 1.040000 0.67 12.675124 \n", - "3 4.181 19.9090 1.023000 0.69 14.053536 \n", - "4 4.773 22.7285 0.981303 0.61 13.605205 \n", - "5 4.844 23.0660 0.921332 0.58 12.326194 \n", - "6 4.458 21.2280 0.832519 0.59 10.427182 \n", - "7 4.504 21.4476 0.818877 0.62 10.889036 \n", - "8 4.644 22.1142 0.945775 0.59 12.339932 \n", - "9 4.267 20.3190 0.966675 0.62 12.177988 \n", - "10 4.557 21.7000 0.851056 0.64 11.819466 \n", - "11 4.651 22.1470 0.902530 0.55 10.993890 \n", - "12 4.595 21.8800 1.044000 0.74 16.904349 \n", - "13 4.897 23.3190 0.995998 0.64 14.864464 \n", - "14 4.601 21.9090 1.011000 0.62 13.733328 \n", - "15 4.728 22.5142 0.945126 0.60 12.767302 \n", - "16 4.867 23.1761 0.926920 0.60 12.889485 \n", - "17 4.508 21.4660 0.895574 0.62 11.919493 \n", - "18 4.630 22.0476 1.025000 0.71 16.045155 \n", - "19 4.575 21.7850 1.010000 0.67 14.742393 \n", - "20 4.659 22.1857 0.970817 0.64 13.784492 \n", - "21 4.723 22.4900 0.991291 0.69 15.383279 \n", - "22 4.988 23.7523 1.009000 0.57 13.660707 \n", - "23 5.010 23.8571 1.047000 0.68 16.985331 \n", - "24 4.438 21.1333 1.045000 0.76 16.784093 \n", - "25 4.682 22.2952 1.031000 0.77 17.699521 \n", - "26 4.161 19.8142 0.993036 0.49 9.641387 \n", - "27 4.782 22.7714 0.971078 0.54 11.940930 \n", - "28 3.617 17.2238 0.967534 0.51 8.498957 \n", - "29 4.440 21.1428 0.996775 0.59 12.434056 \n", - "30 1.659 7.9000 1.005000 0.34 2.699430 \n", - "31 1.329 6.3285 0.938214 0.34 2.018768 \n", - "32 2.350 11.1904 0.969077 0.36 3.903996 \n", - "33 3.415 16.2619 0.932514 0.39 5.914137 \n", - "34 3.839 18.2800 0.898806 0.53 8.708446 \n", - "35 3.817 18.1761 1.007000 0.54 9.883849 \n", - "36 4.599 21.9000 0.974428 0.64 13.657583 \n", - "37 4.749 22.6142 0.968857 0.62 13.584206 " - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df_device = pd.read_excel('./ML Perov Data/ML perov data stanford.xlsx', sheet_name='2020_09_22_device')\n", - "print(df_device.columns)\n", - "df_device.columns = ['ML Condition', 'Sample', 'Temp [degC]', 'speed [mm/s]',\n", - " 'sprayFL [uL/min]', 'plamsaH [cm]', 'gasFL [L/min]', 'plasmaDC [%]',\n", - " 'Isc [mA]', 'Jsc [mA/cm2]', 'Voc [V]', 'FF [-]', 'Efficiency [%]']\n", - "df_device\n" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], - "source": [ - "df = df_device.iloc[:,2:]\n", - "df_cols = df.columns\n", - "\n", - "corr = df.corr(method='pearson')#'spearman'\n", - "corr.columns = df_cols\n", - "df_len = len(df_cols)\n", - "fs = 18\n", - "\n", - "\n", - "fig, ax=plt.subplots(figsize=(df_len*0.8,df_len*0.8))\n", - "sns.set(font_scale=1.5)\n", - "sns.set_style(\"ticks\",{'xtick.direction': 'in', # set the style of the plot using seaborn\n", - " 'ytick.direction':'in',\n", - " 'xtick.top': False,'ytick.right': False})\n", - "mask = np.triu(np.ones_like(corr, dtype=np.bool),k=1)\n", - "cmap = plt.get_cmap('coolwarm')\n", - "sns.heatmap(corr, mask = mask, cbar_kws={\"shrink\": .2}, annot=True, fmt='.2f', \n", - " cmap=cmap, cbar=False, ax=ax, square=True)\n", - "ax.set_xlim(0, df_len)\n", - "ax.set_ylim(df_len, 0)\n", - "ax.set_title(\"Pearson Coefficients for Linear Correlation\", fontsize = 20)\n", - "plt.xticks(rotation=75, fontsize = fs)\n", - "plt.yticks(rotation=0, fontsize = fs) \n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Visualize input data distribution as histogram" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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NjcnS45vizOVA17fbXT/L5JirDpTWZax/aua040bN8oduzIzlabqpvTzV3amsjpZe1wwVj9l68bWxwVL+puWq6ur1XMqzdS4JEZOpp6/qjkZO3Z15WTo+W31GwyxcTFqIipLpVJhxowZKCwsxKhRo3Tl+/btg5+fHxo2NP6FAxFJQ/KJh/nz52PlypUYOHAgPvjgA9SsKXlIRCQxjgtEpGXOorNc/4VIXpo2bYro6GgsXLgQTk5OaNWqFb7++mt88803XFySyEZJ+m5+/fr1WLlyJUaMGIFp06bxd5okS1X5NtQRcVwgotK46CxR9bGn9yQffPABli1bhvXr1+POnTto1aoVFi9eXO4nmERkGySbeLh9+zbmzZsHf39/PPPMMzhz5oze9sDAQH7LSbJgT/fMFhvHBSIyBxedJRKHPb0ncXV1xeTJkzF58mSpQyEiM0j2Dv67776DSqXCr7/+itjY2HLb09PTq/S7XCJ7UVBQoPvdttxxXCAiU6y16Kwt3zbP3IVHpbodm9SLzprKyZz4pLptntgLoVd1MWo4Kexm0Vkisi+STTz861//wr/+9S+pnp7IZvCD9D84LhBRRRxhMeqqLHJcWunHSn07NlPxGXp+KW4xV5rUt80zRcy+52iLURORfaghdQBEcmfsmwwiIvrH/PnzMXv2bMTExGDRokVwcXGROiQiIiIyE38sTSQxQ5dFEhHRP7joLBERkX3jxAMRERHZLC46S0REZP/4Sk1EREQ2i4vOEhER2T9OPBAREZHN4qKzRERE9o+LSxIRERERERGRaDjxQCQx7W2wiIiIiIiIHBEnHogkxt8mExERERGRI+PEA5HEsrKypA6BiIiIiIhINJx4IJKYm5ub1CEQERERERGJhhMPRBJzdnaWOgQiIiIiIiLRcOKBSGJKpVLqEIiIiIiIiETDiQciIiIiIiIiEg0nHoiIiIiIiIhINJx4ICIiIiIiIiLRcOKBiIiIiIiIiETDiQciIiIiIiIiEg0nHogk5uXlJXUIREREREREouHEA5HEnJ2dpQ6BiIiIiIhINJx4IJJYZmam1CEQERERERGJhhMPRBLz9fWVOgQiIiIiIiLRcOKBSGK5ublSh0BERERERCQaTjwQSYwTD0RERERE5Mg48UBEREREREREouHEAxERERERERGJhhMPRERERERERCQaTjwQERER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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAABCYAAAD1CAYAAACMTvStAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjMuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/d3fzzAAAACXBIWXMAAAsTAAALEwEAmpwYAABSEElEQVR4nO3dd3hT9fs//mebpoS2oRALYRQBESpSoFWGVVFkiQyLMt9QC1hxAgIiSwVBZChDZI+CoKBABQrCG5CqyFI+iOJbZX2RXQyUtNCdNM3vj/4Sm+Z0pKQ96/m4Lq9Lzkly7jv369xJXj3Dx26320FEREREREREJAJfsQMgIiIiIiIiIvXixAQRERERERERiYYTE0REREREREQkGk5MEBEREREREZFoODFBRERERERERKLxEzsAb8jJycEff/yBmjVrQqPRiB0OkWLZbDbcvHkT4eHh0Ol0YodTIvYFosrBvkBERcmpLwDsDUSVobS+oIiJiT/++AODBw8WOwwi1diwYQNat24tdhglYl8gqlzsC0RUlBz6AsDeQFSZiusLipiYqFmzJoCCJGvXri1yNN5x+/ZtBAcHix0GlUCNNfrnn38wePBg5z4nZZ70BTXWsjh8LwrwfShQlvdBqX1Bijgu5UPttZJTXwDk3xuKUsP4U3qOSsyvtL6giIkJxyFXtWvXRmhoqMjReEdISIgsDn1TMzXXSA6HOXrSF9Rcy6L4XhTg+1DAk/dBaX1Bijgu5YO1KiCHvgDIvzcUpYbxp/QclZxfcX2BF7+UKK1WK3YIVArWqOIlJSUhMjLSZdkff/yBsLAwt//mzJlT7u2wlv/ie1GA70MBvg/SwnrIB2tFYlLD+FN6jkrPT4gijphQIpPJhLp164odBpWANapYJ06cwNtvv+22/PTp0wgICMDatWtdlteqVavc22It/8X3ogDfhwJ8H6SF9ZAP1orEpIbxp/QclZ6fEE5MEJGkWCwWrFu3DgsXLkRAQACsVqvL+jNnzqBJkyaIiIgQJ0AiIiIiIvIqnspBRJLy448/YuXKlRg/fjxiYmLc1p85cwZhYWEiREZERERERBWBExNEJCktWrRAUlISYmNj4ePj47b+7NmzuH79OqKjoxEeHo4uXbpg27ZtIkRKRERERETewFM5iEhSjEZjsetMJhNSU1Nx6dIljB07FsHBwfjmm28wceJE+Pj4oHfv3pUXKBEREREReQUnJsirLFYb/LXF3xqqtPVEJQkODkZ8fDyaNm3qvNjlo48+ihs3bmDx4sWCExOdOnVy+feQIUMQFxcHo9GI9PR053KTyQSbzeb2fL1eD71eD5PJhJCQEFitVpjNZsH4jEYjrFYrsrKyYDAYYDabkZOT4/Y4nU7nXB8QEACtVguTyST4mgaDAVqtFikpKc6YC8ftoNFoXHJyxFyWnPJsdlTxL/g4ELrQUq4lD5kZd2SV093WKT09HWazWVE5AZ7XKT8/HyaTqcScUlJSBJ9PROLgdzEiKkoOfYETExJlMBjEDqFc/LUa9Horsdj1O+dFV2I0FUuuNZIznU6Hxx9/3G15+/btcfDgQWRmZiIwMNBlXVJSUrH3JNfr9QAKbslU2r2iHUdyaDSaEq+SrNFonK9V2hgpvL60Ky87tu/4sVqcwutKOvqk8HqNBqXut1X+/1jlk9Pd1SknJ0dwvZxzKqysORW+j3pxOeXn55e4LfIefu7Ih5i1UtN3MRKmhl6h9By9nZ8c+gKvMSFRarx3rdywRpXvwoUL2LhxIywWi8vy3Nxc6HQ6BAQElOt1WUsqimOiAN8HaWE95IO1IjGpYfwpPUel5yeEExMSxUNjpY81qnwmkwnTpk3DgQMHnMvsdjv27duH1q1bC14ssyxYSyqKY6IA3wdpYT3kg7UiMalh/Ck9R6XnJ4SnckhUaYf2kvhYo8rXpk0bPPzww5g6dSpu376NmjVrYvPmzThz5gy+/PLLcr8ua0lFcUwU4PsgLayHfLBWJCY1jD+l56j0/ITwiAmJErrAGEkLa1T5NBoNli5dis6dO+PTTz/FyJEjcevWLaxduxbh4eHlfl3WkorimCjA90FaWA/5YK1ITGoYf0rPUen5CeERExKVnp5e4oXTSHysUcUbOXIkRo4c6bKsevXqmD59ule3w1pSURwTBfg+SAvrIR+sFYlJDeNP6TkqPT8hPGKCiIiIiIiIiETDiQkiIiIiIiIiEg0nJoiIiIiIiIhINJyYICIiIiIiIiLRcGJCojQajdghUClYI+VgLakojokCfB+khfWQD9aKxKSG8af0HJWenxBOTEiUGu9dKzeskXKwllQUx0QBvg/SwnrIB2tFYlLD+FN6jkrPTwgnJiRKjfeulRvWSDlYSyqKY6IA3wdpYT3kg7UiMalh/Ck9R6XnJ4QTExJjsdoAoNj71jrWExER0b9sNhtWrVqFLl26IDIyEv369cPRo0fFDouIRMS+QCQffmIHQK78tRr0eiux2PU750VXYjRUkuImj0h+WEsqimOigJzeh/j4eHzyyScYNWoUWrZsia+//hrDhw/H5s2b8eCDD4odnlfIqR5qx1pJgxr6ghA1jD+l56j0/ITwiAmicjKZTGKHQF7CWlJRHBMF5PQ+bNu2DT179sSrr76KRx99FB999BFCQkKQkJAgdmheI6d6qB1rJQ1q6AtC1DD+lJ6j0vMTwokJonKy2XhajVKwllQUx0QBOb0PFosFQUFBzn9rNBro9Xrcvn1bxKi8S071UDvWShrU0BeEqGH8KT1HpecnhBMTREREJHuDBw9GYmIijh49ivT0dKxbtw7nzp1D9+7dxQ6NiETCvkAkH7zGBBEREcnef/7zH/z0008YOnSoc9no0aPRqVMn8YIiIlGxLxDJBycmiIiISNbsdjvi4uJw/vx5TJ06FY0bN8aRI0ewZMkSVKtWDYMHD3Z5fNEfJUOGDEFcXByMRqPzFm16vR4mk0nwcFq9Xu9cHxISAqvVCrPZLBib0WiE1WpFVlYWDAYDzGYzcnJy3B6n0+mc6wMCAqDVat3OMU5PT0dycjIMBgO0Wi1SUlKcMQvdWk6j0Ug+Jwel5ZSeno6cnBxRcqpbt65gjoUlJyd7nBNQ9jqlpKSUGkNF87QvAPLtDQ6O+mRkZACA7PejwjkVHXOOfqiknApLT0+HyWTyWk6e9oWKyOnGjRslbp8TE0RERCRrv/zyC3755Rd88skneOaZZwAA7dq1g81mw8cff4zevXsjMDDQ+fikpCSEhoYKvlbhK6EbjcYSt+tYr9FoSvzSp9FooNPpABR8eStJ4fVFXzM5OdllmWP7ji+kxZFyTsVtX+45JScnO19LzJyKI/Qcb9YpPz/f45i8zdO+AMi3NxTluK6G3Pcjoe07ciraDx3knFNhycnJLuuLPqe01yxPbyju8d7KqbS+wGtMEJWTGm/jo1SsJRXFMVFALu/DP//8AwCIiIhwWf7www8jOzsb165dEyEq75NLPYi1kgK19AUhahh/Ss9R6fkJ4cQEUTmpsWEoFWtJRXFMFJDL+9CwYUMAwIkTJ1yWnzx5En5+fqhdu7YIUXmfXOpBrJUUqKUvCFHD+FN6jkrPTwhP5SAqJ8d5XyR/rCUVxTFRQC7vQ3h4ODp06IBp06YhLS0NjRs3xrFjx7B69WrExsaiWrVqYofoFXKpB7FWUqCWviBEDeNP6TkqPT8hnJggKqeQkBCxQyAvYS2pKI6JAnJ6HxYuXIhPPvkEy5cvx+3bt9GgQQO88847GDhwoNiheY2c6qF2rJU0qKEvCFHD+FN6jkrPTwgnJojKyWq1QqPRiB0GeQFrSUVxTBSQ0/ug0+kwceJETJw4UexQKoyc6qF2rJU0qKEvCFHD+FN6jkrPTwivMUFUTsXdVojkh7WkojgmCvB9kBbWQz5YKxKTGsaf0nNUen5CODFBRERERERERKLhxAQRERERERERiYYTE0REREREREQkGk5MEBEREREREZFoODFBRERERERERKLhxARRORmNRrFDIC9hLakojokCfB+khfWQD9aKxKSG8af0HJWenxBOTBCVk9VqFTsE8hLWkorimCjA90FaWA/5YK1ITGoYf0rPUen5CeHEBFE5ZWVliR0CeQlrSUVxTBTg+yAtrId8sFYkJjWMP6XnqPT8hEhmYiIpKQmRkZEuy+x2O5YtW4YOHTqgVatWGDZsGM6fPy9ShESuDAaD2CGQl7CWVBTHRAG+D9LCesgHa0ViUsP4U3qOSs9PiCQmJk6cOIG3337bbfmSJUuwbNkyvPjii5g/fz7S09MxdOhQpKenixAlkSuz2Sx2COQlrCUVxTFRgO+DtLAe8sFakZjUMP6UnqPS8xMi6sSExWLBqlWrEBsbCz8/P5d1GRkZiI+Px4gRIxAbG4tOnTohPj4emZmZSEhIEClion/l5OSIHQJ5CWtJRXFMFOD7IC2sh3ywViQmNYw/peeo9PyEiDox8eOPP2LlypUYP348YmJiXNadPHkSWVlZ6NSpk3NZcHAw2rZti4MHD1Z2qERERERERERUAUSdmGjRogWSkpIQGxsLHx8fl3UXL14EANSvX99leWhoqHMdEREREREREcmbX+kPqTgl3Z81IyMD/v7+8Pf3d1keGBiIjIyMig6NiIiIiIiIiCqBqBMTJbHb7W5HUTgUt7zwaR8AMGTIEMTFxcFoNDovmKnX62EymWCz2dyer9frnetDQkJgtVqLvfCI0WiE1WpFVlYWDAYDzGaz4LlAOp3OuT4gIABarRYmk0nwNQ0GA3Q6neC6wkwmk2Rzqlu3bqnxJycnuy3TaDSSzakog8EArVbrnCBLT08XvCCrHHNKSUlxxiyUU0pKiuDziYiIiIiIykuyExN6vR4WiwVWqxVarda5PDMzE3q9XvA5SUlJCA0NLfb1HEo6UqPweo1GU+IPbY1G45xIKO2WLoXXl+XHe1nik2tOJT1WTjmFhIQ4Yy5uTDrWF425OGLnVHhsCeWUn59f4vPlqiwTgqQuHBMF+D5IC+shH6wViUkN40/pOSo9PyGSuF2okAYNGsBut+Pq1asuy69evYpGjRqJFBXRv9R4f2GlYi2pKI6JAnwfpIX1kA/WisSkhvGn9ByVnp8QyU5MREZGokqVKti/f79z2e3bt3Hs2DFERUWJGBlRATXeX1ipWEsqimOiAN8HaWE95IO1IjGpYfwpPUel5ydEsqdyBAYGIiYmBgsXLoSvry8aNmyI5cuXIygoCP369RM7PCIEBASIHQJ5CWtJRXFMFOD7IC2sh3ywViQmNYw/peeo9PyESHZiAgDGjh0LX19frFmzBllZWYiMjMTs2bNLPJ+fqLIUvvYJyRtrSUVxTBTg+yAtrId8sFYkJjWMP6XnqPT8hEjmVI6RI0fi119/dVnm5+eHcePG4fDhw/j111+xZs0aNG7cWKQIiVwVd4cLkh/WUr0sVve75AAFF5gtab1acN+QFtZDPlgrEpMaxp/Sc1R6fkIkfcQEERFRRfLXatDrrcRi1++cF12J0RARERGpk2SOmCAiIiIiIiIi9eHEBBERERERERGJhhMTRERERERERCQaTkwQERERERERkWg4MUFUTgaDQewQyEtYSyJh3DekhfWQD9aKxKSG8af0HJWenxBOTBCVkxrvL1zZkpKSEBkZ6bLMbrdj2bJl6NChA1q1aoVhw4bh/Pnzd7Ud1pJImNz2jaNHj6Jfv35o2bIlnnrqKXz66aew2ZRzy1e51UPNWCvpUHpfEKKG8af0HJWenxBOTBCVU0pKitghKNqJEyfw9ttvuy1fsmQJli1bhhdffBHz589Heno6hg4divT09HJvi7UkEianfeOXX37B8OHD0bhxY6xYsQKDBw/GqlWrsGzZMrFD8xo51UPtWCtpUENfEKKG8af0HJWenxA/sQMgkiuj0Sh2CIpksViwbt06LFy4EAEBAbBarc51GRkZiI+Px4gRIxAbGwsAaN26NZ566ikkJCRg2LBh5doma0kkTE77xrx58/DYY49h9uzZAICoqCikpaXh559/xogRI0SOzjvkVA+1Y62kQQ19QYgaxp/Sc1R6fkJ4xARROd3NX+ipeD/++CNWrlyJ8ePHIyYmxmXdyZMnkZWVhU6dOjmXBQcHo23btjh48GC5t8laEgmTy75hNptx4sQJ9O/f32X5uHHj8Pnnn4sUlffJpR7EWkmBWvqCEDWMP6XnqPT8hHBigqic1NgwKkOLFi2QlJSE2NhY+Pj4uKy7ePEiAKB+/fouy0NDQ53ryoO1JBIml33jzJkzsNvtCAgIwKuvvooWLVogKioKixYtQn5+vtjheY1c6kGslRSopS8IUcP4U3qOSs9PCCcmiEhSjEYjqlWrJrguIyMD/v7+8Pf3d1keGBiIjIyMygiPiCQoNTUVADB+/Hg0atQIq1atwqBBg7Bs2TKsXr1a5OiISAzsC0TywmtMEJFs2O12t6MoHIpbXvi0DwAYMmQI4uLiYDQaXWajTSaT4FW69Xo99Ho9TCYTQkJCYLVaYTabBbdlNBphtVqRlZUFg8EAs9mMnJwct8fpdDrn+oCAAGi1WphMJsHXNBgM0Gq1SElJccYsNIuu0WhccnLEXJacNBqN4LYLM5vNssrJkzqVJXe55eStOuXn58NkMpWYkxQu0OW4Fs3jjz+OCRMmAAAeeeQRpKamYtmyZYiLi3MZ52XpC1KsTXp6OpKTkxU73pSUU3p6OnJyciokpyq6APhrS+/bxbFYbSU+32K1IeXm3dVJjn0BkG9vcHDUx/HHGrnvR4VzKjrmHP1QSTkVlp6e7vL5W1pO94TUQhX/8v+0L60v5Fry4Kfxuaucbty4UWIM5Yo+OzsbVatWBVAwG7l79274+vrimWeeQfXq1cvzkkREpdLr9bBYLLBarS63UcrMzIRerxd8TlJSEkJDQ4t9PaCg+Zd2kSHHeo1Gg7p16xb7OI1GA51OB6D0e1AXXl/SaxbevuPDtDiF15U1p7JwxCqXnDypU2nkmJO36uTr61tqTlI4JDowMBAA0L59e5fljz76KDZs2IBr167h3nvvdS4vS18ApFeb5ORkl2VKG2+Fty/3nJKTk52vVRE59Xorsdh1O+dFl/hcf62m1OffbZ3k2BcA+faGooKCgpwxy3k/Etq+I6ei/dBBzjkVlpyc7LK+6HOEVHRfAO4up9L6gkcTE3fu3MGYMWNw584dbNmyBRkZGejTpw+uX78Ou92OpUuXYuPGjW7nfxMReUODBg1gt9tx9epVNGrUyLm86L+JSF0cPy4K38UHAPLy8gAUf0QVESkX+wKRvHh0jYlPPvkEP//8s3PmMSEhAcnJyXj77bexfv16+Pr64pNPPqmIOIkkpyyHv5N3RUZGokqVKti/f79z2e3bt3Hs2DFERUWV+3VZSyJhctk37r//fhiNRuzZs8dl+YEDB1CrVi3Uq1dPpMi8Sy71INZKCtTSF4SoYfwpPUel5yfEoyMmvvvuO8TExGDUqFEAgP379+Oee+7Biy++CAAYPHgw1q5d6/0oiSRIjfcXFltgYCBiYmKwcOFC+Pr6omHDhli+fDmCgoLQr1+/cr8ua0kkTC77hq+vL8aOHYsJEyZg6tSp6NatG44cOYJt27bh/fffh6+vMq71LZd6EGslBWrpC0LUMP6UnqPS8xPi0cTErVu30KRJEwAF52T/9ttv6N69u3N9jRo1kJ2d7d0IiSQqPT29xPP2qGKMHTsWvr6+WLNmDbKyshAZGYnZs2ffVS1YSyJhcto3evfuDT8/P6xYsQJbt25FnTp1MG3aNAwYMEDs0LxGTvVQO9ZKGtTQF4SoYfwpPUel5yfEo4kJo9GIK1euACg4WsJms6FDhw7O9SdOnECdOnW8GiARqdfIkSMxcuRIl2V+fn4YN24cxo0bJ1JURCRVPXv2RM+ePcUOg4gkhH2BSB48mph46qmnsG7dOmRkZGDXrl0IDg5Gx44dYTKZsGrVKiQmJuL111+vqFiJJEVts5hKxloSCeO+IS2sh3ywViQmNYw/peeo9PyEeHRy1dtvv40ePXogISEB1apVw4IFC6DT6WAymbBhwwb07NkTw4cPr6hYiSSluPv4kvywlkTCuG9IC+shH6wViUkN40/pOSo9PyEeHTHh7++PGTNmYMaMGS7LH3jgAecVbonUwmaziR0CeQlrSSSM+4a0sB7ywVqRmNQw/pSeo9LzE+LREROxsbE4evSo23J/f3/UqlUL3333HXr06OG14IiIiIiIiIhI2Uo8YiI7OxupqanOfx87dgxdunRBgwYN3B6bn5+PH3/8EVevXvV+lERERERERESkSKVOTPTu3Rvp6ekAAB8fH8ycORMzZ84UfLzdbsdjjz3m/SiJiIiIiIiISJFKnJgwGAz4+OOP8b///Q92ux1LlixBly5dEBYW5vZYX19fGAwGnspBRERERERERGVW6sUvn3zySTz55JMAgOTkZAwcOBCtWrWq8MCIpE6Nt/FRKtaSSBj3DWlhPeSDtSIxqWH8KT1HpecnxKO7csyaNaui4iCSHTU2DKViLYmEcd+QFtZDPlgrEpMaxp/Sc1R6fkI8mpgAgB9//BE7d+5ESkqK4G1MfHx8sG7dOq8ERyRlJpMJRqNR7DDIC1hLImHcN6SF9ZAP1orEpIbxp/QclZ6fEI8mJjZs2IAZM2YAAO655x74+/tXSFBEchASEiJ2COQlrCWRMO4b0sJ6yAdrRWJSw/hTeo5Kz0+IRxMT69evxwMPPIBVq1ap8s0iKsxqtUKj0YgdBnkBa0kkjPuGtLAe8sFakZjUMP6UnqPS8xPi68mDr1+/jgEDBnBSggiA2WwWOwTyEtaSSBj3DWlhPeSDtSIxqWH8KT1HpecnxKOJiXvvvRcpKSkVFQsRERERERERqYxHExMvv/wyPv/8c5w7d66i4iEiIiIiIiIiFfHoGhO//PILAgMDER0djUaNGsFgMMDHx8flMbwrBxERERERERGVlUcTEwcPHgQA1K5dG9nZ2bh27VqFBEVERERERERE6uDRxMR3331XUXEQEYmmxj01S1xvsdrgr1XXlZGJiIiIiCqLRxMTRPQvo9EodgjkJVWraNHrrcRi1++cF12J0RBJB/uctLAe8sFakZjUMP6UnqPS8xPi0cREbGxsmR63fv36cgVDJCdqvL8wEakL+5y0sB7ywVqRmNQw/pSeo9LzE+LRxMTVq1fdluXn5yM1NRW5ubmoV68emjRp4rXgiKQsKysLOp1O7DCIiCoM+5y0sB7ywVqRmNQw/pSeo9LzE+KVa0zYbDYkJSXh3XffRVxcnFcCI5I6g8EgdghERBWKfU5aWA/5YK1ITGoYf0rPUen5CfH1xotoNBp07doV/fr1w9y5c73xkkSSZzabxQ6BiKhCsc9JC+shH6wViUkN40/pOSo9PyFemZhwaNiwIU6fPu3NlySSrJycHLFDICKqUOxz0sJ6yAdrRWJSw/hTeo5Kz0+I1yYmLBYLduzYgXvuucdbL0lERERERERECueVu3JYLBZcuHABd+7cwciRI70SGBEREREREREp313flQMouMbEfffdh549e2LQoEFeCYyIiIiIiIiIlM8rd+UgIiIikgKLxYLo6Gi0atUKs2fPFjscIpIA9gUi6SvXNSZsNhtOnjyJ3bt3Y//+/fjzzz+9HReR5Knt3sJEpD5y7HOLFy/G33//LXYYFUKO9VAr1kpalNwXhKhh/Ck9R6XnJ8SjIyYA4Pvvv8e0adNgMplgt9sBAD4+PqhVqxamTp2Kjh07ej1IIilS4/2FiUhd5Nbn/vrrL3z++eeoUaOG2KFUCLnVQ81YK+lQel8Qoobxp/QclZ6fEI+OmDh+/DhGjhwJu92OMWPGYMmSJVi8eDHGjBkDHx8fjBo1CidOnKioWIkkRY33FyYidZFTn8vLy8PkyZMRFxcHo9EodjgVQk71UDvWShrU0BeEqGH8KT1HpecnxKMjJhYtWoR69eohISEBer3eZd2gQYPQp08fLFu2DKtWrfJqkERSFBAQIHYIREQVSk59btWqVbBarXj55Zfx7bffih1OhZBTPdSOtZIGNfQFIWoYf0rPUen5CfHoiInff/8d/fr1c5uUAICgoCD07dsXJ0+e9FpwRFKm1WrFDoGIqELJpc+dP38ey5cvx4wZM+Dv7y92OBVGLvUg1koK1NIXhKhh/Ck9R6XnJ8Tja0yUxMfHB1ar1ZsvSSRZJpMJdevWFTsMIqIKI4c+l5+fj3feeQd9+/ZFZGRkmZ7TqVMnl38PGTLEeah3eno6AECv18NkMsFms7k9X6/XO9eHhITAarUWe9it0WiE1WpFVlYWDAYDzGYzcnJy3B6n0+mc6wMCAqDVamEymVwek56eDr1eD4PBAK1Wi5SUFGfMjrgL02g0ks/JQWk5paeno0GDBhWWU0WyWG3w12pKXJ9qLjmnlJSUigyxVOXpC4B8e4ODYz86f/48mjZtKvv9qHBORfcjRz9UUk6Fpaeno3r16mXOqaI/q0vrC7mWPPhpfErM6caNGyVuw6OJiVatWiEhIQGDBg1yO7wkIyMDW7ZsQYsWLTx5SSIiIqJy+/zzz3H9+nWsXLkSeXl5zuV2ux15eXnw83P/qpOUlITQ0FDB1yv8Rbe0c9Id6zUaTYlfCjUajfMK66Vd0Kzw+qKvmZyc7LLMsf3SfqxKOafiti/3nJKTk52vVRE5VSR/rQa93kosdv3OedGl5pSfn19h8ZVFefoCIN/eUFRQUJAzZjnvR0Lbd+RUtB86yDmnwpKTk13WF31OZStLXwBKzqm0vuDRxMSIESMQGxuLnj17IiYmBg0bNgQA/P3339i4cSNMJhOmTZvmyUsSERERldv+/fvxzz//oE2bNi7LT58+je3bt5f4Q4OIlIl9gUh+PJqYaN26NRYtWoQPPvgAH330EXx8fAAUzD7WrFkT8+fPxyOPPFIhgRIREREVNW3aNGRmZrosGzduHBo1aoQ33ngDtWrVEikyIhIL+wKR/Hh8jYlmzZrhmWeewTPPPIOrV68CAK5evQqz2YzWrVt7PUAiIiKi4tx3331uy3Q6HapXr87TS4lUin2BSH48uivH2bNn8dxzz2H9+vXw9/dH9+7d0b17d9y5cwcbN25E7969ceXKFa8GmJqairCwMLf/Ro0a5dXtEBEREREREVHl8+iIiXnz5iEwMBCbNm1yXl8CKDg0asCAARgyZAjmzp2LhQsXei3A06dPAwDWrFmDwMBA5/Lq1at7bRtE5VHaxW+IiOROrn0uMbH4C3TJmVzroUaslfQotS8IUcP4U3qOSs9PiEcTE7/99hveeOMNl0kJh/r16yMmJgarV6/2VmwAgDNnziAkJASPPfaYV1+X6G6p8f7CRKQu7HPSwnrIB2tFYlLD+FN6jkrPT4hHp3Lk5+cL3rfVwW63l7i+PM6cOYOwsDCvviaRN4h9j24ioorGPictrId8sFYkJjWMP6XnqPT8hHg0MREREYFNmzbhzp07busyMzOxZcsWtGrVymvBAQUTE9nZ2Rg4cCBatGiBJ554AqtXr4bdbvfqdog8JeY9xomIKgP7nLSwHvLBWpGY1DD+lJ6j0vMT4tGpHCNGjEBMTAx69uyJXr16oUGDBvDx8cHly5exa9cu3Lx5E7NmzfJacDabDefPn0fVqlUxYcIE1K1bFz/88APmzZuHnJwcjBgxwmvbIvJUeno69Hq92GEQEVUY9jlpYT3kg7UiMalh/Ck9R6XnJ8SjiYlWrVph7dq1mDNnDuLj413WPfDAA5g1axYiIyO9GuDy5ctRt25dNGjQAADQrl07ZGVlYfXq1Rg+fDiqVKnifGynTp1cnjtkyBDExcXBaDQiPT0dAKDX62EymWCz2dy2pdfrnetDQkJgtVphNpsF4zIajbBarcjKyoLBYIDZbBY8jUWn0znXBwQEQKvVwmQyCb6mwWCATqcr9T0xmUySzalu3bqlxp+cnOy2TKPRSDYnhxr31ETVKv+e71W0WWTnWpF666ascnIwGAzQarVISUlxxuyIuzA1HlZGpGZq/GIkZayHfLBWJCY1jD+l56j0/IR4NDEBAK1bt8aWLVtgNptx7do15Ofno06dOqhVq5bXg9NoNIiKinJb3r59e3z11Ve4dOkSmjZt6lyelJSE0NBQwdcqXNjSDo1xrNdoNCX+0NZoNM6JhNKunFp4fVl+vJclPrnmVNJjpZ5Tr7eKv6LzznnRqCrwHKnnJLR9x0RJUfn5+SU+n4iIiIiIyFMeT0w4GAyGCr+Niclkwg8//IAuXbq4bCs3NxcAUKNGjQrdPhERERERERFVrHJPTFQGi8WCKVOmIDs7G0OHDnUu37t3Lxo2bIiaNWuKFxwRiSY1NRWPPPKI2/Knn34an376qQgRERERERFReUl6YqJ+/fro2bMnFi5cCB8fHzRu3Bh79uzBvn37sGTJErHDIyKRnD59GgCwZs0aBAYGOpdXr15dpIiIiIiIiKi8JD0xAQAffvghli5dinXr1uHmzZto3LgxFi1a5HahSyJSjzNnziAkJASPPfaY2KEQKZpGoxE7BCqE9ZAP1orEpIbxp/QclZ6fEMlPTOh0OowdOxZjx44VOxQikogzZ84gLCxM7DCIFE+N91GXMtZDPlgrEpMaxp/Sc1R6fkJ8xQ6AiMhTZ86cQXZ2NgYOHIgWLVrgiSeewOrVq2G328UOjUhRhG4bTOJhPeSDtSIxqWH8KT1HpecnRPJHTBARFWaz2XD+/HlUrVoVEyZMQN26dfHDDz9g3rx5yMnJwYgRI8QOkYiIiIiIPMCJCSKSneXLl6Nu3bpo0KABAKBdu3bIysrC6tWrMXz4cFSpUsX52KLXoxkyZAji4uJgNBqds9F6vb7UbZpMJoSEhMBqtcJsNgs+xmg0wmq1IisrCwaDAWazGTk5OW6P0+l0zvUBAQHQarUwmUyCr2kwGKDVapGSkuKMWWgWXaPRuOVkMplgs9ncHqvX653rQ0JCynQeo9lsllVOntSpLLnLLSdv1SkgIAAmk6nEnFJSUgSfT95Xll5F0lBSrSxWG/y1xffd0tYTlUYNvUJpORbd74vmp4a+wIkJIpIVjUaDqKgot+Xt27fHV199hUuXLqFp06bO5UlJSQgNDRV8LU8+1Bzn+mk0GtStW7fE+HQ6HQCU+qO38PqSXrPw9h0/VotTeF1p5yd6cv6iI1a55ORJnUojx5y8VSfHpIQjZqGc8vPzS9wWeU/hepC0lVQrf60Gvd5KLPa5O+dFV1RYpBJq6BVKy5F9gdeYICKZMZlM2LRpk9tfjnNzcwEANWrUECMsIkUSOpKDxMN6yAdrRWJSw/hTQ45qw4kJIpIVi8WCKVOmYMeOHS7L9+7di4YNG6JmzZoiRUZEREREROXBUzmISFbq16+Pnj17YuHChfDx8UHjxo2xZ88e7Nu3D0uWLBE7PCIiIiIi8pDqJiZ4wSFSKyWN/Q8//BBLly7FunXrcPPmTTRu3BiLFi1yu9AlERERERFJn+omJnhhEVIrJY19nU6HsWPHYuzYsWKHQkREREREd4nXmCAiIiJBSrsdm9yxHvLBWpGY1DD+1JCj2nBigoiIiATxi5+0sB7ywVqRmNQw/tSQo9pwYoKIiIgEmUwmsUOgQlgP+WCtSExqGH9qyFFtODFBREREgkJCQsQOgQphPeSDtSIxqWH8qSFHteHEBBEREQmyWq1ih0CFsB7ywVqRmNQw/tSQo9pwYoKIiEilLFZbievtPvK4hbBamM1msUOgMmKtSExqGH9qyFFtVHe7UCIiIiqgpNsIExERkXzxiAkiIiKSPZvNhrVr1+KZZ55BREQEunfvji+++AJ2u13s0IhIJOwLRPLBIyaIiIhI9pYuXYqVK1fi9ddfR0REBI4fP46ZM2ciOzsbw4cPFzs8IhIB+wKRfHBigoiIiGTN8VfRuLg4vPbaawCAqKgomM1mrFmzhj9AiFSIfYFIXngqBxEREclaRkYGevfuja5du7osb9SoEcxmM7KyskSKjIjEwr5AJC88YoKIiIhkLTg4GFOmTHFb/v3336N27doICAgQISrvMxqNYodAZcRaiU8tfUGIGsafGnJUGx4xQURERIqzZcsWHDlyBC+99JLYoXiN1WoVOwQqI9ZKmpTYF4SoYfypIUe14RETREREpCg7duzA1KlT8fTTTyMmJsZtfadOnVz+PWTIEMTFxcFoNCI9PR0AoNfrYTKZYLPZ3J6v1+ud60NCQmC1WmE2mwVjMRqNsFqtyMrKgsFggNlsRk5OjtvjdDqdc31AQAC0Wi1MJpPLY7Kzs1G1alUYDAZotVqkpKQ4Y3bEXZhGo5F8Tg6VnVONe2qiahWtYCy5VhuqaDWC6wDAYrUhI/12iTllZ2ejXr16gjnVrVu32Nd2KC0nsZlMphLrlJKSIkJUJSutLwDy7Q0Ojv3o+vXraNSokaJ7g6MfejOnEvuCxYYq/sX3hZxcK3RVtOXOqaz7dXE5laWvVDSbzVZiD79x40aJz+fEBBERESnG2rVrMWfOHHTs2BFz586Fj4+P22OSkpIQGhoq+PzCXw5LO1TYsV6j0ZT4pVCj0UCn0wEo+EJaksLrS/ui6dh+aT9WmZNwTr3eShR8/M550cWuc6yviJyEniNVpeWUn59f2SGVqCx9AVBOb2jUqJEzZvaG0l+zcE530xeA8udUVlLuDY7JIUC4TqX1BZ7KQURERIowf/58zJ49G9HR0fj000/h7+8vdkheVdxfKUl6WCvpUHpfEKKG8aeGHNWGR0wQERGR7K1btw4rVqxAbGwsJk+eXOxfROVM6JBokibWShrU0BeEqGH8qSFHteHEBBEREcnajRs3MHfuXDRt2hQ9evTAyZMnXdaHh4fDz49feYjUhH2BSF64NxIREZGsHTp0CBaLBWfPnsWAAQPc1h89erTUc52JSFnYF4jkhRMTREREJGvPP/88nn/+ebHDICIJYV8gkhde/JKIiIiIiIiIRMOJCSIiIiIZcNyCj6SPtSIxqWH8qSFHteHEBBEREZEM8Hx4+WCtSExqGH9qyFFtODFBREREJANms1nsEKiMWCsSkxrGnxpyVBtOTBARERHJQEBAgNghUBmxViQmNYw/NeSoNpyYICIiIpIBrVYrdghURqwViUkN408NOaoNJyaIiIiIZMBkMokdApURa0ViUsP4U0OOasOJCSIiIiIiIiISDScmiIiIiIiIiEg0nJggIiIiIiIiItFwYoKIiIiIiIiIRMOJCSIiIiIiIiISDScmiIiIiGTAYDCIHQKVEWtFYlLD+FNDjmrDiQkiIiKiIixWm6jrhWi12jI/tiK2LycVmV9pr51rtUGn01XY9kk8UuwLQorrFUrqC570Qwex+wKVzE/sAIiIiIikxl+rQa+3Eotdv3NedIU+X0hKSgqMRmOZHlsR25eTisy/LK+t5vdeyaTYF4QU1yuU1Bc86YcO7AvSxiMmiIiIiGTA0y/hRKROaugVashRbTgxQURERCQD6enpYodARDKghl6hhhzVhhMTRERERDLAL+JEVBZq6BVqyFFtODFBRERERERERKLhxAQRERERERERiYYTE0REREREREQkGk5MEBEREREREZFoODFBREREJAMajUbsEIhIBtTQK9SQo9pwYoKIiIhIBoxGo9ghEJEMqKFXqCFHteHEBBEREZEM8PZ4RFQWaugVashRbTgxQURERERERESi4cQEERERkQzo9XqxQyAiGVBDr1BDjmoji4mJzZs3o2vXrmjZsiUGDBiAX3/9VeyQiEhk7AtEVJTS+4LJZBI7BCLZUXpfEKKGXqGGHNVG8hMT27Ztw9SpU/Hss89i0aJF0Ov1iIuLw5UrV8QOjYhEwr5AREWpoS/Ex8eLHQKRrKihLwhRQ69QQ45qI+mJCbvdjkWLFqF///4YMWIEnnzySSxbtgw1atTAunXrxA6PiETAvkBERamlLygpF6KKppa+IETp+QHqyFFtJD0xcenSJVy7dg0dO3Z0LtNqtejQoQMOHjwoYmREJBb2BSIqin2BiIpiXyCSF0lPTFy8eBEA0KBBA5fl9evXx+XLl2Gz2USIiojExL5AREWxLxBRUewLRPLiJ3YAJcnIyAAABAYGuiwPDAxEfn4+srOzERQU5Gws//zzT5le15plLnbd1atXyxmt90g9vtLIPf6SyD23u43fsY+J+WGu1r5Q0dScv5pzB9gXSnK3701FjC1PnsexXXL+xa0vaV1lrS+N1OOTU18A4FFvkGJf8OS1lNQXyhMv+4J48ZXWF3zsdru91FcRyc6dOzFu3DgcPnwYISEhzuVbtmzBu+++ixMnTiAwMBDHjx/H4MGDRYyUSF02bNiA1q1bi7Jt9gUiaWJfIKKi5NAXALA3EFWi4vqCpI+YcNyfNjMz06WhZGZmQqPROJtJeHg4NmzYgJo1a0Kj0YgSK5Ea2Gw23Lx5E+Hh4aLFwL5AJC3sC0RUlJz6AsDeQFQZSusLkp6YcJwTduXKFZfzw65cuYKGDRs6/63T6USbjSVSm6Lnaoq1ffYFIulgXyCiouTSFwD2BqLKUlJfkPTFLxs2bIg6depg//79zmVWqxU//PADoqKiRIyMiMTCvkBERbEvEFFR7AtE8iLpiQkfHx8MHz4cX331FRYsWIADBw7g9ddfR2pqKoYOHSp2eHfNZrNh7dq1eOaZZxAREYHu3bvjiy++gOOyH3a7HcuWLUOHDh3QqlUrDBs2DOfPnxc5avWwWCxYsGABnnrqKURERCA2NhZ//vmncz3rI47y9oXS9rc//vgDYWFhbv/NmTOnkjKrHKmpqYJ5jho1CoB6xvXPP/8s+D44/rt27Zrix0RSUhIiIyNdlpWl/haLBTNnzsRjjz2GyMhIjBo1CiaTqTJDd6Pk7wul9S6SJovFgmeeeQYTJ04UOxTVUnJfKKq8/VwuhPJTwmc0fwu6kvSpHAAwePBg5ObmYv369fjss8/QrFkzxMfHo379+mKHdteWLl2KlStX4vXXX0dERASOHz+OmTNnIjs7G8OHD8eSJUuwcuVKjBs3DvXq1cOyZcswdOhQ7N6923neHFWcWbNmITExEePGjUODBg2wfv16xMbGYseOHahXrx7rI6Ly9IXS9rfTp08jICAAa9eudXlerVq1KjqdSnX69GkAwJo1a1zOr61evToAqGZcN2/eHJs2bXJZlpubi1GjRiE8PBx16tTB0aNHFTsmTpw4gbfffttteVnqP3XqVHz33XeYMGECAgICMH/+fLz88svYunWrqOdmK/X7Qmm9i6Rp8eLF+Pvvv9GqVSuxQ1E1pfaFwu6mn8tBcfkp4XsbfwsWYSdR5OXl2SMjI+0LFixwWf7+++/bH3nkEXt6ero9IiLCvmLFCue6tLQ0e2RkpH3NmjWVHK363Llzx968eXOX9zo7O9vesmVL+5IlS1gfmSltf7Pb7fYZM2bY+/XrJ0J0lWvt2rX2Rx99VHCd2sf1jBkz7O3atbPfunXL+W+ljYnc3Fz7ypUr7c2bN7e3adPGHhER4VxXlvpfunTJ/sADD9h37drlfMyFCxfsYWFh9r1791ZeIipRlt5F0vPnn3/aIyIi7O3atbNPmDBB7HBIoe62n0tdSfnZ7fL/jOZvQXeSPpVDyTIyMtC7d2907drVZXmjRo1gNpvx008/ISsrC506dXKuCw4ORtu2bXHw4MHKDld1qlatis2bN+P55593LvPz84OPjw8sFgtOnjzJ+shIaftbVlYWzpw5g7CwMJEirDwl5anmcf3//t//w4YNGzB69GgYDAYAJb9XcvXjjz9i5cqVGD9+PGJiYlzWlaX+P/30EwCgQ4cOzsc0bNgQTZo0UfwYEUNZehdJS15eHiZPnoy4uDgYjUaxwyEFu9t+LnUl5QfI/zOavwXdcWJCJMHBwZgyZQoefPBBl+Xff/89ateu7Txft+ihZqGhobh48WJlhalafn5+ePDBBxEcHIz8/HxcuXIFkydPho+PD5599llnDVgfeShtfwsICMDZs2dx/fp1REdHIzw8HF26dMG2bdtEirjinDlzBtnZ2Rg4cCBatGiBJ554AqtXr4bdblf1uF6wYAEaNmyI/v37O5cpcUy0aNECSUlJiI2NhY+Pj8u6stT/woULCAkJQUBAQLGPIe8pS+8iaVm1ahWsVitefvllsUMhhbvbfi51JeUHyP8zmr8F3Un+GhNqsmXLFhw5cgTvvvsuMjIy4O/vD39/f5fHBAYGIiMjQ6QI1Wnp0qVYtGgRAGDUqFG477778O2337I+Mld4fzOZTEhNTcWlS5cwduxYBAcH45tvvsHEiRPh4+OD3r17ix2uV9hsNpw/fx5Vq1bFhAkTULduXfzwww+YN28ecnJyoNVqVTmur1y5gu+++w7Tp0+Hr2/BfL1Sx0RJf8Ety+dOZmamy7VJCj/mn3/+8W6wJKhw7yJpOX/+PJYvX47PPvvMbT8i8ra77edSV1J+Sv2MVvtvQU5MSMSOHTswdepUPP3004iJicGKFSsEZwcBFLucKkbnzp3Rtm1b/Pzzz1i6dCmsVit0Oh3rI2NF97fc3FzEx8ejadOmzosmPfroo7hx4wYWL14s2w84IcuXL0fdunWd95Fu164dsrKysHr1arz66quqHNdbtmxBtWrVEB0d7VwWHBysmjHhYLfbS61/WR5DFado7yLpyM/PxzvvvIO+ffu63T2AqLIpvVcr8TOavwV5KockrF27FuPHj0eHDh0wd+5c+Pj4QK/Xw2KxwGq1ujw2MzNTmVdhlbAHHngAbdu2xciRI/HCCy8gPj4eVatWZX1kSmh/0+l0ePzxx92u5Ny+fXtcuXIFmZmZIkXrXRqNBlFRUc5JCYf27dsjOztbteN6//796Ny5s8tfJdQyJgory+dOUFCQYO5KHyNSINS7SDo+//xzXL9+HW+++Sby8vKQl5cHoOAHouP/iSqL0n9HKO0zmr8FC3BiQmTz58/H7NmzER0djU8//dT5xbhBgwaw2+24evWqy+OvXr2KRo0aiRGqqty8eRNff/2126FSzZo1g8ViQXBwMOsjQ8XtbxcuXMDGjRthsVhcHp+bmwudTqeY87hNJhM2bdoEs9nssjw3NxcAVDmuk5OTcf78ebeLT6llTBRWls+dhg0bIiUlBTk5OcU+hryvuN5F0rF//378888/aNOmDZo3b47mzZvj9OnT2L59O5o3b+62XxFVJKX/jlDSZzR/C/6LExMiWrduHVasWIHY2FjMnj0bfn7/nlkTGRmJKlWqYP/+/c5lt2/fxrFjxxAVFSVGuKpy584dTJ48GXv37nVZfvjwYdxzzz3o3Lkz6yMzJe1vJpMJ06ZNw4EDB5zL7HY79u3bh9atWyvmL5MWiwVTpkzBjh07XJbv3bsXDRs2RJcuXVQ3rn///XcAQKtWrVyWq2VMFFaWz52oqCjYbDZ89913zsdcvHgR586dU+wYEVtJvYukY9q0aUhISHD5r2HDhnjqqaeQkJDg9pddooqk9N8RSvmM5m9BV/x0E8mNGzcwd+5cNG3aFD169MDJkydd1oeHhyMmJgYLFy6Er68vGjZsiOXLlyMoKAj9+vUTKWr1aNy4MZ5++mnMmTMHVqsV9evXx759+5CYmIiZM2ciKCiI9ZGR0va3hx56CA8//DCmTp2K27dvo2bNmti8eTPOnDmDL7/8UqSova9+/fro2bMnFi5cCB8fHzRu3Bh79uzBvn37sGTJEgQGBqpuXJ87dw41atRA9erVXZa3adNGFWOisLLU/95770W3bt3w3nvvISMjA9WqVcP8+fMRFhaGzp07i5yB8pTluwInKqThvvvuc1um0+lQvXp1tGjRQoSISM2U/nmuhM9o/hZ0x08zkRw6dAgWiwVnz57FgAED3NYfPXoUY8eOha+vL9asWYOsrCxERkZi9uzZij2vSGrmzJmDxYsXY+XKlbhx4wbuv/9+LFy4EN26dQMA1kdGyrK/LV26FPPnz8enn36KtLQ0PPjgg1i7di3Cw8NFiLjifPjhh1i6dCnWrVuHmzdvonHjxli0aJHzPtlqG9e3bt1CtWrV3JZrNBrVjInCylL/WbNmYdasWZg7dy7y8/Px6KOP4p133oFGoxExcmUqS+8yGAwiREZEUqfkz3MlfEbzt6A7H7vdbhc7CCIiIiIiIiJSJ15jgoiIiIiIiIhEw4kJIiIiIiIiIhINJyaIiIiIiIiISDScmCAiIiIiIiIi0XBigoiIiIiIiIhEw4kJIiIiIiIiIhINJyaoWD///DPCwsJK/O+zzz4DAGzduhVhYWHYunWrx9sp/Hpz584FALzwwgsICwsr82ts2rQJnTp18njbRXXs2BEvvPCCR88ZM2aMM/6OHTvedQxE3lCesSwlEydOdOs34eHhePLJJ/H222/j3LlzxT43IyMDa9aswfPPP4+HH34YERER6Nu3LzZt2oT8/PxSt+3oP2FhYejfvz8AwGazYcqUKYiMjMTTTz+Nb7/91u15Bw4cQJs2bZCenu6yfO7cuS55EMnR3XzOl0fhHvDYY4959bWvXLmCsLAwNG/eHDdv3nRbz32W1ODq1asICwvDxIkTK2V7Yu7ThR06dAitWrWCxWLxagzFOXjwIAYNGoRWrVohMjISQ4cOxW+//eZcz98R//ITOwCSvi5duqBLly6C68LDw72yjdatW6N///5o2rRpuZ5/+PBhPProo3cdx+TJk1G1alWPnjN48GB06NABy5cvR25u7l3HQET/mjRpEmrUqAEAyM7OxuXLl/H1119j7969WLVqFdq1a+fy+L///huvvfYarl27hl69eqFPnz6wWCzYv38/pkyZgv/7v//Dxx9/DB8fnxK3W6NGDUyaNAkGgwEAkJCQgK1bt+KNN97A5cuXMXr0aOzevRsNGjRwPmfRokUYOnQo9Hq9y2v16NEDTZo0webNm3H8+HFvvC1EqvHRRx9Bp9N59TV37tyJqlWrIjs7Gzt27EBcXJzLeu6zRBVHjH26sMOHD+Phhx+Gv7+/V2MQcuzYMQwfPhxNmjTBmDFjkJeXh40bNyImJgYbN25Ey5Yt+TuiEE5MUKnCwsIQHR1doduoX79+ubeRn5+Pn3/+GVOnTr3rODp37uzxc1q3bo3WrVsjISEB165du+sYiOhfnTt3RmhoqMuyF154AX369MHo0aOxf/9+BAYGAgByc3Px+uuvIy0tDQkJCXjggQeczxk2bBimTZvm/CIQGxtb4nYDAgJcetLu3bvRtWtXvPbaa8jPz8ehQ4fw3//+F6+++ioA4LvvvsPly5cxZMgQt9dq1qwZmjVrhqNHj/JHDpGHKuL7xzfffINHHnkE165dw/bt291+xHCfJao4YuzThR09ehQ9evTwegxCZs6ciTp16mDz5s3OP3z27t0b3bt3x4IFC7B27Vr+jiiEp3KQ7P3111+4ffs2oqKixA6FiCpBnTp1MGHCBJjNZnz99dfO5Rs3bsSFCxcwadIkl0kJhwkTJiA4OBhfffWVx9s0mUzOCRJfX1/Uq1cP//zzj3P94sWLMWzYMAQFBZUjIyKqLH/99RfOnz+P1q1b44knnsDZs2fxv//9T+ywiKicPNmnzWYzTp8+7ZWjrEtz+/ZtnD59Gt26dXM5GjskJARt2rTBr7/+WuExyA0nJmTkwIED6NevHyIiItCpUyds2LAB77zzjtv5SP/9738RExODhx9+GOHh4ejYsSM++ugjl3OpLBYLPvzwQ3Tq1Ml53va0adNw+/btyk7rrh05cgTNmjVzHu7dsWNHTJ8+HVu2bMHTTz+Nli1bok+fPvj9999x8+ZNvPnmm4iMjET79u0xf/58l3POi56X37FjR0yZMgWJiYno0aMHWrRoga5du2LDhg2VnicRUDAm33nnHWzZsgWdOnVCREQEBg4ciJ9++qnE59ntdnz55Zfo27cvIiMj0aJFC3Tr1g0rV66E3W53Pu727duYOHEiOnTogPDwcHTu3Bnz5s1zObzwhRdewCuvvIL9+/fj2WefRYsWLdCjRw8cOHAAGRkZmDJlCtq0aYOoqChMmTIFOTk5HsdRmm7dusHf3x8HDx50Ltu1axcCAgKK/UuITqfD5s2bsX379jJvx6FGjRou145IS0tz9pz9+/fj2rVrsr6mB1F5lKVfAMD58+fx5ptvol27dnj44YfxwgsvlOtIhIkTJ6Jnz5745ZdfMGDAALRs2RKdOnXCtm3bYLVaMW/ePDz66KNo06YNRo8ejdTUVLfX2LlzJwCgbdu2ztNUK+u6GURSp/R9+ujRowgODsaDDz5Y5lzK+50nKCgIe/bswdChQ93iSE1NhUaj8fj9UjqeyiET33//Pd544w00bdoUY8aMgclkwuzZsxEQEOA8jBkAtmzZgnfffRcdO3bEuHHjYLVa8e233yI+Ph4AMH78eADA9OnT8c033yA2Nhb169fHuXPnsGHDBly6dAlr1qxx2XZ2djbMZrNbTFWrVvX4egwV4ciRI25HS+zfvx/79u3DkCFDYLfbsWzZMowcORJ6vR5NmjTBxIkTsW/fPqxYsQKNGjXCc889V+zrHzx4EHv27EFMTAxCQkKwadMmTJ8+HaGhoXjyyScrOj0iN0eOHMGOHTvwwgsvoGbNmvjyyy/x0ksvYc2aNWjbtq3gcz755BMsX74czz33HPr374/MzExs374d8+bNQ2BgIAYPHgwAGD16NP766y/ExsaiVq1a+PXXX7Fy5UqkpaXhgw8+cL7en3/+iV9//RWxsbHQ6/VYsWIFRo8ejWbNmqFq1aoYO3Ysjh8/jk2bNqFWrVoYMWKER3GUpkqVKrj33ntx+vRpAAUTHqdOncJDDz0ErVZb7PMaNmxYptcvqm3btkhISMDzzz+PK1eu4MKFC4iKioLdbseiRYvw4osv8mgJUp2y9IuLFy+if//+8PPzQ0xMDAwGA7766isMGzYMGzZsQMuWLT3a5s2bN/Hqq6+iX79+ePbZZ7F+/XpMnjwZO3fuRHp6Ot544w2cP38eGzZsQNWqVTFr1iznc/Pz87Fr1y7Url0bLVq0AADUqlULu3fvxqRJkyrlnHMiKVP6Pn3kyBE88sgjzutMlTWX8nzn0Wg0gt85Tp8+jRMnTuDxxx/36H1SA05MyMTMmTNRv359fPXVV84Lxjz00EN44403XCYm1qxZg8jISCxdutS50w0aNAidOnXCwYMHnRMTO3fuRJ8+fTB27FjncwMCAnDw4EFkZma6vGZ8fLxzYqOwESNGYOTIkRWSb1nl5ubixIkTeOmll1yW37hxA4mJic6raaelpSE+Ph4PPfQQFixYAADo1asX2rZti0OHDpU4MXH9+nVs377deWh4ly5d0L59e+zYsYMTEySK5ORkLFmyxHlNlOjoaDz99NOYN28eNm3a5PZ4q9WKL774Aj169MDs2bOdy/v164eoqCgcPHgQgwcPxq1bt3DkyBGMHz/eeX5mv379YLfbceXKFZfXvHnzJpYvX46nnnoKAODn54fp06fDZrM5+8XAgQNx/PhxHDp0CCNGjChzHGVVrVo1XL58GUDBXx/y8vJQs2bNMj/fE8OGDcOhQ4fQt29fAMDQoUPRtm1b7N27F//88w9iYmIqZLtEUlXWfvHJJ58gLy8PW7dudV4stnv37ujSpQvi4+OxcOFCj7ablpaG9957z7nPhYaG4uWXX8bFixexZ88e5w+RU6dO4dChQy7PPXbsGEwmE2JiYpzfkbp06YINGzYgKSkJzzzzTPneDCIFUMM+ffToUbzyyise51Ke7zxCMjMzMWHCBADAyy+/7NH7pAacmJCB06dP4/Lly5g4caLLVWw7d+6M++67z+Xwqh07diA7O9vlivO3bt1CtWrVkJWV5VxWu3Zt7N6923mYVrVq1TB69GiMHj3abfvR0dHo3bu32/L69et7J8G78Msvv8But6N169Yuy++9916XW3w1atQIAFzuLhIQEIB77rmn1NsKNWrUyOV89Zo1ayIkJAQpKSneSIHIY/fdd5/LhVoNBgOio6PxxRdf4NatW7jnnntcHq/VanHkyBFYrVaX5ampqQgKCnL2Br1ej4CAAGzcuBGhoaFo3749AgICXP464VClShW0b9/e+W/HPlb4tr0+Pj6oV68eTCaTR3GUVV5enrPX+foWnJlos9k8eo2yql69OjZv3oxz585Br9ejXr16sNvtWLx4MeLi4hAYGIjDhw9jzpw5MJvN6Nq1KyZMmIAqVapUSDxEYitLv8jPz8eBAwfw5JNPutzBpkaNGti4caPzdChPFf4sd/xFsn379i5/HQ0NDXU7h/ubb75xe77jR8zWrVs5MUGqpvR9+tKlS7h27Zrz+hKe5FKe7zxFZWdn47XXXsPp06fxyiuvFHuEq5pxYkIGLl26BAAuO43Dfffdh1OnTjn/rdVq8X//93/45ptv8Pfff+Py5cu4desWAKBevXrOx73//vsYPXo0Jk2ahPfeew8RERHo0qUL+vTp43aru/r161fKRWLK4/Dhw4iMjHS77VDRH2aO87gct/4rvLy089qLPgcA/P39Xa5NQVSZ7r//frdlDRo0gN1ux7Vr19zGP1DQG3744QckJSXhwoULuHTpkvOaMo59wN/fH9OnT8d7772HUaNGwd/fH23btkXXrl3Ru3dvlx/Z1atXh5/fvx8hjn1MaN8rvI+VJY6ySktLc+6fwcHB0Gq1gqedeYtGo3GZpPzvf/+LlJQUxMTEwGw244033sCQIUPQvn17TJgwAXq9HmPGjKmweIjEVJZ+kZaWhqysLMHvL+W9PTjg2mfK2nssFgv27t2LwMBA1K1bF1evXgVQ8N3IMbF448YN1KpVq9xxEcmZ0vfpw4cPIzQ01PmHVU9yKe93Hoc7d+7glVdewYkTJ9CnTx9+NygGJyZkIC8vDwAEz30s+te4Dz74AF988QUefPBBREREIDo6GpGRkfjggw9w/fp15+OioqLw/fffO/87fPgwZs2ahc8++wxbt24V/DEuRUePHkXXrl3dlhduHoUVPpKkrBx/iSWSCqFrKDiOFBC6mJLdbsfrr7+O77//Hg8//DAiIyMxYMAAtGnTxu32lr169UL79u2xf/9+HDhwAEeOHMGhQ4ewceNGbNmyxdmHyrOPeRJHaTIyMnDlyhV06NDBud3IyEj88ccfyMvLKza+BQsW4MqVK5g0adJdnfaRn5+PxYsX46WXXkJAQAD27NkDu92OkSNHws/PD/3798eWLVv45YMUrbR+4ehL5fnsLYnQ/l3aNg4cOIA7d+4AcP3ramGJiYkYPnz43QdIJEFpaWkICgpy238Kf29Q8j599OhRlz+0epLL3fyuuHXrFuLi4nDq1CkMGDAA06ZN8/r7pxScmJABx8zexYsX3S6UcvHiRef/X7t2DV988QWio6Px0UcfuTyu8GkHFosFp06dQu3atdGjRw/06NED+fn5WLt2LT766CPs2rVLFleXT01NxalTp/D++++LHQpRpXJcV6GwS5cuQaPROG9pWdjx48fx/fff4/XXX8ebb77pXJ6Xl4e0tDRnj8nMzMSpU6fQpEkT9O3bF3379oXFYsHHH3+M9evX49ChQ253AfJEWeMoC8dEQOHDKLt06YJjx45h165dgvdJz8nJQUJCAmw2G6pXr17uPICCO4CkpaVh0KBBAAp6bLVq1ZxfXqpXr44bN27c1TaIpKws/eKJJ56ATqcT7Fnx8fG4efMmJk6cWCnxOq7cP3HiRLdec+PGDUybNg3btm3jxAQp0ueff44ZM2Zg9+7daNy4MQA47x5RrVo1AMrep/Pz8/Hzzz+7/GaoUaNGheeSkZHhnJQYOnQoJk2adFevp3T8U7AMhIeHo06dOkhISHC55edvv/2Gv/76y/lvx+HQRQ/zPnDgAC5evOg88iI1NRUDBgzAihUrnI/x9fV1Xs1WLkcIHD16FEFBQQgPDxc7FKJK9b///Q+//fab898pKSnYsWMHHnnkEQQHB7s9Pi0tDYB7b9i8eTOys7OdveHcuXMYPHgwEhISnI/x9/d33lbrbm9tVdY4SnPjxg18+umnMBqN6NWrl3P5gAEDUK9ePXz00Uc4e/asy3NsNhvef/99pKSkYPjw4SXeuaM0NpsNS5YswfDhw513JqpVqxZSU1Od18m4evUqjEZjubdBJHVl6Rd+fn547LHHcODAAZejNm/fvo34+Hi3i+pWlIyMDPzwww+oX78+hg4dis6dO7v8N2jQIDRv3hznz5/HyZMnKyUmosrkOJ278O8Gx/87TltQ8j79xx9/4M6dOy538auMXKZPn45Tp04hNjaWkxJlwCMmZMDX1xcTJ07E6NGjMXDgQERHR8NsNmP9+vUup3fcf//9qFu3LpYvX47c3FzUrl0bv//+O7Zt24YqVaogMzMTAJxf5jdu3Ijs7GxERkYiLS0NX3zxBUJCQu7q4k/btm1z+cHk0KxZM/znP//x+PWmTJkiuHzQoEE4evQo2rVrJ5uJFCJv8ff3x/DhwzFkyBDodDps3LgR+fn5zrvuFBUZGYmgoCDMmjUL165dQ3BwMH7++Wfs3r3bpTe0atUKrVu3xoIFC3D9+nWEhYXh+vXr+OKLL3Dfffe53ZbXU2WNo7D9+/c7L0CVm5uLv//+G9u3b0dubi5WrVrlcn2ZKlWqYPHixXjxxRfRt29f9OrVCy1atEBaWhr27NmDU6dOoVu3bhg2bNhd5bFz505kZGS49LTHH38cfn5+GDduHNq2bYsvv/zSozuMEMlNWfvFW2+9hX79+qFfv34YPHgwgoKCsHnzZmRlZQlecLsi7Nu3D7m5uejTp0+xh1APHDgQ7733HrZu3YpWrVpVSlxEleXxxx9HaGgoZsyYAZPJhPz8fMTHx6NmzZrO0yCUvE/XqVMHzZo1c7s4Z0Xmcv78eSQmJqJatWpo1qwZEhMT3R4jdHSnmnFiQia6deuGBQsWYNmyZfj4449hNBoxadIkbN++3XmxN39/f6xcuRKzZ8/G+vXrYbfbce+992Ly5MnIy8vDhx9+iD/++APh4eH44IMPUL9+fezatQu7du1C1apVERUVhTFjxtzV9SWOHTuGY8eOuS3v1KlTuSYmhG59CABPPPEEjhw5ghdffNHj1ySSu4iICPTo0QNLly5Feno6WrdujbfeesvlwoyFhYSEYOXKlZg7dy6WLVsGf39/NGrUCPPnz8fvv/+O9evXIyUlBSEhIViyZAkWL16M77//Hps2bUJwcDC6du2KN998U/A6N57wJA6HwlcD12q1MBqN6NixI4YPH+68KnZhDz74IBITE/HZZ5/hxx9/xO7du2G32xEWFoaZM2fi+eefv6tzO202G5YuXYqXXnrJZVIkJCQECxcuxKxZs3D8+HF0794dr7/+erm3QyR1Pj4+ZeoXjRs3xqZNmzB//nysXr0avr6+aNmyJebMmYMmTZpUSqw7d+6Er69vibcG79mzJ+bMmYPdu3dj8uTJvKMOKYq/vz8+++wzzJw5E6tXr4bVakWbNm0wadIkBAUFAVD2Pt2sWTPBP65UZC6O30N37twp9mgJTky48rF7ehl0qnQ2mw23b98WnDDo1asXqlWrhg0bNogQmXeEhYXhueeew+zZs8UO5a688MILuHbtGr777juxQyEF69ixI+rVq4fPP/9c7FAUq6L25YkTJ2Lbtm04c+aMV1+XSImksL9IIQYipeD+VDL+juA1JmTBZrPhiSeecDut4cyZMzh37hxatmwpUmREREREREREd4encsiAv78/unXrhoSEBPj4+CA8PBw3btzAl19+iRo1atz1+dJScOXKFSQmJqJp06Zo1qyZ2OF45Pjx47h27ZrLnU+ISN6ysrKQmJgIg8GA9u3b39VrnTp1CmfPnq20i4IRKUliYiJ0Oh2efvrpStsm91miiiPGPi1l/B3xL05MyMSMGTPQqFEj7NixA9u2bYNer0dUVBRGjx6NWrVqiR3eXTt+/DiOHz+O4cOHy25iYsOGDdi9ezeAf696TETylpqaivHjx6NVq1Z3PTGxa9curFq1ykuREanL+PHjERISUqk/YrjPElUcMfZpKePviH/xGhNEREREREREJBpeY4KIiIiIiIiIRMOJCSIiIiIiIiISDScmiIiIiIiIiEg0nJggIiIiIiIiItFwYoKIiIiIiIiIRMOJCSIiIiIiIiISzf8H8xuRuO+QqIAAAAAASUVORK5CYII=\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAABB0AAAD1CAYAAAAcXY1iAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjMuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/d3fzzAAAACXBIWXMAAAsTAAALEwEAmpwYAAA+d0lEQVR4nO3deXhTZfr/8U8JKaVQCqGlUhYBF0DKpqggMmLBBSiLIuAIIlpBEfUaFHEdGUYRZXNhB5VNcAREkQERwWV04IuDOipfFWbcWIoBDGJLaZum+f3hr/kmdE2b5DTnvF/X5XXJOSfJfT958pynd06eE+P1er0CAAAAAAAIsVpGBwAAAAAAAMyJogMAAAAAAAgLig4AAAAAACAsKDoAAAAAAICwoOgAAAAAAADCorbRAVRGXl6e9u7dq+TkZNlsNqPDAUzJ4/Ho2LFjSktLU1xcnNHhVIhxAQg/xgUAZ4q2cQGA8aKi6LB3716NHDnS6DAAS1i9erW6detmdBgVYlwAIodxAcCZomVcAGC8qCg6JCcnS/p9cDvrrLMMjiY6nTx5UomJiUaHYUpmaduff/5ZI0eO9H3earqaOi6YpT9EE9o8fBgXIsvqfdnK+UdT7tE2LgAwXlQUHYovkTzrrLPUvHlzg6OJTklJSVwCFyZma9touSS5po4LZusP0YA2Dz/Ghciwel+2cv7RmHu0jAsAjGf4QpK7du3SsGHD1KlTJ1155ZV64YUX5PF4jA7LdOx2u9EhmBZtC3/0h8ijza3BCvMFq/dlK+dv5dwBmJ+hRYdPP/1UY8eO1TnnnKPFixdr5MiRWrp0qRYuXGhkWKbkdDqNDsG0aFv4oz9EHm1uflaZL1i9L1s5fyvnDsD8DP15xezZs9WzZ089/fTTkqQePXro119/1e7du3X33XcbGRoAAKghmC8AABC9DCs6uFwuffbZZ5o/f37A9kmTJhkUEQAAqGmYLwAAEN0M+3nFvn375PV6FR8frzvvvFMdO3ZUjx49NHfuXBUVFRkVFgAAqEGYLwAAEN0MKzqcOHFCkjR58mS1bt1aS5cu1U033aSFCxfqxRdfNCosAABQgzBfAAAguhn28wq32y1Juvzyy/Xggw9Kkrp3764TJ05o4cKFyszMLHErnj59+gT8+5ZbblFmZqZSUlKUnZ0tSUpISJDT6Sx1ReuEhATf/qSkJLndbrlcrlLjS0lJkdvtVm5urhwOh1wul/Ly8kocFxcX59sfHx8vu91e5mJADodDdrtdx48f98VcHLc/m80W8pyys7Pl8XhMlZMU+D7VT0hUrL302zfl5bsVV8celpzy8/OVnZ1taN+rV7+B6sSW/XHOLyhUbVtMuTkdPXq0zMcD0arA7SlzXChvH2oOK80XsrOzlZWVVaPOrZGcL2RnZ8vpdNbonBo1TlbdOmXfaeJ0vlsnfjkWsK0y71Pxex8N7xPzBQDBMqzoUK9ePUlSr169ArZfdtllWr16tQ4fPqyWLVsG7NuxY0eZ991OSEjw/X9KSkq5r12832azKTU1tczjbDab757JDoej3Of031/ec/q/fvGJpSyhzCkvL082m81UORXzz2ng/RtLPWbT7MGSwpNT06ZNfa9vZN8rK3fp//IvLycuUw6Nit4vhF55bR5rt1U4LqBms9J8IS8vz/c8/q9v9Lk1UvMF//xrck4VnW/rlhFDeTmV9d7XxPeJ+QKAYBn284riCULxNxjFCgsLJUkxMTERj8nMuP9z+NC28Ed/iDza3NysNF+wel+2cv5Wzh2A+RlWdDj33HOVkpKirVu3Bmz/8MMP1aRJEzVr1sygyMzp+PHjRodgWrQt/NEfIo82NzcrzRes3petnL+VcwdgfoYVHWrVqqX77rtP7733nqZMmaJdu3Zp9uzZeuONNzRhwgTVqmVYaKZU0aWJqDraFv7oD5FHm5ubleYLVu/LVs7fyrkDMD/D1nSQpCFDhqh27dpavHixNmzYoKZNm2rq1KkaMWKEkWGZUvFChwg92hb+6A+RR5ubn1XmC1bvy1bO38q5AzA/Q4sOkpSRkaGMjAyjwzA9TmbhQ9vCH/0h8mhza7DCfMHqfdnK+Vs5dwDmZ55rEgEAAAAAQI1C0QEAAAAAAIQFRQcAAAAAABAWFB0AAAAAAEBYUHSwCJvNZnQIpkXbwh/9IfJoc5iF1fuylfO3cu4AzI+ig0Vw/+fwoW3hj/4QebQ5zMLqfdnK+Vs5dwDmR9HBIrKzs40OwbRoW/ijP0QebQ6zsHpftnL+Vs4dgPlRdAAAAAAAAGFB0cEiEhISjA7BtGhb+KM/RB5tDrOwel+2cv5Wzh2A+VF0sAin02l0CKZF28If/SHyaHOYhdX7spXzt3LuAMyPooNFeDweo0MwLdoW/ugPkUebwyys3petnL+VcwdgfhQdAAAAAABAWFB0AAAAAAAAYUHRAQAAAAAAhAVFBwAAAAAAEBYUHSyCWzGFD20Lf/SHyKPNYRZW78tWzt/KuQMwP4oOFsHJLHxoW/ijP0QebQ6zsHpftnL+Vs4dgPlRdLAI7v8cPrQt/NEfIo82h1lYvS9bOX8r5w7A/Cg6WERSUpLRIZgWbQt/9IfIo81hFlbvy1bO38q5AzA/ig4W4Xa7jQ7BtGhb+KM/RB5tDrOwel+2cv5Wzh2A+VF0sAiXy2V0CKZF28If/SHyaHOYhdX7spXzt3LuAMyPogMAAAAAAAgLig4AAAAAACAsKDoAAADAMgrcnmrtBwAEp7bRAQBAaQoKCjR48GB17txZTz/9tNHhAABMItZu08D7N5a5f9PswRGMBgDMjysdANRI8+bN0/fff290GAAAAACqgaKDRaSkpBgdgmnRtqH39ddfa9WqVWrUqJHRoQSN/hB5tDnMwup92cr5Wzl3AOZH0cEiuP9z+NC2oVVYWKhHHnlEmZmZUTkJoz9EHm0Os7B6X7Zy/lbOHYD5UXSwiNzcXKNDMC3aNrSWLl0qt9utcePGGR1KldAfIo82h1lYvS9bOX8r5w7A/FhI0iIcDofRIZgWbRs63333nRYtWqTly5crNja2Uo/p06dPwL9vueUW31US2dnZkqSEhAQ5nU55PCVXJE9ISPDtT0pKktvtlsvlKvW1UlJS5Ha7lZubK4fDIZfLpby8vBLHuVwu3/74+HjZ7XY5nc5Sn9PhcMhut+v48eO+mIvj9mez2QzNKS4uzpCcGjVOVt069lJfR5JO57uVl5enrKysMnMqS4Hbo1i7rdznPvHLsZDnVCwa3qejR4+W2T4IPaufT6ycv5VzB2B+FB0soviPIIQebRsaRUVFevTRR3XDDTeoa9eulX7cjh071Lx581L3JSQk+P6/op9qFO+32WxKTU0t8zibzaa4uDhJpU8S/fuD//7yntP/9Yv/EC2LETn5MyKnilaZP/3//9D2559TWSqzgn3dM3K02vtUVFRU7mMRWlY/n1g5fyvnDsD8KDpYRGnfiCE0aNvQWLVqlY4cOaIlS5aosLDQt93r9aqwsFC1a0fHcEV/iDzaHGZh9b5s5fytnDsA84uOWTwA09u+fbt+/vlnXXzxxQHbv/32W7355pvlXtEAAAAAoGai6ACgRpg6dapOnToVsG3SpElq3bq1JkyYoCZNmhgUGQAAAICqougAoEZo06ZNiW1xcXFq2LChOnbsaEBEAAAAAKqLW2YCAAAAAICw4EoHi6hoFXdUHW0bPhs3ln1ngZqK/hB5tDnMwup92cr5Wzl3AObHlQ4WwW2Ywoe2hT/6Q+TR5jALq/dlK+dv5dwBmB9FB4twuVxGh2BatC380R8ijzaHWVi9L1s5fyvnDsD8KDpYRHx8vNEhmBZtC3/0h8ijzWEWVu/LVs7fyrkDMD+KDhZht9uNDsG0aFv4oz9EHm0Os7B6X7Zy/lbOHYD5UXSwCKfTaXQIpkXbwh/9IfJoc5iF1fuylfO3cu4AzI+iAwAAAAAACAuKDgAAAAAAICwoOgAAAAAAgLCg6AAAAAAAAMKCogMAAAAAAAgLig4W4XA4jA7BtGhb+KM/RB5tDrOwel+2cv5Wzh2A+dWIokNBQYH69eunhx56yOhQTIv7P4cPbQt/9IfIo82tw+zzBav3ZSvnb+XcAZhfjSg6zJs3T99//73RYZja8ePHjQ7BtGhb+KM/RB5tbh1mny9YvS9bOX8r5w7A/AwvOnz99ddatWqVGjVqZHQoppaSkmJ0CKZF28If/SHyaHNrsMJ8wep92cr5Wzl3AOZnaNGhsLBQjzzyiDIzMxlswyw7O9voEEyLtoU/+kPk0ebmZ5X5gtX7spXzt3LuAMzP0KLD0qVL5Xa7NW7cOCPDsAROZuFD28If/SHyaHPzs8p8wep92cr5Wzl3AOZX26gX/u6777Ro0SItX75csbGxRoUBAABqMOYLAABEN0OKDkVFRXr00Ud1ww03qGvXrpV+XJ8+fQL+fcstt/gutSyuECckJMjpdMrj8ZR4fEJCgm9/UlKS3G63XC5Xqa+VkpIit9ut3NxcORwOuVwu5eXllTguLi7Otz8+Pl52u11Op7PU53Q4HLLb7Tp+/Lgv5tIq2zabLeQ5ZWdny+PxmConqeT7VJFw5JSfn6/8gkLViS3741S8P1x9rzK5ezyecnM6evRohc8BAJFktflCdna2srKyaty5NVLzhezsbDmdzmrn1KhxsurWqd7dIMrKKTU1tcLHZmVlBfy7Mu9T8XsfDe8T8wUAwapS0eH06dOqW7euJOnEiRPasmWLatWqpX79+qlhw4YVPn7VqlU6cuSIlixZosLCQt92r9erwsJC1a5delg7duxQ8+bNS92XkJDg+/+Kfu9ZvN9ms5V78rDZbIqLi5NU8f2T/fdXdEIqfv3iE0tZQplTVlaWbDabqXIq5p9TRcKRU506dVQntrYG3r+xzGM2zR4c8Jzh6nvlKZ70SKXnVFRUVKnnAYBIsdp8ISsrK2BbTTm3Rmq+kJWVFbD/zMdU9Jz+OVXmnFyeYHI6U1nHl5dTWe99TXyfmC8ACFZQRYfffvtNEydO1G+//aZ169YpJydHQ4cO1ZEjR+T1erVgwQKtWbNGLVq0KPd5tm/frp9//lkXX3xxwPZvv/1Wb775ZrmTBQAAYA3MFwAAiH5BFR2ee+457d6927eQ0/r165WVlaXJkycrLS1NDzzwgJ577jnNnj273OeZOnWqTp06FbBt0qRJat26tSZMmKAmTZoEmQYqYrPZjA7BtGhb+KM/RB5tbl5Wmy9YvS9bOX8r5w7A/IIqOrz33nsaNWqU7r33Xkm/fwPRuHFj3XbbbZKkkSNHatmyZRU+T5s2bUpsi4uLU8OGDdWxY8dgQkIlmfkWY0ajbeGP/hB5tLl5WW2+YPW+bOX8rZw7APML6paZv/zyi8477zxJvy948+9//1s9e/b07W/UqJFOnz4d2ggREtyKKXxoW/ijP0QebQ6zsHpftnL+Vs4dgPkFdaVDSkqKDh48KOn3qxw8Ho969+7t2//ZZ5+padOmVQpk48ayF/wBAACQmC8AABBtgio6XHnllVqxYoVycnK0efNmJSYmKj09XU6nU0uXLtXGjRt11113hStWVEN5q16jemhb+KM/RB5tDrOwel+2cv5Wzh2A+QX184oHHnhAAwYM0Pr169WgQQM9++yziouLk9Pp1OrVq5WRkaGxY8eGK1ZUQ1n3Y0b10bbwR3+IPNocZmH1vmzl/K2cOwDzC+pKh9jYWD355JN68sknA7a3a9dOH374oelWkTYTj8djdAimRdvCH/0h8mhzmIXV+7KV87dy7gDML6grHUaPHq1du3aV2B4bG6smTZrovffe04ABA0IWHAAAAAAAiF7lXulw+vRpnThxwvfvTz75RFdddZXOPvvsEscWFRXpH//4hw4dOhT6KAEAAAAAQNSpsOgwZMgQ3218YmJi9NRTT+mpp54q9Xiv1xtwC00AgLkVuD2KtdvCtr8yr5+amlrlx1dHuHMDAAAwg3KLDg6HQzNnztRXX30lr9er+fPn66qrrlLbtm1LHFurVi05HA5+XgEAFhJrt2ng/WXfwnDT7ME1+vHVYeRrAwAARIsKF5K84oordMUVV0iSsrKydOONN6pz585hDwyhxa2Ywoe2hT/6A4Cqsvr4YeX8rZw7APML6u4V06dPD1ccCDNOZuFD28If/QFAVVl9/LBy/lbOHYD5BVV0kKR//OMf2rRpk44fP17q7X1iYmK0YsWKkASH0HE6nUpJSTE6DFOibeGP/gCgqqw+flg5fyvnDsD8gio6rF69Wk8++aQkqXHjxoqNjQ1LUAi9pKQko0MwLdoW/ugPAKrK6uOHlfO3cu4AzC+oosPKlSvVrl07LV26lMExyrjdbtlsrKIeDrQt/NEfAFSV1ccPK+dv5dwBmF+tYA4+cuSIRowYQcEhCrlcLqNDMC3aFv7oDwCqyurjh5Xzt3LuAMwvqKJDy5Ytdfz48XDFAgAAAAAATCSoosO4ceO0atUq/ec//wlXPAAAAAAAwCSCWtPh008/Vb169TR48GC1bt1aDodDMTExAcdw9woAAAAAACAFWXT46KOPJElnnXWWTp8+rcOHD4clKAAAAAAAEP2CKjq899574YoDAAAAAACYTFBrOiB6paSkGB2CadG28Ed/AFBVVh8/rJy/lXMHYH5BXekwevToSh23cuXKKgWD8OH+z+FD28If/QFAVVl9/LBy/lbOHYD5BVV0OHToUIltRUVFOnHihPLz89WsWTOdd955IQsOoZObm6u4uDijwzAl2hb+6A8Aqsrq44eV87dy7gDMLyRrOng8Hu3YsUOPPfaYMjMzQxIYQsvhcBgdgmnRtvBHfwBQVVYfP6ycv5VzB2B+IVnTwWaz6eqrr9awYcM0a9asUDwlQszlchkdgmnRtvBHfwBQVVYfP6ycv5VzB2B+IV1IslWrVvr2229D+ZQIkby8PKNDUIHbU639RiovNr6dgL+a8FkDEJ2sPn5YOf9Q5R7Ncy0A5hXUzyvKU1BQoLfeekuNGzcO1VPCZGLtNg28f2OZ+zfNHhzBaIITzbEDAABrYL4CoCYKyd0rCgoK9MMPP+i3337TPffcE5LAAAAAAABAdKv23Suk39d0aNOmjTIyMnTTTTeFJDAAAAAAABDdQnL3CgAAAAAAgDNVaU0Hj8ejvXv36vDhw4qNjVXTpk3VoUOHUMeGEOLez0Bk8FkDUFVWHz+snL+VcwdgfkEXHd5//31NnTpVTqdTXq9XkhQTE6MmTZpoypQpSk9PD3mQqD7usABEBp81AFVl9fHDyvlbOXcA5hfULTP37Nmje+65R16vVxMnTtT8+fM1b948TZw4UTExMbr33nv12WefhStWVAP3fwYig88agKqy+vhh5fytnDsA8wvqSoe5c+eqWbNmWr9+vRISEgL23XTTTRo6dKgWLlyopUuXhjRIVF98fLzRIQCWwGcNQFVZffywcv5Wzh2A+QV1pcOXX36pYcOGlSg4SFL9+vV1ww036IsvvghZcAgdu91udAiAJfBZA1BVVh8/rJy/lXMHYH5BFR0qEhMTI7fbHcqnRIg4nU6jQwAsgc8agKqy+vhh5fytnDsA8wuq6NC5c2etX79eubm5Jfbl5ORo3bp16tixY8iCAwAAAAAA0SuoNR3uvvtujR49WhkZGRo1apRatWolSfr++++1Zs0aOZ1OTZ06NRxxArAIj8ejlStXau3atTpy5IhSU1N10003aeTIkYqJiTE6PAAAAABBCKro0K1bN82dO1dPPPGEZsyY4fsDwOv1Kjk5WXPmzFH37t3DEigAa1iwYIGWLFmiu+66S126dNGePXv01FNP6fTp0xo7dqzR4QEAAAAIQlBFB0lq3769+vXrp379+unQoUOSpEOHDsnlcqlbt24hDxCAdXg8Hi1btkyZmZkaP368JKlHjx5yuVx6+eWXKToAAAAAUSaoNR3279+v6667TitXrlRsbKz69++v/v3767ffftOaNWs0ZMgQHTx4MFyxAjC5nJwcDRkyRFdffXXA9tatW8vlcpW6ngwAAACAmiuoosPs2bNVr149bd68We3atfNtnzRpkjZv3iy73a5Zs2aFPEhUn8PhMDoEoEKJiYl6/PHHdcEFFwRsf//993XWWWdFxX3M+awBqCqrjx9Wzt/KuQMwv6CKDv/+9781ZswY3wKS/lq0aKFRo0bpX//6V6hiQwhx/2dEq3Xr1mnnzp26/fbbjQ6lUvisAagqq48fVs7fyrkDML+g1nQoKipSXl5emfu9Xm+5+2Gc48ePKyUlxegwgKC89dZbmjJliq655hqNGjWq1GP69OkT8O9bbrlFmZmZSklJUXZ2tiSpTly8Yu22Uh+fX1CoOrG15XQ6lZSUJLfbLZfLVeqxKSkpcrvdys3NlcPhkMvlKjHm5eTkKCkpybc/Pj5edru9zHuwOxwO2e1232c0OzvbF7c/m80WkFNCQoKcTqc8Hk+JYxMSEnz7Q5GTJMXFxZWaU2pqaqnP66+inGqyrKwsSaW/TwkJCeU+tsDtKbPfSaHve1Lg+1SvfgPViS37NJ9fUKjatphy+97Ro0fLzRGhZfVztZXzt3LuAMwvqKJDly5d9Nprr+nGG29UgwYNAvadOnVK69atU+fOnUMaIEKDExmizbJly/TMM88oPT1ds2bNKvN2mTt27FDz5s1L3ef/R+HA+zeWesym2YMl/d9nxGazlfuHtM1mU1xcnKSKL4f131/RH+fFr19cMCiL/76KPtdG51SsopxqsjNz9H+fKhJrt5XZ76TI9L1gXr+096moqKjc10FoWf1cbeX8rZw7APMLquhw9913a9SoUcrIyNDAgQN19tlnKyYmRgcOHNDmzZt17NgxTZ8+PVyxohoq860cUFPMmTNHixcv1pAhQzRt2jTVrh30jXYMw2cNQFVZffywcv5Wzh2A+QU1k+/cubPv28eXXnopYF+7du00ffp0de3aNaQBIjQ4mSFarFixQosXL9bo0aP1yCOPlHmFQ03FZw1AVVl9/LBy/lbOHYD5Bf31Ybdu3bRu3Tq5XC4dPnxYRUVFatq0qZo0aRKO+ABYyNGjRzVr1iydf/75GjBggL744ouA/WlpaVF11QMAAABgdVWevTscDm7vAyCkPv74YxUUFGj//v0aMWJEif27du1i3AEAAACiiKFfGXo8Hq1cuVJr167VkSNHlJqaqptuukkjR46MukuqAVTf9ddfr+uvv97oMADUMMwXAACIXoYWHRYsWKAlS5borrvuUpcuXbRnzx499dRTOn36tMaOHWtkaAAAoIZgvgAAQPQyrOjg8Xi0bNkyZWZmavz48ZKkHj16yOVy6eWXX2YSEWI2W9n3igcQOnzWgNCy0nzB6uOHlfO3cu4AzK+WUS+ck5OjIUOG6Oqrrw7Y3rp1a7lcLuXm5hoUmTlx/2cgMvisAaFlpfmC1ccPK+dv5dwBmJ9hVzokJibq8ccfL7H9/fff11lnnaX4+HgDojIvbsUERAafNSC0rDRfsPr4YeX8rZw7APMz7EqH0qxbt047d+7U7bffbnQoAACghmK+AABA9KgxN7x/6623NGXKFF1zzTUaNWpUqcf06dMn4N+33HKLMjMzlZKSouzsbElSQkKCnE6nPB5PiccnJCT49iclJcntdsvlcpX6WikpKXK73crNzZXD4ZDL5VJeXl6J4+Li4nz74+PjZbfb5XQ6S31Oh8Mhu92u48eP+2IujtufzWarUk6NHEmKtZf+m8CEhAQVuD0q8lQ9p0aNk1W3jr3U55ekArdH+Xm55eZUkaysrGq9T+XFVlbbSNLpfLdO/HJMUunvU6i+fSgrp4ra1j++Yv7vU2VuI+nxeMrte0ePHg0yG5SGb6qA8DL7fCE7Ozvs84Xq5lSVc5Z/TmXNV4rHz3y3R3XKOWfnF3hUJzZ8ayBUNGeoSFZWVsC//d+n2Dp1VSe25PTbl3tBoerE1q7y+1TZ+UB15qrMFwAEq0YUHZYtW6ZnnnlG6enpmjVrVpm3v9qxY4eaN29e6j7/iX5Ff9wW77fZbEpNTS3zOJvNpri4OEmqcBD331/ec/q/fvEEoCxVyWng/RvLPGbT7MGSvXo5VfT8sfbyc6qIf9tV5X0qS6zdVmHsdc94Df/3KVTKyynY+IpVZoJR/Jrl9b2ioqJKPQ/K53Q6+W0uECZmny+cOX6Ec75Q3Zyqes6q7Hyluvuro7w5Q2Weu6x2LX6fKhN7OPqe/2OqM1dlvgAgWIYXHebMmaPFixdryJAhmjZtmmrXNjwkAKiy0r5hBFB9VpgvMH4AAMzI0DP2ihUrtHjxYo0ePVqPPPJImd9YAAAA62K+AABA9DKs6HD06FHNmjVL559/vgYMGKAvvvgiYH9aWpopv8UAAACVx3wBAIDoZthZ+uOPP1ZBQYH279+vESNGlNi/a9euSv82DQAAmBPzBQAAopthRYfrr79e119/vVEvDwAAogDzBQAAolstowMAADPhlpkAqorxAwBgRhQdACCE+KMBQFUxfgAAzIiiAwCEkNPpNDoEAFGK8QMAYEYUHQAghJKSkowOAUCUYvwAAJgRRQcACCG32210CACiFOMHAMCMKDoAQAi5XC6jQwAQpRg/AABmRNEBAAAAAACEBUUHAAAAAAAQFhQdAAAAAABAWFB0AAAAAAAAYUHRAQAAAAAAhAVFBwAIoZSUFKNDABClGD8AAGZE0QEAQsjtdhsdAoAoxfgBADAjig4AEEK5ublGhwAgSjF+AADMiKIDAISQw+EwOgQAUYrxAwBgRhQdACCEXC6X0SEAiFKMHwAAM6LoAAAhlJeXZ3QIAKIU4wcAwIwoOgCwtAK3p9z9+RXsr+jxRjM6v5rePkaqqG2qux8AAKAmqG10AABgpFi7TQPv31jm/k2zB1e4vyYzOr/KvL5VVbdtaFsAABANuNIBAAAAAACEBUUHAAAAAAAQFhQdACCE4uLijA4BQJRi/AAAmBFFBwAIIYfDYXQIAKIU4wcAwIxMVXQI9yrt0b6SeLjjr+n5lyfcsVu5ba3G5XIZHQKASihvXDVqvlDZ8SPa5yNmFu7zfUV9s7rPT98BEA6muntFuFdpj/aVwsMdfzS3T01vm2huW6uJj483OgQAlVDeuGrUfKGy4wfnhJorEud75hMAoo2prnQAAKPZ7XajQwAQpRg/AABmRNEBAELI6XQaHQKAKMX4AQAwI4oOAAAAAAAgLCg6AAAAAACAsKDoAAAAAAAAwoKiAwAAAAAACAuKDgAAAAAAICwoOgBACDkcDqNDABClGD8AAGZE0QEAQshutxsdAoAoxfgBADAjig4AEELHjx83OgQAUYrxAwBgRhQdACCEUlJSjA4BQJRi/AAAmBFFBwAIoezsbKNDABClGD8AAGZE0QEAQog/GgBUFeMHAMCMKDoAAAAAAICwoOgAAAAAAADCgqIDAAAAAAAIC4oOAAAAAAAgLCg6AEAI2Ww2o0MAEKUYPwAAZkTRAQBCKCUlxegQAEQpxg8AgBlRdACAEOKWdwCqivEDAGBGFB0AAAAAAEBYUHQAgBBKSEgwOgQAUYrxAwBgRoYXHdauXaurr75anTp10ogRI/T5558bHRIAg0XzuOB0Oo0OATClaB4XKovxAwBgRoYWHd544w1NmTJFgwYN0ty5c5WQkKDMzEwdPHjQyLAAGCjax4WXXnrJ6BAA04n2caGyGD8AAGZkWNHB6/Vq7ty5Gj58uO6++25dccUVWrhwoRo1aqQVK1YYFRYAA5lhXIiWOIFoYYZxobLMlg8AAJKBRYeffvpJhw8fVnp6um+b3W5X79699dFHHxkVFgADMS4AOBPjAgAA0c2wosOPP/4oSTr77LMDtrdo0UIHDhyQx+MxICoARmJcAHAmxgUAAKJbbaNeOCcnR5JUr169gO316tVTUVGRTp8+rfr160uSb0Lx888/V/i87lxXmfsOHTpU4f7qPr/RjM4/3K9fkbKePxSxRfv+ihR/voycwIdrXJDC2zcqs81I0T4uhGt/NOQe7tevCONC6cI5X6js+GH0fMTovluTx41o31+RmjAuAIguMV6v12vEC2/atEmTJk3SP//5TyUlJfm2r1u3To899pg+++wz3wRjz549GjlypBFhApazevVqdevWzZDXZlwAaibGBQBnMnJcABBdDLvSofhe1KdOnQqYRJw6dUo2my3gG420tDStXr1aycnJstlsEY8VsAKPx6Njx44pLS3NsBgYF4CahXEBwJlqwrgAILoYVnQo/m3mwYMHA36nefDgQbVq1Srg2Li4OCqpQASc+Ztpo16fcQGoORgXAJzJ6HEBQHQxbCHJVq1aqWnTptq+fbtvm9vt1gcffKAePXoYFRYAAzEuADgT4wIAANHNsKJDTEyMxo4dq7/97W969tln9eGHH+quu+7SiRMnNGbMGKPCilpr167V1VdfrU6dOmnEiBH6/PPPyz3+73//uzIyMtSxY0dde+21+tvf/hahSKPXjh071LVr1wqP279/v2655RZ17dpVvXv31pIlS2TQ0ilRJxrGhWA/a/7mzZuntm3bhjE68wm2vV0ulyZPnqxLLrlE3bp105133qkDBw5EKFqEQzSMC9Vx4sQJtW3btsR/9957r9GhhVVp51Sv16uFCxeqd+/e6ty5s2699VZ99913BkUYXqXlv3fv3lL7wjPPPGNQlAAQGob9vEKSRo4cqfz8fK1cuVLLly9X+/bt9dJLL6lFixZGhhV13njjDU2ZMkUTJkxQx44dtWrVKmVmZmrjxo2ltmXxolz9+vXTgw8+qB9//FEzZszQyZMndccddxiQQc332Wef6YEHHqjwuF9++UW33nqrzjvvPD333HP63//9Xz333HOy2WzKzMyMQKTRryaPC8F+1vzt379fixYtilCk5hBse7vdbt16663Kz8/XE088IZvNpmeffVZjx47Vpk2bFBsba0AWCIWaPC5U17fffitJevnllwPWp2jYsKFBEYVfWefU+fPna8mSJZo0aZKaNWumhQsXasyYMdqyZYtvbQ8zKCv/b7/9VvHx8Vq2bFnA9iZNmkQqNAAIDy+iWlFRkffKK6/0Pv74475tBQUF3vT0dO8TTzxR6mMyMjK8I0aM8BYVFfm2vfrqq95OnTp5XS5X2GOOJvn5+d4lS5Z4O3To4L344ou9Xbp0Kff4559/3nvJJZd4c3NzfdueffZZ7yWXXOIt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XoMHD5YkFRUVqUmTJnrnnXc0efLkMuOz2WwaMGCAmjRpovbt22vXrl0UHYAwu+yyy3TppZeG/Ngz+Y8JZWnVqpVatWqlAwcOUHSApfXt21fNmzev8Lh9+/ZJku677z5fUWHfvn1q3769JkyY4Duuos9eaarymJokKSkpIIctW7boj3/8o2699VYNGzZM3bt314cffqiBAwdKkl577TXFxcX5/u2vW7du6tatm9avX6/Dhw9HLAcAiAb8vMIiEhISdOWVV2rPnj365ZdfSuz/5ZdftHv3bl177bWKjY2NeHy1atVSRkaGDh06pL1795bY73a79e6776p79+5q0qRJxOMDACAaud1uSVK9evUCtvn/G79zOp2+Qk79+vXVsGFD/fzzz5Kk/Px8LVmyRBMmTJDNZjMyTACIOhQdLGTgwIHyeDylfju4detWeTyegJ9W7NmzR2PGjFHXrl3VtWtXjR49Wv/6179KPPaLL77Q2LFj1a1bN1166aUaN26c75uVYOOTpHfeeafEvp07d+rXX3+N2E8/AACIdunp6Zo3b54kqU+fPr51DA4fPqxPPvnEt47DoUOHSl3TYePGjRo6dKi6dOmiP/zhD3r88cflcrl8+0tb0+H999/XjTfeqM6dO+viiy/WPffcox9++CHgmLZt22rJkiVatmyZ+vbtq7S0NA0cOFBvv/12iRw+/PBDjRo1Sl27dlXPnj01ceJE31ou9913n9LS0vTbb78FPCY7O1sdO3bUM888E1R7NWrUSNnZ2ZJ+vwIzJyfH9xOWV199VfXr11dGRkZQzwkAoOhgKVdccYUSExO1devWEvu2bNmiZs2aqVu3bpKkHTt26Oabb9aRI0c0fvx4jR8/XkeOHNGYMWO0Y8cO3+P27NmjkSNH6rvvvtPtt9+u8ePH67///a9Gjx4d9AJvF1xwgc4999xSiw6bN29W3bp1ddVVVwWZNYDqys7OlsvlKvFf8TeoVT0WQNX99ttvpX7WXC6XPB6PJOmRRx7xnTcffvhhTZo0STNmzFCjRo3Upk0bzZgxo8zz6tKlSzV58mTVqVNH9913n6677jq99dZbuuOOO0osolhsw4YNGj9+vOrWrasHHnhAY8aM0eeff67hw4eXKDy8+uqrWr58uYYPH67JkycrNzdXEydO1P79+33HbN68WXfccYdOnjype+65R6NHj9bOnTs1ZswY/fbbb8rIyJDb7db27dsDnnvbtm0qKCgo9WcQ5bn44ou1YcMG/fe//9Xy5cvldrt1ySWXKC8vTy+++KImTJigWrWYOgNAsFjTwUJiY2N1zTXXaMOGDTpx4oSveu90OvXpp59q3LhxiomJUWFhof76178qJSVFr7/+uurXry9JuvHGG5WRkaGpU6fqD3/4g+x2u5555hk1bNhQr7/+uu/5rrjiCvXv319r1qwpc32GsgwcOFDPPvusvvnmG7Vv317S7ws37dixQ3369OFyUMAA/r/79rdy5coS6zcEcyyAqrvuuuvK3Pfmm2+qffv26tu3r7755hu9++67AWtAPP/88wHrGZz5JcHJkyc1d+5c9erVS4sXL/b9nKB58+Z67LHH9M9//lNXXHFFwGNycnI0bdo09e/fX3PmzPFtHz58uAYMGKBZs2Zp/vz5vu2//vqrtm3bpuTkZElS586dNXz4cG3evFnnn3++ioqKNH36dJ1//vlau3at4uLiJEkdO3bUrbfeqk2bNmn48OFq2LCh3n77bV1//fW+596yZYvatGmjCy64IKg2/dOf/qTbbrtNAwYMUK1atfTggw+qZcuWevnll9WgQQP1798/qOcDAPyOooPFDBo0SGvXrtX27ds1bNgwSdLbb78tr9fr++nC119/rZ9//lmTJk3yFRwkqUGDBho1apRmz56tvXv3qmXLlvryyy912223Bayg3bp1a73++usBq2dX1sCBA/Xcc89p69atvqLDhx9+qJycHH5aARjkwQcfVLt27UpsL21bMMcCqLqZM2cqKSmp1H3VvaPEzp07lZ+fr5EjRwasXzBo0CBdcMEFpd6h5p///KdycnLUt2/fgJ9g2Gw234KMhYWFvrs+XHTRRb6CgyTfOb/41t579+7VsWPHdOedd/oKDtLvi9WuW7dObdq0kd1u932ZcvLkSSUmJsrlcul//ud/NH78+KDzbtGihTZv3qz9+/crJSVFycnJys3N1YsvvqjHHntMtWrV0ptvvum7Q8X111/P1Q8AUAkUHSymW7duSk1N1datW31Fh82bN/t+2iD93zcerVu3LvH44olGVlaWbyJy9tlnlzgu2G8XijVr1kwXXnih3nnnHd99szdv3qzGjRurZ8+eVXpOANXToUOHSl+lEMyx5SkoKNDJkycDtsXFxSkhIaHazw2YwYUXXlipu1dURfHdF848v9epU0cdOnQo9TEHDhyQJN+5uzQul8u3GPSZt8YtXsS6qKio3BgkBdxae9CgQXrttde0fft2DR06VFu3blVhYWHQP63wjyMtLc3371deeUWNGzdWv3799J///EcPPfSQ/vznP6tly5a6//77lZKSouHDh1fptQDAKig6WExMTIwGDBigZcuW6ddff1V2dra+/PLLgIWgvF5vmY8v3me3230Tg5iYmJDGOHDgQP3lL3/Rvn371KJFC33wwQe64YYbStwTG4B5ff755xo9enTAtuuuu05PP/20QREB1lGV83vxY5544okyiyGJiYm+/6/o6oDKxnDRRRcpNTVVb7/9toYOHaq3335baWlppRYrgnXq1Cm99NJLmjp1qmJiYrR161a1bNnSd+vxa6+9Vps3b6boAAAV4K84Cxo0aJCWLl2q999/Xy6XSzabTQMGDPDtb9asmSTp+++/L/HY4oWgzjrrLKWkpEj6v283/M2cOVOJiYkaN25c0PH169dP06ZN07vvvqtzzz1Xp0+f5qcVgMW0a9dOy5YtC9jG7XKByCj+eeSBAwcCrnosKCjQAw88oIEDB6pv374BjymeOzgcDl122WUB+3bv3q2ioqKgbsntH8OZHn74YV144YUaNmyYYmJi1L9/f61YsUJZWVn69NNPg15PqiyrVq1SkyZNdM0110iSjh8/HnCFRsOGDUu9qxcAIBA/QrOg888/X23bttUHH3yg999/X927dw+YzHfo0EHJycl69dVXlZOT49uek5OjNWvWKDk5WWlpaUpJSVG7du20efPmgOMOHjyolStX6vjx41WKr2HDhurVq5cvvlatWgVcSgnA/BITE3XZZZcF/Ff8EzAA4XXZZZfJbrdr7dq1AVc/bt26tdQ7YBU/pk6dOnrxxRcD7lbjdDp11113adasWUFdOZGWliaHw6ENGzaooKDAt/3TTz/Vhg0blJub69s2cOBAud1uzZw5U16vV/369Qsm3VLl5ORo2bJluueee3xxJycn68iRI742OXTokO8LGABA2bjSwaIGDRqkBQsWKC8vT0899VTAPrvdrscee0wTJ07U0KFDdcMNN0iS1q9fr6NHj+qFF17wXRb58MMP6/bbb9fQoUM1bNgw1apVS6+88ooaNGigsWPHViu+iRMn6scff9SYMWOq/DwAAJjR9u3bAxZxPlPxnSmqonHjxpowYYKee+453Xbbberbt69+/vlnvfLKK7r00kuVnp5e4jEOh0P33Xefpk+frhEjRmjQoEEqLCzUmjVrlJ+frwcffDCoGGJjY/XQQw/pwQcf1B//+EcNGjRIp06d0sqVK3XOOef41qWSfr8y6rzzztOWLVt06aWXhqQQsHz5cp111lkBtxTt27ev5s2bp0cffVQtWrTQtm3bNGXKlGq/FgCYHUUHi8rIyNDs2bNVp06dUu/Rfe211yoxMVELFizQ/PnzVbt2bXXu3FnTpk1Tt27dfMd1795dK1as0AsvvKD58+erTp06uvjii/XAAw8ErEodrPT0dNWrV0/Z2dnVmjgBAGBG06dPL3d/dc+d48ePV3JyslauXKmnn35aycnJGj58uO65554y12MYM2aMUlJStGzZMj377LOKi4tThw4dNHPmTF100UVBxzB48GAlJCRo0aJFmj17tho0aKArr7xS999/v+Lj4wOOHThwoObMmaOMjIwq5esvOztbK1as0PTp0wOuzmjXrp2mTZum+fPn6/3331dmZqaGDh1a7dcDALOL8Za3aiAQJjfffLMOHz6s9957r1rP89BDD+mNN97Qvn37QhQZACMEOybMnTtX8+bN044dO8K2gj+A6LFkyRLNnTtXH3/8ccCClWVJT09Xs2bNtGrVqpDGEar5DQCYCWs6AAAAIGoVFBRow4YN6tu3b6UKDgCAyOLnFTBMbm6uNm7cKIfDoV69egX12G+++Ub79+/XwYMHwxQdgEirzJjw448/6osvvuDqJgByOp2aPn26/vvf/+qnn37SzJkzg3r88ePHtXHjRjVr1izgp6NVsWfPHh0+fLjKi2gDgJlRdIBhTpw4ocmTJ6tz585BFx02b96spUuXhikyAEaozJiwa9cu/eUvf4lsYABqpMTERO3Zs0eFhYWaMmWKOnbsGNTjv//+e02ePFn9+/evdtFh9erV2rJli6T/u30oAOB3rOkAAAAAAADCgjUdAAAAAABAWFB0AAAAAAAAYUHRAQAAAAAAhAVFBwAAAAAAEBYUHQAAAAAAQFhQdAAAAAAAAGHx/wDRbPl0kyM/ogAAAABJRU5ErkJggg==\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "df = df_device.iloc[:,2:]\n", - "df_cols = df.columns\n", - "n_col = 4 # num of columns per row in the figure\n", - "\n", - "for n in np.arange(0, 12, n_col):\n", - " fig,axes = plt.subplots(1, n_col, figsize=(18, 3.5), sharey = False)\n", - " fs = 20\n", - " for i in np.arange(n_col):\n", - " if n< len(df_cols):\n", - " axes[i].hist(df.iloc[:,n], bins=25)####\n", - " axes[i].set_xlabel(df_cols[n], fontsize = 18)\n", - " else:\n", - " axes[i].axis(\"off\")\n", - " n = n+1 \n", - " axes[0].set_ylabel('counts', fontsize = 18)\n", - " for i in range(len(axes)):\n", - " axes[i].tick_params(direction='in', length=5, width=1, labelsize = fs*.8, grid_alpha = 0.5)\n", - " axes[i].grid(True, linestyle='-.')\n", - " plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "data": { - "image/png": "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p07F1KlTLR0GEZnIsGHDMGzYsHLX4XkEkfWz2DMriIiIiIiIiIjEcLKCiIiIiIiIiCSFkxVEREREREREJCmcrCAiIiIiIiIiSeFkBRERERERERFJCicriIiIiIiIiEhSOFlBRERERERERJLCyQoiIiIiIiIikhROVhARERERERGRpHCygoiIiIiIiIgkhZMVRERERERERCQpnKwgIiIiIiIiIknhZAURERERERERSQonK4iIiIiIiIhIUjhZQURERERERESSwskKIiIiIiIiIpIUTlYQERERERERkaRwsoKIiIiIiIiIJIWTFUREREREREQkKZysICIiIiIiIiJJ4WQFEREREREREUkKJyuIiIiIiIiISFI4WUFEREREREREksLJCiIiIiIiIiKSFE5WEBEREREREZGkcLKCiIiIiIiIiCSFkxVEREREREREJCmcrCAiIiIiIiIiSeFkBRERERERERFJCicriIiIiIiIiEhSOFlBRERERERERJLCyQoiIiIiIiIikhSLT1bs2bMHvXv3RocOHRAeHo5z586Vu/6VK1cQFRWFoKAg9OjRAwkJCRAEwUzREpFUGTuWEFHNx3GBqGbjeQRRzWbRyYp9+/Zh0aJFGDx4MNauXQtXV1dER0cjPT1ddP179+5h3LhxkMlkWLVqFUaMGIFVq1Zh69atZo6ciKTE2LGEiGo+jgtENRvPI4hqPotNVgiCgLVr12LEiBGYMmUKunfvjvj4eNSvXx9JSUmi2+zcuROFhYWIj49H9+7dMWnSJEycOBEJCQlQqVRmLgERSUFlxhIiqtk4LhDVbDyPILINFpusuH79Om7duoXQ0FBtmp2dHXr06IETJ06IbpOamorg4GA4Ojpq08LCwvDgwQP88ssv1R4zEUlPZcYSIqrZOC4Q1Ww8jyCyDRabrLh27RoAoEWLFjrpzZs3x40bN6BWq0W3EVu/ZH5EZFsqM5YQUc3GcYGoZuN5BJFtqG2pD87JyQEAODs766Q7OzujqKgI+fn5cHFx0dtGbP2S+WloBqm///7boHhUeQq9tJs3bxq0rbXlJ5YX86taflJp28rmV9kYNP3Lkgf+xowlphgXAOnsO8Z8jjk/yxrKZK46snRbVOWzOC6IM2ebMgbGILUYpDIuWMN5BGDe/YCKsS3MrzrHBYtNVmievCuTyUSXl5Vellq1dC8SyczM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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAABB0AAAD1CAYAAAAcXY1iAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjMuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/d3fzzAAAACXBIWXMAAAsTAAALEwEAmpwYAABSL0lEQVR4nO3deVxU1f8/8BeMg6wiEzqKmiAfdxQwNyxSQbFEw9xTBI2wcq9Mzfq4VCqWay4opqaJppimqaWBaBqm+TGzLNHMBdEGcUBBwBmG+/vD38yXcYZlgOEOw+v5ePh4OOcu8z5z7zncec+959gIgiCAiIiIiIiIiKiK2YodABERERERERFZJyYdiIiIiIiIiMgsmHQgIiIiIiIiIrNg0oGIiIiIiIiIzIJJByIiIiIiIiIyizpiB1AeBQUF+OOPP9CgQQNIJBKxwyGyShqNBnfv3oWPjw/s7e3FDqdM7BeIzI/9AhE9qab1C0QkvhqRdPjjjz8wevRoscMgqhXi4+PRuXNnscMoE/sFourDfoGInlRT+gUiEl+NSDo0aNAAwOPOrVGjRiJHU3n379+Hq6ur2GFQKWrjMfr3338xevRoXXuzdKb0C7XxeBrDz+Exfg6PledzYL9A1YHHwnJYY79AROKrEUkH7S2SjRo1QtOmTUWOpvLc3d15O5qFq83HqKbckmxKv1Cbj2dx/Bwe4+fwmCmfA/sFMiceC8thjf0CEYmPA0mKQCqVih0ClYHHyLrweD7Gz+Exfg6P1fbPobbX35LwWFgOHgsiMgcmHUSgUCjEDoHKwGNkXXg8H+Pn8Bg/h8dq++dQ2+tvSXgsLAePBRGZA5MORERERERERGQWTDoQERERERERkVkw6UBEREREREREZsGkAxERERERERGZBZMO1Uil1gAAPDw8SlxW05QWd02uk7FjpF1GVFO5PWV8TvXadl7zcyCi2qy0vq6k/pGIqDLqiB1AbWInlWDgO/uMLvt2aVg1R1M1WCeqCWQymdghWASHulKj53ZtO6/5OTxW29tFba+/JeGxqF68ziGi6sY7HYjI6nHecSJDtb1d1Pb6WxIeCyIi68akAxFZvczMTLFDILI4tb1d1Pb6WxIeCyIi68akAxFZPblcLnYIRBantreL2l5/S8JjQURk3Zh0ICKrl5OTI3YIRBantreL2l5/S8JjQURk3Zh0ICKrxwtaIkO1vV3U9vpbEh4LIiLrxqQDEREREREREZkFkw5EREREREREZBZMOhARERERERGRWTDpQERERERERERmwaQDEVk9iUQidghEFsfc7SIpKQn+/v56ZYIgIDY2Fr169YKvry/GjRuHq1evlrmvs2fPYtiwYfD19UVISAh2795d6fjYL1gOHgsiIuvGpAMRWT3OAU9kyJzt4ty5c3j33XcNytesWYPY2Fi8+uqrWLZsGXJycjB27NhSZy+4evUqXnvtNTRt2hSrVq1Cr1698P777+P777+vVIzsFywHjwURkXVj0oGIrB6nYyMyZI52oVKpsGHDBkRERKBOnTp6y3Jzc7Fx40ZMmjQJERERCA4OxsaNG/Hw4cNS71yIi4tDkyZNsGzZMjz//POYPXs2XnrpJaxZs6ZSsbJfsBw8FkRE1o1JByIiIqoSP/74I+Li4jBjxgyEh4frLfvtt9+Ql5eH4OBgXZmrqyu6du2KEydOlLjPlJQU9OrVCzY2NrqyPn364PLly1AoFFVfCSIiIqpSTDoQkdVzcXEROwQii2OOdtGhQwckJSUhIiJCL0kAANevXwcANGvWTK+8adOmumVPysvLQ0ZGBpo3b65Xrt1HSduVB/sFy8FjQURk3Zh0ICKrx19DiQyZo13I5XLUq1fP6LLc3FzY2dnBzs5Or9zJyQm5ubklbqNd58ltii+vCPYLloPHgojIutUpexUioppNo9GIHQKRxanudiEIgsHdD1ollQuCUOpyW1vjv50Uf4QDACIjIxEVFQW5XK4bP0Cj0UChUBj9HFxcXODi4gKFQgF3d3eo1WoolUqj7yWXy6FWq5GXlweZTAalUomCggKD9ezt7XXLHR0dIZVKS/yyLZPJIJVKkZmZqYvZ2LgHEolEr07amGtanXJycnQxW0udirO0Otnb2xvdn5ZGoym1ThkZGaVuT0T0JCYdiIiIyOxcXFygUqmgVqshlUp15Q8fPizx9npnZ2fdOsVpX5e0XVJSEpo2bVpiHMDjL7plzZqgXS6RSODh4VHiehKJRPdFTiaTlbrP4stL22fx99d+ES1J8WU1sU63b9822K6m18kYS6pTabRJEm3MT9apqKioXPshItLi4xVERERkds2bN4cgCLh165Ze+a1bt+Dl5WV0GycnJzRo0ABpaWl65drXJW1HREREloNJByKyOKdOncKw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YExSUhKaNm1qdFlpyYYn19H+Ai+RSODh4VHi+hKJRPdFrqwZdoovL22fxd9f+0W0rHiLb1PWPi2pTkql0uA9anqdjLGkOpVGmyTRxvxknYqKisq1HyIiLT5eQURERNVm2bJliImJQVhYGD777DPY2dmVuO7Bgwfxyy+/6JUJggCVSgU3Nzdzh0rVhFMhExFZN97pQERERNViy5YtWL9+PSIiIjB79uwS73DQ2rFjB3Jzc7Fnzx7Y2j7+neT48eMoKChA586dqyNkqgbG7nQgIiLrwaQDERERmV1GRgaWLFmCVq1aITQ0FL/99pvech8fH9y+fRtKpRJ+fn4AgNdffx3R0dF49913MXjwYFy/fh0rV65Ev3790KlTJxFqQeZgbBwCIiKyHkw6EBERkdmdPHkSKpUKly9fxogRIwyWnzp1CmvXrsXevXt14zUEBgYiNjYWa9aswcSJE+Hs7IwhQ4Zg6tSp1R0+ERERVRCTDkRERGR2gwcPxuDBg0tdJyYmBjExMXplvXv3Ru/evc0ZGhEREZkRB5IkIiIiIiIiIrNg0oGIiIiIiIiIzIJJByIiIiISjb29vdghEBGRGTHpQERERESi4XSZRETWjUkHIiIiIhKNUqkUOwQiIjIjJh2IiIiISDSOjo5ih0BERGbEpAMRERERiUYqlYodAhERmRGTDkREREQkGoVCIXYIRERkRkw6EBEREREREZFZMOlARERERERERGbBpAMRERERERERmQWTDkRERERERERkFkw6EBEREREREZFZMOlARERERKKRyWRih0BERGbEpAMRERERiUYqlYodAhERmRGTDkREREQkmszMTLFDICIiM2LSgYiIiIhEI5fLxQ6BiIjMiEkHIiIiIhJNTk6O2CEQEZEZMelARERERKJh0oGIyLox6UBEREREREREZsGkAxERERERERGZBZMORERERERERGQWTDoQERERERERkVkw6UBEREREopFIJGKHQEREZsSkAxERERGJRi6Xix0CERGZEZMORERERCQaTplJRGTdmHQgIiIiIiIiIrNg0oGIiIiIROPi4iJ2CEREZEaiJx127dqFkJAQdOzYESNGjMCvv/4qdkhEJDL2C0TWy9T2ffnyZURGRsLf3x+9evVCXFwcBEGopmipOigUCrFDICIiMxI16bB3717MnTsXL730ElatWgUXFxdE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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "df = df_film.iloc[:,1:-1]\n", - "df_cols = df.columns\n", - "n_col = 4 # num of columns per row in the figure\n", - "\n", - "for n in np.arange(0, 8, n_col):\n", - " fig,axes = plt.subplots(1, n_col, figsize=(18, 3.5), sharey = False)\n", - " fs = 20\n", - " for i in np.arange(n_col):\n", - " if n< len(df_cols):\n", - " axes[i].hist(df.iloc[:,n], bins=30)####\n", - " axes[i].set_xlabel(df_cols[n], fontsize = 18)\n", - " else:\n", - " axes[i].axis(\"off\")\n", - " n = n+1 \n", - " axes[0].set_ylabel('counts', fontsize = 18)\n", - " for i in range(len(axes)):\n", - " axes[i].tick_params(direction='in', length=5, width=1, labelsize = fs*.8, grid_alpha = 0.5)\n", - " axes[i].grid(True, linestyle='-.')\n", - " plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Visualize the partial/marginalized dependence as scatter plot" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The color is for variable speed [mm/s] with red as the highest and blue as the lowest\n" - ] - }, - { - "data": { - "image/png": "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EEknHRQZbPHixYwdO7bVz6uqqpgyZQrz589vsTwUCnHzzTdzyCGHMHbsWH7+859TVlYW73DTWmtl8dprr3HCCScwatQojjrqKB555JEWn0tZtN/e8lgpxT//+U+mTZvGgQceyE9+8hPWr1/f4jupksd7S9/q1aspLi7e4+/WW29t/o7d02eaJg8//DCzZs1izJgxHHvssTz66KM0PfyXDmX4Y2lMh3IUexcKhbjjjjuYPn06Y8aMYe7cuXz11VfNn8eqftfW1jJv3jwmTZrEhAkTuOGGG2hoaEhIGhMhFAoxa9Ys5s2b17xM8q5t1dXVe21Xfv7znwOZnX9PP/00Rx11FKNHj+bMM89kxYoVbX7/888/5+yzz2bs2LEcccQR3H333YTD4QRFmxl+7Jqlydq1azn//PMZO3Ys06ZN4/7770deFoit9pbF559/znnnncf48eOZOnUq1113HRUVFe3biBIZ6bPPPlNjx45VY8aMafU7V199tRo2bJi66667WiyfN2+emjhxonruuefUG2+8oY488kh14oknqkgkEu+w01JrZfHaa6+p4uJideutt6ply5ap22+/XQ0bNkw9//zzzd+Rsmif1vJ4/vz5atSoUWrBggVq0aJF6tRTT1VTp05VdXV1zd9JhTxuLX3PPPOMGjNmjFqxYkWLv23btjV/x+7pu+uuu9TIkSPVP/7xD7Vs2TJ11113qf3331/df//9Sqn0KMMfS2M6lKPYuz/84Q9q7Nix6rHHHlNLly5Vl1xyiTrooIPU1q1blVKxq9/nnXeemj59unr99dfV888/ryZPnqwuueSShKc3Xm677TY1bNgwdf311zcvk7xr27Jly9SwYcPU0qVLW7QrGzZsUEplbv49//zzavjw4Wr+/Pnq3XffVRdeeKEaO3as2rx5816/v2nTJjVmzBj105/+VC1ZskT95z//UaNHj1a33HJLgiNPX+25ZlFKqYqKCnXwwQer888/X7377rvqnnvuUfvvv7964IEHEhRp+mtvWXz33Xdq1KhR6tJLL1Xvvvuuevnll9URRxyhTjzxRBUKhX50O9JBkWGCwaC6//771YgRI9SECRNarWCLFy9WEydOVKNGjWrRQbFp0yY1fPhw9dprrzUv27BhgyouLlYLFy6Me/zppK2ysCxLHX744eqPf/xji99cffXV6pprrlFKSVm0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o0eP5owzzuCTTz7pcj4UFxdzww03sHz5cubMmcOBBx7I1KlTuf322zFNk++++44LL7yQsWPHcuihh/KnP/0Jv9/f/PuPP/6Y4uJiXnrpJZ5++mlmzZrFqFGjOOaYY3jppZcAWLx4MaeccgoHHnggRx99NI899tgecYTDYT766KMWFyubNm3iF7/4BdOnT2fkyJHMmDGDP/zhD5SXl3c53SJ9xWPfbvruqlWrmvfts846C6UUAO+88w4XXXQRkydPZsSIEUyePJkrrriixfvov/nNbyguLmbZsmV7xPzJJ59QXFzMHXfcEYcc6Zymsa2uuOKKPT7rylgPTb788ktqamqaX+mKRVuyt7iKi4uZN28en3/+Oeeddx5jx45lwoQJ/PKXv+zQeBki83T0+L83L774Iueddx4TJkxg5MiRTJ06da/nBDNmzOCCCy5gzZo1zcfcSZMmceONN+L3+ykrK+OXv/wl48aNY8qUKfzP//wPVVVVLdbx5ZdfcumllzJ16lRGjRrF0Ucfzd///ncaGhpafC8cDrNgwQLOOOMMxo0b1zwe3I033rjHOhPFNE0efvhhTjzxRMaMGcPhhx/Otdde2yKfmsa6WL58Offff3/z+dBJJ53EkiVLAHj22WeZNWsWBx54ICeccAJvvvlmUtJjJ/KKRxqpr69nzpw5rF27lilTpnDUUUexdetWnn76aZYsWcKTTz5Jr169Wvzml7/8JbW1tRx33HFs376dhQsXctFFFzFjxgzeffddjj76aILBIC+//DKXXXYZb775Jr17927+fSAQYO7cuTgcDs466yzWr1/Pyy+/zMcff8wzzzzT4rvtVVpayllnnUVFRQUzZsygqKiIJUuWcNFFF+31+9dffz0vvfQSQ4cO5ayzzsLv9/PGG29w1llncd999zFlypQW6X377bcpLi5mzpw5bN68mRtuuIEBAwa0WOfy5csJBAIceuihbT7G6Xa793oiJEQ8/OlPf+LJJ59k4sSJHHbYYdTX1/P6669z4YUX8vDDD+/xmPDFF19MeXk5p556KqWlpbz99tssX76cRx99lOHDhwPRg/7FF1/MRx99xOjRozn33HOprKzkjTfeYOnSpTzyyCMMGzaseZ3t3d8sy+Liiy9m+fLljB49miOPPJIvv/ySn/70p2RlZXU5L1auXMlLL73EtGnTOPvss3nrrbe47777qKys5K233mLkyJGcffbZvP/++zz66KMYhsFvfvObFut4+OGH2bRpE8cddxyTJ0/mhRde4LrrruPbb7/lkUce4ZhjjmH8+PG8/PLL3HTTTfTu3bt5vBmIvubV2NjIIYccAvzwWlh1dTVHH300vXr1Ys2aNTzxxBN8/PHHvPzyyzidzi6nXaSfeOzbTS6//HJGjRrFIYccgtfrRdM0Hn30Uf70pz8xcOBAjj/+eJxOJ19++SWLFy/mo48+4s0336RXr16cdNJJPPfcc7zyyiscfPDBLdb78ssvA9FBbzPFkiVL6N69+x6vlHS1Ldmbr776irlz5zJu3DjOPvtsVq1axRtvvMHq1at5/fXXcblc8UyqSHEdaSN2deutt/LQQw8xfPhwTj75ZDRN45NPPuHVV1/ls88+480338Tj8TR/f+vWrZx99tmMGTOGs846iyVLlvDUU09RU1PD6tWr6dGjB2eccQYrVqzglVdewe/3c8899wCwYcMGfvKTn6DrOscccwx5eXmsWLGCf/3rX3z55Zctxmm65pprWLhwIePGjeOMM84gFAqxdOlSnnrqKb766iuee+65+GXmXliWxaWXXsqSJUvYb7/9OO2006iurub111/no48+4tlnn21xDXTzzTdTWlrKcccdh8/n46WXXuLyyy/nrLPO4rnnnuPYY49l8uTJvPjii/zqV79i4MCBHHDAAQlNk51IB0Uauf3221m7di033nhjizv6ixcv5oorruB///d/ufPOO1v8pq6ujpdeeql5sM1rrrmGV199lbfeeovXX3+9eefq168f8+fPZ/HixcyZM6f597W1tQwcOJBHH320ucF66KGHuPXWW7njjju45ZZbOpyOO+64g/Lycm655RZOPvlkIDqTxqWXXrrHXcg33niDl156ieOPP55bb70VhyNapS+55BJOO+00rr/+ehYtWoTL5WLhwoW8/fbbzJw5k//7v/9rvlB47LHHuOmmm1qst7S0FIDBgwd3OH4h4qGhoYGnn36aCRMm8MgjjzQvP/300znttNN47LHH9riIqamp4aWXXqKwsBCAhQsX8vOf/5z//d//bV7HggUL+Oijj7jooou49tprm3973nnncfbZZ/Ob3/yGZ599FujY/vbCCy+wfPlyTj31VP785z+j69EH9v7617/y4IMPdjk/1q1bx69//evmd7DPOOMMZs2axbPPPstPf/pTrr/+egCuuOIKDj/8cF599dU9OijWrl3L008/zciRIwEYPnw4N954Iw899BD33Xcf06ZNA6LvgJ533nm8+uqrLS4qli5dSu/evRk6dCgAr7/+OiUlJdx8882ceuqpzd+76aabeOyxx/jggw+a1ylEk3jt200OOuigFiPVh0Ih7rjjDgYPHswLL7zQohP+D3/4A0888QTvvPMOZ555JhMmTKBfv368/fbb/PGPf2y+KA6FQixcuJBRo0ax7777ttjeokWLWh0P4fzzz28xuHeqWbp0KQcffPAer3x2tS3Zm7Vr13Lttdc235xRSnHRRRexdOlSPvroIw477LDYJ1CkjY60EU3Kysr497//zYQJE1iwYAGGYTR/dskll/Dee+/x6aefthgUesuWLcydO5cbbrgBiHaIHnbYYSxcuJBjjjmG//u//0PTNEzTZNasWSxatAi/309WVhZPP/009fX1LFiwgMmTJzev89JLL+Xdd99l3bp1DB06lC+++IKFCxdywgkn8Pe//735e5FIhJNPPpnVq1ezYcOGPQaaXbBgQavtTVdnLnn++edZsmQJxxxzDH/729+a28bp06dzzTXX8K9//Yvf/va3LfLplVdeoaioCICePXty33338fjjj/P88883dxqNHj2aefPm8dprr0kHhUh9kUiEF198kaFDh+7xusERRxzBQQcdxNtvv01DQwM5OTnNn51yyiktdt6DDjqIV199leOOO65Fz9/o0aMB9nrScfXVV7foTT3//PN59NFHWbhwITfddFOHevlDoRBvvfUWQ4cObe6cAPB6vfzP//wPZ5xxRovvN1043XDDDc0XSwADBgzgrLPO4t5772XZsmVMmzaNF154AYjeAd71LubZZ5/NI488woYNG5qX1dfXA5Cdnd3u2IWIJ8uyUEqxfft2ysvL6dmzJwCjRo1i0aJF9OnTZ4/fXH755c0nJwBHH30048aNY/ny5ZSVldH7/9u796AoqzcO4N8VWAG5rYYoKFgg0CxBiKLOBIqVNIsiC2pNmSVjMhqZaI41WVSYIgN4AcE7jkobqIgRV7mILIEiESoCyiVA8YoCKiK57O8P5n1/LLsrLBdBfD5/1bvvvnsW9zz7nmfPeY6REY4fPw49PT34+fnJPJeZppyQkMDeJKjS3xITE8HhcLBu3To2OQF0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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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2uTz+TBp0iTMnTtX7VCIATS1noeL2tqnh300nQtWpZundYSrV199FW+88Qbuuece9O/fH9u3b8dzzz0Ht9uNO+64AwsXLsQbb7yBhx9+GMnJyXjttddw66234uuvv0ZUVJTa4eve888/jy+++AIPP/wwunTpgv/85z+YPXs2VqxYgeTkZMpPmLnQ9nbgwAE4HA4sXry4yufatm2rUsQt48CBAwCAd955BxEREcHlsbGxAGCYft2rVy98/PHHVZZ5vV7cd9996N27N9q3b48ff/xRt31ix44deOSRR2osb0j+n3rqKXz33Xd47LHH4HA4MG/ePNx5551Yvnw53avdAi5Uu4g2vfLKKzh27Bj69eundihE55pTz8NBXe3Tw3EbnQtWw4lqAoEAz8jI4PPnz6+y/K9//SsfOnQodzqdvH///vz1118PvlZUVMQzMjL4O++808rRGk9JSQnv1atXlXXtdrt53759+cKFCyk/YeZC2xvnnD/77LN8xowZKkTXuhYvXsyHDx9e62tG79fPPvssHzJkCM/Pzw/+W299wuv18jfeeIP36tWLDxo0iPfv3z/4WkPyf/LkSZ6ens6/+uqr4HuOHz/O09LS+KpVq1qvIQbRkNpFtGfv3r28f//+fMiQIfyxxx5TOxyiU82t51pXX/s4D/99NJ0L1kS3daiotLQUU6dOxRVXXFFledeuXVFQUICffvoJLpcL48aNC74WExODwYMHY+PGja0druHY7XZ88sknuOaaa4LLTCYTGGPw+XzYtWsX5SeMXGh7c7lcOHjwINLS0lSKsPXU104j9+sjR47ggw8+wAMPPID4+HgA9a+rcLVhwwa88cYbePTRR3HTTTdVea0h+f/pp58AAKNHjw6+JyUlBT169NB9H1FDQ2oX0ZZAIIDHH38ct99+O5KSktQOh+hYc+u51tXXPiD899F0LlgTDU6oKCYmBk8++SR69uxZZfn333+Pdu3aBe/f7dSpU5XXO3bsiBMnTrRWmIZlMpnQs2dPxMTEQJZlnD59Go8//jgYY7j66quDOaD8hIcLbW8OhwOHDh1CdnY2pkyZgt69e+Pyyy/HZ599plLELefgwYNwu92YOXMm+vTpg1GjRuGtt94C59zQ/Xr+/PlISUnBddddF1ymxz7Rp08frFu3DrNnzwZjrMprDcn/8ePHkZCQAIfDUed7SOg0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3LhxOHXqVJU6QggJDwUFBdi4cSMGDx6MSZMmAQCWLl1a72fUOmeouHXj7rvvDg5MAEBCQgJmzpwJn89n6DpEgxMaderUKTz44IMYPnw4MjIycMcdd+Do0aO4/PLLa9z7eebMGTz11FMYP348+vTpg4yMDFxzzTX473//W+N7V65ciZkzZ2LQoEHIyMjAtddeiw8//BCc89ZqWovx+/346aefcOmllwaXpaWl4YknnsDWrVsxa9Ys9OvXDyNHjsS8efMgSRKOHDmC22+/HRkZGbj00kvxf//3f3C73cHP1zbnxM0334yxY8ciJycHDz30EIYMGYJ+/frhxhtvbLF7YokxVdxznZ+fj0ceeQQDBw7E4MGDcc899+Dw4cMN+o7PP/8cN998MwYNGoTevXtj5MiReOihh3D69Okq78vLy8Pjjz+Oyy+/HH369MHIkSPxyCOP4OTJk1XeN3bsWNx66604ePBgcNsZMmQInnzySbjdbuTm5uKBBx7AgAEDMGzYMDz88MMoKCio8h1+vx9LlizBddddhwEDBqB3794YM2YMnnzyyRrvvRCz2RysiV9//XVw+apVq+B0OnHVVVehU6dOdX7+4Ycfxl//+ld07969UX8XAKKiosAYqzJvRWlpKSIjI4P/XrhwIZKTkw1zrygxhl9//RV33XUXRo4ciT59+mDChAn45z//idLS0hrvXbt2LW6++WYMHDgQQ4YMwa233opt27Y1+W83d79e2eeffw6z2YwhQ4bgyiuvBAB8+umnTY6NkHBTVlaG5557DhMnTkSfPn0wbNgwzJkzB3v37q3x3szMTDzxxBMYNWoU+vXrh9/85jd455134Pf7m/S3586di549e6KwsBB//vOfMXToUGRkZOD222/HqVOn4PP58I9//AMjR47EJZdcgptvvhkHDhyo9bu+/PLL4EBjv379kJycHDwOqE1t5wwAsG3bNtx1110YMmQIBgwYgJkzZ9aYM665NWjEiBGYM2cO+vTpUyMui8UCAA2eD0uP6LYODTp58iRmzpyJoqIijB8/Hh07dsT333+PWbNmQZZltGvXLvjezMxMTJ8+HW63G5dffjnat2+P3NxcrFq1Cn/9618hSRJuuukmAMBXX32Fhx56CCkpKZg2bRoEQcC6devw9NNPo7CwEH/4wx/UanJI/PLLLygrK8OIESOqLN+1axe++OILjB49GjfccANWr16N119/Hfn5+Vi9ejV69+6NG264ARs2bMD7778PURTx+OOP1/u3ysrKMGvWLNjtdkydOhV5eXn4+uuvcfvtt+Ozzz5Djx49WrKpxGDuuOMOnDt3Dtdeey1ycnKwZs0abN26Fe+//36VX+2re/HFF/HOO+8gPT0d06ZNA2MM27Ztw8qVK/Hzzz/j22+/hc1mg9frxR133IFDhw7h8ssvx8SJE3Hq1Cl89dVX2LRpE7755hvExsYGvzczMxM33HAD+vfvj5kzZ2Ljxo34+OOPUVRUhD179iAhIQHXXXcddu7ciS+//BJutxsLFy4Mfv6hhx7CqlWrMGDAAFx33XXw+XzYtGkTPv74Y+zduxfLli1r1PqpuCRzx44dwWUVV0JUP/Co7bNNvaTTYrGgZ8+eWL58Oa688kqUlZVh9erVwctIT548ic8++wx///vfIYpik/4GIVpz/Phx3HbbbRAEARMnTkR0dDR27tyJN998E7/++iuWLFkSfO/rr7+OefPmoU2bNpgwYQKsVitWrlyJW2+9FW+88UaN/XVDhWK/fujQIezfvx9jxoxBdHQ0Bg0ahLZt22LNmjUoLi6udQ4bQvTmgQcewIYNGzBmzBiMHz8+eDy7adMmLF++HBdddBEAZXu5+eabUVxcjNGjR6Nr167YunUrXnzxRRw8eLDJk0pyzjF79mzIsoxp06bh0KFD2LRpE+666y506dIFhw4dwsSJE3Hu3Dl8++23uPPOO7Fq1aoqVxwAyi0doihiwoQJAJT5od58802sWLGiym2WFWo7Z/jiiy/wpz/9CTabDePGjUNcXBxWrVqFP/zhD3juuedw7bXXBt/bnBpUMf9VbSoGQpryg4lucKI5d911F09NTeXffPNNcJnX6+U33HADT01N5TfddFNw+V/+8heemprKN2/eXOU7du3axVNTU/n1118fXDZt2jTev39/7nQ6g8ucTicfMWIEHzp0KJdlmXPO+enTp3lqaiofM2YMf/nll2v976effqry9yre3xQ//fQTT01N5c8++2yV5S+//DJPTU3ly5Yta9D3zJs3j1966aU14kpNTeWLFy8OLjt69Ghw+QsvvBBc7nQ6+SWXXMKHDRtWb2w33XQTT01N5b///e+5z+cLLn/ttdd4amoq/8c//lFrO9asWdOgdhBS4bHHHgtuW/n5+cHl3377bY1aUPHeffv2cc45z8nJ4enp6fzGG2/kgUCgyvfecccdPDU1lW/cuJFzzvl3333HU1NT+UsvvVTlfW+99RZPTU3l77//fnDZmDFjamwTxcXFvF+/fjw1NZXfd999wVoSCAT45ZdfzlNTU7nL5eKcc75z506emprKH3rooSp/y+/388mTJ/PU1FR+7NixGn+vPi6Xi6empvLBgwcHl02fPp2npqbyvXv31vvZhqprO/7pp594//79gzVl5MiR/MSJE5xzzh988EE+efLk4PrgnHNJkur8G6mpqfzqq68OSbyEhELF8UDlWvPCCy/w1NRU/uOPP1Z575133slTU1P5oUOHOOecHzt2jPfs2ZNPnDiRnz17Nvi+EydO8P79+/PJkyfX+7er17QKzd2vV2/HihUrgsuee+45npqaypcsWVJrTBX7/+Li4npjJ0SLqh9XHzx4kKempvJHH320yvu++eabGtvSrFmzeFpaGl+1alVwmSzL/Le//S1PTU3le/bsqfPv1nWcX7GNz5gxg3u93uDy66+/nqempvKxY8dWOWeZO3cuT01N5f/73/+qfM+hQ4d4amoqv+2224LL9u/fz1NTU/nUqVNrjan6OUNRUREfMGAAHzZsWJVjkPz8fD5y5Eg+ePDg4DF/qGpQdcuXL+epqal88uTJNY4VjHR8QFdOaExBQQHWr1+PgQMHYuLEicHlFosFDz/8MG644YYq77/66qvRr18/DB8+vMryvn37wmazIT8/P7iMcw6Px4PDhw8jIyMDABAZGYlPP/0U0dHRNSaAOnPmTJ0TsMyZMwdDhgxpVltDbePGjbXOuGuxWDBr1qzgvy+66CLExcWhsLAQv/3tb4PLIyMj0a1bN+zatQsejwc2m63ev/fb3/62yj3kl112GebPn0+TZZGQ+/3vf1/laRITJkzAgAEDsHXrVuTm5iIpKanGZywWC/7+97+je/fuNX61HzRoENavXx+sDxWPvjx48CC8Xm/w/stZs2bhyiuvrHK1VoWKCeIAZVLHbt26Yc+ePbjtttuCtUQURfTq1QsnT55EVlYWunXrhnbt2uGFF16ocbWCyWTCgAEDcOjQIeTn56Nr164NXj8Vl0FWvqS84lF/ERERDf6ephgyZAhWrFiBDRs2wGq1Bn9tOXToEL7++mssWLAAjDEsWrQIb7/9NtxuN8aOHYtnn30W0dHRLRobIS2hol78+uuvGDp0aHD5888/DwDBWvXtt98iEAjgnnvuQWJiYvB9Xbp0wWOPPQaPxwO/39+kuViau1+XZRkrV66E3W7HuHHjgu+fPHky3n33XXz66aeYPXt2o+MiJJxUbMvHjx+vckvi+PHjsXbtWnTo0AEAkJOTg+3bt2PEiBG44oorgp9njOHBBx9E//79g/vhprjhhhuqfD4jIwM7d+7E9ddfX+U2yb59+2L58uU1jrO/+OILAMqE1RXS09PRo0cP7Nu3D3v37kWvXr2qfKb6OcP69evhdDrxxz/+scrxR3x8PP70pz/hzJkzcLlcwSuqQn1u8cMPP+DJJ5+E2WzGs88+C0Ew7swLNDihMXv37oUsy+jbt2+N1/r16weTqWrKKi5JLioqwv79+3Hq1CkcP34cv/zyC7xeLyRJCr73+uuvx1NPPYWZM2ciLS0No0aNwmWXXYYBAwbUuhEMHjwY7733Xugb2QIKCgqwb98+/O53v6vxWvv27WsUTYfDAbfbXeWACUDwpMzn811wcCIlJaXKvysKqM/na2z4hNSrttmm+/bti59//hkHDhyodXAiLi4OV111FWRZxqFDh3D06FGcPn0aBw8exA8//ADg/IHJ8OHD0alTJ6xduxbDhw/H8OHDMWrUKIwePRrt27ev8d1ms7nGY/0cDgcA5Qk3lVXepgCgXbt2mDZtGgKBAPbu3Yvjx4/j1KlT2L9/f424GqqsrKxKDBXtP3HiBIqLixv1XU3RqVOnGpeNvvTSS+jVqxfGjx+P9evXY/78+bj//vuRmpqKZ555Bs8++yz+/ve/t3hshITatGnT8N///hf//Oc/8f7772PUqFEYNWoURowYUWUbrLg3vH///jW+Y+bMmc2Kobn79R9++AFnz57FlVdeWSXmPn36ICUlBQcPHsTu3btrPRYjRC/S0tKCAwEjRozA4MGDMWrUKIwZM6bKXE0HDx4EUPu23KtXrxon/o3VuXPnKv9u6PEEoBwvfPnll7BYLFUGTgDgqquuwrx587B06dIqMdZ2zlBfvaqYj6ayUJ5bfP/997j//vsRCATw97//Hf369avxHiOhwQmNKSwsBKDM2FqdKIpVfj0FgOLiYjz//PNYuXIl/H4/GGNITk7G0KFDsW/fvirvnTlzJtq0aYP//Oc/+Pnnn3Hw4EG8+eabSEpKwty5c2vd+MLF5s2bwRjDsGHDarxW/b60Cs2dOb96Uar4tZjrYHJRoi21DT5U1Ii6JnsClBmh//Wvf+HEiRMAlB1n7969kZ6ejh9++CHYV+12Oz755BO89tpr+Oabb7B69WqsXr0agiDg8ssvxzPPPFNlzon6Bu4a8uvJRx99hIULF+Ls2bMAlCsv+vXrF/x1obHbUMWvKJUPpjp27IidO3fi1KlT9Z5g+Hw+5Obm1jtpZmPt3r0ba9euxVtvvQUAwQOje+65BwCQlZWF559/Hk8++WSVX4UICQfp6en45JNPsGjRIqxfvx6ffPIJPvnkEzgcDsyePRsPPPAAGGPBq5daoo83d7/++eefA1Am0a08kW5lS5cupcEJomuMMbz99tt466238OWXX2LDhg3YsGEDnn32WQwfPhz/93//h44dOwYH+Vtqf1V5gLCyhhxP/PTTT8jJyQGAOuePWrlyJebOnRs8dqntnKGx9SpU5xZLly7FU089BcYYXnjhBVx11VWN+rwe0eCExlRsFLXNeA2c/4WwwiOPPIL169dj5syZmDJlClJTU4Pf8eWXX9b4/OWXX47LL78cJSUl2LJlC7777jt8+eWXeOihh9C9e3ekpqaGuEWtY9OmTejduzfi4uLUDoWQkPN4PDV2hBWDEnX1+V27duH+++9Hu3btMG/ePPTp0wedOnUCYwxvvPFG8CqFCvHx8XjiiSfw+OOP4+DBg9i4cSO++OILrFq1CoIg4N///ndI2vLNN9/gqaeeQlpaGp566in06tUreHXGU089hV27djX6O7dv3w4AwdvVAGUizC+//BKbN2/G5MmT6/zsunXr8MADD+Dqq6/GP/7xj0b/7dq89NJLGDhwYHAyzpMnT1aZ3ColJQWyLCMzM7PeCU0J0ar09HT8+9//hs/nw86dO7FhwwYsX74cixYtQlJSEmbNmhU84SgrK6tRpzweDywWiyqXLpeVlWHt2rWIjIyschl4Bc45li5diq+++gp/+tOf6jxxIkSrTp06he3btyMjI6PKLQoVVyVW/oEhIiIC999/P+6//34cP34cmzdvxpdffokffvgBf/zjH7F06dIq23J1siw36GrjllIx0Dhu3Lhaf9jdsmULTpw4gW+//RZTp04FUPs5Q31t9Pl8EAShxtXrzbVo0SLMnz8fVqsV8+fPr3KLmZHR4ITG9OrVC4wx7N69u8ZrR44cqbLRlJSUYP369ejduzeefvrpKu/NzMyE1+sN/gLp8/nw9ttvw26349Zbb0V0dHRwoKJjx454+eWXsXPnzrAcnOCcY/PmzZg+fbraoRDSIn799VeMGjWqyrKdO3fCZDLVeTnlV199BVmW8dRTT2H06NFVXjt27BiA81f5bNu2DatWrcLs2bPRuXNnpKenIz09HTfddBOGDx8ePPkPhZUrVwIA/vWvf9V4qk31uBoi8P/t3X9M1fX3wPEnUCQCKWt3OIG1iy4EtcHakIapwPXHkIjLaIkIy2kxgqEr+8FsLDKc1YzyEoUMDMMxvDG4ocC1W0ZdgQuiwM3MavEHZTOoCBmF3uDzh+PGFfD7Va9cwPP4i73va29e9w/evF7nfV7nWCxUVFQA2AQhIiMjmT9/PjU1NTz33HMTZkb8+++/lJWVAdxy14Drtba2YjQarfcdnePYI3Zy9EvMZNXV1ZjNZl599VVcXV1ZsWIFK1asICIigqSkJNra2ti8eTMPPfQQBoOBzs7OcenZb7zxBjqdjtraWrtmLf1/6PV6/v77bxISEnj99dcnHNPd3U1TUxO1tbWythAzzueff86+ffvYu3evTXBiNDtgNBPyu+++Q6fTsX79eoKDg1EqlSiVShITE4mOjqazs5MrV64QEBAAMOHe5OzZsyQlJbFjxw7S0tLu/JcbY3BwkM8++wx3d3feeeedCQMkOp2Ol156Ca1WS1xc3KR7htH9T2dn57iaesXFxeTn53Po0CFCQ0PtMvfDhw+Tl5eHh4cHhYWFt9w1bDa6e6ttTFPe3t6Eh4fT2NhIQ0OD9fpor9+x7r33Xpydnenv77dZ7P7zzz/s2bMHwNp72NXVlWPHjvHee+/R3d1tc5/RlOjRwjczzYULF+jp6ZmwGKYQs4FGo7HJpqqvr8dkMhEVFWVz3GKs0TOOvb29NtebmpqsAQKLxQJAT08PH3/8MSUlJTZje3t7GRoaGldf4nZMNq/q6mpaWlps5vV/sVgs5Obm8sMPPxAREWGTOeHp6cnOnTu5evUq27dvtwY+Rg0NDbFnzx5Onz5NUFDQDbMrbkZeXh7h4eE2dUKUSiVms9kaoGhvb+eee+4Zt2ETYiZob2+nrKyMuro6m+s///wz8N9aIiYmBmdnZz788EPrkVW49la3rq4OPz+/KQ9MwH9vWm+UPh0fHw/AJ598MhVTEsKuAgMDgf9aasO1fURjYyMuLi4sW7bMeq2kpISCggKblwIDAwP89ddfKBQKXF1d8fPzIyQkBKPRyNdff20dNzw8TFFRESMjI3YL8N+MEydOMDg4yNq1ayfN3Fi3bh0eHh6cPn2arq6uSfcMKpWKuXPncvjwYZuCm319fVRUVODu7j5hPYpbce7cOd58801cXV0pKSmRwMR1JHNiGtq9ezdPPfUUaWlpqFQqvL29OXXqFH/88QeANQ3Szc2NtWvXotfrefLJJwkPD2dwcJCTJ0/S29vLvHnzuHz5MsPDwzg7O/P888+Tnp6OWq1mw4YNzJs3j2+++Ybm5mZCQ0Nv+8HS09NDcnLyhJ/NnTuXwsLCW7rvwYMHqaqqmvCzpKQkuru78fDwsNtDQ4jppquri7i4ONasWcOlS5cwGAzWWjGTiY6O5tChQ+Tk5NDa2opCoeDChQsYjUa8vLz4/fff6evrA679Uw4JCaG8vJzvv/+e4OBgBgYG0Ov1AGRmZtrtu8TGxnL8+HEyMjLYuHEjHh4emM1mWlpaeOCBB2zmNZZGo7H+fPXqVXp7e2lqauLixYsEBQVZOwWMlZiYyG+//UZBQQExMTGsXLmSRYsW0dfXR3NzMxcvXsTf35+CggK7pGt+9dVXtLW1odVqba4nJCSQlpbG008/jVKppLKykvj4eKk3IWak7du3U1dXx65du6ivr+fBBx/kl19+4cSJEygUCrZs2QLAokWLyMjI4MCBAzzxxBNEREQwMjJCbW0tQ0ND7Nu3b8rn/uuvv9LS0sKCBQtu2HFs3bp15OTkcPbsWX788UebY1lCTHehoaEsX76c+vp6tm3bxpIlSzAajfz0008kJydbO0U9/PDDrF+/Hr1ej1qtJiwsDIvFgsFg4M8//yQ3N9d6z5ycHLZs2UJqaioqlQofHx+am5v59ttvSUlJcUh9ltFAY2xs7KRj3NzciI6O5ujRo2i1Wry8vCbcM8yfP5/s7GyysrJQq9VERUXh7u5OfX09PT095Ofn31ZHkrE0Gg0Wi4WlS5da63xc77HHHrtr9zUSnJiG/P39KS8vZ//+/TQ2NmKxWAgLCyMvL4/Y2Fibs+d79+5lwYIFGAwGysrKUCgULF++nGeffZZjx45RWlqKyWTi0UcfJSoqiuLiYoqKijh58iT9/f0sXLiQ9PR0nnnmmds++3nlyhXrm8/reXp63vJ9u7q66OrqmvCzqKgojEYjYWFhdj8LJsR0sX//fiorK6msrMTNzY24uDh27tw5YaHMUYGBgRw8eJADBw5gMBhwcXHBx8eHzMxMEhISWLVqFQ0NDaSmpuLq6kphYSFFRUUYDAaOHDnCfffdR3BwMKmpqTzyyCN2+y5r1qwhLy+PoqIiampqmDNnDn5+fmRnZxMSEoJaraahoWFcJsPYtsbOzs7cf//9LF68mK1bt7Jp06ZJFw07duxg9erVHDlyBLPZTGtrKyMjI/j7+7N582aSk5Ptdlb23XffJSIiYtwiLTIykuzsbD744APMZjPR0dG8/PLLdvmdQkw1X19fysvLKSgo4MyZM3zxxRd4eXkRGxtLRkaGzXMpPT0dpVJJaWkpOp0OJycnQkJCyMzMdMhm5tNPP2VkZISYmJhx7dPHmjNnDhs3bqSiogKtVktWVtYUzlKI2+Ps7ExRURFvv/02X375JSaTCR8fH3bt2sW2bdtsxr711lssW7aMmpoaKioqcHJyYunSpWRnZxMZGWkdFxAQgFarRaPRcOrUKQYGBvD19SUrK8shbXcvXbqEyWRCoVBMWAx/rPj4eI4ePYpOp2Px4sWT7hnUajXe3t4UFhai1+uxWCwEBQWRm5vL6tWr7Tb3trY24FoGxblz5yYc4+npedcGJ5xGpLXAtDI8PEx3dzcLFy4cV/G1u7sblUpFYmIir732mmMmeAeYTCZSUlJISUlh9+7djp6O3Wk0GvLz83n//fdRqVSOno6YQV555RWqqqqorq62pmkKx5iKv+OAgACWLFli7dkuxN1sOj7/kpOTaWlpobW11fr2WQhxY7N9nT8V7qb1gdScmGacnJyIi4vj8ccfH1c0rbi4GOCGqYhCCCGEEEIIIcRMI3nw04yTkxObNm2ipKSE2NhYVq1ahYuLC2fOnKG9vZ2VK1eyYcMGR0/zjujo6ECj0RAYGDgrMgwMBgPnz5+f9KiLEGLmOX78OOfPn0elUtnlbW5/fz+lpaV2mJkQs1N5eTkKhQK1Wu2wIrIfffQRly9ftimUJ4S4ObNtnX+n3a3rAwlOTEMvvvgi/v7+aLVaqqqqsFgs+Pr68sILL7B169YbnpOcyTo6Oujo6ECtVs+Kh5bBYJi0kKcQYmaqra0FwMfHx27BibH1NIQQtkZbBYeGhjosOHF9BX8hxM2bbev8O+1uXR9IzQkhhBBCCCGEEEI4lNScEEIIIYQQQgghhENJcEIIIYQQQgghhBAOJcEJIYQQQgghhBBCOJQEJ4QQQgghhBBCCOFQEpwQQgghhBBCCCGEQ0lwQgghhBBCCCGEEA71PzVKuC5LCQkiAAAAAElFTkSuQmCC\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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n9ckO+DBccB2XBwHcgIefN6mu5ptQyji4tm7iIBhGJgG1GgZwWbpuiCp0KnAjwvYtottO1imSUHOwQuIrutSU+vg3fs1wL7HHKvVv0VEREQO0OzIiZEjR7boANXV1RiG0eQ3laWlpYc18qKsrIxf//rXdOrUidNPPx2o/2Nv/vz5DB8+/Bvns5BDd7Bn/eWbGYZBj+IcyveECEVssnwWHfMDGIbRZG49HoMeXTxsKosRDEA0Vr+9e5eUr/KbtjLluqC+llyHm+/8HD+GYbCnun5CzI75Afy+gy9NahgGfbp7WbspQjBgErPrR1X1UP8WEREROUCr/kKKRqPMmDGDv/3tb+zevRuAjh07csUVV/C9730P04zPCqUjRoxg2LBh/OpXv2LPnj106tSJv//976xevZqZM2fG5RzSWDQaxbIO/ge3HJxpGnTukHXA9uZye/WEfB5/fg8btkUJBkwmn5NHpw66eWlOplwX1NeSKx75zsv2kZd9aI9bXX5OPk++sIfPN0cJ+A0uOzuP0s7ew4pDREREJBO16g7oN7/5DfPmzeOiiy6iV69ehMNhPvroI+69914qKiq4/fbb4xKcZVk89NBD3HPPPTzwwAPs3r2bo446iscee4yjjz46LueQxmprawkEAqkOIyM1l9v8HIv/nFyIbbuY5jevcNPeZcp1QX0tuVKV79ygyY2XdFD/FhEREfkGza7WcTBDhgzhzjvv5Nxzz220/dZbb+WNN95gyZIlcQuwpTRTuEhytKW+1pZiFWnL1NdERETkcDX7/MVFF13EokWLmnzNMAxqamoO2F5bWxu3RzokNSoqKlIdQsaKV27XbHaY9YbNrDdsPtuiifXaKvW15Er3fK/a5PDMGzZ/f9Pm823q1yIiItL+NPtYR48ePZgyZQrHHnssN998M8OHD2947cILL+TOO+/k7bffplevXsRiMT7++GM++OADbrzxxqQELokRCoVSHULGikdu1251mP+hg2UBLsz/sH6lj95dVBRsa9TXkiud8716s8OCjxwsE3Dh5fddzhtl0KOzHgERERGR9qPZO5p77rmHOXPmkJeXx+WXX84111zDsmXLAPjZz37GT37yEzZv3sxTTz3F008/TU1NDT//+c+56aabkha8SHuzYmP9c+s+D/i8X20Tkbbr0w0uptG4X6/cpNETIiIi0r4cdELMAQMG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aUvXBREREUlPhuu6bqqDiIf333+fyy67LNVhiLQbTz/9NMOHD091GAel64JIcrWF64KIiIikp4wpToRCIZYvX06nTp2wLCvV4YhkLNu22bFjB0cffTSBQCDV4RyUrgsiydGWrgsiIiKSnjKmOCEiIiIiIiIibZMmxBQRERERERGRlFJxoh35+9//zhlnnMHgwYO55JJLWLp06UH3f/HFFzn33HMZNGgQZ511Fs8880ySIm3bFi5cyNChQ79xvzVr1nDllVcydOhQxowZwyOPPIIGMmWOQ+1v+3vwwQfp379/AqPLPIea74qKCm677TZGjhzJ8OHDuf7669m4cWOSohURERGRr1Nxop2YM2cOv/rVrzj//POZNm0aubm5XHPNNWzatKnJ/V944QV+9KMf0bdvXx566CEuu+wyfv/73/PHP/4xyZG3LR9++CG33nrrN+63c+dOrr76agzD4L777uPb3/429913H48++mgSopREO9T+tr81a9YwY8aMJESZOQ4139FolKuvvpply5bxm9/8ht/97nds2rSJKVOmEIlEkhy9iIiIiIDmnGgXXNdl3LhxnHTSSfz6178G6v84P+usszj11FP5+c9/fsB7zjvvPLKzs5k5cyaGYQDwzDPP8Nvf/pbXX3+dDh06JPUzpLtIJMITTzzB/fffTzAYJBqNHvSb2wceeICnn36a119/naysLADuu+8+Zs6cyaJFi/B6vckKXeKsNf1tH9u2ueSSS/jyyy8pKytj9erVyQq7zWpNvp999lnuvPNOXnrpJUpKSgBYuXIlU6ZMYcaMGRx99NFJ/QwiIiIiopET7cKGDRvYsmULY8eObdjm9XoZM2YMb731VpPvWbduHSeeeGJDYQJg2LBhhEIh3nvvvYTH3Na8+eabPPLII9x2221cfvnl37j/O++8w/HHH99QmAA47bTT2L17N5988kkiQ5UEa01/2+fxxx+npqamRW1I6rUm3wsWLOCkk05qKEwADBw4kEWLFqkwISIiIpIiKk60A+vXrwegZ8+ejbZ3796djRs3Ytv2Ae/p2rUrW7dubbRt8+bNAGzZsiUxgbZhgwYNYuHChVxxxRWNCjrNWb9+fZO/j32vSdvVmv4G9TfZ06ZN4ze/+Q0+ny/RYWaM1uR79erVHHHEETz44IOceOKJHH300Vx77bUHXPNEREREJHlUnGgHqqurAcjOzm60PTs7G8dxqKurO+A9559/Ps8//zzPPvsslZWVLFu2jHvuuQfDMKitrU1K3G1JcXExeXl5Ld6/urq6yd/Hvtek7WpNf3Ndl5///OdMmDCB4cOHJyXOTNGafFdUVDB79mzeeust7rrrLn7/+9+zdu1arrvuOmKxWFLiFhEREZHGPKkOQBJv37QizX2j39T26667jh07dvCLX/yCn//85xQUFPCzn/2M2267rdGjCBJ/pqmaYVvWmv72zDPPsGHDBh5++OGExpaJWpPvWCxGNBrlT3/6U0NRsXv37lx00UXMnz+fs88+O3EBi4iIiEiTdBfUDuTm5gJQU1PTaHtNTQ2WZR3wjSOAz+fjjjvu4P3332fu3Lm89dZbDBs2DNd1yc/PT0rcmSwnJ6fJ38e+16TtOtT+tm3bNv7whz/ws5/9jEAgQCwWa7jhjsViOI6TnMDbqNZc34LBIIMHD2402mnQoEHk5eWxZs2axAYsIiIiIk3SyIl2YN+z2Js2bWr0XPamTZvo1atXk+959913MU2TUaNG0bdvX4CGlQMGDhyY2IDbgV69ejXM4bHPvmUPjzjiiFSEJHFyqP3t3Xffpaamhh/84AcHvPatb32LqVOnctNNNyUs3rauNde3Hj16EI1GD9gei8VaNGeMiIiIiMSfRk60A7169aJr164sWLCgYVs0GuX111/n+OOPb/I98+bN484772z42XVd/va3v1FSUkL//v0THnOmO+6443jnnXcazd+xYMECCgoKGDBgQAojk8N1qP3t1FNP5R//+Eejf1dffTUA//jHP/j2t7+dtNjbotZc30aPHs2HH35IWVlZw7YlS5ZQW1vL0KFDEx6ziIiIiBxIIyfaAcMwmDJlCr/5zW/Iz8/n2GOP5amnnmLXrl1cddVVAGzcuJGKigqGDBkCwMUXX8xzzz3HXXfdxdixY3nhhRdYtGgR99xzD5Zlpe7DtFFfz+/kyZN56qmnuPbaa7nmmmtYtWoVjzzyCD/60Y+0UkMbd6j9rUOHDnTo0KHRMT744AOg/lEDObjWXN+uuuoqnnvuOaZMmcIPfvAD6urq+P3vf8/QoUMZPXp06j6MiIiISDum4kQ7cdlllxEOh3nyySd5/PHHGThwIH/5y18alq986KGHmDNnTsOjG4MHD+b+++/n/vvv5+9//zu9evXi3nvvZfz48an8GG3W1/PbuXNnHnvsMe666y5+8IMfUFRUxA9/+EOuueaaFEcq8XCo/U0Oz6Hmu7CwkJkzZ/K73/2OW2+9Fa/Xy9ixY/npT3+qCWlFREREUsRw9828JiIiIiIiIiKSAvqKSERERERERERSSsUJEREREREREUkpFSdEREREREREJKVUnBARERERERGRlFJxQkRERERERERSSsUJEREREREREUkpFSeEa665hv79+7NgwYKD7mfbNieccAJDhw6lpqYmSdHBd7/7Xfr379/wb+nSpfTv35+JEyd+43v/+te/0r9/f6ZNm3bAcVauXJn44EUy2OzZsxv1qeb+7bu2bN68uUX733XXXQc97+23395o/8WLF7c4xm86toiIiIikhifVAUjqTZw4kUWLFvHSSy9x2mmnNbvf22+/zc6dO5k0aRLZ2dlJjLDelClT8Pv99OnTh169erFy5UrWrVtH7969m33PCy+8gGEYTJw4kdLSUkaOHMmCBQtYtWpVEiMXyWwjR45k5MiRzb7+9T5aWlrKpEmTmt3/mGOOadF5L7nkEjp16kRpaWmz+wwcOJCpU6eyZcsW5syZ06LjioiIiEjyqTghnH766eTk5PDqq69SV1dHVlZWk/u98MILAAe9qUika6+9lry8PKC+oHLfffcxd+5cpk6d2uT+Gzdu5OOPP2bkyJF0796d7t27A7BlyxYVJ0TiaOTIkdx0000t3r+0tPSQ9m/OpZdeysCBAw+6z8CBAxk4cCCLFy9WcUJEREQkjemxDiEQCHDmmWdSW1vL66+/3uQ+dXV1LFiwoGH0QapNmDABwzB46aWXmt3n+eefB1JXTBEREREREZGWUXFCgK9u4OfNm9fk66+++iq1tbVMnDgRwzAAiEQizJgxg7PPPpujjz6aUaNG8f3vf59PPvmkyWM899xzXHzxxQwdOpQTTzyR73//+60ewVBSUsLIkSNZu3Ytq1evbnKfF198kWAwyJlnntmqc4iIiIiIiEhyqDghAAwfPpxu3brxxhtvUF1dfcDr++Zu2FfECIfDXHXVVdx7771YlsWll17KCSecwKJFi7j00ksPmFzzl7/8JT/96U/ZuXMnEyZMYMyYMbzzzjtceumlrS5QHKygsnz5ctatW8cZZ5yRkvkxREREREREpOU054QAYBgGEyZMYPr06bz66qucf/75Da/t2rWLRYsWMXz48IZ5G/785z/zwQcfcMEFF/Cb3/wGj6e+KX366adMnjyZn/zkJxx33HHk5OTw7rvvMmvWLIYPH84f//hHcnJyALjwwguZPHky9913HzNmzDjkmM844wzuuOMOXnrpJW6++eZGr6V6fgyR9mTJkiVMmzatydcmTZpEt27dGm3bsmVLs/uPHDmSUaNGxT1GEREREUlvKk5Ig4kTJzJ9+nTmzZvXqDjx0ksvEY1GG93oz5kzh6ysLH72s581FCYAvvWtbzF58mQeffRR5s+fzwUXXMDcuXMB+NGPftRQmAA49thjueWWW8jPz29VvNnZ2Zx++un885//ZPny5Rx99NEAOI7D3LlzKS0t1U2OSBIsWbKEJUuWNPnayJEjmyxOPPjgg03uP3XqVPVbERERkXZIxQlp0KNHD4YNG8aiRYuorKxsWBnjxRdfJCsrq2HuhurqajZt2sSxxx7bqNiwz7Bhw3j00UcbHtdYtWoVlmUxaNCgA/a99tprDyvmSZMm8c9//pN58+Y1FCcWL17Mjh07uOGGGxrmxxCRxJk6deohrb4xcuRI/vrXvyYwIhERERFpa1SckEYmTpzIBx98wPz587nooovYsmULH374Ieeff35DIaKmpgaA3NzcJo/RuXNnAEKhEACVlZX4/X68Xm/c4x01ahRdu3bl5Zdf5tZbb8UwDJ5//vlG82OISOabPXs2W7ZsabRt4MCBnHbaaSmKSEREREQOhSbElEbGjx+P3+9vWKJz7ty5uK7b6EZ/3wSTZWVlTR6jsrISgIKCAgCCwSDhcJhYLHbAvnV1dYcVr2manH/++WzZsoWPPvqISCTCK6+8wrBhw+jRo8dhHVtE2o45c+bw4IMPNvr39Yl5RURERCR9aeSENJKbm8tpp53G/Pnzqays5OWXX6a0tJTjjjuuYZ+cnBy6devG+vXrqaiooLCwsNEx3nvvPQD69u0LQL9+/Vi5ciUrVqxg8ODBjfa94YYb+OSTT3jrrbfIyspqVcwTJ07kj3/8I/Pnz2fXrl1UVVVp1IRIO6PHRERERETaNo2ckANMmDCBaDTKM888w6effsr5559/wNwNkyZNIhQKcffddzcaEfHpp5/y1FNPkZeXx9ixYwEaJte89957Gx71AFi6dClLlixh6NChrS5MABxxxBEMHjyYV199lVdeeYWsrCzOOuusVh9PREREREREkksjJ+QAo0ePplOnTjz00EMAXHDBBQfsM2XKFBYtWsQLL7zA6tWrOe6449i5cycLFizAdV3uvffehjkqRo8ezYUXXshzzz3HhAkTOOmkk6ipqWHu3LlkZ2fzy1/+8rBjnjhxInfccQdlZWWcfvrpTU7UKSIiIiIiIulJIyfkAJZlcd5551FXV9fs3A1+v5/HH3+cH/zgB0SjUWbOnMm///1vTj31VGbNmnXAJHR33XUXv/rVrwgEAsyaNYtXXnmFk08+mZkzZ9K9e/fDjvmcc87B6/VSV1fXZDFFRERERERE0pdGTkiTfvzjH/PjH//4oPsEAgFuvPFGbrzxxm88nmEYTJ48mcmTJ8crxEYKCgpYvnx5Qo4tIk274IILDqkY2K1bN1avXp3AiERERESkrdLICRERERERERFJKY2ckDbjkUcewe/3c+WVV5KXl3fI7589ezZbtmxh5cqVCYhORJJt5syZdOrUiUmTJtGtW7cm91m5ciULFixgy5YtSY5ORERERA6FihPSZvzpT38C6lcKaU1xYs6cOSxZsiTeYYlIisyaNQuAkSNHHrQ48eCDDyYzLBERERFpBcN1XTfVQYiIiIiIiIhI+6U5J0REREREREQkpVScEBEREREREZGUUnFCRERERERERFJKxQkRERERERERSSkVJ0REREREREQkpVScEBEREREREZGU+v+ydxKta5R1lwAAAABJRU5ErkJggg==\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "df = df_device.iloc[:,2:]\n", - "\n", - "n_col = 4 # num of columns per row in the figure\n", - "y_indx = -1 #PCE (efficiency%)\n", - "color_indx = 1 #\n", - "column_names = df.columns.values\n", - "fs = 20\n", - "\n", - "cmap = plt.get_cmap('coolwarm', 10)\n", - "print(\"The color is for variable\", column_names[color_indx], \"with red as the highest and blue as the lowest\")\n", - "\n", - "for n in np.arange(0, 12, n_col):\n", - " fig,axes = plt.subplots(1, n_col, figsize=(18, 3.5), sharey = False)\n", - " for i in np.arange(n_col):\n", - " #print(n)\n", - " if n< len(column_names)-1:\n", - " im = axes[i].scatter(df.iloc[:,n],df.iloc[:,y_indx], \n", - " c=df.iloc[:,color_indx], s = 20, cmap=cmap, alpha =0.8, edgecolors = 'face')\n", - " axes[i].set_xlabel(column_names[n], fontsize = fs)\n", - " else:\n", - " axes[i].axis(\"off\")\n", - " #axes[i].set_title(sf_cols[n])\n", - " n = n+1 \n", - " axes[0].set_ylabel(column_names[y_indx], fontsize = fs)\n", - " for i in range(len(axes)):\n", - " axes[i].tick_params(direction='in', length=5, width=1, labelsize = fs*.8, grid_alpha = 0.5)\n", - " axes[i].grid(True, linestyle='-.')\n", - " plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": { - "scrolled": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The color is for variable speed [mm/s] with red as the highest and blue as the lowest\n" - ] - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAABDQAAAD2CAYAAAApgdfWAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjMuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/d3fzzAAAACXBIWXMAAAsTAAALEwEAmpwYAABaMklEQVR4nO3de1hU5doG8HsxDGdEEUVFEqUEz6J4wCMKGpmZZipqmm3STE3baVnb0jLL6tuJhkqShzxlYmq6tdQNKWooap5LsUwFPIwgqJxmGGbW98f6mI+RYRgQZpjh/l1X19W86zDPep6ZJfPMmncJoiiKICIiIiIiIiKyInaWDoCIiIiIiIiIqLLY0CAiIiIiIiIiq8OGBhERERERERFZHTY0iIiIiIiIiMjqsKFBRERERERERFbH3tIBWJpSqcTFixfRqFEjyGQyS4dDZJM0Gg0yMzPRvn17ODk5WTqcCvG8QFTzrO28QERERLVPnW9oXLx4EePHj7d0GER1wubNmxEcHGzpMCrE8wKR+VjLeYGIiIhqnzrf0GjUqBEA6Q+qJk2aWDia6vHgwQN4eHhYOgwqR12sz507dzB+/Hjd+622q8x5oS7W0xDmQcI8SEzJg7WdF4iIiKj2qfMNjZLLyZs0aYLmzZtbOJrq4eXlxct3a7G6XB9r+flGZc4LdbmepTEPEuZBUpk8WMt5gYiIiGqfWjUpaGJiIoKCgipc78qVK3j55ZcRFBSE0NBQxMXFQRRFvXVOnTqFUaNGoVOnThg8eDB++OGHmgq71pHL5ZYOgYxgfSqntp8XWE8J8yBhHiTMAxEREZlDrWlonD59Gm+//XaF6927dw+vvPIKBEHA0qVLMXr0aCxduhRr167VrXP16lW8+uqraN68OWJiYhAaGop58+Zh3759NXkItYZCobB0CGQE62M6azgvsJ4S5kHCPEiYByIiIjIHi//kpKioCOvXr8eyZcvg4uICtVptdP3NmzejuLgYsbGxcHZ2Rv/+/VFUVIS4uDhMnDgRcrkccXFx8PHxwZIlSyAIAvr164ecnBysWLECERERVYpTqxUhCIAgCFXa3lyUhUWWDqFaqVRqyGQC7O0t/lKtFqKoBR65asCaabVaaLSA3L56e6PWcl4oVmsgam2nnlWlLFBCrTJeo7pAo9FCo+HrQRRF8G1BRERE5mDxT4mHDx9GXFwc3nnnHdy/fx/r1q0zun5ycjJCQkLg7OysGwsPD0dsbCwuXLiALl26IDk5GcOGDdNrPoSHh2P37t1QKBTw9vY2Ob5ClYh9v2mRkSVCLgMGdLJDQPNac2GLzqVT13En7iu4q+4iX14fmRPfQKd+AZYOq8py7uXh70NH0USeDbVWBoV7G/QI62jpsKpMFLUoSklA0R8n4FKsgbJ9Dzj2HAzBrva9lkyV+Ms1rN6SAZVKRBt/J7w9swPqN3CueEMT1PbzQn5uIa4dOgJPZKFYK0Pq7c4I6Ga977eqyrv/EItm7salBx4QIKJbsyN456sxde7nBhqNFomnC3BP5QTAG45phRgS7AAnx7o3N8SVm1ocPKdFobIhfBtrMKSbHZwda/cXAURERGS9LP5pqkOHDkhMTMTEiRNNuvrh+vXraNGihd6Yr6+vbllBQQHu3r1rdJ3KOHBGi5tZIpwdAEEAEs5ocfd+7frqSZmvQlbs/8BFdQ+FMjc4Fucj79svkZ2Za+nQquyvpGNoIs9GoUYOLQQ0y/sdl86lWTqsKlP/cRJFv6cAckeIcjnUl05C/XuKpcOqstTULHy9MR12ANxdBFz+W4mlsRerbf+1/bxw7XAyGohZUGnlEAG43T6Nm3/XvUvsl727G3888ICjUAQHoRgptzywftFuS4dldicvFeKeygmiKELUilCJciScUVo6LLPLfCDiv6e1AAAHey1uZYs48H+PiYiIiGqCxRsa3t7eqFevnsnr5+XlwdXVVW+s5HFeXh7y8vL0xgytUxk3s0Q4/l8zw14m/VrgTk7tamik/3UXDpoCqO1dIAgC1DInyLTFuH4hw9KhVVkju2woNfYABGhFO9gJIu7fybJ0WFVWnHEVsLOTrsgQ7AA7mTRmpVL/zIFWC8jlAgQ7Aa5OAi79VX0f4Gr7ecFDk4UiUQ4IArSwgyCIeHAns1L7sAXX7tnDHhrYCQLsBECAiNS/Cy0dltkpHgoQRUCA9G+FKIrIU9etq1QAQJEjQhSlfysFAXB2ADKyate/l0RERGRbLP6Tk+pkZ2enu6tBed/q2pVziX9YWJje45dffhlRUVFwcWyI/EIt5PbSthqtBoV593Hrlv5cFe7u7nB3d4dCoYCXlxfUajWys7MNPpe3tzfUajUKCgrg6emJ7OxsKJVlPww6OTnplru4uEAulxucaE0NFSBqAVEDUZBB0GphJ2qglRfj1q1beuvKZDJ4e3sjNzdXF7dCoYBGoymzX0seU4HWAa4yFdRaOwAiRFGAYG9f5nis5ZjkogxytRoyuSMAQKMugty1HnJzc3VxW9Mx2QkqAIBWo4VgZwdVMeDuKr3nDB3T3bt3DcZjDjVxXlCKDnCECsWiPfB/8wUo1eoyr09LvocAwNPTE3K5HFlZWbrXU3W+3tzkGmSq7ABI38KLEODuIuryYI3HVJU6yWAHQXAAdJ/dBdiJxQBgtcdUlToV5DlAo3GDSiVCgAh1MSC3UwOwr3XnBSIiIrINVtfQcHNzQ35+vt5YyWM3Nze4ubnpjT26jru7u8H9JiYmonnz5mXGwzqL2JMCqDUANICftz26tWsIOzvDH4xKfocvk8nQrFmzco9DJpPByckJgPTHrDGllxvaZ7NmwM9dX4DnbztKvh7E3daDMLRvh3L3WToPFc0dYIljyvELAtJT4GCnhgDgbnF9dO7dBk5O5X/rWZuPSVt/CAp234FYmAeZKMLO3QMOXfrDyc293NckUHuPqVHjJkg5fQqX/lZCgBYyOwHT/+Gvi/nRY9Jqa/ayc3OfF1SB3SGkHoUD1IAAPBAaokOvjpDLDc+ZYIn3kKHnN1Sb0ir7env5H+3w2fK/odRK/5TUlxXiH2/114vH2o6pJObK1Klfp2Ls/U0L/F9jTBCBHk8JuuUlrOmYjCnvmJo0EXEnT4v0TBFaUYQMQER3J13M5j4vEBERke2zuoaGn58fMjL0f0qRnp4OAGjVqhVcXV3RqFEj3dij67Rs2bJSz+fbSMC4ATLcyRHhKAeeaCSU28ywpGdmPo9zh9vg3rWbqOfTCEPD21s6pMfSrosfMhq64vb1u7B3kKNdl5ZwdLTeS7jtXNzhOuI1FN/6G1qtFg7N/SE4Vs8EmpYgt7fDgveCcfr0beTlqREY4IlmPqb/RKS6mfu88ESAD+7Vj8C9jLuwk9sjoM0TsC+nmWHLgsI744smHji8+xwEOwHPTBiAhj6NLB2W2dVztcfz3Yvxx7UiqDVatPZ1REMPJ0uHZXZ2dgKGdrdDWqaIQpUWTT1lqO9W+/69JCIiItthdQ2Nnj17YuvWrSgoKICLiwsAICEhAfXr10dgYCAAICQkBAcPHsSsWbMgk8l067Ru3RoNGzas9HN6uArwcK39f5R16tca6Ne63MuarU3zFo3QvIXtfDgSHJ0gb9lWuqOGFTczStjb26F7dx9LhwHAMueFht4eaOjtAYVCUSebGSVatG+JCe1bQqFQoKG37bxfK8vFyR7BbeylPHi4WToci7GzE+DnLUChyER9N9PvHERERERUFRafFLQiaWlpOHv2rO7xuHHjoFarMWXKFBw8eBCxsbGIi4vDlClT4ODgAACIiorCtWvXMGvWLCQlJWHx4sXYvXs3pk+fbqGjMK/K3H6SzI/1eXy16bzAekqYBwnzIGEeiIiIyBxqfUNj5cqVGDNmjO5x48aNsW7dOhQXF2PmzJmIj4/Hm2++iaioKN06gYGBiI2NRXp6OmbMmIFDhw5h8eLFiIiIsMQhmJ2hyeSo9mB9Hl9tOi+wnhLmQcI8SJgHIiIiMgdBLJn+v47KyMhAWFhYuZP/WaNbt25VOPkcWU5drI+1vc8qE29drKchzIOEeZCYkgdrOy8QERFR7VPrr9AgIiIiIiIiInoUGxpEREREREREZHXY0CAiIiIiIiIiq8OGBhERERERERFZHTY0bJBMJrN0CGQE62NbWE8J8yBhHiTMAxEREZkDGxo2yNvb29IhkBGsj21hPSXMg4R5kDAPREREZA5saNig3NxcS4dARrA+toX1lDAPEuZBwjwQERGROdiXt+Dy5ctV3mlgYGCVtyUiIiIiIiIiqki5DY3hw4dDEIQq7fTSpUtVDogen7u7u6VDICNYH9vCekqYBwnzIGEeiIiIyBxqpKFBlqVQKPj75VqM9bEtrKeEeZAwDxLmgYiIiMyh3IbGZ599Zs44qBppNBpLh0BGsD62hfWUMA8S5kHCPBAREZE5cFJQIiIiIiIiIrI65V6hMWLECERGRmLMmDG6x6YQBAE7duyonuiIiIiIiIiIiAwot6Fx6dIlZGZm6j02BefdICIiIiIiIqKaZvJtWx/nNq5ERERERERERNWJc2jYIN4ur3ZjfWwL6ylhHiTMg4R5ICIiInMo9wqN8ty4cQP37t2DVquFKIoAAFEUUVxcjPv37yMpKQmff/55tQdKpuMfkrUb62NbWE8J8yBhHiTMAxEREZmDyQ2N7OxsvPbaa7h48WKF67KhYVkKhQLe3t6WDoPKwfrYFtZTwjxImAcJ80BERETmYPJPTpYtW4YLFy7gqaeeQmRkJFxdXdGxY0eMGTMGwcHBEEURDRs2xM6dO2syXjKBl5eXpUMgI1gf28J6SpgHCfMgYR6IiIjIHEy+QuPIkSNo2bIlfvzxR9jZ2eHevXtQKpX48MMPAQA//vgj3n33XZw9exaBgYE1FS+ZQK1WQyaTWToMKgfrY1tYTwnzIGEeJMwDERERmYPJV2jcvXsXvXv3hp2dtEmbNm1w7tw53fLhw4ejW7du+PHHH6s9SKqc7OxsS4dARrA+toX1lDAPEuZBwjwQERGROZjc0HBycoKjo6Pu8RNPPIGHDx9CoVDoxjp27Ij09PTqjZCIiIiIiIiI6BEmNzT8/f31rsho2bIlRFHEH3/8oRvLy8tDYWFh9UZIRERERERERPQIkxsaQ4YMwalTpzB37lxkZGQgICAAjRs3xldffYWrV68iJSUFe/fuhZ+fXw2GS0RERERERERUiYbG+PHjMXjwYOzatQunTp2CTCbDtGnTcOnSJQwdOhSTJk1CXl4eJk+eXJPxEhERERERERGZfpcTe3t7fPXVVzh37hyaNGkCAIiMjISHhwf27t0LR0dHDBs2DP3796+xYImIiIiIiIiIgEo0NEp06tRJ7/EzzzyDZ555ptoCosfn7e1t6RDICNbHtrCeEuZBwjxImAciIiIyh3J/crJ48WIcPXrUnLFQNVGr1ZYOgYxgfWwL6ylhHiTMg4R5ICIiInMot6Gxfv16nD17tsz4iRMnsHz58pqMiR5TQUGBpUMgI1gf28J6SpgHCfMgYR6IiIjIHEyeFLTEiRMnsGLFipqIhaqJp6enpUMgI1gf28J6SpgHCfMgYR6IiIjIHCo9h0ZNiI+Px+rVq3Hnzh20adMG7777LoKCggyuO3DgQNy8edPgsjfeeAMzZswAADz33HO4cuWK3vL69esjJSWleoOvhbKzs/nHZC3G+pjGWs4LrKeEeZAwDxLmgYiIiMzB4g2NnTt3YsGCBZg+fTo6dOiAjRs3IioqCrt27YKvr2+Z9ZcvX46ioiK9sXXr1uHw4cMYMmQIAKCoqAh///03Zs+eje7du+vWs7e3+OGahVKptHQIZATrUzFrOi+wnhLmQcI8SJgHIiIiMgeLfsIXRRExMTEYPXq07hvUXr16ISIiAuvXr8f7779fZpu2bdvqPb5w4QISEhKwcOFCtGrVCgBw9epVFBcXIywsDP7+/jV/IERUbXheICIiIiIiU1R6Do3qdOPGDdy8eRMDBw7UjcnlcoSGhuLIkSMm7eOTTz5Bhw4d8MILL+jGUlNT4eTkBD8/v+oOmYhqGM8LRERERERkCos2NK5fvw4AaNGihd64r68v0tLSoNFojG6fkJCAM2fOYO7cuRAEQTeempoKDw8P/POf/0SXLl3QtWtXzJs3D3l5edV+DERUvXheICIiIiIiUxj9ycnOnTtx4sQJvbGSifcmTpxocBtBELB+/XqTnrzkg4Srq6veuKurK7RaLQoLC+Hm5lbu9uvXr0fXrl3LTBSYmpqKrKwsBAQEYOLEibh06RK++uorZGRkmBwbEVkGzwtERERERGQKow2NmzdvlnvngEcbHSVKfyNaEVEUjW5jbF9///03Tpw4gWXLlpVZNmfOHBQVFaFz584AgODgYDRs2BD//Oc/cerUKQQHB5fZJiwsTO/xyy+/jKioKHh7eyM3NxcA4O7uDoVCYfAbYnd3d91yLy8vqNVqZGdnG4zd29sbarUaBQUF8PT0RHZ2tsEJ1JycnHTLXVxcIJfLoVAoDO7T09MTcrkcWVlZcHJyQm5uri7u0mQymVUeU0nMtnBMDg4OUCgUNnVMFdXp7t27Brc1xNrOC05OTlZdG6B6Xm+FhYW4deuWTR2TIRUdU0kebOmYSlTmmOzs7KDRaKrtvEBERERkSLkNjcTExBp/cnd3dwBAfn4+vLy8dOP5+fmQyWRlvqF9ND4XFxcMGDCgzLJHJwgEgL59+wIALl++bPCDS2JiIpo3b240TkD6w8+YkuUymQzNmjUrdz2ZTAYnJycAqPDWdqWXG9vno/GVjvtR1nhMJX+gl8dajqlkfyUx28IxlX5+Q8ek1WqNbluaNZ4XKlKba1OarbzeSuMx1d5jqsx5gYiIiMiQchsaPj4+Nf7kJb+RT09P1/u9fHp6eoUT9x05cgT9+vWDo6Oj3nhxcTF2796NwMBAvQ8wJd82NWjQoJqir72ys7Mr/IOULIf1Mc7azgusp4R5kDAPEuaBiIiIzMGik4L6+fmhadOmSEhI0I2p1WocOnQIISEh5W4niiIuXryou3S8NHt7e8TExCAmJkZv/MCBA5DL5Qa3sTUuLi6WDoGMYH2Ms7bzAuspYR4kzIOEeSAiIiJzMDqHRk0TBAGTJ0/Gxx9/DA8PD3Tp0gWbNm1CTk4OJk2aBABIS0tDdna23geOmzdvIj8/Hy1btjS436lTp2L+/PlYtGgRBg4ciAsXLmDFihWYMGGCWa48sTS5XG7pEMgI1sc4azsvsJ4S5kHCPEiYByIiIjIHizY0AGD8+PFQqVTYsGEDvv32W7Rp0wZr1qyBr68vAGDlypXYuXMnUlNTdduUTGxW3m+Mx4wZA7lcjnXr1iE+Ph5eXl6YNm0apkyZUvMHVAsoFIoKf9dMlsP6VMyazgusp4R5kDAPEuaBiIiIzEEQS24pUEdlZGQgLCzM6OR/1ubWrVv8Q7IWq4v1sbb3WWXirYv1NIR5kDAPElPyYG3nBSIiIqp9LDqHBhERERERERFRVZjc0Bg7diyWLVtWk7EQEREREREREZnE5IbG77//joKCgpqMhYiIiIiIiIjIJCY3NJo3b4709PSajIWIiIiIiIiIyCQm3+Xk888/x+uvv45Zs2Zh8ODBaN68ORwdHQ2uGxgYWG0BUuV5enpaOgQygvWxLaynhHmQMA8S5oGIiIjMweSGxqhRoyAIAvbv348DBw4YXffSpUuPHRhVnVwut3QIZATrY1tYTwnzIGEeJMwDERERmYPJDY3hw4dDEISajIWqSVZWFry9vS0dBpWD9bEtrKeEeZAwDxLmgYiIiMzB5IbGZ599VpNxUDXiH5G1G+tjW1hPCfMgYR4kzAMRERGZg8kNjdJu3bqFy5cvQ6lUon79+vD39+cfL7VIbm4u3N3dLR0GlYP1sS2sp4R5kDAPEuaBiIiIzKFSDY2MjAx88MEHOH78uN64IAjo2bMnPvroI/j6+lZrgFR5/EOydmN9bAvrKWEeJMyDhHkgIiIiczC5oZGZmYmxY8ciMzMTHTp0QJcuXdC4cWM8fPgQJ06cQHJyMiZMmIAdO3ZwdnMiIiIiIiIiqlEmNzSWL1+OzMxMfPjhh4iMjCyzfNu2bfjggw+watUqvPfee9UaJBERERERERFRaXamrpiUlITevXsbbGYA0m1de/fujcTExGoLjoiIiIiIiIjIEJMbGllZWWjdurXRdVq3bo27d+8+dlBERERERERERMaY3NDw8vLClStXjK6TmpqKBg0aPHZQ9HhkMpmlQyAjWB/bwnpKmAcJ8yBhHoiIiMgcTG5o9OvXD8nJydi+fbvB5Vu2bMGxY8fQv3//aguOqoa30K3dWB/bwnpKmAcJ8yBhHoiIiMgcTJ4U9I033kBiYiLef/99/PjjjwgODoa7uzsUCgVOnz6NixcvomHDhpg+fXpNxksm4O3yajfWx7awnhLmQcI8SJgHIiIiMgeTGxqNGjXC999/j/fffx8pKSk4efKk3vIePXpg4cKF/FaGiIiIiIiIiGqcyQ0NAPD19cX69etx584dXLp0CXl5eXB1dUWbNm3QtGnTmoqRKonfitVurI9tYT0lzIOEeZAwD0RERGQOlWpolGjSpAmaNGlS3bFQNVEoFLxSphZjfWwL6ylhHiTMg4R5ICIiInMweVJQsh4ajcbSIZARrI9tYT0lzIOEeZAwD0RERGQObGgQERERERERkdVhQ4OIiIiIiIiIrA4bGkRERERERERkddjQICIiIiIiIiKrU+m7nFy9ehX+/v66x99//z1OnToFHx8fvPTSS2jUqFG1BkiVx9vl1W6sj21hPSXMg4R5kDAPREREZA4mNzTy8vIwY8YMpKSk4NixY6hfvz6io6MRFxcHURQBADt37kR8fDxv6Wph/EOydmN9bAvrKWEeJMyDhHkgIiIiczD5JyerVq3C8ePH0b9/fwBAYWEhNmzYAE9PT2zatAmfffYZsrOzsWLFihoLlkyjUCgsHQIZwfrYFtZTwjxImAcJ80BERETmYPIVGgcOHEC3bt3w9ddfAwASEhJQWFiIl156CcHBwQgODsaRI0dw+PDhGguWTOPl5WXpEMgI1se2sJ4S5kHCPEiYByIiIjIHk6/QuH37NoKCgnSPDx8+DEEQ0K9fP92Yj48PcnJyqjdCqjS1Wm3pEMgI1se2sJ4S5kHCPEiYByIiIjIHkxsaHh4eePDgge7x4cOH4ezsrNfkuH79epUmBY2Pj8fgwYPRsWNHjBkzBmfOnDG6/tSpUxEQEFDmv/z8fN06p06dwqhRo9CpUycMHjwYP/zwQ6XjslbZ2dmWDoGMYH1MYy3nBdZTwjxImAcJ80BERETmYPJPTtq2bYt9+/Zh6NChOH/+PO7cuYMhQ4bA3l7axb59+5CYmIhnn322UgHs3LkTCxYswPTp09GhQwds3LgRUVFR2LVrF3x9fQ1uc/nyZUycOLHMczk7OwOQ7sTy6quvYsCAAXjjjTdw9OhRzJs3D25uboiIiKhUfERkfjwvEBERERFRRUxuaLzxxht45ZVXMHHiRIiiCEdHR7z22msAgEWLFmHTpk1o0KABpk2bZvKTi6KImJgYjB49GjNmzAAA9OrVCxEREVi/fj3ef//9Mts8fPgQt2/fRt++fdG5c2eD+42Li4OPjw+WLFmi+1lMTk4OVqxYwQ8uRLUczwtERERERGQKk39y0r59e2zbtg2TJk3CpEmTsHXrVgQEBOiWjR07Fj/88AP8/PxMfvIbN27g5s2bGDhwoG5MLpcjNDQUR44cMbhNamoqAOie25Dk5GSEhoZCEATdWHh4OK5cucKZ14lqOZ4XiIiIiIjIFCZfoQEAfn5+mDt3bpnx4cOHY/jw4ZV+8uvXrwMAWrRooTfu6+uLtLQ0aDQayGQyvWWpqalwcHDA0qVL8csvv0CpVKJ///744IMP0KhRIxQUFODu3bsG91nynN7e3pWOlYjMg+cFIiIiIiIyhclXaJS4evWq3uPvv/8ec+bMQXR0NDIzMyu1r7y8PACAq6ur3rirqyu0Wi0KCwvLbJOamoqioiK4urpi+fLlWLBgAc6ePYuXX34ZRUVFRvdZ+jmJqHbieYGIiIiIiExh8hUaeXl5mDFjBlJSUnDs2DHUr18f0dHRiIuLgyiKAKSJ/OLj49GkSROT9lmyXelLwEszND5p0iQ8++yz6NmzJwCgW7du8Pf3x+jRo/HTTz8hJCTE6D7t7Az3cMLCwvQev/zyy4iKioK3tzdyc3MBAO7u7lAoFNBoNGW2d3d31y338vKCWq0ud5Z3b29vqNVqFBQUwNPTE9nZ2VAqlWXWc3Jy0i13cXGBXC4v99J4T09PyOVyZGVl6WIuibs0mUzGY7LwMXl5eUGhUNjUMVVUp7t37xrc1hBrOy94e3tbdW2A6nm9abVa3Lp1y6aOyZCKjqkkD7Z0TCUqc0weHh7QaDTVdl4gIiIiMsTkhsaqVatw/PhxhIaGAgAKCwuxYcMGeHp6YtmyZcjIyMD777+PFStW4OOPPzZpn+7u7gCA/Px8eHl56cbz8/Mhk8nKfJsKAP7+/vD399cb69SpE+rVq4fU1FQMGjRIt4/SSh6XPOejEhMT0bx5c6NxAqjwsvSS5TKZDM2aNSt3PZlMBicnJwDSH7PGlF5ubJ8lz69UKnV/zJbH2o6pJGZbOCalUmlzx1T6+Q0dk1arNbptadZ2Xihdz/LU5tqU9jivN6VSqYu9NGs+pvIYO6ZH82ALx/QoU45JqVTqGjAlMT/OeYGIiIjIEJN/cnLgwAF069YNX3/9NerXr49ff/0VhYWFeOGFFxAcHIzhw4fj6aefxuHDh01+8pLfs6enp+uNp6enlzu56N69e3Hy5Em9MVEUUVRUhAYNGsDV1RWNGjUyuE8AaNmypcnxWauCggJLh0BGsD7GWdt5gfWUMA8S5kHCPBAREZE5mNzQuH37NoKCgnSPDx8+rLv1YQkfHx/k5OSY/OR+fn5o2rQpEhISdGNqtRqHDh3SXSL+qC1btuCTTz7R+2YnKSkJSqUSwcHBAICQkBAcPHhQ7zLchIQEtG7dGg0bNjQ5PmtV0bdrZFmsj3HWdl5gPSXMg4R5kDAPREREZA4mNzQ8PDzw4MED3ePDhw/D2dlZr8lx/fp1NGrUyOQnFwQBkydPxvfff4/o6GgkJSVh2rRpyMnJwaRJkwAAaWlpOHv2rG6b1157DZcvX8bbb7+NX3/9FZs3b8Y777yDp59+Gl26dAEAREVF4dq1a5g1axaSkpKwePFi7N69G9OnTzc5NmtW3u+kqXZgfYyztvMC6ylhHiTMg4R5ICIiInMweQ6Ntm3bYt++fRg6dCjOnz+PO3fuYMiQIbC3l3axb98+JCYm4tlnn61UAOPHj4dKpcKGDRvw7bffok2bNlizZo3udoorV67Ezp07kZqaCgDo27cvYmNjsWLFCkyfPh1ubm4YOXIkZs2apdtnYGAgYmNj8e9//xszZsxAs2bNsHjxYkRERFQqNmtlaDI3qj1Yn4pZ03mB9ZQwDxLmQcI8EBERkTkIYsktBSpw8eJFvPLKK8jLy4MoinB0dER8fDwCAgKwaNEibNq0CQ0aNMCWLVvK/Z17bZSRkYGwsDCjk/9Zm1u3blU4+RxZTl2sj7W9zyoTb12spyHMg4R5kJiSB2s7LxAREVHtY/IVGu3bt8e2bduwdetWiKKI4cOHIyAgQLds7NixePXVV+Hj41NjwRIRERERERERAZVoaADSZH1z584tMz58+HAMHz68umIiIiIiIiIiIjKqUg0NAMjJycHPP/+My5cv48GDB1i2bBl+++03aLVadOvWrSZiJCIiIiIiIiLSU6mGxn/+8x/Mnz8fSqUSoihCEAQAwKFDh7B69WqMHTsW8+fPr5FAyXROTk6WDoGMYH1sC+spYR4kzIOEeSAiIiJzMPm2rSkpKZg7dy4aN26MhQsX4sUXX9QtCw8PR0BAALZs2YIff/yxJuKkSvD09LR0CGQE62NbWE8J8yBhHiTMAxEREZmDyQ2N2NhYeHp6Ij4+HqNGjUKTJk10yzp16oRNmzahSZMm+O6772okUDJddna2pUMgI1gf28J6SpgHCfMgYR6IiIjIHExuaFy4cAERERHw8PAwuNzNzQ3h4eG4du1atQVHVePi4mLpEMgI1se2sJ4S5kHCPEiYByIiIjIHkxsaWq22wnWKiopQXFz8WAHR45PL5ZYOgYxgfWwL6ylhHiTMg4R5ICIiInMwuaEREBCAQ4cOoaioyODyvLw8JCUlITAwsNqCo6pRKBSWDoGMYH1sC+spYR4kzIOEeSAiIiJzMLmh8fLLLyMjIwNTpkzB77//rmtsaLVaXLhwAVOmTIFCocC4ceNqLFgiIiIiIiIiIqASt2195plncOXKFXz99dd6dzjp2LEjNBoNRFHEhAkT8Nxzz9VIoEREREREREREJUxuaADArFmzMGDAAPzwww/4448/kJubCxcXFwQEBGDEiBHo0aNHTcVJRERERERERKRTqYYGIF2R0bFjR70xlUoFR0fHaguKiIiIiIiIiMgYk+fQAIArV65g2rRp2LZtm9543759MXXqVNy8ebNagyMiIiIiIiIiMsTkhkZqaioiIyNx8OBBPHjwQDeuVCrRrl07HD16FCNHjsS1a9dqJFAynaenp6VDICNYH9vCekqYBwnzIGEeiIiIyBxMbmgsW7YMoijiu+++w6uvvqobd3Jywrp167Bx40YUFhYiOjq6RgIl08nlckuHQEawPraF9ZQwDxLmQcI8EBERkTmY3NA4f/48hg4diqCgIIPLg4KCMGTIEBw/frzagqOqycrKsnQIZATrY1tYTwnzIGEeJMwDERERmYPJDY2CgoIKv3FxdXWFSqV67KDo8Xh7e1s6BDKC9bEtrKeEeZAwDxLmgYiIiMzB5IbGk08+iaSkJOTn5xtcrlKpcOTIEbRq1aragqOqyc3NtXQIZATrY1tYTwnzIGEeJMwDERERmYPJDY0xY8bg5s2bmDp1Ks6dOweNRgMA0Gq1uHDhAqZNm4a0tDSMGTOmxoIl0/APydqN9bEtrKeEeZAwDxLmgYiIiMzB3tQVR44ciXPnziE+Ph6RkZGQyWRwdHSESqWCRqOBKIoYOXIkIiMjazJeIiIiIiIiIiLTGxoAsHDhQjzzzDPYu3cvUlNT8fDhQ7i4uKB169YYNmwYevfuXVNxEhERERERERHpVKqhAQAhISEICQnRG1OpVHB0dKy2oIiIiIiIiIiIjDF5Dg0AuHLlCqZNm4Zt27bpjfft2xdTp07FzZs3qzU4IiIiIiIiIiJDTG5opKamIjIyEgcPHsSDBw9040qlEu3atcPRo0cxcuRIXLt2rUYCJdPJZDJLh0BGsD62hfWUMA8S5kHCPBAREZE5mNzQWLZsGURRxHfffYdXX31VN+7k5IR169Zh48aNKCwsRHR0dI0ESqbz9va2dAhkBOtjW1hPCfMgYR4kzAMRERGZg8kNjfPnz2Po0KEICgoyuDwoKAhDhgzB8ePHqy04qhreLq92Y31sC+spYR4kzIOEeSAiIiJzMLmhUVBQALlcbnQdV1dXqFSqxw6KiIiIiIiIiMgYkxsaTz75JJKSkpCfn29wuUqlwpEjR9CqVatqC46qxt3d3dIhkBGsj21hPSXMg4R5kDAPREREZA4mNzTGjBmDmzdvYurUqTh37hw0Gg0AQKvV4sKFC5g2bRrS0tIwZsyYGguWTKNQKCwdAhnB+tgW1lPCPEiYBwnzQEREROZgb+qKI0eOxLlz5xAfH4/IyEjIZDI4OjpCpVJBo9FAFEWMHDkSkZGRNRkvmaCk2US1E+tjW1hPCfMgYR4kzAMRERGZg8kNDQBYuHAhhgwZgj179iA1NRUPHz6Ei4sLWrdujWHDhqF3795VCiI+Ph6rV6/GnTt30KZNG7z77rvlTj4KAKdPn0Z0dDQuXboEJycn9OrVC++88w68vLx06zz33HO4cuWK3nb169dHSkpKlWIkIvPieYGIiIiIiIypVEMDAHr27ImePXtWWwA7d+7EggULMH36dHTo0AEbN25EVFQUdu3aBV9f3zLrX716FZMmTUKvXr3w5Zdf4uHDh1i2bBmioqLwww8/QC6Xo6ioCH///Tdmz56N7t2767a1t6/04RKRBfC8QEREREREFbHoX/KiKCImJgajR4/GjBkzAAC9evVCREQE1q9fj/fff7/MNps2bUKjRo0QExOju+tKixYtMGrUKCQnJ6N///64evUqiouLERYWBn9/f7MeExE9Hp4XiIiIiIjIFCY3NEp/o2mMIAgmX75948YN3Lx5EwMHDtSNyeVyhIaG4siRIwa3efLJJ/Hkk0/q3UK25M4qGRkZAIDU1FQ4OTnBz8/PpDiIqPbgeYGIiIiIiExhckPDzc3N4LhSqcT9+/eh1WrRunVrg5eDl+f69esApG9SS/P19UVaWho0Gg1kMpnesvHjx5fZzy+//ALg/z/ApKamwsPDA//85z9x9OhRCIKAiIgIvPfee+Uehy3h7fJqN9bHOGs7L7CeEuZBwjxImAciIiIyB5MbGiUfDgzJzc1FbGwstm/fjujoaJOfPC8vDwDg6uqqN+7q6gqtVovCwsIKP2jcvn0bX3zxBdq3b6+b2yM1NRVZWVkICAjAxIkTcenSJXz11VfIyMjA+vXrDe4nLCxM7/HLL7+MqKgoeHt7Izc3F4D0B5pCoTA4e7u7u7tuuZeXF9RqNbKzsw0+l7e3N9RqNQoKCuDp6Yns7Gwolcoy6zk5OemWu7i4QC6Xl3srPE9PT8jlcmRlZeliLom7NJlMxmOy8DG5uLhAoVDY1DFVVKe7d+8a3NYQnhes9z306L5t4ZgeZcox5ebm2twxAZWvk0ajqbbzAhEREZFBYjUaN26cOHXqVJPX3717t9i6dWsxMzNTbzw+Pl5s3bq1mJeXZ3T7W7duiYMHDxZDQkLEGzdu6MZ///138cyZM3rr7t27V2zdurV48uRJvfH09HSxdevWYnp6uslx13Z37tyxdAhkRF2sT2XeZ9Z2XqiL9TSEeZAwDxJT8mCL//4SERGRedlVZ3MkKCgIJ0+eNHn9kktS8/Pz9cbz8/Mhk8nKfENb2pUrVxAZGYm8vDysXbsWTzzxhG5Z27Zt0blzZ731+/btCwC4fPmyyfFZq9K3qaTah/UxztrOC6ynhHmQMA8S5oGIiIjMoVobGpcuXYIgCCavX/Ib+fT0dL3x9PR0oxP3nTt3DuPHj4dMJsPmzZsRGBioW1ZcXIwdO3bgjz/+0Num5PLZBg0amByftVKr1ZYOgYxgfYyztvMC6ylhHiTMg4R5ICIiInMweQ6NxMREg+OiKKKgoACHDh1CcnIyBg0aZPKT+/n5oWnTpkhISECfPn0ASH8EHTp0CKGhoQa3SU9Px+TJk+Hl5YVvv/0W3t7eesvt7e0RExODwMBAxMbG6sYPHDgAuVxe5htaW5SdnY1mzZpZOgwqB+tjnLWdF1hPCfMgYR4kzAMRERGZg8kNjenTpxu9+kIURTRu3Bhz5swx+ckFQcDkyZPx8ccfw8PDA126dMGmTZuQk5ODSZMmAQDS0tKQnZ2t+8Dx6aefIi8vD/Pnz8ft27dx+/Zt3f6aNWuGxo0bY+rUqZg/fz4WLVqEgQMH4sKFC1ixYgUmTJgAHx8fk+MjIvPjeYGIiIiIiExRLQ0NBwcHtGrVCv3794dcLq9UAOPHj4dKpcKGDRvw7bffok2bNlizZo3u9q8rV67Ezp07kZqaCrVajcOHD0Oj0WD27Nll9vXOO+8gKioKY8aMgVwux7p16xAfHw8vLy9MmzYNU6ZMqVRsRGQZPC8QEREREVFFBFEURUsHYUkZGRkICwtDYmIimjdvbulwqsWtW7d4qW8tVhfrY23vs8rEWxfraQjzIGEeJKbkwdrOC0RERFT7PNakoCqVCjdu3ChzNwIiIiIiIiIioppUYUPjl19+wXvvvad3W0NRFPHll1+iZ8+eiIiIQPfu3fHmm28iJyenRoMlIiIiIiIiIgIqmENj/vz52LZtGwAgNDRUdxvE6OhofPPNNxAEAb169YIgCDhw4AD++usv7NixAw4ODjUfOZXr0Ts8UO3C+tgW1lPCPEiYBwnzQEREROZQ7hUav/zyC+Lj49GmTRusXr1ad7tEhUKBtWvXQhAEfPzxx1izZg1Wr16NmJgY/PXXX9iwYYO5YqdyqNVqS4dARrA+toX1lDAPEuZBwjwQERGROZTb0Pjhhx9Qv359bNiwAb1794ajoyMAYN++fSguLsYTTzyBF198Ubd+WFgYunTpgn379tV81GRUQUGBpUMgI1gf28J6SpgHCfMgYR6IiIjIHMptaJw/fx6hoaFwc3PTG09OToYgCBg4cGCZbTp16oQbN25Uf5RUKZ6enpYOgYxgfWwL6ylhHiTMg4R5ICIiInMot6Hx4MGDMr+B1Wq1+O233wAAISEhZbaxt7fnZaa1QHZ2tqVDICNYH9vCekqYBwnzIGEeiIiIyBzKbWi4u7uXuWvJ+fPnkZeXB3t7e3Tr1q3MNtevX0eDBg2qP0qqFKVSaekQyAjWx7awnhLmQcI8SJgHIiIiModyGxodOnRAcnIytFqtbmzPnj0ApKsznJ2d9dbPzMzE0aNH0aFDhxoKlYiIiIiIiIhIUm5DY/To0cjIyMBbb72FkydPYvPmzdi6dSsEQcD48eP11s3Ozsabb74JpVKJYcOG1XjQRERERERERFS32Ze3ICwsDOPHj8fmzZuxf/9+AIAoihg3bhz69++vW2/q1Kk4duwYVCoVIiIiEB4eXvNRExEREREREVGdVm5DAwA++OADPP300zh48CCKi4vRu3dvhIaG6q3z999/w9XVFVOmTMHUqVNrMlYiIiIiIiIiIgAVNDQAoHv37ujevXu5y3fs2FHm1q5kWU5OTpYOgYxgfWwL6ylhHiTMg4R5ICIiInModw4NU7GZUft4enpaOgQygvWxLaynhHmQMA8S5oGIiIjM4bEbGlT7ZGdnWzoEMoL1sS2sp4R5kDAPEuaBiIiIzIENDRvk4uJi6RDICNbHtrCeEuZBwjxImAciIiIyBzY0bJBcLrd0CGQE62NbWE8J8yBhHiTMAxEREZkDGxo2SKFQWDoEMoL1sS2sp4R5kDAPEuaBiIiIzIENDSIiIiIiIiKyOmxoEBEREREREZHVYUODiIiIiIiIiKwOGxpEREREREREZHXY0CAiIiIiIiIiq8OGhg3y9PS0dAhkBOtjW1hPCfMgYR4kzAMRERGZAxsaNkgul1s6BDKC9bEtrKeEeZAwDxLmgYiIiMyBDQ0blJWVZekQyAjWx7awnhLmQcI8SJgHIiIiMgc2NGyQt7e3pUMgI1gf28J6SpgHCfMgYR6IiIjIHNjQsEG5ubmWDoGMYH1sC+spYR4kzIOEeSAiIiJzYEPDBvEPydqN9bEtrKeEeZAwDxLmgYiIiMyBDQ0iIiIiIiIisjq1oqERHx+PwYMHo2PHjhgzZgzOnDljdP0rV67g5ZdfRlBQEEJDQxEXFwdRFPXWOXXqFEaNGoVOnTph8ODB+OGHH2ryEIiomvG8QERERERExli8obFz504sWLAAw4YNQ0xMDNzd3REVFYX09HSD69+7dw+vvPIKBEHA0qVLMXr0aCxduhRr167VrXP16lW8+uqraN68OWJiYhAaGop58+Zh37595josInoMPC8QEREREVFF7C355KIoIiYmBqNHj8aMGTMAAL169UJERATWr1+P999/v8w2mzdvRnFxMWJjY+Hs7Iz+/fujqKgIcXFxmDhxIuRyOeLi4uDj44MlS5ZAEAT069cPOTk5WLFiBSIiIqoUa1GhEvYODrCTWbwHVC6tVoviQhW0WrHila2EqC4CZPYQ7Gpv3itDmZuHotxCS4dRq1nTeUHUFAOi7bzfqkqdlwetUmnpMCxOWVgEVYHK0mFYnFajQbGSeSAiIqKaZ9GGxo0bN3Dz5k0MHDhQNyaXyxEaGoojR44Y3CY5ORkhISFwdnbWjYWHhyM2NhYXLlxAly5dkJycjGHDhkEQBL11du/eDYVCUanbyeVnP8DxYzeR59gQglaDAPccBIS0q8LR1qzsv27g1qqlEPJyoHV0hcOrM9G4fYClw6oyUZmPwoRt0CjSATs7OPZ4Gg5tgy0dVpWpVUp8885uXBSeAkSgrRiPyZ89B0cX54o3rmOs4bwgFimh/uNXaB9mwUMrolgIhn2zJ6twtNZNnZeHsy+8gpxTqYAA3HumFzp8uxx29hb9p8Xsios1WL/5T5y66QGITmhR/2+8MaEJXOu5WDo0s7uekIT8HzcCxWpcaNoSrWbMgmvDBpYOi4iIiGyURb/2vn79OgCgRYsWeuO+vr5IS0uDRqMxuI2h9UuWFRQU4O7du0bXqYyTx9KR5+AJWXEhBK0GqXmeuPun4cveLaVYVYRbK/8NoeAhRGd3CMVFuBsXDeUD651lXpm0W2pmODoDMjlUx/dBcyfN0mFV2Y+f7MJFPAVHdQEciwvwh/AUdnyyy9Jh1UrWcF5Qp6ZA+zALsHeETO6A4r9OQ/sgq1L7sAWXps9FzqlU2Dk7wM5RDsVPybj2+VJLh2V2B/ZewambHpDLRDjYi7h+3xkbtmVYOiyzy0r9G/k71kOU2UN0dofdneu4tuprS4dFRERENsyiDY28vDwAgKurq964q6srtFotCgvLXpqfl5dncP2SZcb2Wfo5TZUr94RMo4IdABk0ECEgW/GgUvuoaXmKLAiqQoiO//dtoNwJglaD3Jt3LBvYY9Ao0gAHJwiCAEEmA0QtNFm3LR1WlV3NcoSdqIEAEQJE2InF+Du77n17awprOC+IDzIBewcIggDpF14itHnZldqHLXhw+g8I9nawEwQIdgIgADm//mbpsMzuqkIGiICdAAgCYG8HXL9f966+yr12XfoJlkwOQIDo5Aoh409Lh0VEREQ2zOJzaADQuwS8tPLGy2NnZ1fhPu3KmYshLCxM7/HLL7+MqKgoyIsLUGTvDEGjhigAgIiiYiVu3bqlt767uzvc3d2hUCjg5eUFtVqN7GzDH3C8vb2hVqtRUFAAT09PZGdnQ2ng9+dOTk665S4uLpDL5VAoFGXWU6sKpT8iNcUQ7WSAVis1NIqLoH4kTplMBm9vb+Tm5uriVigUBr/1tuQxOckcYK8qgODohGK1GjLBDkrYIeuR47GWY/KQK5EGme6x1s4e9eVK5Obm6uK2tmMq4enpCblcjqysLF3Mjx7T3bt3DW5riDWcF+o7uEBbmAfI7AFRhEajRW5uQZn3mzXUBqj6682hsScKb92DKIrQilopz571dOdHazymqtSpnmMRREinYUEANCJQz0ENAFZ7TFWpk1IQpDxotYAgQFAXQeNSDwAe+7xAREREZIhFGxru7u4AgPz8fHh5eenG8/PzIZPJynybCgBubm7Iz8/XGyt57ObmBjc3N72xR9cpec5HJSYmonnz5mXGO7cqxMk0ERqZAyAI8CzKRPuBQbCTyQzsBbrf4ctkMjRr1szgOiXLnZycAEh/zBpTenl5+/x76FgU7tkifWATtXAYMAytOpY/10fpPFQ0d4AljkkT/iIK938HsUgFmSjC3qcVnNp2QT07w3kHavcxjZoRgmvRV5EvrwdBBFyLHuDF6V11HzrKU5uPqbznN3RMWq3W6LalWcN5QevcHTifBPH/rrqx8/KBS0BHCILhxkhtrk1plX29uSxdiJPPToKmoAgA4NS4Pjr8eyEcm/z/ttZ2TCUxV6ZOI4bKkbr+Pu4rpX9SHe1FjA131C0vYU3HZEx5x9TE2xuXfj8P4a9zgGAH2NnBe+Jrupgf57xAREREZIhFGxolv2dPT0/X+217eno6/Pz8DG7j5+eHjAz93yaX3MqxVatWcHV1RaNGjcrc3rHkccuWLSsVY5NAP4Q2zMa99Cw4OMrRJLB9rbzTSash4chpE4C8jNsocpLDv1uQpUN6LDJvX7iMfB3azFuAgyNkTVuU+2HRGng91QIfLHDFud0noFKq0G1kH9Rr1sjSYdVK1nBesKvnBYduz0D7MBs5ublo2DKw0leO2AL3Du3QK+U/yNp7AHmFhfCfGAm5h4elwzK7eg3d8f4UOc6kpCEvX4ng7s3RsInxpoAtspPJ0GbWDNy9kIr7CgWad+4AN2+vijckIiIiqiKLfkL08/ND06ZNkZCQoBtTq9U4dOgQQkJCDG7Ts2dPJCcno6CgQDeWkJCA+vXrIzAwEAAQEhKCgwcP6l2Gm5CQgNatW6Nhw4aVjtO9kSf8urRGs3Yta2Uzo0SDlr7w7dsdW//7s6VDqRZ2rvVg7xcI+2YtrbqZUcLd2wt9Jg9BSu4lNjOMsJbzguDoAlmj5oj7fmedbGaUcGraBM1fnYgduffqZDOjhIubE3qHtcZvF/fUyWZGCTuZDE06t8WO346xmUFEREQ1zqKfEgVBwOTJk/H9998jOjoaSUlJmDZtGnJycjBp0iQAQFpaGs6ePavbZty4cVCr1ZgyZQoOHjyI2NhYxMXFYcqUKXBwcAAAREVF4dq1a5g1axaSkpKwePFi7N69G9OnT7fAUZrf+vXrLR0CGcH6GGdt5wXWU8I8SJgHCfNARERE5mDxr73Hjx+Pd955B7t27cLMmTORm5uLNWvW6G6nuHLlSowZM0a3fuPGjbFu3ToUFxdj5syZiI+Px5tvvomoqCjdOoGBgYiNjUV6ejpmzJiBQ4cOYfHixYiIiDD78RFR5fG8QEREREREFRHEkun/66iMjAyEhYWVO/mfNQoICEBqaqqlw6By1MX6WNv7rDLx1sV6GsI8SJgHiSl5sLbzAhEREdU+Fp0UtDYo+T39nTt3LBxJ9Xp0gkSqXepafUreX4ZuL1kbVfa8UNfqWR7mQcI8SCrKg7WdF4iIiKj2qfNXaJw6dQrjx4+3dBhEdcLmzZsRHBxs6TAqxPMCkflYy3mBiIiIap8639BQKpW4ePEiGjVqBJlMZulwiGySRqNBZmYm2rdvDycnJ0uHUyGeF4hqnrWdF4iIiKj2qfMNDSIiIiIiIiKyPha/ywkRERERERERUWWxoWGFNBoN1q1bh2eeeQadO3fGkCFDsGnTJpRcbCOKImJjYxEaGopOnTrhlVdewdWrVy0cdd1SVFSE6OhoDBgwAJ07d8bEiRPx+++/65azRtajovfbxYsXERAQUOa/zz//3MKRV7+cnByDxzpz5kwAdeN1nZKSYjAHJf/dvHmzTrwmEhMTERQUpDdmSv2Liorw6aefonfv3ggKCsLMmTOhUCjMGToRERHZkDp/lxNrtHLlSsTFxWHatGno3LkzTp06hU8//RSFhYWYPHkyVqxYgbi4OMyZMwc+Pj6IjY3FpEmT8NNPP8Hd3d3S4dcJixcvxq5duzBnzhy0aNECGzZswMSJE7F79274+PiwRlakovfb5cuX4eLignXr1ult17hxYwtFXHMuX74MAFi7di1cXV114/Xr1weAOvG6bteuHbZu3ao3plKpMHPmTLRv3x5NmzbFsWPHbPo1cfr0abz99ttlxk2p/4IFC/DLL79g7ty5cHFxwZIlSzBlyhTs2LGD89UQERFR5YlkVYqLi8WgoCAxOjpab/zDDz8Ue/bsKebm5oqdO3cWV61apVt2//59MSgoSFy7dq2Zo62bHj58KLZr104v34WFhWLHjh3FFStWsEZWpKL3myiK4qJFi8RRo0ZZIDrzW7dundirVy+Dy+ry63rRokVijx49xHv37uke2+JrQqVSiXFxcWK7du3Ebt26iZ07d9YtM6X+N27cEAMDA8W9e/fq1rl27ZoYEBAg7t+/33wHQkRERDaDPzmxMnl5eRg+fDgGDx6sN96yZUtkZ2fj+PHjKCgoQFhYmG6Zh4cHunfvjiNHjpg73DrJ2dkZ8fHxeOGFF3Rj9vb2EAQBRUVFOHfuHGtkJSp6vxUUFCA1NRUBAQEWitC8jB1rXX1d//XXX9i8eTPefPNNeHp6AjCeJ2t2+PBhxMXF4Z133sFLL72kt8yU+h8/fhwAEBoaqlvHz88PTz31lE2/RoiIiKjmsKFhZTw8PDB//ny0bdtWb/zgwYNo0qSJ7rfIvr6+esubN2+O69evmyvMOs3e3h5t27aFh4cHtFot0tPT8a9//QuCIGDYsGG6OrBGtV9F7zcXFxdcuXIFt2/fxvPPP4/27dtj0KBB2Llzp4UirlmpqakoLCxEZGQkOnTogH79+mH16tUQRbHOvq6jo6Ph5+eH0aNH68Zs9TXRoUMHJCYmYuLEiRAEQW+ZKfW/du0avLy84OLiUu46RERERJXBOTRswLZt25CcnIz3338feXl5cHBwgIODg946rq6uyMvLs1CEddfKlSsRExMDAJg5cyZatWqF//73v6yRFSv9flMoFMjJycGNGzfw1ltvwcPDA3v27MG7774LQRAwfPhwS4dbbTQaDa5evQpnZ2fMnTsXzZo1w6FDh/Dll19CqVRCLpfXudd1eno6fvnlFyxcuBB2dtL3A7b8mvD29i53mSn/9uTn5+vNvVJ6nTt37lRvsERERFQnsKFh5Xbv3o0FCxbg6aefxksvvYRVq1aV+easRHnjVHPCw8PRvXt3pKSkYOXKlVCr1XBycmKNrNSj7zeVSoU1a9agdevWugkfe/Xqhbt372L58uVW/eHVkK+//hrNmjVDixYtAAA9evRAQUEBVq9ejalTp9a51/W2bdtQr149PP/887oxDw+POvWaKCGKYoX1N2UdIiIiosrgT06s2Lp16/DOO+8gNDQU//73vyEIAtzd3VFUVAS1Wq23bn5+vs3cZcCaBAYGonv37njjjTcwYcIErFmzBs7OzqyRFTL0fnNyckKfPn3K3L2ib9++SE9PR35+voWirX4ymQwhISG6ZkaJvn37orCwsE6+rhMSEhAeHq53VUJdek2UZsq/PW5ubgaP35ZfI0RERFSz2NCwUkuWLMFnn32G559/Hl999ZXuD+oWLVpAFEVkZGTorZ+RkYGWLVtaItQ6JzMzE9u3by9zmX2bNm1QVFQEDw8P1sjKlPd+u3btGr777jsUFRXpra9SqeDk5FRmrgBrplAosHXrVmRnZ+uNq1QqAKhzr+tbt27h6tWrZSaMrUuvidJM+bfHz88PWVlZUCqV5a5DREREVBlsaFih9evXY9WqVZg4cSI+++wz2Nv//y+HgoKC4OjoiISEBN3YgwcPcOLECYSEhFgi3Drn4cOH+Ne//oX9+/frjf/6669o2LAhwsPDWSMrYuz9plAo8NFHHyEpKUk3JooiDhw4gODgYJu6jL6oqAjz58/H7t279cb3798PPz8/DBo0qE69rs+fPw8A6NSpk954XXpNlGbKvz0hISHQaDT45ZdfdOtcv34df/75p02+RoiIiKjmcQ4NK3P37l38+9//RuvWrfHss8/i3Llzesvbt2+Pl156CcuWLYOdnR38/Pzw9ddfw83NDaNGjbJQ1HWLv78/nn76aXz++edQq9Xw9fXFgQMHsGvXLnz66adwc3NjjaxERe+3Ll26oGvXrliwYAEePHiARo0aIT4+HqmpqdiyZYuFoq4Zvr6+GDp0KJYtWwZBEODv7499+/bhwIEDWLFiBVxdXevU6/rPP/9EgwYNUL9+fb3xbt261ZnXRGmm1P+JJ55AREQEPvjgA+Tl5aFevXpYsmQJAgICEB4ebuEjICIiImskiKIoWjoIMt2OHTvw3nvvlbv82LFjqFevHpYuXYqdO3eioKAAQUFBmDdvHvz9/c0Yad1WWFiI5cuX4+eff8bdu3fx5JNPYurUqYiIiAAAFBcXs0ZWwJT3m52dHZYsWYJDhw7h/v37aNu2LebMmYPg4GAzRmoeSqUSK1euxJ49e5CZmQl/f39Mnz4dgwYNAlC3XtcffvghkpOTceDAgTLL7t+/b/OviZiYGKxduxZnzpzRjZlS/4KCAixevBj79++HVqtFr169MG/ePKN3UCEiIiIqDxsaRERERERERGR1OIcGEREREREREVkdNjSIiIiIiIiIyOqwoUFEREREREREVocNDSIiIiIiIiKyOmxoEBEREREREZHVYUODiIiIiIiIiKwOGxpUbTIyMhAQEFDhf5988olum4CAAAwcOLBKz5eSkqK333fffRcAEBMTg4CAAOzYsaNS+xs8eDA+//zzKsVSXmylj9VU7777rt5xJSQkVEtMRI8qea1dunTJ0qFUq4EDB5Y577Rr1w7dunXD6NGjsXr1aqhUKqP7uH79Oj7//HMMHToUQUFB6Ny5M0aMGIFVq1ahoKDApDhKzkUl/5Wck/Lz8zF79mwEBQVhwIABiI2NhVarLbP9l19+iX79+pWJ9dFz7fPPP29iZoiIiIhsi72lAyDb4+PjgxEjRpS7vFOnTtX6fJ06dULfvn3Rpk2bKu8jPT0dN27cQJ8+faolJh8fH8yYMaNKxxoeHg4fHx+cOHECJ06cqJZ4iOqiGTNm6P6/qKgI2dnZOHbsGP7nf/4Hu3fvxsaNG+Hh4VFmu82bN2Px4sXQaDTo3bs3evfuDZVKhZSUFCxZskS3raenp0lxDBkyBK1atdKdo6Kjo/Hzzz/j+eefR35+PpYuXYp69eph/Pjxum2ysrKwceNGvPPOO3B0dNTbX7169XTHtnz58krnhYiIiMhWsKFB1c7HxwdvvPGG2Z6vU6dOj/18R44cgZOTE4KDg6slpubNm1c5pvDwcISHhyMmJoYNDaLHYOg9WFRUhA8//BDbt2/HW2+9hTVr1ugt37VrFxYuXIgWLVogNjYW/v7+umUajQbR0dH45ptvMHXqVMTHx5sUx7PPPovw8HAAgCiK2L59O0aNGoWPPvoIABAVFYXvv/9er6Hx9ddfw9PTE6NGjSqzv3r16umOjQ0NIiIiqsv4kxMiAEePHkVwcHCZb0KJyLY4ODjgo48+QmBgII4ePYrjx4/rlj18+BAff/wx5HI51qxZo9fMAACZTIY5c+YgKCgI586dw+HDhyv9/NnZ2SgoKEBgYKBurE2bNsjIyNA9vn37NrZu3YoZM2ZALpdX4SiJiIiI6gY2NGxMWloa3nrrLfTq1QtBQUGYPHkyrl69ikGDBmHChAl66968eRMLFixAeHg4OnTogKCgILzwwgvYsmVLmf3u2bMHkZGR6NatG4KCgjBy5Eh89913EEXRXIdWY9RqNY4fP46+ffvqxgICAjBv3jycOHEC48aNQ6dOndCnTx8sWbIEGo0Gf/31F6KiohAUFIS+ffvi448/RmFhoW57Q3NoTJgwAQMHDsSdO3cwe/Zs9OjRA506dcL48eORkpJi1mMm21UyL8a9e/fw9ttvIzg4GN27d8e0adPw559/mrSPH3/8ERMmTEC3bt3Qvn179OnTB7Nnz0Z6erreellZWfjXv/6FQYMGoUOHDujTpw/efvtt3LhxQ2+9gQMHYtKkSUhNTdW9b3r06IH58+ejsLAQCoUCb775Jrp27YqQkBDMmTMH2dnZevtQq9VYv349Ro8eja5du6J9+/YYMGAA5s+fX2bdisjlct358KefftKN79+/H7m5uXjuuefg6+tb7vZz5szBhx9+iCeffLJSzwsA7u7uEARBbx6OvLw8uLm56R6vWLECPj4+nBuDiIiIqAL8yYkNuXHjBiIjI3H//n2Eh4ejefPmOHjwIMaNGwetVosmTZro1s3IyMCLL76IwsJCDBo0CE2bNoVCocD+/fvx4YcfQqPR4KWXXgIA7N27F7Nnz4afnx9GjBgBOzs7JCYm4qOPPkJOTg6mT59uqUOuFmfPnkV+fj569+6tN37u3Dns2rULoaGhGDt2LA4cOIBVq1bh3r17OHDgANq3b4+xY8fi8OHD2LRpE2QyGf71r38Zfa78/HyMGzcOzs7OGD58OLKysvDTTz8hKioKO3fuxFNPPVWTh0p1yOTJk5GZmYmRI0fizp07+O9//4sTJ05g06ZNelcHPOrzzz/H2rVrERgYiBEjRkAQBJw8eRJ79uzBb7/9hn379sHJyQkqlQqTJ0/GlStXMGjQIERERCAtLQ179+7F0aNH8fPPP6N+/fq6/WZkZGDs2LHo3LkzIiMjceTIEWzduhX379/HxYsX4eXlhdGjR+PMmTP4z3/+g8LCQqxYsUK3/ezZs7F//3507doVo0ePRlFREY4ePYqtW7fi999/x/bt2yuVn5Kfl50+fVo3VnLFRenmZnnbVvXnaQ4ODmjbti127NiBIUOGID8/HwcOHEC3bt0ASOfxnTt34osvvoBMJqvScxARERHVFWxo2JDFixcjOzsby5YtQ0REBADgn//8JyZNmoTffvtNb924uDjk5ORg3bp16NWrl278pZdewqhRo7Bnzx5dQ2PNmjVwcXHB9u3bdd8izpgxAxEREdi0aROmTZsGQRB0+7h58yZiYmIMxti9e3f06NGjWo/7cR09ehTe3t5lmgl//vkn3nvvPUyaNAkAMHr0aDzzzDP44Ycf8I9//ANz584FAEybNg39+/fHnj17Kmxo3L9/H127dsWyZct0l5I/9dRTiI6Oxq5duzBnzpzqP0Cqk+7fv49du3bpJq7cv38/Zs6ciU8++QQbN240uI1CocC3336Lbt26Yf369XofqKdMmYKkpCScOnUKffr0QXJyMv744w9Mnz4dM2fO1K23Zs0afPHFF9i7d6/enBDp6emYOHEi5s2bBwB4/fXX0a9fP+zfvx8RERFYunQpBEGARqPBM888g4SEBBQWFsLZ2Rlnz57F/v378dxzz+Hf//63bp/FxcUYMWIELl68iGvXrqFly5Ym58fb2xsAkJmZqRu7c+cOAMDPz8/k/VTF3LlzMXXqVISGhgIAGjdujLfeegsA8NVXX6FVq1YYMmSIbn2tVgs7O15QSURERPQoNjRsRHZ2NpKSkhAcHKxrZgDSt4Fz5szB2LFj9dYfNmwYOnXqpNfMAICOHTvCyckJ9+7d042JogilUok///wTQUFBAAA3Nzf88MMPqFevnl4zA5AaGuVNVDdjxoxa19A4cuSIwbubODg4YNy4cbrHrVq1QoMGDZCTk4N//OMfunE3Nzf4+/vj3LlzUCqVcHJyMvp8//jHP/R+F9+/f39ER0fj5s2b1XA0RJLXX39d7y4cTz/9NLp27YoTJ05AoVDoPtCX5uDggC+++AJPPvlkmasDunXrhqSkJN25oeQ2o6mpqVCpVLr5Z8aNG4chQ4boXRFWoqQ5CEgTW/r7++PixYt45ZVXdOcRmUyGdu3a4caNG7h16xb8/f3RpEkTfPbZZ2WuirC3t0fXrl1x5coV3Lt3r1INDQcHBwDSzz1KPHz4EADg6upq8n6qokePHti9ezcOHz4MR0dHhIWFoUGDBrhy5Qp++uknxMTEQBAEfP3111izZg0KCwsxcOBALFq0CPXq1avR2IiIiIisCRsaNuL333+HVqtFx44dyyzr1KkT7O31S11yyfT9+/dx6dIlpKWl4dq1azh79ixUKhU0Go1u3TFjxmDBggWIjIxEQEAA+vXrh/79+6Nr164GvzXs3r17ud8A1zbZ2dn4448/8Oqrr5ZZ1rRpU92HnhIuLi4oLCxEo0aN9MZLPswVFRVV2NB49NvfkqteioqKKhs+UblKfsJQWseOHfHbb7/h8uXLBhsaDRo0wHPPPQetVosrV67g6tWrSE9PR2pqKpKTkwH8fyOjV69e8PX1RUJCAnr16oVevXqhX79+CA0NRdOmTcvsWy6Xw8fHR2/MxcUFgHRXoNJKv58AoEmTJhgxYgSKi4vx+++/49q1a0hLS8OlS5fKxGWq/Px8vRhKjv/69et48OBBpfZVFb6+vnpXsADAsmXL0K5dO4SHhyMpKQnR0dGYNWsWWrdujYULF2LRokX44osvajw2IiIiImvBhoaNyMnJAQB4eXmVWSaTyfS+qQWABw8eYPHixdizZw/UajUEQYCPjw969uyJP/74Q2/dyMhINGzYEBs2bMBvv/2G1NRUfPPNN/D29sa7776rd2m0tfn1118hCAJCQkLKLHN2dja4zePedeDRJknJN9O2MMEq1R6GGhYl54fc3Nxytztw4AC+/PJLXL9+HYD0gb99+/YIDAxEcnKy7nXq7OyM+Ph4xMbG4ueff8aBAwdw4MAB2NnZYdCgQVi4cKHeHBrGGn2PvicM+f7777FixQrcvXsXgHSFR6dOnXRXR1X2/VNyRVTpyT+bN2+OM2fOIC0tzWBzuERRUREUCoXRiUMr6/z580hISMDq1asBANu2bUO7du0wbdo0AMCtW7ewePFizJ8/X28CUSIiIqK6jD/KtRElf+CWvny6tJJvI0u8/fbb2LlzJ0aOHIktW7bg1KlTSExM1LsrR2mDBg3Cxo0bcfz4cSxfvhwvvPACsrOzMXv2bFy5cqV6D8aMjh49ivbt26NBgwaWDoWoWimVyjJjJY2M8l7v586dw6xZs1BUVIQlS5bgv//9L06fPo2NGzcabPp5enpi3rx5OHLkiG4OGH9/f93kwtXl559/xoIFC9CgQQOsWLEChw4dwsmTJ7F69Wq0adOmSvs8deoUAOh+Rgf8/2Sgv/76q9FtExMTER4ejrfffrtKz23IsmXLEBwcrIvhxo0baNGihW65n58ftFqt3u1diYiIiOo6NjRsRLt27SAIAs6fP19m2V9//aXX0Hj48CGSkpLQvn17fPTRR+jSpYuuIZKRkQGVSqX7trOoqAixsbH49ttvAUjfig4aNAiLFy/G66+/Dq1WizNnztT8AdYAURTx66+/lrm7CZEtuHDhQpmxM2fOwN7eHu3atTO4zd69e6HVarFgwQI8++yzeOKJJ3RXEP39998A/v9KopMnT2LRokVIS0uDIAgIDAzE5MmTsW3bNri4uOgaBtVhz549AIAvv/wS4eHhej9peTQuUxQXF2Pr1q0AgKFDh+rGBw4ciPr16+M///lPmVvUltBoNNi0aRMAVNu54+TJkzh69CjefPNNvRhL//SPP0kjIiIiKosNDRvh7e2N3r17Izk5GUlJSbrxoqIi/M///I/eunK5HHZ2dnj48KHeH8lKpRIff/wxAECtVgOQLgXfs2cPli1bVuYP/JJLtps1a1Yjx1TTUlNTkZmZaXBCUCJrFxMTo3fF1r59+5CSkoKwsDC9n4KUVjJ3RVZWlt74sWPHdE2F4uJiANLdQTZu3Ii1a9fqrZuVlQWVSlVmvozHUV5cP/74I06cOKEXV0WKi4vxySef4M8//8SAAQP0rtBwd3fHm2++CbVajVdffVXXLCmhUqnw8ccf49SpU2jbtq1eM+RxREdHo3fv3nrznrRs2RIXLlzQNTXOnj0Le3v7MvONEBEREdVlnEPDhsybNw9jxozB66+/jvDwcHh7e+PXX39FdnY2AOgm8HR2dsagQYOwf/9+jBo1Cr1790ZBQQEOHjyIrKwseHh4IDc3V3erwLfeegvTp0/HiBEjEBERAQ8PD1y8eBHHjx9H9+7dH/tbyszMTEyYMMHgMhcXF6xatapK+42Li8POnTsNLhs/fjzS09Ph5uaGzp07V2n/RLXZtWvXMHz4cISGhkKhUCAhIUE37015hgwZgnXr1uGjjz7CyZMn0ahRI6SmpuLo0aNo0KAB7t27h/v37wMAwsPDERQUhC1btuDKlSvo3Lkz8vLysH//fgDQu5Xr4xo2bBj27t2LGTNm4Nlnn4WbmxsuXLiAEydOoGHDhnpxlVb69tFqtRpZWVk4duwYbt26hbZt22Lx4sVlthk7dizu3r2LlStXYujQoejTpw/8/f1x//59HD9+HLdu3UKrVq2wcuXKMpMtV8Xhw4fx22+/Ydu2bXrjL774Il5//XVMmjQJLVu2xPbt2/HCCy9w/gwiIiKiUtjQsCGtWrXCli1b8OWXXyI5ORnFxcXo2bMnoqOjMWzYML1JLj/99FM0adIECQkJ2LRpExo1aoQOHTpgypQp2LNnD9avX4+UlBSEhIQgLCwMa9aswTfffIODBw/i4cOHaNasGaZPn47JkycbvNNJZRQVFem+ZX2Uu7t7lfd77do1XLt2zeCysLAwHD16FD179qyWDyVEtc2XX36J7du3Y/v27XB2dsbw4cPx5ptvGpwstESbNm0QFxeHr776CgkJCZDJZPDx8cHMmTPx4osvol+/fkhKSsJrr70GBwcHrFq1Ct988w0SEhKwefNmODo6onPnznjttdfQtWvXajuW0NBQREdH45tvvsF//vMfODk5wdfXF/Pnz0dQUBBGjBiBpKSkMldMlL59tJ2dHerVq4cnn3wSr7zyCiIjI8udjHTWrFno378/Nm/ejAsXLuDkyZMQRRGtWrXCuHHjMGHChArvZmSqpUuXYsCAAWUmIR04cCDmz5+P2NhYXLhwAUOGDMHcuXOr5TmJiIiIbIUg8tYKNkGr1SI9PR3NmjUrcxeO9PR0hIeHY+zYsdU6UZ+lpaSkYOLEiZg4cSLmzZtn6XCqXUxMDJYvX44VK1YgPDzc0uGQlXj33Xexc+dO/Pjjj1WeMJOqhznewwEBAQgMDMSuXbtqZP9EREREtRnn0LARgiBg+PDheO6558pMHrdmzRoAQI8ePSwRGhEREREREVG147X2NkIQBERGRmLt2rUYNmwY+vXrB5lMhtOnT+Ps2bPo06cPIiIiLB1mjTh37hxiYmLQpk0bm7iSISEhAZcuXSr3ZzhEZF327t2LS5cuITw8vFqumnn48CHWr19fDZERERERWTc2NGzI22+/jVatWmHbtm3YuXMniouL0bx5c8yePRuvvPKK7vaLtubcuXM4d+4cRowYYTMNjfImMyUi6/PTTz8BAHx8fKqtoVF6fhAiIiKiuopzaBARERERERGR1eEcGkRERERERERkddjQICIiIiIiIiKrw4YGEREREREREVkdNjSIiIiIiIiIyOqwoUFEREREREREVocNDSIiIiIiIiKyOv8Lsyy1jCQsC8YAAAAASUVORK5CYII=\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "df = df_film.iloc[:,1:-1]\n", - "\n", - "n_col = 4 # num of columns per row in the figure\n", - "y_indx = -1 #PCE (efficiency%)\n", - "color_indx = 1 #\n", - "column_names = df.columns.values\n", - "fs = 20\n", - "\n", - "cmap = plt.get_cmap('coolwarm', 10)\n", - "print(\"The color is for variable\", column_names[color_indx], \"with red as the highest and blue as the lowest\")\n", - "\n", - "for n in np.arange(0, 8, n_col):\n", - " fig,axes = plt.subplots(1, n_col, figsize=(18, 3.5), sharey = False)\n", - " for i in np.arange(n_col):\n", - " #print(n)\n", - " if n< len(column_names)-1:\n", - " im = axes[i].scatter(df.iloc[:,n],df.iloc[:,y_indx], \n", - " c=df.iloc[:,color_indx], s = 20, cmap=cmap, alpha =0.8, edgecolors = 'face')\n", - " axes[i].set_xlabel(column_names[n], fontsize = fs)\n", - " else:\n", - " axes[i].axis(\"off\")\n", - " #axes[i].set_title(sf_cols[n])\n", - " n = n+1 \n", - " axes[0].set_ylabel(column_names[y_indx], fontsize = fs)\n", - " for i in range(len(axes)):\n", - " axes[i].tick_params(direction='in', length=5, width=1, labelsize = fs*.8, grid_alpha = 0.5)\n", - " axes[i].grid(True, linestyle='-.')\n", - " plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Reduce the Data Complexity - Dimensionality Reduction using tSNE\n", - "- t-Student Stochastic Embedding (tSNE, tee-s-nee)\n", - "- More readings about `tSNE` vs `PCA` at this blog post: https://towardsdatascience.com/an-introduction-to-t-sne-with-python-example-5a3a293108d1\n" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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331Hly5daNy4MTt27GD8+PGUL1+el156KV9rsgUyuGFh8+bN48qVK0ybNs3kToIDBw7w9ttv8/nnn/Pll1+anPP48WN27txpnJh04sSJ/PDDD+zfv5/du3cbO0KZMmVYtGgRBw4coF+/fsbzHz16RPny5Vm/fr0xXFatWsWcOXOYP38+s2fPznEd8+fPJzw8nNmzZ9O9e3cgdcUSf39/s99m7tmzh507d/Lqq68yZ84c7O1Tf8xGjhxJz549ef/99wkKCkKn07Fv3z5++ukn2rVrx4IFC4wfGjZs2MCnn35q8rr//PMPABUrVsxx+4UoCGJiYti8eTMNGjTg22+/NW7v1asXPXv2ZMOGDWYfZqKioti5cyeenp4A7Nu3j3HjxvH5558bX2Pt2rUcP36c4cOH89577xnPHTBgAG+88QYffvghW7duBXLWP7dv386JEyfo0aMHn332GXZ2qTf7BQQEsHLlylz/efz9999MmTLF+Px479696dy5M1u3bmXo0KG8//77ALz99tu0bNmSH374wWxw48qVK2zevJkaNWoA8OKLLzJt2jRWrVrFsmXLaNWqFZD6TOqAAQP44YcfTD5cBAcH4+XlxQsvvADA7t27uXv3LjNnzqRHjx7G4z799FM2bNjA0aNHja8pRG5YKg/S1K1b12SW/6SkJObPn0/FihXZvn27yS8JPv74YzZu3MjBgwfp06cPDRo0oEyZMvz000988sknxg/USUlJ7Nu3j5o1a1K5cmWT9wsKCspw/odBgwaZTLZua4KDg3n55ZfNHoXNbf6k58qVK7z33nvGXx4pisLw4cMJDg7m+PHjtGjRIu8LFIVSTvIkTVhYGGvWrKFBgwasXbsWrVZr3Ddy5EgOHz7MyZMnTSboDg0NZeDAgUydOhVIHXht0aIF+/bto1OnTixYsACNRoNer6dz584EBQURHx9PkSJF2Lx5M9HR0axdu5bGjRsbX9Pf359Dhw7x999/88ILL/DHH3+wb98+XnvtNQIDA43HpaSk0L17d86fP8/169fNJvBdu3ZthtmU2xVitm3bxpEjR+jUqRNffPGFMUdbt27NxIkT+eabb/joo49M/px27dpF6dKlAShZsiTLli3j//7v/9i2bZtxwKlWrVp88MEH/PjjjzK4kQ4Z3LCglJQUduzYwQsvvGD2iETbtm2pW7cuP/30EzExMbi6uhr3+fn5mXS0unXr8sMPP/DKK6+YjPDVqlULIN2LiQkTJpiMmg4aNIj169ezb98+Pv300xyN/CclJbF//35eeOEF48AGgLOzM5MmTaJ3794mx6d9iJo6darxgxNAuXLl6Nu3L19//TW//vorrVq1Yvv27UDqb5Kf/G3oG2+8wbfffsv169eN26KjowFwcXHJdtuFKEgMBgOKonDv3j3Cw8MpWbIkADVr1iQoKIhSpUqZnfPWW28ZLzwAOnbsSL169Thx4gRhYWF4eXmxdetWihYtyvjx403OTbtNeteuXcYLgJz0zx9//BGNRsPEiRONAxuQerdV2gVHbuh0OpOB2cqVK+Ph4cHDhw8ZOnSocburqyve3t6cPXuWhIQEk2xLu/U0Td26dYHUFZ+eHISoXbs2YJ6XR44cMbkIMxgMQOpvT7t162a8cBs/fjxvvfWW8e9MiNyyVB6k6dChg8m5er2eGTNm8Nxzz5nd/diwYUM2btxofFxWo9Hw+uuvs3TpUg4fPkz79u0BOHz4MI8ePUr3ro0DBw5w4MCBdGvt3r27zQ5uPH78mD///JO+ffua7ctt/qTHycnJ5NFbjUZD8+bNCQ4OlslDRZ7KSZ6k0el0BAQEUKVKFZOBDYAGDRpw+PBhs8fu4d9JcAGKFi2Kt7c358+fZ8iQIcZBQ61WS/Xq1bl58yZ3797F29vb+G/yuXPnTAY3Zs2aBWBsf6lSpZg9e7bZxOD29vbUq1ePK1euEBERYTa4sW7duiz/nJ7Vjz/+CKQ+5vfk565XXnmFv//+m6pVq5oc36FDB+PABvybJ02aNDG5kyazz39CBjcs6vr168TFxaHX69NdIzkxMRG9Xs/ly5epV6+ecXv58uVNjku7/fvpVVjSJr9LSkoy2a7RaMw6d1pg7N+/n1u3blGlSpVs1xEaGkpcXJzJP+BpatSoYXaL9oULF3B0dEz3+fa0wYqLFy/SqlUrzp8/T7FixcxqtrOzw9fX12Rwo1ixYgCyMoGwWUWLFqVLly78+OOPtG7dGl9fX1q0aEHr1q0z7JMNGjQw21arVi1OnTrFpUuXcHV15fr165QsWZKvvvrK7NgHDx4AqX3uhRdeyFH/vHTpEqVLl6Z48eImx+l0OqpXr87x48dz/GfwpOeff95soNXZ2Zn4+HizQYQn8+7JwY0KFSqYHJeTvIyMjOSvv/4yebyuY8eOLFmyhA0bNrB7926aNWtGixYtaNmypQxsiDxliTx48sPI032gSJEidOnSBUjt6yEhIdy6dYu///7bOMdH2gcJSJ2/aunSpezatcs4uLFr1y7s7e155ZVXzNoxa9asfF0tJb8cO3YMg8FgfHTtSbnJn4yULl3aLBfTlpbMzvlCZFdO8iSNh4cHr732GgaDgStXrhASEkJoaCiXL182ztHzZI4AODg4mC3ZnDbAmlVf6d69Oxs3biQwMJD169fTokULWrRoYXzcLk2pUqXo3r07KSkpXLhwgevXr3Pr1i0uXryYYbsgdVA2o1Uut23bxpQpU9Ldlx1p11BP/zlqNBqzX0ZB7j//iVQyuGFBaR/Cr127xuLFizM87tGjRybfZzSfRHbvtvDw8Ej32LQ15nP629a09qV3x4RWqzW56yTt9dMmScvqNR8+fJjhGu9Pz0WSNgfHrVu3smzzzZs3KVOmjMlvpoUoCObMmUONGjXYtm2bcU3ywMBAatSowWeffWb2THd6FxdP9uW0CbXCw8Oz1edy0j8fP35sNrCRxt3dPZMqsyejeTtyMqdFRq+Rnbw8evQoGo3GZA6gtDthvvrqKw4cOMCuXbvYtWsXDg4O+Pn58dFHH8kz7yLP5HUePOnJQcA0v//+O7NmzeLChQtA6kXyiy++SPXq1bl3755x0lFI/eDu6+vL4cOHiYmJQVEUDh48SPPmzU1+26t2wcHBVK1aNd3BzdzkT0bSOzftN9tP/v0IkVs5yZMn7d+/n7lz53Ljxg0g9XNLjRo1ePHFF/n111/Nfk7Ty6I0WfWVF198kc2bN/P1119z+PBhNm/ezObNm3F2dmbgwIG8++67xv7x3//+lyVLlnD//n0gdQC5du3axjs/87v/PH782PjnmR2WyJPCSD75WVDaYMDrr79OQEBAvr1vYmJiutvTBls8PDxy9HppH2LSCzpFUUxWMIDUkHNxceHQoUNZvrarq6vZbMdpnt5ev359nJ2dOXXqlNmt6U9KSkqiV69e6PV6fv755zz5ECZEXnFwcGDo0KEMHTqUu3fvcvToUfbu3UtwcDD+/v4cOHDA5MN9QkKC2T94aX3Rw8PDOBhav379dO/GeFpO+mfRokUzvMCJi4vL8vyCLjg4mBo1aphlYrly5Zg5cyZ6vZ7z589z5MgRtm3bxqZNm3BzczOZ10SI3MjrPMjMnTt3GD58OE5OTsyYMYN69epRsWJFtFotu3fvTnc1kW7dunHmzBkOHjyIXq8nKSmpQE4kmvbhJr3fzD59jZJTwcHBxjtehFCTZ8mTs2fP8s4771CqVCnmzZtHzZo1KVeuHBqNhuXLl6e7wlJuvfjiiyxYsICkpCTOnDnDL7/8wrZt2/j666/x8vKiX79+7Nmzh+nTp1O1alWmT59O9erVef755wGYPn06Z8+ezfN2ZcXZ2ZnY2Nh098XFxWW6OIJ4drIUrAVVqlQJnU7HhQsX0h0tXLNmDUuXLuXhw4d5+r6xsbGEhISYbT979iweHh5mq5BkpXz58ri5uXHmzBmzfVevXiUhIcFkW9WqVfnnn3/SXTbx0KFDzJ8/n0uXLgFQvXp1/vnnH+Mo69PtfZJOp6NLly7Ex8ezevXqDNu7Y8cOHj16RPXq1WVgQxQooaGhzJs3j4MHDwKptx/36tWLlStX0rhxY8LCwsyW+zt37pzZ65w5cwZ7e3uqV6+Om5sbpUuXTrcvQmp/WLRokfF1c9o/7927Z7bks16vz/VEW9amKApHjx41u9X8wIEDfPzxx8TExKDVaqlduzZjxowxDhydOnXKGs0VKmSJPMhMUFAQCQkJjBs3jt69e+Pt7W18Zj7tmuHpa5UuXbqg0+k4ePAgBw8exM3NjTZt2jxzzZaSNgCU3kBGesthZ1dISAh37941mZdHCLV4ljz58ccfMRgMTJ8+nVdeeYXy5csbBxevXbsG5O0dRjt27GDGjBkoioJOp6NRo0a89957xsf90/5N/uGHHwCYO3cu7dq1Mw5sWKpd2eHj48Pdu3fTvd7q1q0bHTt2zNf2FBYyuGFBjo6OdOnShatXr5p9GP/tt98ICAjgu+++s8gH8MDAQJNnsVatWkVoaCjdu3c3mwAoKw4ODrz66qvcunXLpI6kpCTmzp1rdnz37t1RFIUZM2aYtOH+/ftMnz6d5cuXG+9q8fPzQ1EUAgIC0Ov1xmN37tyZbuiOHz8eNzc3Fi9ezJYtW8yC6pdffuHzzz/H3t6eiRMn5qhOISzNycmJb775hi+//NKkbyQlJREeHo5OpzO79XnRokUmdzHt3buX3377jbZt2xrnoenevTtRUVEEBgaa/Oby6tWrfPrpp6xevdrk2Oz2z7QJhGfPnk1ycrLx2JUrVxrn8rBVly9fJjw83OxDy7Vr19i4cSMbN2402Z42cdeTk30JkRuWyoOMpD2n/XTfvXTpknFSvZSUFJN9RYsWpXXr1hw5coSjR4/SqVMn4+sUJMWLF8fd3Z0///zTZDLDv/76K1t3qWUkODiYIkWKmMyLJoRaPEueZJQjx44dMw4wPJ0jufHHH3+wfv169uzZY7I9beA37d/kjNq1Y8cOTpw4keftyo6uXbuiKAqBgYEmn3H27NnDzZs3TR6JFXlHHkuxsPfff58zZ84wZ84cDhw4QK1atQgLC2P//v3Y29szc+ZMk1UI8srvv/9Ojx49aNKkCSEhIQQHB/PCCy8wevToZ3q98ePHc+zYMWbPnk1wcDDe3t4cO3aMqKgoswsdPz8/fv75Z/bt28fly5dp3rw5KSkp7Nmzh6ioKCZOnGi8e+SVV15h586d7Nq1i6tXr9KoUSNu3rzJoUOHjKsmPDkYU6JECVatWsWIESP46KOPjMtROTg4cPHiRX7//XccHByYNWuWcXZyIQqKkiVLMmjQIFavXs2rr75Ky5YtsbOz48iRI4SEhPD222+bzWFz/fp1unXrRqtWrQgLCyMoKAgvLy8++OAD4zEjR44kODiYb7/9llOnTtGwYUMeP37M3r17iY+PJzAw0Pi6OemfXbp0Yd++fezdu5fr16/TpEkTrl69yvHjxylTpoxNz9R95MgRXF1dqVOnjsn23r17s3nzZgIDAzlx4gRVq1YlIiKCvXv34uzszMiRI63TYKE6lsqDjLRu3Zq5c+eybNkyrl27Rvny5bl586bxjgxIXRryad27d2ffvn0AefpIStpS0+np2LEjb775ZrZfS6vV0qNHD1atWkWvXr3o2LEjkZGR7N27l1q1anHy5Ml0z5swYUKGgzWfffYZwcHBNGzYUJ55F6r0LHnSpUsXVq9ezSeffMLvv/9OyZIluXz5MsHBwXh4eBAREZFujjyr4cOHs2fPHiZNmsTevXupUKECd+7cYf/+/ZQsWdKYE127duXHH39kzJgxvPLKK7i6unLu3DlOnDhB8eLF87xd2dGzZ0/279/Pjh07uHz5Mo0aNTJ+Bixbtmy6k4qK3JPBDQvz9PRk8+bNLFu2jJ9++olvv/0WT09P2rRpw9tvv22ytE9eWrFiBYsWLWLTpk24u7szcOBAxo4da3ahlF3u7u5s3LiRL7/8kgMHDnDy5Enq1q3LggUL6NOnj8mxGo2GhQsXsmHDBrZt28aWLVtwcnKiSpUqDBkyxGSNd41Gw6JFi/jqq6/4/vvv2bBhAxUqVCAgIICff/6ZPXv2mM2tUatWLXbv3s2mTZsICgpi3759REdH89xzz9GzZ0+GDh2Kt7f3M9UphKW99957VKhQgS1btrB9+3b0ej1VqlRh9uzZJkstp5k7dy7fffcd3333HUWKFKFbt268++67JhOBOTk5sW7dOlasWMHu3bv5v//7P9zc3Khbty7+/v40bNjQeGxO+ifAvHnzqFGjBlu3bmXjxo1UrFiRxYsXs3XrVpse3AgODqZx48Zmkw67u7uzfv16vvrqK44ePcrx48dxdXWlRYsWjBkzhhdeeMFKLRZqZIk8yIiXlxerV69m3rx5HD9+nODgYEqXLs2AAQPw9/enQ4cOHDlyBEVRjLeZAzRv3hwXFxeKFStmthJbbty5cyfDDHmWa6MJEyZQpEgRduzYwbfffkvFihX5z3/+Q7FixTIc3MjsOfyoqCh+//13+QAiVOtZ8qRatWosX76chQsXEhQUhFarpUyZMowbN46ePXvSokULDh8+jL+/f560sWzZsmzcuJGlS5dy+vRpfv75Zzw8POjatStjxowxtrVVq1bMnz+fb775hl27duHk5ES5cuWYNm0avr6+dO/encOHD/Pqq6/mSbuyQ6vV8tVXX7Fy5Up27tzJhg0bcHV15bXXXmPChAny6LylKEJV3nzzTcXHx0d59OjRM50fGhqq+Pj4KG+99VYetyx9d+/eVaKjo9Pd179/f6V27dqKwWDIk/dauHCh4uPjo/z000958npCWNL777+v+Pj4KH/99Ze1m5IraqkjI61bt1bq1atn7WYIlbNmPwoJCVF8fHyU+fPn5/t7p0ftmZITub3my0pycrJSs2ZNxcfHx+SrTp06xmOOHDmi+Pn5KbVq1VJat26trFy50ux1/vzzT+XNN99U6tSpozRt2lSZO3eukpSUZHLM9evXFX9/f6VevXpKw4YNlWnTpmV4fShyR/pQ3vPx8VG6du1q1TbYwuecv/76S3nppZeUe/fuZXrc/fv3lalTpyqtWrVS6tSpo3Tv3l3ZvXt3tt5D7twQVvXNN9+wYcMGvv32W5PfLp85c4ZTp07RvHlzk98gCSGEECJ/KIrC0qVLsbOzo0ePHtZujshn169fJzExkTlz5lCxYkXj9rTHqU+fPs2oUaPo3Lkz77zzDqdOnSIgIABFURg2bBgAN2/eZPDgwfj6+rJgwQJCQkKYP38+MTExTJs2DUhdfnzQoEGULFmSOXPmEBERwRdffME///zDsmXL8r1uIUTeCwkJwd/fP8u5T5KSkhg+fDjR0dGMGzeO5557jn379vHuu++i1+uzvPtGBjdEuq5du8aiRYsoU6YMfn5+FnufHj16sHnzZuMtsV5eXty+fZugoCBcXFx4//33c/0eQUFBXLx4McNne4UQlrdx40ZKlixJ9+7dKVu2rLWbk2tpM7WnLbEthJokJSXh5+dHYmIit27dokePHjleac3S1JYpObFmzRqio6Mt/mjgpUuXsLOzo2PHjmZLhgIsXLiQl156iS+++AKAFi1akJKSwtdff82AAQPQ6XQsX74cNzc3li5dik6no2XLljg5OfHZZ5/h7++Pl5cXGzZs4PHjx+zYscO4BKmXlxcjR47k7NmzMoeasAkPHjxg0aJFuLm5MXjw4Hx734L+OSclJYVNmzYxd+5ck6XNM/LLL79w6dIltmzZQq1atQBo2rQpd+/e5ZtvvslycENWSxHpun79OosXL2b79u0WfZ/q1auzefNmWrRowfHjx1m1ahUnT56kS5cubNu2LU/mzggKCmLx4sUFttMLURhs2rSJxYsX2/Q8HU9avHgxixcvJjo62tpNESLP6XQ6HBwcCA8Pp0uXLkydOtXaTTKjtkzJiXXr1uVL7RcvXqR8+fLpDmwkJiZy8uRJOnToYLK9Y8eOPH78mNOnTwNw9OhRWrdubTIpa6dOndDr9QQHBxuPadCggXFgA6BZs2a4uLhw+PBhS5QmRJ578OABixcvNq4+lV8K+uecU6dOERgYyNChQ5k0aVKWx7u4uNCnTx9q1qxpsr1y5crcunUry/M1ipLPi/5aWUJCAufPn6dkyZI5XhJViDR6vZ7w8HBq1KhhNuHpk6KiokyW2Xqaq6trlsv3iYJHckTkBcmRwk1yROSFnOZI2tfTvLy8zO7OGTZsGPfv3+e5557j9OnT2Nvb07lzZyZPnsw///zDK6+8wtKlS2nbtq3xnEePHtGwYUM+/vhjunXrRp06dZg6dSoDBw40ee169erxxhtvMGnSJJo0aULXrl2ZMmWKyTFdu3bF29ub+fPnP8sfTaEgOSLygiVz5MGDB2g0GooXL862bduYMmUKhw8fplSpUtluX3JyMq+++iru7u5s3rw502ML3WMp58+fp3///tZuhlCJDRs2ZDh7fFRUFB2aNuVRJs+Wubu7s3//fvlgYmMkR0RekhwpnCRHRF7KbY44Ojryyy+/mOTIpUuXiImJoVevXowaNYrz58+zaNEirl+/zoQJEwDMVuFzcXEBICYmxnhnW3or9bm4uBg/HEVHR2d5jEif5IjIS5bIkRIlSuS6XV988QU3btxgyZIlWR5b6AY3SpYsCaT+5eVkxCgvpY2Cq5naa7xw4QLjxo0z/jylJyYmhkcpKWyoU4dSjo5m+/9JTKT/H38QExMjH0psjORI/lB7jZIjhZvkiOWpvT7IeY4Ut7Mj+akPJ2FJSQz/30DGkzkyf/583N3dqVq1KgANGjSgePHivPfeexw9ejTTdtnZ2ZHVzeFpE5Pm9pjCTHLE8tReH1g2R3JDURS++OIL1q5dy7Bhw2jXrl2W5xS6wY20W7ZKlSpltQmo7OzsKF26tFXeO7+ovcb79+8DZOsWwFKOjpRN53lVYbskR/KH2muUHCncJEcsT+31Qd7kiHN8fLrHP7mKXZpWrVqZfB8bG2vyfdqdFm5ubsa7MZ4+Ju04Nzc3IPXOjoyOUfvfX25Jjlie2usDy+bIs0pKSuKDDz7gxx9/ZNiwYUyePDlb58lwqBBCCCGEEMIoIiKCLVu2EBoaarI9ISEBgOLFi6PVas0m+Ev7vlKlSri4uODl5cXNmzfNXjs2NpZKlSoZj336GL1ez+3bt43HCCEKj5iYGIYMGcKePXv48MMPsz2wATK4YRVpI9VqpvYa054pFcJa1N7HQP01So4Ia1N7H1N7fWC5HNFoNEybNo3169ebbN+9ezdarZaXX36Z+vXrs3//fpPHT/bt24ebmxs1atQAUpdwPHjwIElJSSbHaLVa450hTZs25bfffiMqKsp4THBwMHFxcbz88ssWqU/kHbX3M7XXBwXrekSv1/PWW29x9uxZ5s+fz6BBg3J0fqF7LKUgKAydJLs1GhQDIdzjFvfxwI2XKI+TRpf1iVZWkEJAFE6SI//SGwxsv5/IL1HJ1HDR8ubzRXC2L/hj95IjwtrUniM5qi85Cc4dhFt/QekXoG4HsC+81yOenp7079+fb7/9FldXV+rXr8+pU6f4+uuv6d+/PxUqVOCtt95iyJAhjB8/nu7du3PmzBlWrlzJxIkTjcvHDh8+nB9//JGRI0cyaNAgbty4wbx58+jdu7fxVv9+/fqxfv16Bg8ezOjRo4mKiuKLL76gRYsW1K1b1yL1ibwjOfKvJCWFS4QSziNKUYxqlMdOI9cjmYmMjOTWrVtUqVIFV1dX/vvf/3LixAn69OlDqVKl+OOPP4zHajQaateunenryeCGFYSFheHl5WXtZlhUdmsM4gx/cQsDCho0/MVN+imtC3wQPHjwwNpNEIWc5Mi/+p2PZnd4EimAvQa23k9mr2/RAj8RneSIsDa150i26zMYYM37cOMsKAbQOsDlYzDgc8s3MpcsmSPvv/8+Xl5efPfddyxfvhwvLy/GjRvH8OHDAWjSpAmLFi1i4cKFjB49Gi8vLyZPnszQoUONr+Ht7c2qVasICAhg3LhxeHh4MGTIEMaOHWs8xtPTk3Xr1jFz5kwmTZqEi4sLnTp1ytGt6MJ6JEdS6RU9W/iF+0ShAOexI5RwOtHA8o3MJWtejxw6dIgpU6awbt06GjVqxL59+wDYtGkTmzZtMjlWq9Xy119/Zfp6MrhhBXmxJE5Bl50aFUUhhH/+N7ABCgqPiOMhMRSnqOUbmQuenp7WboIo5CRHUj1ONvBLZBJ6QAukKHA+JpmIFChZwH/pKjkirE3tOZLt+h7ehXtXQVFAowV9Ctw8D3o9ZGOCPWuyZI44ODgwYsQIRowYkeEx7du3p3379pm+Tv369dm8eXOmx/j4+LBmzZpnaaawMsmRVGFE8ZAYFEAD6DFwi3CLti2v5Nf1iJ+fH35+fpluW7duXa7eQwY3rCA5OTlbs9HasuzUqNFocFZ0JJBI2tOaOuxxpeCvCJCcnJz9g8+dATuN+XZD5kukCZEZyZFULlooag+P9aAn9YLC016Dhw386yY5IqxN7TmS7fpcioGDDhIARQ8aOyhaosAPbIDkiLA+yZFUbhRB+7/pLNMGOIribNnG5RE15YgNXP6pT2RkpOqXFMpujV1oSBCneUgMxXChFbVx1DjkQwtz59GjR9k/uG0zcE0n3GLiYNfPedcoUahIjqTS2tmxtqY7Ey7FcDNBz0uu9szzccO+gD+SApIjwvrUniPZrs/JFXpOgd1fweNwKOMDr4+3fAPzgOSIsDbJkVRuGmfaK74c4QLxJPE8nrTDNx9amHtqyhEZ3BCWd/cC/LoMUhKhWmeo3c24q6TGnTdobb22CSFswl39LU4n/4aCgWra2lRxeNG4r7G7jl8bySMeQogsbNgAH30EGg3MmgV9+vy774X68M5K67VNCGEbli6FOXNAp4OFC6FzZ+OuKpoyVKGMFRsnCv6vtoTtO/4NJMeBnR2c/x4e37N2i4QQNsRgMHAq+RgG9ACc158m0ZBg5VYJIWxKQgK8917q5KF6PUyYACkp1m6VEMKW/PMPfPpp6mea+HgYM8baLRJPkcENYXkpSWDnAHb/u1Eo2fRDyYlTeoa8pWeQv57DwXorNFAIUdDp0WOHFg12KCjoeepDyZEfwL8NvNUBzhyxTiOFEAVXUlLqYIZOl/qVnGw+uPHbTpjVAwL7w/U/rdNOIUTBFR+fOjjq4JCaIwnp/KIlIABKl4Zq1eBPyZH8JoMbwvJqvAb6JEiMgdI1wKOCcZder+eLBRAZCVGP4MulEB8vAxxCiH/Z2dnxgt1LpJBEMkmUtauIs53rvwfEx8BX0+HRQ4i8D/Mmpl58CCFEmqJFYeBAiIqCR49g+HBwcvp3f+Q9+GExxD2GqDDYMM1qTRVCFFCVKsFrr8HDhxAbC+++a7r/xAn4z39S91+9Cl27WqWZhZnMuSEs76XOUL4hJMeDe+nUW7n+JykFEpNSBz8h9RcrEVFQtuAvmCKEyEfVdXWoYPBGwYCbnbvpzocPUpdu1Dmmfp+UmHrHmFaCRAjxhMBAeOut1OuQSpVM90XeTV0K1l6X+t+kOOu0UQhRsK1YAX//DUWKQPnypvuuXEnND50uNWciIqzTxkJMBjeswMvLy9pNsDizGl2Lp3tcEUctlSvquXYj9fvnS8Hzz1m2bXlB7Wt6i4KvMOaIq51bBgeWg1Jl4Z/bqd9XqgaOBX9gQ3JEWJvacyTd+ry90z+4fE1w9YSY/30YqVTHYu3KS5IjwtoKXY7Y2UHVqukf/Npr4OGReucGQM+elm1cHlFTjsjghhWofT1oyFmNc2fCj/tS7yJ/pSM28WeTo/Wgr54Dx3S6WmLWE5ldvHiRnj17cuDAAUqVKmXcfvnyZQICAjh9+jROTk40a9aM999/3ySczp07R0BAAOfPn8fFxQU/Pz/Gjh2Lg0PBX2pXZE1y5AlaLXyxHfZtBHt76NDX8o3LA/mVI0JkRO05kqP6dDp4Zw2c2AlF3KBe5yxPKQgkR4S1SY48wd0dzp9PvbujXDl4803LNi6PqClHZHDDCuLi4nB68jlPFcpJjVqtlq5dLNygPJaQ3gRCGXm5Mbi7mm9/FAMXd2V4WkhICP7+/qQ8NeFZaGgo/fv3p1q1aixYsICoqCjmzp3L6NGj2bRpEwA3b95k8ODB+Pr6smDBAkJCQpg/fz4xMTFMmybPEauB5MhTdDp4bZBlG5TH8iNHhMiM2nMkx/UVcYaWb1iuQRYgOSKsTXLkKSVLwpQplmuQBagpR2Rwwwo8PT2t3QSLU3uN7u7uWR/0jFJSUti0aRNz585N9y6LxYsX4+npyYoVK3B0TJ1jwM3NjU8++YTQ0FDKlSvH8uXLcXNzY+nSpeh0Olq2bImTkxOfffYZ/v7+qr+FsDBQex8D9ddoyRwxGAxs2rSJ//u//+P27dsUL16ctm3bMnbsWFxdUy9KBg8ezLFjx8zO3bp1KzVr1gTgxo0bzJ49m5MnT6LVaunUqRPvvfee8TWEbVN7H1N7fWDZHBEiO9Tez9ReH6grR2S1FCuIjIy0dhMsTu01Pnr0yGKvferUKQIDAxk6dCiTJk0y2acoCkFBQfTs2dM4sAHQpk0bDh8+TLly5QA4evQorVu3Rpc2UyvQqVMn9Ho9wcHBFmu7yD9q72Og/hotmSMrVqxgxowZtGrViiVLljBkyBB27NjBO++8Yzzm0qVLDBw4kE2bNpl8ef9vToJHjx4xaNAgHjx4wJw5c5g4cSK7d+9m4sSJFmu3yF9q72Nqrw8smyNCZIfa+5na6wN15YjcuWEFObr1x0apvcbExMQcHZ+UlERSkunzbHFx8eke6+3tTVBQEMWLF2fbtm0m+27fvk1MTAylSpVi2rRp7N69m+TkZNq2bct//vMfPDw8iI+P5969e1R6aiZ4T09PXF1duX79eo7aLgomtfcxUH+NOc2R7FIUhRUrVtCnTx/jQMTLL7+Mh4cH48eP5+LFi3h6evLw4UOaN29OnTp10n2dDRs28PjxY3bs2IGHhweQOrHayJEjOXv2LLVr17ZI+0X+UXsfU3t9YLkcESK71N7P1F4fqCtHZHBDiHxw5coVzp+/YLLtYYqS7rGZzVj88H+zLwcEBFCvXj2+/PJL7t69S2BgIOPGjePbb78lOjoaIN3bxl1cXIiJiXnWMoQQNiA2NpauXbvSubPphIiVK1cG4NatW9y/fx+AqhnN+E7qHWANGjQwDmwANGvWDBcXFw4fPiyDG0IIIYQoUGRwQ4h84OPjQ8WKpndS3I2OY8HGoBy9TlJSEgClSpViwYIFaDQaIPVZubFjx3L8+HGzOzaeZmcnT6MJoWaurq589NFHZtuDglLzpkqVKgQFBaHT6Vi4cCFBQUHExcXRuHFjPvzwQ2OGXLt2ja5du5q8hlarpWzZsnIHmBBCCCEKHPmUI0Q+0Ol0uLq6mHw5OxfJ8euk3Y3RvHlz48AGQNOmTYHUJWLTjomNjTU7PyYmBjc3t2cpQQhhZUlJScTExJp8ZfR429POnj3L8uXLadeuHd7e3ly6dImkpCScnJxYvHgxn3/+Obdu3aJ///6Eh4cDEB0dLXeACSGEEMJm2MSdG3q9nnXr1rF582bu3btH6dKl6devH/3790ej0aAoCl9//TWbNm3i4cOH1K1bl48++sg4KZoQVhV6CSJ15ttjk3L8UuXKlUOj0Rjv4Eij1+sB0Gg0uLi44OXlxc2bN02OiYiIIDY2Nss7O9RKckTYtNBLxCTFEBn50GRzeJIhy1NPnTrFqFGjKFu2LJ999hkAb731Fn369KFx48bG43x9fencuTPr169n/Pjxmb5mYb0DTHJE2LQ8vB4Rz05yRNi0Ap4jNjG4sXTpUpYvX87bb79NnTp1OHnyJDNnziQ+Pp4RI0awZMkSli9fzqRJkyhTpgxfffUVgwcPZvfu3QXyt9RqXgs6jdprfHKlkizVqQ+e6fwcRkbD/r9z9L4uLi7Uq1ePn376iXfffde4VOzPP/8MQP369YHUOzkOHjzI5MmTjSum7Nu3D61WS8OGDXP0nmohOWJ71F5jTnPE1c0R3VMDm4aoGLj0fYan7d69mw8++ICKFSuyYsUK4/wZPj4+ZseWK1fOeFcHpN4pltEdYKVLl85+21VEcsS2qL0+sN71iHh2kiO2Re31gbpypMAPbuj1elavXs2wYcN46623AGjSpAmRkZGsWrWKN954g5UrVzJmzBgGDhwIpH7Aa926NVu3bmXIkCHWbH66CsN6yWqv0ZrrQY8fP57BgwczatQoBg8ezO3btwkMDKR9+/a89NJLAAwfPpwff/yRkSNHMmjQIG7cuMG8efPo3bt3ofxQIjlim9ReY05zROegQ+dg+tsS58T0JyYGWL16NXPmzKFhw4YsWbLEeFGsKAo7d+6kbNmyxgHRNAkJCcYBkEqVKpndAabX67l9+zYdO3bMUdvVQHLE9qi9PrDu9YjIOckR26P2+kBdOVLg7yuNiYmhW7dudOjQwWR7pUqViIyM5Pjx48TFxdG2bVvjPnd3dxo2bMiRI0fyu7nZUhjWS1Z7jdZcD7p+/fqsWbOG+Ph4Ro8ezeLFi+nZsydz5841HuPt7c2qVauIi4tj3LhxrF69miFDhjB16lSrtduaJEdsk9prtGSObNmyhdmzZ9O5c2dWrFhh8ts+jUbDypUrmTlzJgbDv4+1XLhwgVu3bhnv7mratCm//fYbUVFRxmOCg4OJi4vj5ZdftljbCyrJEduj9vrAutcjIuckR2yP2usDdeVIgb9zw93dnWnTppltP3jwIKVKlSIsLAxIvZ32SWXLljXeql/QODs7W7sJFqf2GvPrFjU/Pz/8/PzMttevX5//+7//y/Tc+vXrs3nzZks1zaZIjtgmtddoqRyJiIjg888/p0yZMvTv35+//vrLZH/58uUZM2YM48aNY9KkSfTo0YO7d+/y5ZdfUq1aNV5//XUA+vXrx/r16xk8eDCjR48mKiqKL774ghYtWlC3bl2LtL0gkxyxPWqvDwrHLfNqIjlie9ReH6grRwr84EZ6tmzZwq+//spHH31ETEwMOp3OOK9Amqxmc39yRBRg0KBBDBs2DC8vL6KjowFwc3MjLCzMOFnjk9zc3Iz7S5QoQXJycoYje15eXiQnJxMXF4enpycxMTHpHuvk5ISnpyeRkZE4Ozvj4OBgDLmneXp64uDgwIMHD4xtTmv3k7Rabb7UFBkZSUJCgnG/wWDAzs5OVTWlcXJyMs51IWyX5EjB73OSI8/myJEjxMfHc+fOHfr372+2PyAggNdff50lS5bw9ddfM2bMGJycnGjfvj0TJkxAq9UCqX+W69atY+bMmUyaNAkXFxc6derE5MmTLdJuWyQ5UrD7XFqGqKmmJ8n1iDpIjhTsPic5YltsbnDj+++/Z/r06XTs2JE333yTZcuWmSyJ+aSMtgMcOHCAsmXLprvvydt3vby8Mm1P2n6tVpvpXAZardY4KpaUlJTpsU8+25XV/Ahp75/2w54RS9f09PNod+/eNTlXDTU96caNG5m+nyjYJEfSf/+C1uckR55Nt27d6NatW5bHtWvXjnbt2mV6jI+PD2vWrMmbhqmM5Ej671+Q+tzTGfL0flus6WlyPWLbJEfSf/+C1OckR2xLgZ9z40mrV69m8uTJtGrVisDAQDQaDW5ubiQlJZGcnGxybGxsbIGcUVgIYV2SI0KI3JIcEULkluSIEHnPZu7cmDdvHsuWLaNbt258/vnn2NunNr1ChQooisLt27epVKmS8finvxfCah78DcnpPMv2yPzWMGFZkiPCZkmOFBiSI8JmSY4UGJIjwmYV8ByxicGNtWvXsmzZMgYOHMiHH35ocluWr68vjo6OBAUFMWLECCB1xtcTJ04wZswYazVZiH+9WBtKpLPE0oNHwMl8b05hJTkibJrkSIEgOSJsmuRIgSA5ImxaAc+RAj+4cf/+fQIDA/Hx8eGVV17h7NmzJvtr1KjBm2++yZdffomdnR0VK1bk66+/xtXVlV69elmp1UKIgkRyRAiRW5IjQojckhwRwrIK/OBGcHAwSUlJXLlyhT59+pjtP3bsGBMmTMDOzo5Vq1YRFxeHr68vs2fPlmfThBCA5IgQIvckR4QQuSU5IoRlFfjBDT8/P/z8/LI8btKkSUyaNCkfWpR7mc1WqxZqr9HdPZ3bsUSBJTlim9Reo+SIbZEcsT1qrw8kR2yN5IjtUXt9kH85cvHiRXr27MmBAwcoVapUts6ZM2cOFy9ezPbKbTa1WopaqGkt4YyovUa11ycKvsLwM6j2GtVenyj41P4zqPb6oHDUKAo2tf8Mqr0+yJ8aQ0JC8Pf3JyUlJdvnfPvtt6xatSpH7yODG1bw4MEDazfB4tReY2RkpLWbIAo5tfcxUH+NkiPC2tTex9ReH0iOCOtTez9Te31g2RxJSUlhw4YN9OrVi8TExGydExYWxsSJE5k5c2aOH8eSwQ0r8PLysnYTLE7tNZYoUcLaTRCFnNr7GKi/RskRYW1q72Nqrw8kR4T1qb2fqb0+sGyOnDp1isDAQIYOHZrtR63mz5/PX3/9xerVq6lWrVqO3q/Az7mhRtHR0aqfFEjtNcbGxmb7WMOjG+i1zulsj8vLJolCRu19DNRfo+SIsDa19zG11weSI8L61N7P1F4f5DxHkjWOGAwGk+1JkenniLe3N0FBQRQvXpxt27Zl6z2GDx9O5cqVsbOzY8mSJdluG8jghlUUhk6i9hpzFALlX8LgVcx8u2sUcCDP2iQKF7X3MVB/jZIjwtrU3sfUXh9IjgjrU3s/U3t9kPMcufDgNufPXzDZHhWTnO7xz3JXSJUqVXJ8ThoZ3BBCCCGEEEIIIUSWfHx8qFixksm2u+GPWLRrpZVa9C8Z3BBCCCGEEEIIIUSWHBx0ODjoTLY5Z3DnRn6TCUWFEEIIIYQQQghh02RwQwghhBBCCCGEEDZNBjesQKvVWrsJFqf2Gu3spOsI61J7HwP11yg5IqxN7X1M7fWB5IiwPrX3M7XXB9bNkcjISP744w9iYmLy5PUkEa2gMKyXrPYaZV15YW1q72Og/holR4S1qb2Pqb0+kBwR1qf2fqb2+sC6OXLo0CH69OnDhQsXsj44G2Rwwwqio6Ot3QSLU3uNOVkySQhLUHsfA/XXKDkirE3tfUzt9YHkiLA+tfcztdcH+Zcjfn5+XL58mVKlSplta9SoUbrnfPvtt6xZsybb7yGrpQhhYYkJoSTEPUxne97cfiWEUD/JESFEbkmOCCFyq6DniAxuWIGbm5u1m2Bxaq/RxcUl28cmPudDQilP8+2ayLxskihk1N7HQP01So4Ia1N7H1N7fSA5IqxP7f1M7fWBunJEHkuxgrCwMGs3weLUXuODBw+s3QRRyKm9j4H6a5QcEdam9j6m9vpAckRYn9r7mdrrA3XliAxuWIFer7d2EyxO7TUaDAZrN0EUcmrvY6D+GiVHhLWpvY+pvT6QHBHWp/Z+pvb6QF05ku3Bjfv373Pjxg2Sk5MzPS4iIoJLly7lumFCCPWRHBFC5IZkiBAityRHhFCvLAc3zpw5w+uvv07Lli3p3LkzjRo1YsaMGRnOHLtx40a6d++e5w0VQtguyREhRG5IhgghcktyRAj1y3Rw49KlSwwePJirV6/SuHFjWrRogUajYcOGDXTv3p2QkJD8aqcQwkZJjgghckMyRAiRW5IjQhQOma6WsmjRIvR6PWvXrqV+/foAPHz4kICAALZv386AAQNYs2YNPj4++dJYIQqbixcv0rNnTw4cOGCyJnT79u25deuW2fHHjh3D0zN1BuNz584REBDA+fPncXFxwc/Pj7Fjx+Lg4JBv7QfJESFE7kiGCCFyS3JEiMIh08GNkydP0rFjR2MIAHh4eDBr1izKlSvHwoULGTp0KBs3bqRcuXIWb6xaFIYlhdReY06WTIpJ/ofHyXHpbH+c6XkhISH4+/uTkpJisj02NpbQ0FAmTpxIw4YNTfYVLVoUgJs3bzJ48GB8fX1ZsGABISEhzJ8/n5iYGKZNm5bttucFyRHLUHsfA/XXmB85ogaSIZaj9j6m9vpAciS7JEcsR+39TO31gbpyJNPBjdjYWLy8vNLd9/bbb5OcnMxXX31lDIMSJUpYpJFqUxg6idprzEkIKMUqo3gWN9+eEJHu8SkpKWzatIm5c+eme5fF5cuXURSFtm3b4u3tne5rLF++HDc3N5YuXYpOp6Nly5Y4OTnx2Wef4e/vn2G/tgTJEctQex8D9ddoyRxRE8kQy1F7H1N7fSA5kl2SI5aj9n6m9vpAXTmS6ZwbpUuX5syZMxnuf+edd+jRowehoaEMHTqUqKiovG6fKhWG9ZLVXqMl14M+deoUgYGBDB06lEmTJpntv3jxIk5OTlSsWDHD1zh69CitW7dGp9MZt3Xq1Am9Xk9wcLAlmp0hyRHLUHsfA/XXqKZ15S1JMsRy1N7H1F4fSI5kl+SI5ai9n6m9PlBXjmQ6uNGuXTv++OMPZs2aRWxsbLrHzJgxg1atWnHlyhX69OnDlStXLNJQNSkMo8FqrzFtXovsSkpKIiY21uQrNs78li4Ab29vgoKCGDNmDFqt1mz/5cuXcXd3Z8KECdSvXx9fX1/Gjx9PeHg4APHx8dy7d49KlSqZtdnV1ZXr16/nqO25JTliGWrvY6D+GnOaI4WVZIjlqL2Pqb0+yN8cGTNmDO3btzfZFhwcTI8ePahduzZt2rRh1apVZuedO3eOAQMG4OvrS7NmzZg3b57ZMqw3btxg1KhR1K9fn0aNGjF9+nRiYmLyrO2SI5aj9n6m9vpAXdcjmQ5ujB49Gh8fH9auXUuDBg1Yvny5+QvY2bFw4ULat2/PzZs3+emnnyzWWLXIal1tNVB7jTmt78qVK/zwww8mXz///HO6x5YoUYLixc1v90pz6dIlHjx4wAsvvMDXX3/NlClT+P333xk4cCAJCQnGJc1cXV3NznVxccnTi4XskByxDLX3MVB/jWqvL69IhliO2n8G1V4f5F+NO3fuNOtXp0+fZtSoUVSuXJlFixbx2muvERAQwMqVK43HpM0B5ujoyIIFCxg6dCirV69m1qxZxmMePXrEoEGDePDgAXPmzGHixIns3r2biRMn5ln7JUcsR+39TO31gbpqzHTODRcXFzZt2sS6dev46aefMhy50ul0LFq0iHXr1rF06VIePXpkkcaqRWRkJKVLl7Z2MyxK7TXm9Gfcx8eHik/dSXH/XiTbl5/M8Xt/9NFHKIpC7dq1Aahfvz7e3t7069eP77//npYtW2Z6vp1dpmOaeU5yxDLU3sdA/TXKz3j2SIZYjtr7mNrrg/zJkbCwMD7//HOTVdsAFi5cyEsvvcQXX3wBQIsWLUhJSeHrr79mwIAB6HS6bM0BtmHDBh4/fsyOHTvw8PAAwMvLi5EjR3L27Fnj9U5uSI5Yjtr7mdrrA3Vdj2T5KcfJyYmRI0eyZcsW/Pz8Mj124MCB/PLLL2zfvj3PGiiEGuh0OlxdXEy+XJydn+m1atWqZfYPfb169XBzc+PSpUvGOzbSu+0yJibGKhMjSY4IIXJDMkQI6/noo49o2rQpTZo0MW5LTEzk5MmTdOjQweTYjh078vjxY06fPg1kbw6wo0eP0qBBA+PABkCzZs1wcXHh8OHDeVaH5IgQ6pfrX+GeOHGCxYsXG7/X6XS8+OKLuX3ZDB04cABfX1+TbYqi8NVXX9GqVStq167NkCFDCAkJsVgbhLCWuLg4vvvuOy5dumSy3WAwkJycjIeHBy4uLnh5eXHz5k2TYyIiIoiNjTWbi6MgkBwRQuRGfmcISI4I25STOcAAtmzZwoULF/jPf/5jsj00NJTk5GSza4oKFSoAcP369WzPAXbt2jWzY7RaLWXLls3XecIkR4SwfXkyuLFkyZK8aEuWTp8+zXvvvWe2fcmSJcblm+bNm0d0dDSDBw82zj0ghDVFGMK5b/jH7CvCEJ7j13J0dGT27Nkm//gC/PzzzyQkJNCwYUMAmjZtysGDB0lKSjIes2/fPrRarfGYgkRyRIjM5TRHDAYDGzdu5LXXXsPX15d27doxa9Yskzl3CsIkf3klPzMEJEeEbYowhHP9cQjn7v5h8nX5/l/pHn/nzh1mzZrF9OnTzSYczGh+r7QlJWNiYrI9B1h0dHSBmCdMckSIrOXl5xpLyHTOjYIiKSmJtWvX8uWXX+Ls7Gxy8RUTE8PKlSsZM2YMAwcOBFLnIGjdujVbt25lyJAh1mq2EADYF6mAg7P5s532RXK+7JJWq+Xtt99m9uzZfPbZZ7Rp04YrV66waNEi2rZtS6NGjQAYPnw4P/74IyNHjmTQoEHcuHGDefPm0bt3b9U/N5gRyRFhy3KaIytWrGDBggUMGzaMJk2acP36dRYuXMjVq1dZuXKlcZI/X19fFixYQEhICPPnzycmJoZp06YB/07yV7JkSebMmUNERARffPEF//zzD8uWLbNovQWV5IiwZfZFKlCsZFFcipkOYhZxjAB+MNmmKAoffvghLVu2pGPHjmavpShKpu9lZ2eXrWOykt/zhOUHyRFhy/Lyc40l2MTgxi+//MLy5cuZPHkyUVFRrF692rjv7NmzxMXF0bZtW+M2d3d3GjZsyJEjRyQEhOoMGTIEV1dX1q1bx5YtW3B3d6dv376MHTvWeIy3tzerVq0iICCAcePG4eHhwZAhQ0yOKWwkR0RhoSgKK1asoE+fPsbVBl5++WU8PDwYP348Fy9eZP369QVikj9bIzkibJ2DTofDE/NfAMS5xJsdt2HDBi5fvsyuXbtISUkB/h3QSElJMc7f9fT8Xml3Wri5uWV7DjBXV9cMj1HjL2QkR4SwHJsY3KhZsyYHDhygaNGiLFq0yGTfjRs3AChXrpzJ9rJly2a41Ka1eXl5WbsJFqf2GvNrzWs/P790J73q1asXvXr1yvTc+vXrs3nzZks1zeZIjtgetddoqRyJjY2la9eudO7c2WR75cqVAbh161aGk/x98sknBAcH06NHjywn+SuMgxuSI7ZF7fWB5XJk3759PHz4kGbNmpntq169Oh9//DFarZZbt26Z7Ev7vlKlStmeA6xSpUpmx+j1em7fvp3uXSO2TnLEtqi9Psi/zzX5IdeDG66urjz//PN50ZYMZfZDFRMTg06nM7lAg6yf03tyRBRg0KBBDBs2DC8vL+MzbW5uboSFhaHX683Od3NzM+4vUaIEycnJREZGZtj+5ORk4uLi8PT0JDw8HIPBYHack5MTnp6eREZG4uzsjIODA2FhYem+pqenJw4ODjx48MDY5vSexdNqtflSU2RkJAkJCcb9KSkp2Nvbq6qmNE5OTqpaD7ogkByRHJEcyVpyUhJJSabnxMWaTwTo6urKRx99ZLY9KCgISL2zK7uT/HXt2tXkGGtM8pcd+ZEhIDmSnoLc59IyRE01PcmS1yOffPKJ2d0US5Ys4eLFiyxevJiyZcuyZ88e9u/fz6BBg9BoNEDqoIibmxs1atQA/p0DbPLkyca+8fQcYE2bNmXVqlVERUVRrFgxAIKDg4mLi+Pll1+2SH3pkRyRHJEcsX25HtwYPHgwgwcPzoOmPBtFUYyB+rSMtkPq7MRly5ZNd9+TS2VmNVqXtl+r1WZ665xWq8XJyQlInX356YmZnvTkvqxux0t7/7Qf9oxYuqan64mMjDTZpoaannTv3r1M30/kjOSI5MjTbQbJkadduXKF8xcumGyLizK/nTw9Z8+eZfny5bRr146iRYsCtjPJX3ZYO0NAcqQg9rmnM+Tp/bZY09MsdT2SdqfXk4oVK4ZOp6NmzZoAvPXWWwwZMoTx48fTvXt3zpw5w8qVK5k4cSJFihQBsjcHWL9+/Vi/fj2DBw9m9OjRREVF8cUXX9CiRQvq1q1rkfrSIzkiOSI5YvsynaXn7t27Ob6AOXv2LOvWrctVo3LCzc2NpKQksxGn2NjYTH+ArCmzHy61UHuN7u7u1m6CzZAcsQy19zFQf405zREfHx9efeVVk682rdtked6pU6cYPnw4ZcuW5bPPPrO5Sf5sIUNAcqQgUnt9YN3rkSZNmrBo0SJCQkIYPXo0u3btYvLkyYwYMcJ4TNocYHFxcYwbN47Vq1czZMgQpk6dajzG09OTdevWUaxYMSZNmsT8+fPp1KkT8+fPz7O2So5Yjtr7mdrrA3V9rsn06qRt27asXbvWbPuRI0eYNWtWuudkts8SKlSogKIo3L5922T77du3zW65LSgyun1ITdRe46NHj6zdBJshOWIZau9joP4ac5ojDjodLq4uJl/OLs6ZnrN7926GDBnC888/z5o1a/Dw8MiTSf7Su6PDUmwhQ0BypCBSe32Qv9cjs2fP5qeffjLZ1r59e3bt2sX58+c5cOAAQ4cONTsvbQ6wc+fO8csvvzBhwgQcHBxMjvHx8WHNmjWcPXuWX3/9lU8//TRPc0ZyxHLU3s/UXh+o63NNpoMbiqKk+xsea4xkZsTX1xdHR0fjs8SQ+hd04sQJmjRpYsWWZSy9553URu01JiYmZvvYf5RI7igPzL7+UdQfliA5Yilq72Og/hotnSOrV69mwoQJ1KlThw0bNvDcc88B5Mkkf/l5kW0LGQKSIwWR2usDuR7JLskRy1F7P1N7faCuHLGJ1VIy4+LiwptvvsmXX36JnZ0dFStW5Ouvv8bV1TXL1SSEyA/O9qVxcShptj3B3skKrRHpkRwRBV1Oc2TLli3Mnj2bLl26MGfOHLPJ6Wxpkj9bITkiCjq5Hin4JEdEQVfQc8TmBzcAJkyYgJ2dnfGZPl9fX2bPnl1gn00TQhQ8kiNCLSIiIvj8888pU6YM/fv356+//jLZX758eZua5M+WSI4IIXJLckSIZ2dzgxtjx45l7NixJtvs7e2ZNGkSkyZNslKrhBC2RHJEqNmRI0eIj4/nzp079O/f32x/QEAAr7/+OqtWrSIgIIBx48bh4eHBkCFDTPpF2iR/M2fOZNKkSbi4uNCpUycmT56cn+UUWJIjQojckhwRIm/Z3OCGEEIIITLWrVs3unXrluVxaZP8ZSZtkj8hhBBCiNy4ePEiPXv25MCBA5QqVSrD42JjYwkMDGT//v3ExcVRv359pk6dSsWKFbN8j/xby00Ypa03rGZqr9HR0dHaTRCFnNr7GKi/RskRYW1q72Nqrw8kR4T1qb2fqb0+yJ8cCQkJwd/fn5SUlCyPHT9+PHv37mXSpEnMmTOHsLAwBg4cSHR0dJbnyuCGFRSG9ZLVXqOa1oMWtkntfQzUX6PkiLA2tfcxtdcHkiPC+tTez9ReH1g2R1JSUtiwYQO9evXK1qosJ0+e5PDhw8yZM4fu3bvToUMH1qxZQ3R0NBs3bszy/CwfSzlw4AB37twx2Xbp0iUApkyZYnZ82j6RscjISNV3FLXXqKb1oPOD5EjeU3sfA/XXKDmSfZIhlqH2Pqb2+kByJCckRyxD7f1M7fWBZXPk1KlTBAYGMmzYMLy8vPjoo48yPf7o0aO4uLjQtGlT4zZPT08aNGjAL7/8wsiRIzM9P8vBjYsXL3Lx4sV0923fvj3d7RqNJquXLdScnZ2t3QSLU3uNOblF7RaPeYSD2fZHPM7LJhVokiN5T+19DNRfo+RI9kmGWIba+5ja6wPJkZyQHLEMtfcztdcHOc+Rh3otBoPBZPvjlMh0j/f29iYoKIjixYuzbdu2LF//2rVrVKhQAa1Wa7K9fPny7NmzJ8vzMx3cWLduXZYvIHLOwcH8Hxa1UXuNOamvGCXxwMtsu6aQPBUmOWIZau9joP4aJUeyRzLEcqSP2T7JkeyRHLEctfcztdcHOc+ROxcfcOHCeZPtSQ9j0j2+RIkSOWpLTEwMrq6uZttdXFyIiUn/PZ6U6eBGw4YNc9QYkT1hYWGULl3a2s2wKLXX+ODBA2s3wWZIjliG2vsYqL9GyZHskQyxHLX3MbXXB5Ij2SU5Yjlq72dqrw9yniM+Pj5UqlTRZFvU3XAuL9iV67YoipLhPju7rAdiZSlYIYQQQgghhBBCZEmnc0CnM73bI6lI1ndVZIerqyu3b9822x4bG5vuHR1Py3RwY/Hixc/csDFjxjzzuUII9ZAcEULkhmSIECK3JEeEsA2VKlXi2LFjKIpiMufNzZs3qVSpUpbn59ngxtMT7kgQCCFAckQIkTuSIUKI3JIcEcI2NGvWjK+//ppff/3VuGJKZGQkJ0+exN/fP8vz82RwIykpiRUrVvDXX38B0KBBg2ydJ4RQP8kRIURuSIYIIXJLckSIgikyMpJbt25RpUoVXF1dadCgAQ0bNmTChAlMmjSJYsWKsWjRItzc3HjjjTeyfL1MBzfatWuX5QucPn2a//znP4SEhFC0aFHee+89evXqlf2KhBCqJjkihMgNyRAhRG5JjghRMB06dIgpU6awbt06GjVqBKQORs6ePZuAgAAMBgP16tVjwYIFuLu7Z/l6zzyhaExMDIGBgWzevBmDwUCnTp346KOPcrzcS2Hk6elp7SZYnNprzE7nShOixOJqiDbbHqPE5mWTbJLkyLNTex8D9dcoOZJ7kiG5o/Y+pvb6QHIkL0iO5I7a+5na64P8yxE/Pz/8/Pyy3Obu7s6sWbOYNWtWttuV5pkGN4KCgpgxYwZhYWGUKlWK6dOn07p162d5qUJJ1ku2fTmpr6TBE3dDSbPtjwz6vGySzZEcyR219zFQf42SI7kjGZJ70sdsn+RI7kiO5J7a+5na6wN15UiOBjfCw8P59NNPCQoKQqPR8OabbzJ+/HhcXFws1T5VevDgAV5eXtZuhkWpvcbIyEhrN8FmSY7kDbX3MVB/jZIjz0YyJO+ovY+pvT6QHHlWkiN5R+39TO31gbpyJNuDG//973+ZO3cu0dHRvPDCC3z22WfUrl3bkm1TLbV3EFB/jXKr4rORHMk7au9joP4aJUdyTjIkb6m9j6m9PpAceRaSI3lL7f1M7fWBunIky8GNa9eu8Z///IfTp0/j4ODAO++8w4gRI7C3f+bpOgq96Oho3NzcrN0Mi1J7jbGxhfv51JySHMl7au9joP4aJUeyTzLEMtTex9ReH0iO5ITkiGWovZ+pvT5QV45kuRTs8uXLSU5Oplq1anz88cdUrlyZhISELF/Y1dU1zxqpNoWhk6i9RjWFgKVJjliG2vsYqL9GyZHskQyxHLX3MbXXB5Ij2SU5Yjlq72dqrw/UlSNZDm6kuXjxIn369MnWi2o0GuP60EKIwk1yRAiRG5IhQojckhwRonDIdHCjQYMG+dUOIYRKSY4IIXJDMkQIkVuSI0IUDpkObnz77bf51Q4hRDouXrxIz549OXDgAKVKlUr3mJkzZ7J+/Xqz3yycO3eOgIAAzp8/j4uLC35+fowdOzbfl7SSHBFC5IZkiBAityRHhCgcZAYdISzsYnICRZLizLbHJ2f+nGdISAj+/v6kpKRkeMzvv//OunXrsLOzM9l+8+ZNBg8ejK+vLwsWLCAkJIT58+cTExPDtGnTnq0QIYTVPGuOCCFEGskRIURuFfQcyfbgxp07d/Dw8MDZ2dm47f79+2zevJkbN27w3HPP0a1bN3x8fCzSUDXRarXWboLFqb3GpwcTMlPG4I6bwcNse7QhMd3jU1JS2LRpE3Pnzs30LovY2FimTJmCl5cX4eHhJvuWL1+Om5sbS5cuRafT0bJlS5ycnPjss8/w9/e32rJWkiN5R+19DNRfoyVzRK0kQ/KW2vuY2usDyZFnITmSt9Tez9ReH6grR7Ks5MqVK/j5+dGuXTt+//134/aLFy/StWtXlixZwg8//MCqVavo3r07GzZssGiD1aAwrJes9hotuR70qVOnCAwMZOjQoUyaNCnD4wICAihRogR+fn5m+44ePUrr1q3R6XTGbZ06dUKv1xMcHGyRdmdGciTvqb2PgfprVNO68pYmGWIZau9jaq8PJEdyQnLEMtTez9ReH6grRzId3IiMjGTAgAH89ddf1K5dG09PTwAMBgOTJ08mKiqKWrVq8d///pf//ve/1KtXj5kzZ/Lnn3/mS+NtVXR0tLWbYHFqrzGnSyYlJSUTGxtr8hUXZ35LF4C3tzdBQUGMGTMmw9Hio0ePsnPnTmbNmmU22hofH8+9e/eoVKmSyXZPT09cXV25fv16jtqeW5IjlqH2Pgbqr1FNS69ZkmSI5ai9j6m9PpAcyS7JEctRez9Te32grhzJ9LGU1atX8+jRIwICAujatatx+6+//srff/+No6MjixYt4rnnngNg6dKltG/fnnXr1hEYGGjZlot8k2SIRYMWBzsnazfFZl25coULF86bbDM8epzusVmNnkZHRzN16lTGjRtnNoCRth/SX5fdxcWFmJiY7DY7T0iOCICYMHBwAUfzH0shMiUZIgAUBWL+AceioHOxdmuErZEcEQAGxUAsiTjhgINGpp5Uo0z/Vg8fPkz9+vVNQgDg4MGDADRr1swYApD6Yaply5YcP37cAk1VDzc3N2s3IduuRR/ifuIFNGgoV+RlSrvUydZ5tlTjs3BxydmVlY+PD5UqVTTZFnMvjF3L/y/H7z1z5kxKlSrF4MGD092vKEqm5+fkubq8IDliGbbSxxQFvh8OV74HOwdoHwi1+mXvXFup8VnlNEcKK8kQy7GVPmbQw9a+cPMQaB3hteXwQpesz7OV+nJDciR7JEcsx1b6mV7Rs4NjhBGFA1o6Kw0oq8n6cQxbqS831JQjmX7KuXPnDtWqVTPb/ttvv6HRaGjatKnZPi8vLyIiIvKuhSoUFhZm7SZkS6I+mvuJF9DiiB323I4/jsFgyNa5tlLjs3rw4EGOjtfpHHBxcTH5enIiq+w6ePAgP/74IzNmzMBgMJCSkmL8O0n7/7Q7NtK7xSwmJibfQ1pyxDJspY+F/Zk6sOHkAXb2cGh61gNwxnNtpMZnldMcKawkQyzHVvrYjUOpAxtOxQEFDkxNHTjNiq3UlxuSI9kjOWI5ttLPrnKPMB7ihAMp6DnKhWwFia3UlxtqypFMBzcMBgP29qY3d0RERHD16lUAGjdubHZOdHT0M31oywubN2+mQ4cO1KpViz59+nDmzBmrtCMrer3e2k3IFg0aQAMYUFB4mKJQ6WgEvsciOP04OdNzbaXGZ5XdQZ68tm/fPhITE3n11VepXr061atXZ+nSpej1eqpXr86SJUtwcXHBy8uLmzdvmpwbERFBbGxsuo+yWJLkiGXYSh/TOgAaUAyg6BWiHiv0H25gzEQ9obcz70e2UuOzslaO2BpbyxCQHMlr2rSFwwypd3FE3YC5peCbhvDoVsbn2Up9uSE5kj2SI5ZjK/3MDjtSP9cABgW7U39AqVLQogVk8uHeVurLDTXlSKaDG6VLl+bGjRsm2w4dOmTcV7lyZbNzTpw4QZkyZfKsgdm1fft2pk+fTteuXVm0aBFubm4MGzaM0NDQfG+LWui0rpRxqoeBFOIMBr687UtMMvyTZGDiZfVPrpNXzsYlciLW/OtsXM6XTBozZgxbt241+erduzdardb4/wBNmzbl4MGDJCUlGc/dt28fWq2Whg0b5llt2SE5UriVfAnqDIPEaEhIhr+rGkhKhPAIWLkue3dwiLzNEVtjSxkCkiOWUK4ZvNgTEqJAn5iaJ8kJEHEVfv7I2q2zHZIjN0y2SY4ULt6UogLPkUgyTmEPaTFqESQmwrlzMHOmtZtnMwp6jmQ6uNGyZUuOHDlinCk4KSmJdevWodFoePXVV82O3759O1evXqV58+aWaW0GFEVh0aJF9O7dmzFjxtCyZUu++uorPDw8WLt2bb62RW3KuTaigecoxlztycFH3sQr8DgFYlPUM8JnaZUUN3wMRc2+Kik5fzykbNmy1KxZ0+Qr7RnRmjVrGperGj58OOHh4YwcOZKDBw+yevVqZs2aRe/evSldunSe1pcVyRHRfha8c8vAxdcMRD4HCYkQF5f6X5E9eZkjtsZWMgQkRyzFzg5e+wpGXyH1ytUASTGQ9BiS1DPJv8VJjkiOFGZ2Gjte0TRk5P16DK42Fq/T1yA6GmJjU79EthT0HMl0QtHhw4ezbds2BgwYQO3atQkNDeXevXuUKFGCIUOGGI87efIkQUFBrF+/nqJFizJw4ECLN/xJN2/e5M6dO7Rp08a4zcHBgVatWnHkyJF8bYsaKcDDZA1OGoUEJfV7/3LqmXhGjby9vVm1ahUBAQGMGzcODw8PhgwZwtixY/O9LZIjAiAuAZIN4OAAyf97qs2va+bnCAG2kyEgOWJpsXdBo0mdnNiQnHo90myKtVslbIHkiEijvfx36sVI2gWJosCECdZulsgjmQ5ueHp6snHjRqZNm8aJEycAqF69OrNmzaJYsWLG4959910ePHiAu7s7S5YsoXjx4hZt9NPSbjOrUKGCyfZy5cpx69Yt9Ho9Wq02X9ukJlo7O+oXtefE4xR0ikLFIloGl3a0drMKBT8/P/z8/DI9ZuzYsekOWtSvX5/NmzdbqmnZJjkiAIq6QbnScOsO6BygYgVoUDd/V+4RtslWMgQkRyzNswoULQePb6XOvVG+GZSpb+1WCVsgOSKMfH2hZMnUQQ29Hrp2hapVrd0qkUeyXOC3UqVKfPvtt8TFxXHz5k3Kli1rttrCgAEDcHNzo2vXrsTHx3Ps2DGaNGlisUY/LSYmBjBfxsbFxQWDwUB8fLxxBYk0bdu2Nfl+0KBBDBs2DC8vL6KjU+eTcHNzIywsLN2JZNzc3Iz7S5QoQXJyMpGRkem2z8vLi+TkZOLi4vD09ERRFO7evWt2nJOTE56enkRGRuLs7IyDg0OGM/R6enri4ODAgwcPjG1Oa/eTtFptntQ0ryRsdtCQrGjo6Z46ypmQkGCsKTIykoSEBONrJSYmcvfu3QJd09Oe/nt6uqY0Tk5OqloyKT9IjkiOAPgPhaPHi6Cxg6aN4zEYMu9zkiMijS1kCEiO5Eef6/CthsubnHEoouDzRhx6fcZ9Li1DCnpNT5McsQzJEcmRtJrs1q+nyPbtGIoVI75PH7z0eskRlchycCONs7Mzfn5+jBkzhtGjR5vs8/f3N/7/smXL2LBhA6dPn867VmYhbVlBjUaT7v70th84cICyZcume/yTQZc2h0FG0vZrtdpM5zLQarU4OTkBZDk5kaenp/H/s5ofIe39037YM5IXNU156o/ryZqebHN6CmpNT8tuTXFxcZm+n0if5Ej6ClOOVPFO+z8P437JEZFdBTlDQHIkrc0W7XOloXKNtCOKGfdLjojskhzJWOHJkdJQrx6QdjUiOaIWmQ5uHD16lJCQEOP3iqLwxx9/sG7dunSPT05OZvfu3fl+q1TaD0NsbCwlSpQwbo+NjUWr1Ra40aiwsLAsf2htzY/hidxL1NPXyxFXB60qa3ySmtaDtjTJEctQYx+7pb9OspJERU0VtFrJkbxy8eJFevbsyYEDByhVqpRxe/v27bl1y3wdzWPHjhkvgs6dO0dAQADnz5/HxcUFPz8/xo4di4ODg9l5lmIrGQKSI9am18NfmyAlCWoNgAcP1FVfeuR6JHskRyxHdTmi6LlIKHbYUZUyPLj/QFX1pUdNOZLp4EbRokWZPXs2iqKgKAoajYbg4OAsJ7N5880387SRWUl7Ji00NNTk+bTQ0FAqVqyYr23JjieDSg1G/RXFhn9SZwicdSOO8008VFfj07Ia0RX/khyxDLX1sV8SfuIfbgNwiT/phJ/qanxafuRISEgI/v7+pKSkmGyPjY0lNDSUiRMnmi0PXbRoUSB1UrvBgwfj6+vLggULCAkJYf78+cTExDBt2jSLt/3J9thChoDkiLWtawO3j6X+/4kvYfhJddWXHrkeyR7JEctRW45s4CARpD4icp4b9CjR1Motsjw15Uimgxs1a9bkq6++IjIyEkVR+PDDD2nXrp3Zc12QeouUvb09Xl5eNGjQwGINTk/FihV5/vnnCQoKolmzZkDqiOuhQ4do1apVvrYlO5KTk1U1EdDWsGQcNKk/TPeTFHaGJ9OtGKqq8WnJacs9ZMPvj5OxdzI/PuVx9l/DlkmOWIbaciSMu9iRWk8sMUQQTtHkYqqq8WmWzJGUlBQ2bdrE3Llz073L4vLlyyiKQtu2bfH29k7nFWD58uW4ubmxdOlSdDodLVu2xMnJic8++wx/f/98+02WrWQISI5YU1JM6sCGvROggfsXIPzvZEq9qI76MiLXI9kjOWI5asqRSCWaSKKxJ3XC87tEkJCcgIu2YN0tk9fUlCNZzrnRsmVL4////vvvGQaBNWk0GkaMGMGMGTNwd3enbt26rF+/nocPHzJ48GBrN89MZGRkls9n2ZKi9hrCkxQUDWgA7yJ2REY+UFWNT3v06FG2j62KM0U05qEYjzPquQksc5IjeU9tOaJFSwopaAANGpxxVl2NT7Nkjpw6dYrAwEDjhHIfffSRyf6LFy/i5OSU6W8Bjx49SuvWrdHpdMZtnTp14pNPPiE4OJgePXpku/25ZQsZApIj1qQtAlod6JMADWjsIcEuElBHfRmR65HskxyxDDXlSBEc0aDBgAIoaLEjMjISl9LqHtxQU45ke0JRgFmzZlmqHbnWv39/EhMTWbduHWvWrKFatWqsXLmScuXKWbtpqrexZlEGnH9MtB78S+to4K7jbqy1WyUKKskRkZ4mDq04kXwEAwaqaWvhqi3KY2Ks3awCJSk5ieQk09+MJMSnPwmYt7c3QUFBFC9enG3btpntv3z5Mu7u7kyYMIGjR4+i1+tp1aoVH374ISVLliQ+Pp579+5RqVIlk/M8PT1xdXXl+vXreVdYDhXkDAHJEWvRauH11bBnHCgGaDsTdK5ZnycKJ8kRkZ4iGh2tlFr8yl9ogDb4oiX9iV1FwZSjwY2CbujQoQwdOtTazSh0GhXTcaWZup63E4WX5Ih1PK8ty+vaN6zdjALtypUrXDh/wWSbXcwj40zvT8rqGehLly7x4MEDXnjhBQYMGMC1a9dYuHAhAwcOZPv27cYl6Z5ebhBSlyNMW6pQpE9yxDqq90r9SpPO6pRC2AzJEeuoralMbSobv7+LBIktUdXghhBCCKFWPj4+VKpoeidFQvg9zuxanuPX+uijj1AUhdq1awNQv359vL296devH99//73J7dvpsbOzy/F7CiGEEKLw+eGHH/jqq68IDQ2lTJky+Pv7061btwyPv3TpEl988QWnTp3C0dGRli1bMnHixGzN9SVXJ0IIIYQN0DnocHFxMflyLuL8TK9Vq1Yt48BGmnr16uHm5salS5eMd2zExpo/YxgTE2NcqlAIIYQQIiO7d+9m0qRJNGvWjCVLltCwYUPef/999u7dm+7xt27don///ty+fZtPP/2UgIAAwsPDeeONN3j8+HGW7yd3bgghhBCFSFxcHHv27KF69eq8+OKLxu0Gg4Hk5GQ8PDxwcXHBy8uLmzdvmpwbERFBbGys2VwcQgghhBBPmz9/Pp07d2bKlCkANG/enEePHvHll1/SqVMns+PXrVtHSkoKq1evNk5U27hxYzp16sSKFSuYMGFCpu8nd25YQX4tn2dNaq9RbWt6C9uj9j4G6q/RWjni6OjI7NmzWbx4scn2n3/+mYSEBBo2bAhA06ZNOXjwIElJScZj9u3bh1arNR4jbJva+5ja6wO5HhHWp/Z+pvb6wHI5Ehoayq1bt+jQoYPJ9o4dO3Lt2jVCQ0PNzrl+/To+Pj4mK/A4OjpSs2ZNDh8+nOV7yp0bVqCm9aAzovYac7Ie9OkHKdiRYrbd8MB8mxDZpfY+Buqv0Vo5otVqefvtt5k9ezafffYZbdq04cqVKyxatIi2bdvSqFEjAIYPH86PP/7IyJEjGTRoEDdu3GDevHn07t1bNcv+FXaFoY+puT6Q6xFhfWrvZ2qvD3KeI+iTMBgMJtuViESzY69duwZgdrdnhQoVgNSBjKdXAHr++ef5+++/SUlJwd7+36GK27dvpzsY8jQZ3MjE+eQ4DiY8pqxWx6tFPHDQ5M1SQHFxcTg5OeXJaxVUaq8xISEh28c2cXDE2cH8zyLOwZGf87JRokA6FJHEDxGJ1HKxp9/zTtjbSY5kl9prtGaODBkyBFdXV9atW8eWLVtwd3enb9++jB071niMt7c3q1atIiAggHHjxuHh4cGQIUNMjhH5IyLhKlHJN3GzL01JpxfRyPVItqi9PpDrEZF9l5Xb3CWCspSkCs9LjmST2uuDnOfItavXuHDBdPU2TXwUT88CltHKay4uLgDprrzWrVs3tm7dytSpU3nnnXdwdHRk3bp1xgGPrMjgRgb+To5nalQosejRA38mxTK9WN6sLe3p6Zknr1OQqb1Gd3d3azdB2IDgh0m8eeERMSlgAM48TmZ+taJ58tpq72Og/hrzK0f8/Pzw8/Mz296rVy969eqVzhn/ql+/Pps3b7ZU00Q2RCRe5e/ovehJ5h/+JF4fSQXXpnny2mrvY2qvD+R6RGTPBeUmh/iTFFL4k+s0pyZ18c6T11Z7P1N7fZDzHPHxqWp2N0Z8xD2O//KVyTZFUTJ9nfRWXqtfvz4zZ85k1qxZ7NixA41GQ/v27XnjjTfYsmVLlm2TOTcycCY5lsfoSQb0wKHkaK4nZ39UKzORkZF58joFmdprfPTokbWbIGzA/ogkHqVAkgLJCqy5l0hofN7c/qv2Pgbqr1FyRGRHROI1UkhEQY+Cnjvxp0g2xOfJa6u9j6m9PpAcEdkTwj2SSMaAgh4Dx7lIsiLXI9mh9vog5zmi0zmYr97mXMTsuLSV1Z5eeS3tjo2MVl7z8/Pj+PHj7N27l+DgYBYtWkR0dDTFihXLsm0yuJGBKvZFMAAKoCH1D+q2ISnzk7IpJ7f+2Cq115iYaP5cmRBPq+umRa88kSMaCE3MfBQ7u9Tex0D9NUqOiOxwtS9BaoqkUUg2xOXJa6u9j6m9PrBsjiiKwpo1a+jYsSO1atWia9eu7Nq1y+SY4OBgevToQe3atWnTpg2rVq0ye51z584xYMAAfH19adasGfPmzTN7xv/GjRuMGjWK+vXr06hRI6ZPn57uLevi2XjihsK/SWLAQCLZn2chM2rvZ2qvDyyXI2l3d9y6dctke9pKbOmtvBYSEsLOnTvRarVUqlTJONnpX3/9xUsvvZTle8rgRgZ8HZypoy2CFnAAPDX21LA3H5ESQoiMdH3OiTqudjhooIgdlNZpqOWq7kmphBB5q5RTbZzsPAE7NNhTRFsMJ20xazdLFALLli0jICCAbt26sWzZMpo2bcqkSZPYvXs3AKdPn2bUqFFUrlyZRYsW8dprrxEQEMDKlSuNr3Hz5k0GDx6Mo6MjCxYsYOjQoaxevZpZs2YZj3n06BGDBg3iwYMHzJkzh4kTJ7J7924mTpyY7zWrVQN8cKPI/1JESwnccUHd80gI66tQoQJly5Zl7969Jtv3799PxYoV052c/MqVK0yePNlk8tDjx49z6dIl2rVrl+V7ypwbGdBoNMz2qMAP8Q+JNKTQxakYHloHazdLCGFD7DQadtf1YNWdBB6nGBhapgiu9jKmLITIPjs7LbU9+nIv/k8URc/zRWpjp5FBUmFZycnJrFq1ijfeeIO33noLgCZNmnD+/HnWr19Ply5dWLhwIS+99BJffPEFAC1atCAlJYWvv/6aAQMGoNPpWL58OW5ubixduhSdTkfLli1xcnLis88+w9/fHy8vLzZs2MDjx4/ZsWMHHh4eQOrymyNHjuTs2bPUrl3ban8OauGocaCv0opz3EAL1MY7zyYUFSIzo0ePZsqUKbi7u9OqVSsOHDjAnj17mD9/PpD62M+tW7eoUqUKrq6utGzZkrJlyzJhwgTGjh1LZGQks2bNonbt2nTt2jXL95Or7EwU0djRy7k4/q5elLN3tHZzhBA2qJiDHRMqOvNxFVfKF5EPJEKInHOwc6a8S2MquDZFp3XN+gQhckmr1fLtt98ycuRIk+0ODg4kJiaSmJjIyZMn6dChg8n+jh078vjxY06fPg3A0aNHad26NTqdznhMp06d0Ov1BAcHG49p0KCBcWADoFmzZri4uHD48GFLlVjouGmK8LKmGo001XDS6LI+QYg84OfnxyeffEJwcDCjR4/m999/Z86cOXTp0gWAQ4cO0adPH+PqK87OzqxYsQJ3d3fGjx9PYGAgXbp0YcWKFdlaklfu3ABITIRhw+C336B+PVi5CpyfXsxGiGdzPlTBPtpgtj0lKm/mXhAFgyE5npSLa1Bi7oB7FXTVBqCxk8EMkTckRwqHZH0cl6P3kKB/SDGHSni7tZHfroo8cz5UQfMoBYNBb7JdH2U+p5ydnR1Vq1YFUufeiIiIYNu2bfz66698+umnhIaGkpycbPbMfIUKFQC4fv06tWvX5t69e2bHeHp64urqyvXr1wG4du2a2W9ktVotZcuWNR4jsu+xEsc+ThFtiKeqoQwv278kOSLyzLNcj/Tt25e+ffumuy+9Fd0qVarEihUrnql9MrgBMH8+/PgDOOhh13YYHw9fbYd0lqfJC2pfKxnUX6OjY/bv5Hm5iAOuLuYj5DGJDuzIwzYJ69Jf/xEl8iLokyA+nKSESBzrTbDY+6m9j4H6a5QcEU+7HnuYR8m3UUjmn8QzJBviqFbsNYu9n9r7mNrrg5znyI1bV7lw/oLJdiUxKtPz9u/fz7hx4wBo1aoVXbt25eLFiwC4upreSeTi4gKkroYQHR2d7jFpx6VNGBodHZ3lMSL7DvIH9+IekfxAy3Hn68QGl6RD9+cs9n5q72dqrw/UdT0igxsAd++mfiBx1qUuZ3Djbwi7BM9nPSPrsygM6yWrvUZZV148TUmMSs0R/vfbkUchGKLvYudmPllSXlB7HwP11yg5Ip6WpI9BIZnUHFF4mHyNRH0sjloXi7yf2vuY2uuDnOeIj48PlSpWNNkWG3WX/X8tyvCcl156ifXr13P58mW+/PJLRo4cyTvvvJPp+9jZ2aEomd9ZZpeNXyJm5xhhKlqfSHKUBk2SHbjo+XNfIq07gYOF1kVQez9Te32grusRSQyAoUPByQFiEsFJBy1fAMX8dpu8UhjWS1Z7jbKuvHiatmyL//2fAdCAne5//28Zau9joP4aJUfE00o51fnf/ymkXqLZYboMbN5Sex9Te32Q8xzROehwcXE1+XIukvngWbly5WjQoAFvvvkmU6dO5bfffjPui42NNTk27U4LNzc3490YTx+TdpybmxuQemdHRsekd0eHyFxtfSXQAsWS4W4ROFDKou+n9n6m9vpAXdcjMrgBUKcO7NoEb7WCaZ3g5TZQyjJ3bUDqRClqp/YaC8MtaiJntB4+aHx6g7YIaJ2wK9UAO7eyFns/tfcxUH+NkiPiaSWKvEA5x6bYocMOB54vUgtHC04gqvY+pvb6wHI5EhUVxY4dOwgLCzPZ/tJLqdfHt2/fRqvVcuvWLZP9ad9XqlQJFxcXvLy8uHnzpskxERERxMbGGufiqFSpktkxer2e27dvm83XIbJW07EitX5shmFsPQyvt6CJv4PF7toA9fcztdcH6roekcdS0jTqBPXaQHICFClq0bdycFD/krJqr1Ht9YlnoyvXBsPzzcCQjJ3OMreRpykMP4Nqr1Ht9YlnU6FoE8oY6gIK9naWveBU+8+g2usDy9VoMBj44IMPePvtt43zbUDqyiYANWvWpH79+uzfv59BgwYZJ6zct28fbm5u1KhRA4CmTZty8OBBJk+ebFwxZd++fWi1Who2bGg8ZtWqVURFRVGsWDEAgoODiYuL4+WXX7ZIfWrXfpg7LXqnPmrg6GbZ91J7P1N7faCuGuXOjSfZ6yw+sAGYjYKrkSVqTIqDA1Nh25tw50Sev3yOPHjwwLoNEAWWnb3O4gMbIDnyzKKjYeJEGDQIzp/P+9fPAckRkRF7O0eLD2yA+nPEUvVFxOl5/9dY3jkSx/VHKRZ5j+yyVI54enrSr18/li9fzvLlyzl27BiLFy9m3rx59OrVi8qVK/PWW29x+vRpxo8fz+HDh1mwYAErV67E39+fIkVSbxUYPnw44eHhjBw5koMHD7J69WpmzZpF7969KV06dU6qfv36odPpGDx4MD/99BNbtmzhvffeo0WLFtStW9ci9RUGjm6WH9gAyZFn9egO7PKHXaPg8R2LvEW2qel6RO7cEDbj2/Zw+1jq/1/8Dvz/hBIvWLdNQggb07gx/G+Wf7Ztg7//hlKWfR5ZCKEeBkWh3a5Y7sSmzoby440UzvRxxU2nvt8XTpkyheeff56tW7eyaNEiSpUqxbhx4xg2bBgATZo0YdGiRSxcuJDRo0fj5eXF5MmTGTp0qPE1vL29WbVqFQEBAYwbNw4PDw+GDBnC2LFjjcd4enqybt06Zs6cyaRJk3BxcaFTp05Mnjw532sWIj8kJ8DyuhAXnvr95R3wzg1wUM/TIVYjgxvCJigK3P0dsEtdoTclEc7/H7Sabu2WZe1yiIJjmPmEcIkxWU8Sd/HiRXr27MmBAwco9cQHsO+//55vvvmGW7duUapUKfr378+AAQNM1jE/d+4cAQEBnD9/HhcXF/z8/Bg7dqyqbj0TIkdiY+Hy5dQQsbNL/f7HH+F/F+oFWW5yRAiRd+7EKNyNTb31WaOBx8lw8r6e1mUL/uBGTnPEwcGBESNGMGLEiAxfs3379rRv3z7T961fvz6bN2/O9BgfHx/WrFmT6TFCqEX4eYh7ABpt6tpYceEQcQlK1bF2y7JW0K9HZHBD2AydGyQ8BIMCaOC5GtZuUfY0KGZPUXfzrvbY3p6LmZwXEhKCv78/KSmmt7zu2rWL9957jyFDhvDhhx9y9uxZZs+eTWJiovEC5ObNmwwePBhfX18WLFhASEgI8+fPJyYmhmnTpuVleULYDkdHKFIkdVBDUVIHOKpVs3arsuVZc0QIkbeKOYKDHSQZAAW0GijnVvAHNkByRIiCwq002GnBkPK/tbEcwNVGbiIt6DliG2ksCj2NBvpsgyIlwb4I1HoTqvlZu1WWkZKSwoYNG+jVqxeJiYlm+5ctW0br1q354IMPaNKkCaNGjaJr166sX7/eeMzy5ctxc3Nj6dKltGzZkqFDhzJlyhT++9//qv7ZSCEyZG8P69eDhwc4O8OYMdCkibVbJYSwIW46O75oqsPNAVzsYUJtB6q4a63dLCGEDXErDe0CUn9xq3ODDl/YzuBGQSd3bgibUbElTC4En8tPnTpFYGAgw4YNw8vLi48++shk/6JFi7C3N+26Dg4OJgMhR48epXXr1saZyQE6derEJ598QnBwMD169LBsEUIUVK+/DhER1m6FEMKGvfGCE2+8IA/HCyGeXZN3U79E3pLBDSvw9PS0dhMsTu01uru75+j45KQkkpKSTLbFxcWme6y3tzdBQUEUL16cbdu2me1/cs33qKgofvrpJ3bs2GGcwCs+Pp579+6ZrQ3v6emJq6sr169fz1HbRcGk9j4G6q8xpzkiRF5Tex9Te30gOSKsT+39TO31gbpyRAY3rKAwTOio9hpzWt/lK1e4cP6CyTZ9ysN0jy1RokS2XvP8+fPGOzBq1KjBkCFDAIiOjgbA1dXV7BwXFxdiYmKy3W5RcKm9j4H6a1R7faLgU/vPoNrrg8JRoyjY1P4zqPb6QF01ypwbVqCmtYQzovYaIyMjc3R8VR8fXn31FZOvNm3b5KoNpUqVYt26dcyaNYvw8HD69etHQkICipL5bMV2dtLt1UDtfQzUX2NOc0SIvKb2Pqb2+kByRFif2vuZ2usDdeWITX3KiYmJoXXr1uzdu9ds38mTJ+nVqxe1a9emQ4cObN261QotzB4vLy9rN8Hi1F5jdu+uSOOg0+Hi6mry5ezskus2NGrUCD8/P+bOnUtISAg//fST8Y6N2Fjzx15iYmJwc3PL1fvaOskR26H2GnOaI6LgkByxDWqvDyRHbJnkiG1Qe32grhyxmcdSYmJiePvtt7l7967ZvpCQEIYPH07r1q0ZO3YswcHBTJ06FVdXVzp16mSF1mYuOjpa9R8wbb3GCCWa69zDA1cq8zwajcZkf3oDBxm5cU6hiJP53RTxCTlfDzoxMZGffvqJl156icqVKxu3v/TSSwDcv38fFxcXvLy8uHnzpsm5ERERxMbGms3FUZhIjtgWW6/x2g0DZ/5QqFBBQ706mgKTIyJ3JEdshxrqu2qI5i99NN52LrxkV1RyRCUkR2yHGuq7rYTzDw8pTXFKa4qb7VdTjtjE4MaJEyeYPn06ERnMcL98+XLKlCnDvHnz0Gg0tGjRgocPH7JkyRIJASux5RojlWi+I5hEklAAXyrTnJomx+QkBGp5aSjman6TVFSMhl9y2DZ7e3s++eQT2rRpw5w5c4zbjx49CoCPjw8ATZs25eDBg0yePNm4Ysq+ffvQarU0bNgwh++qDpIjtseWa7wSYmDGbIW4eEBR6NdbocfrpstFWitHxLOTHLEttl7fef0jFiWHkIABjR4G25Wnle45k2MkR2yP5IhtsfX6rii3OcAfJJOCHXZ0VOrxgqaMyTFqyhGbeCxl9OjR+Pj4sGLFinT3//rrr7Rq1cpkNLtdu3ZcuXKFsLBCsHaoyFM3CSOeRFLQk4Ke04QQpyRYu1kAaLVaRo0axY4dOwgMDOTYsWOsWbOGDz/8kKZNm9KsWTMAhg8fTnh4OCNHjuTgwYOsXr2aWbNm0bt3b0qXLm3lKqxDckTkp99OKDyOhqQkSEiEjVshNs5g7WaJXJIcEfnpuD6SGFJIwkACBjYYbpOsSI7YOskRkZ+ucIdEktFjIIkUDnIWQxbz89kym7hzY8OGDfj4+HD79m2zfXFxcdy/f58KFSqYbC9XrhwAN27cKBTPSom844Ebev69eFBQCCOKSpSyYqv+NWzYMIoWLcq6detYt24dHh4e9O3bl7Fjxxr/IfT29mbVqlUEBAQwbtw4PDw8GDJkCGPHjrVy661HckTkp3JlISUF7DSg0YBeD3fvwgtVrN0ykRuSIyI/Pa9xRA9o/veViJ6HShLPaZys3DKRG5IjIj+5UQQDCmlDZYkkk0QyTuis2i5LsergRnJyMrdu3cpwf4kSJXB3dzfeap+etGUtXVxMJ2dM+z6jZS/btm1r8v2gQYMYNmwYXl5exqU03dzcCAsLQ6/Xm53v5uZm3F+iRAmSk5MznGnWy8uL5ORk4uLi8PT0JD4+Pt1n7JycnPD09CQyMhJnZ2ccHBwyHKH19PTEwcGBBw8eGNuc1u4nabXafKkpMjKShIR/726Ijo7m7t27NlmTW5w9nh5uRBqisVPAXrEjJSKeu4bUvzMnp/y7qPDz88PPz89se69evejVq1em59avX5/NmzdbqmkFhuSIKVvsc2rMkeovxvFcCXceRBqwt1NwLqKAEsndu6m/LcnPHBFZkxwxZYt9Lr0cScsQW62pXpyO753tiTakYKeAqwLxkQ+4+78bryVHChbJEVO22OfUmCM+cV5c8LhBgiEFjQJF9A5ERISj+d9wh9pyxKqDG2FhYXTp0iXD/VOmTGHw4MGZvkbaspdPT7CUJqNlLw8cOEDZsmXT3ffkc1VZjY6m7ddqtZne7q/Vao0/PEWKFMn0WE9PT+P/Z/UIQdr7p/2wZ8TSNT3ZZoC7d++anGtrNfVSmhNsd4F4EqlLFcqXMn3G9caNG5m+n8g/kiPps7U+93SbwfZzZM4MA99utCM+AXp21/DCC8+bHCc5UnBIjqTP1vrc021+OkOe3l/Qayrl5MTHelc2p9zGgIKfQxkquDqbHCc5UnBIjqTPlvqcGnPkeScneiotOG53CS12NNVWo1hp03aoKUesOrhRtmxZLl++nKvXyGjZy7TvC+IEMFqtNuuDbJwt1XgvzMD9cKhcUYOba+o/Ji4aJzpSL8NzMvrHReQ/yRH1sqUa7+rjeUgylexccNaktvu5knZMHJfxOZIjBYfkiDrZWn2h+jiiSaGynQtO/8uR57VOvKPN+Hk2yZGCQ3JEnWypPkWBf85CciyUrgf2/7sp4zlNMbrSOMPz1JQjNjHnRmZcXFwoWbIkoaGhJtvTvi+Iy14WhmflbKXGX48bWPKNQmISFPdUmPUxeHpkHWJqWg9aSI4UVLZS48/JYWzU3yYFhedwZJquGi52Wf/zKjmiLpIjBY8t1bcr+S7f6++RgkJZnPhIVw1HO7keKWwkRwoeW6rv56lwahnoU6B0A3hzD2gdsj5PTTmiimGaJk2acPDgQZNnk4KCgvDx8aF4cfO1fK0tveet1MZWaty6UyE6BhIS4PYdWLwse+flZMkkYRskRwoeW6nxB/0/xKInAQM3iWdDcsbPXD9JckR9JEcKFlupz6Ao7NWHGXPkb+LYkXwnW+dKjqiP5EjBYiv1pSTAmVWQ8BiSouHGQTi1PHvnqilHbP7ODUhdPaJnz56888479OrVi19//ZXvv/+eL7/80tpNEwWcoqSuYqD534oGV66mPu+Y0bOOz+LO7/AonZ4Wk5JnbyHygOSIeFYKYADjTOQXlMd5/h6SI7ZBckQ8Cw1gQMFA6m8dFeAP5RF98vh9JEdsg+SIeFYGPSh60NiBYoDLu6Dh6Lx9j4KeI6oY3HjxxRf56quvCAwMZMyYMZQuXZpZs2bRqVMnazctXQXxebm8Zis19vGDmXNTBznsteD1XMaTOD3p6VmsM1OtggbPIuavGRmvYfs/OWqusCDJkYLHVmp8Vfs8K/U3AHAASmVzmUbJEfWRHClYbKU+jUZDRzsvNhtS79bQAc9rimTrXMkR9ZEcKVhspT57J6j5Jvy+CNCAvTMUz+by82rKEZsa3Mhsop7mzZvTvHnzfG7RswkLC7Op57eeha3U2KiBHcMGGtgbBO5FYfTI7N2x8eDBAwu3TFiK5IjtsJUaW9uX5JGSxFFDBMU1joxwyN4z0ZIjtktyxDbYUn2vOZQmNlnPKcNDSmmcGOxQPlvnSY7YLskR22BL9bWfnXrnxrWfwKsmtJ2VvfPUlCOqmHPD1qxcudLaTbA4W6lRo9Hw+itavpqvZfYnWsqVyV6X2Lx5s4VbJkTmbKWP5Yat1Gin0eCnK8tcp9p86PgiJewcs3VefuXIxYsXqV69Ov/8Y/orleDgYHr06EHt2rVp06YNq1atMjv33LlzDBgwAF9fX5o1a8a8efNITk7Ol3YLy7OVPvasbKk+rUZDf1155jnVZrJjVYra6bJ1nlyPCGuzpX72LGypPntH6LIQxlyEXpvBMZs3nVg6R3744QdeeeUVatWqRefOndmxY0emx0dGRjJlyhSaNWtGw4YN8ff3z/ZytTK4YQVr1661dhMsTu01fvfdd9Zugijk1N7HQP015keOhISE4O/vT0qK6cOwp0+fZtSoUVSuXJlFixbx2muvERAQYHIRd/PmTQYPHoyjoyMLFixg6NChrF69mlmzsvmrIFHgqb2Pqb0+kOsRYX1q72dqrw8smyO7d+9m0qRJNGvWjCVLltCwYUPef/999u7dm+7xiqIwevRofvnlFyZNmkRAQADh4eEMHDiQR48eZfl+NvVYihBCCCGylpKSwqZNm5g7dy4ODubrwC1cuJCXXnqJL774AoAWLVqQkpLC119/zYABA9DpdCxfvhw3NzeWLl2KTqejZcuWODk58dlnn+Hv728zt+kKIYQQwjrmz59P586dmTJlCpD6yNWjR4/48ssv051H5saNG5w+fZo5c+bQrVs3ALy9vWnXrh0///wz3bt3z/T95M4NIYQQQmVOnTpFYGAgQ4cOZdKkSSb7EhMTOXnyJB06dDDZ3rFjRx4/fszp06cBOHr0KK1bt0an+/f2+E6dOqHX6wkODrZ8EUIIIYSwWaGhody6dSvd641r164RGhpqdk5iYiJgOsmpu7s7AFFRUVm+Z6G7cyNtzeinnz3Ob7dv37bq++eHwlDjk2uQZ+RRYvo/axltFwWf5Ej+KQw1ZjdHUpKTSUoxne/icVJYusd7e3sTFBRE8eLF2bZtm8m+0NBQkpOTqVTJdOLTChUqAHD9+nVq167NvXv3zI7x9PTE1dWV69evZ9lmkTnJkfyh9vrSyPVI4SQ5kj/UXl+avL4euXbtGkCm1xvlypUz2ffiiy/SqFEjlixZQuXKlfHw8GD27Nk4OzvTrl27LNtX6AY3wsPDAejfv79V29G2bVurvn9+KAw1hoeHGzvo01xdXXF3d2fJHxn/rLm7u+Pq6mqp5gkLkRzJP4WhxtzmiKOjo1mOlChRIsPjo6Ojja/9pLTfksTExGR4TNpxMTExGb6+yB7Jkfyh9vrSyPVI4SQ5kj/UXl+avL4eyc71Rno+/vhjhg8fTpcuXQDQ6XQsWbLEbCAkPYVucKNGjRps2LCBkiVLotVqrd0cYaP0ej3h4eHUqFEjw2OKFSvG/v37M/0Q4OrqSrFixSzQQmFJkiMiL+Q0R9K+nubl5ZWjHFEUJdP9dnZ22TpG5I7kiMgLcj1SuEmOiLxgqeuRZ7mWCAkJoW/fvpQvX54PP/wQJycnNm/ezLhx41ixYgX169fP9DUL3eCGk5NTln8oQmRHRiObTypWrJhcLKiQ5IjIK9bIETe31LXhYmNjTbanXai4ubkZf8vy9DFpx6W9hnh2kiMir8j1SOElOSLyiiVyJDvXG09bs2YNAKtWrTLOtdG0aVP69evHzJkzzR61fZr86kUIIYQoRMqXL49Wq+XWrVsm29O+r1SpEi4uLnh5eXHz5k2TYyIiIoiNjTV7flYIIYQQ4klp1wpPX2+kXVukdy1x9+5dvL29jQMbABqNhnr16nH16tUs31MGN4QQQohCxNHRkfr167N//36TW0b37duHm5ub8bbUpk2bcvDgQZKSkkyO0Wq1NGzYMN/bLYQQQgjbUaFCBcqWLcvevXtNtu/fv5+KFStSunRps3MqVarE33//zePHj022nz17ljJlymT5njK4kU9Onz7NgAEDqF+/Ps2aNWPy5Mk8ePDA5JiTJ0/Sq1cvateuTYcOHdi6dauVWvvsNm/eTIcOHahVqxZ9+vThzJkz1m7SM9Pr9axevZrOnTtTp04dunTpwvr1640fBhRF4auvvqJVq1bUrl2bIUOGEBISYuVWCzWTHLE9BTVH3nrrLU6fPs348eM5fPgwCxYsYOXKlfj7+1OkSBEAhg8fTnh4OCNHjuTgwYOsXr2aWbNm0bt373QvSIRtkByxPQU1R0ThJTliW6yZIaNHj+aHH37g008/5ZdffmH69Ons2bOHd955B4DIyEj++OMP46MqgwcPRqvVMnToUPbv388vv/zCxIkTOXHiBGPHjs36DRVhcVevXlVq1qyp+Pv7K4cOHVK+//57pW3btkrXrl2VpKQk4zG1a9dW3n33XeXw4cPK559/rvj4+Ch79uyxcuuzb9u2bcqLL76oLFq0SDl06JAybNgwxdfXV7l165a1m/ZMFi5cqNSoUUNZunSp8uuvvyoLFy5UqlWrpixfvlxRFEVZtGiRUrNmTWXt2rVKUFCQ0qNHD6VZs2bK48ePrdxyoUaSI5Ijz+q7775TfHx8lHv37pls379/v/Lqq68q1atXV9q0aaOsXLnS7Nzff/9d6dWrl1KjRg2lefPmyty5c40/b8L2SI5IjgiRW5Ijtpcj1s6QjRs3Ku3bt1dq1KihdO7cWdm+fbtxX9o1yvHjx43brl69qowaNUqpW7euUq9ePeWNN95Qjh49mq33ksGNfPDxxx8rbdq0MbkgPHv2rOLj46McOnRIURRFmTx5stKlSxfFYDAYj5k0aZLy6quv5nt7n4XBYFBat26tTJs2zbgtKSlJadOmjTJjxgwrtuzZpKSkKL6+vsr8+fNNtn/88cdK48aNlejoaKVOnTrKsmXLjPuioqIUX19fZdWqVfncWlEYSI5IjgiRW5IjkiNC5JbkiG3lSGHLEHksJR9UqVKFoUOH4uDgYNxWuXJlAG7fvg3Ar7/+SqtWrdBoNMZj2rVrx5UrVwgLC8vfBj+DmzdvcufOHdq0aWPc5uDgQKtWrThy5IgVW/ZsYmJi6NatGx06dDDZXqlSJSIjIzl+/DhxcXEm6167u7vTsGFDm6xXFHySI7bXryRHREEjOWJ7/UpyRBQ0kiO21a8KW4YUuqVgraF///5m237++WcgNQzi4uK4f/++2RI85cqVA+DGjRt4eXlZvqG5cOPGDcB8GaFy5cpx69Yt9Hq9Ta2/7e7uzrRp08y2Hzx4kFKlShmDOe3vKE3ZsmWNf7dC5CXJEckRIXJLckRyRIjckhyxrRwpbBkigxu5lJycbLa8zZNKlChhspQNwL179wgICKBGjRo0btyY8PBwAFxcXEyOS/s+bYKVgiytjenVYDAYiI+Px9XV1RpNyzNbtmzh119/5aOPPiImJgadTodOpzM5xsXFxSb+vkTBIjmSSnIkleSIeBaSI6kkR1JJjohnITmSSu05ouYMkcGNXAoLC6NLly4Z7p8yZQqDBw82fn/v3j0GDx6MwWBg/vz5aDQa40y1T9669SQ7u4L/9FBWNWS03VZ8//33TJ8+nY4dO/Lmm2+ybNky1dYq8p/kSCrJkX/Zeq0i/0mOpJIc+Zet1yryn+RIKjXniNozRAY3cqls2bJcvnw5W8deuXKFESNGkJKSwqpVqyhfvjyAceQvNjbW5Pi0793c3PKwxZaR1sbY2FhKlChh3B4bG4tWqzUb+bQlq1evZs6cObRp04bAwEA0Gg1ubm4kJSWRnJxs8sxhbGysTfx9iYJFciSV5EgqyRHxLCRHUkmOpJIcEc9CciSVWnOkMGRIwR86U4mzZ8/Sv39/tFotGzZs4MUXXzTuc3FxoWTJkoSGhpqck/Z9pUqV8rWtzyLtmbT0aqhYsaIVWpQ35s2bx+zZs3n99ddZuHCh8ZatChUqoCiKceKkNLdv37aJvy9hmyRHbJPkiChIJEdsk+SIKEgkR2xPYckQGdzIB6GhoYwYMYISJUqwcePGdDtFkyZNOHjwIHq93rgtKCgIHx8fihcvno+tfTYVK1bk+eefJygoyLgtOTmZQ4cO0aRJEyu27NmtXbuWZcuWMXDgQGbPno29/b83Ovn6+uLo6GhS76NHjzhx4oTN1isKNskR2+xXkiOiIJEcsc1+JTkiChLJEdvrV4UpQ+SxlHwwc+ZMYmJimDZtGvfu3ePevXvGfaVLl+a5555j2LBh9OzZk3feeYdevXrx66+/8v333/Pll19aseXZp9FoGDFiBDNmzMDd3Z26deuyfv16Hj58aPJsnq24f/8+gYGB+Pj48Morr3D27FmT/TVq1ODNN9/kyy+/xM7OjooVK/L111/j6upKr169rNRqoWaSI4Ot3bwckxwRBY3kyGBrNy/HJEdEQSM5MtjazcuRwpYhGiVtxhRhEcnJydSpU4eUlJR090+ePJlhw4YBcOTIEQIDA7l27RqlS5fG398fPz+//Gxurq1atYp169bx8OFDqlWrxvvvv4+vr6+1m5Vj27ZtY8qUKRnuP3bsGEWLFmXBggVs376duLg4fH19mTp1Kt7e3vnYUlEYSI5IjgiRW5IjkiNC5JbkiO3lSGHLEBncEEIIIYQQQgghhE2TOTeEEEIIIYQQQghh02RwQwghhBBCCCGEEDbt/9u7t5CotjiO4z+70YMaCeVLLzHRTAY1BSpOOqQNEUZWEEGKkg8RRgZlZFMTRhLde+gCmUQljWJFF1GMmqJghDII6mGysKCLoBRDmkqk5Hk4nAMeNcezx3E2fT8wD+6911r/9eDv4c+eNTQ3AAAAAACAqdHcAAAAAAAApkZzAwAAAAAAmBrNDQAAAAAAYGo0N6Lcq1ev5Pf7B10LBoM6fvy4srOzZbfbtXTpUq1Zs0YnT55UMBgcMkd+fr6sVqvOnDkz4jo+n09Wq1Vnz57999qzZ89ktVpD+nz+/HnMe+vt7VVWVpYOHz485rEAQkeOADCKHAFgFDmC8TZlogvAyB4/fqyioiKVlpYqPT1dkvT+/Xvl5uaqq6tLTqdTTqdT/f39CgQCqqys1I0bN3T16lXZbLYh8128eFGrV6+WxWIZUx02m00ul+u3z8THx49pzv7+fu3evVttbW1jGgdgbMgRAEaRIwCMIkcQCTQ3olgwGNSvX78GXSsrK1NPT4+qq6tlt9sH3btz545KS0vldrt1+/btIfP19fXJ4/GourpaMTExIdexYMECFRcX/689DOfbt2/atWuXmpqawjYngOGRIwCMIkcAGEWOIBL4WoqJ9PT0qLm5WXa7fUgASNK6deu0ZMkSBQIBffr0acj9pKQkvXjxQjU1NRGodnj19fXKzs5WU1OTli1bNmF1AH8qcgSAUeQIAKPIEYwHmhtRau/evXK73ZKkI0eOyGq16sOHD5Kkjx8/6sePH8OO27dvnyoqKjRz5swh9w4ePKhp06bp1KlT6ujoGL/if6O2tlbTp0/XhQsXtHXr1gmpAfhTkCMAjCJHABhFjiBSaG5EKZfLpRUrVkiS0tPTtX37ds2ZM0cLFy5Ue3u7Nm7cqJs3b+rr16+Dxi1atEjLly9XbGzskDnnzp2roqIidXd369ChQxHZx39t27ZNjY2NyszMnJD1gT8JOQLAKHIEgFHkCCKFMzeilMvlUldXlx4+fKiMjAxt3rxZ0t/dzsLCQr1580b79++XJM2bN0+pqanKzMyUw+HQ5MmTR5x3y5YtamxslM/n0/3797Vy5cpRa3n9+vWg04b/a/HixXI6nSHtKy0tLaTnABhHjgAwihwBYBQ5gkihuWEyVqtV9fX1unz5shoaGtTW1qbW1la1trbK6/Vq/vz5OnbsmJKSkoYdP3XqVJWXl2vTpk0qLy9XWlqa4uLifrtmS0uLWlpaRrxfUFAQcggAmHjkCACjyBEARpEjCDeaGyaUkJCgkpISlZSU6N27d3r69Kn8fr/8fr/evn2rwsJC1dXVKTExcdjxdrtdubm5unbtmk6cODHqq1zr16/X0aNHx2MrACYIOQLAKHIEgFHkCMKJ5obJWSwWWSwW5eXlqaOjQ8XFxXr58qVqa2u1Y8eOEcft3LlTPp9P169fV05OTtjquXLlir5//z7oWkpKilJTU8O2BoDwIkcAGEWOADCKHIFRHChqIhUVFUpPT9fz58+HvZ+YmPjvScT/nEA8ktjYWJWVlWlgYEAHDhzQz58/w1JjVVWVzp07N+jT3NwclrkBGEeOADCKHAFgFDmC8cCbG1EsJiZm0N+xsbH68uWLHjx4oOTk5N+OmT179qjzZ2VladWqVbp3754qKyuNFyzp0aNHYZkHQHiQIwCMIkcAGEWOIBJ4cyOKTZnyd++pr69PkpSTk6MZM2bI6/Xq1q1bQ57v7u7W6dOnNWnSJK1duzakNTwej+Lj4xUIBMJXOICoQY4AMIocAWAUOYJI4M2NKPbPwTk1NTXq7OxUfn6+zp8/r6KiIrndbl26dEkpKSmKj49Xe3u7njx5os7OTrndbtlstpDWmDVrlvbs2SOPxzPiM6P9ZJIkZWRkyG63h7w3AJFBjgAwihwBYBQ5gkiguRHFkpOTlZeXp7t378rr9crhcMjhcKixsVFVVVXy+/1qaGhQb2+vEhIS5HA4VFBQMOZ/xg0bNqiurm7E75CN9pNJkhQXF0cIAFGIHAFgFDkCwChyBJEQMzAwMDDRRQAAAAAAAPxfnLkBAAAAAABMjeYGAAAAAAAwNZobAAAAAADA1GhuAAAAAAAAU6O5AQAAAAAATI3mBgAAAAAAMDWaGwAAAAAAwNRobgAAAAAAAFOjuQEAAAAAAEztLx+VAN51dDflAAAAAElFTkSuQmCC\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAABDAAAADvCAYAAAAEsyT/AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjMuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/d3fzzAAAACXBIWXMAAAsTAAALEwEAmpwYAABjF0lEQVR4nO3deXhTZf7+8XeSbnShUJYiFNkURJFNKIug7Agq28gIsggiOiKOX9GBwX3UcRsUZRFwFBVFXFAUERAL4oioiAiKbMpatrIUShfapsn5/cGvkZDubZrk5H5dV6/LnnOSfD7S52765JznWAzDMBARERERERER8WNWXxcgIiIiIiIiIlIcTWCIiIiIiIiIiN/TBIaIiIiIiIiI+D1NYIiIiIiIiIiI39MEhoiIiIiIiIj4PU1giIiIiIiIiIjf0wSGnxo3bhzNmjUjKSmpyOMcDgedO3emTZs2ZGZmVlJ1MGrUKJo1a+b6+vnnn2nWrBmDBg0q9rFvv/02zZo1Y+bMmR7Ps337du8XLyIuH3/8sdsYLOwrP4sOHjxYouP//e9/F/m6//znP92O/+GHH0pcY3HPLWJ2JR23zZo1c3vcvn37uPXWW2nTpg1t27Zl7ty5ZGVlMXXqVDp06EDLli258847Xc//5ptvlrq2Hj160K5duwrq1PcKy6rjx49zxx130LJlS/r06cMHH3xQ4OMnTZrEkCFDPLb/8MMPbs87YcIEr/YhImIWIb4uQAo2aNAg1q1bx4oVK+jVq1ehx3377becPHmSwYMHExUVVYkVnjN+/HjCw8Np0qQJDRs2ZPv27ezdu5dGjRoV+pjPPvsMi8XCoEGDqFevHomJiSQlJbFjx45KrFxEzpeYmEhiYmKh+y8c0/Xq1WPw4MGFHt+qVasSve7NN99MrVq1qFevXqHHNG/enIkTJ3Lo0CGWLFlSoucVCQbFjdsLTZkyhc2bN9O7d28aN25Mu3btmDNnDh9//DEtWrSgc+fONGrUyDXmWrduXeqaRo8eTW5ubqkf5+8uzKpHHnmEH374gZtuuol9+/bxyCOPULt2bbp16+Z6zI4dO1i+fDnz5s3zeL569eoxceJEzpw5w4IFCyqrDRGRgKcJDD/Vu3dvoqOjWbNmDWfPnqVKlSoFHvfZZ58BFPmHhDfdcccdVK1aFTg36fLSSy/x+eefM3HixAKPP3DgAFu2bCExMZH69etTv359AA4dOqQJDBEfSkxM5J577inx8fXq1SvV8YUZPnw4zZs3L/KY5s2b07x5c3744QdNYIicp7Tj9rfffqNOnTrMmjXLtW3OnDkAvPjiizRo0MC1vbhxWZgxY8aU6XH+7vysOnnyJF999RX3338/d9xxB4Zh0L9/f9577z23CYyXX36ZNm3acO2113o8X0JCAvfccw8HDx7UBIaISCnoEhI/FRERQd++fcnKymLt2rUFHnP27FmSkpJcZzH42sCBA7FYLKxYsaLQY5YuXQr4bsJFREQkWNntdqpVq+a2Lf9sierVq/ugosB06NAhAC677DIALBYLl112GQcPHnQd88svv7BmzRr+7//+zxclioiYliYw/Fj+H/nLly8vcP+aNWvIyspi0KBBWCwW4Nwbkblz59K/f39atGhBhw4duOuuu/j1118LfI6PPvqIoUOH0qZNG66++mruuuuuMp8JUbduXRITE/njjz/YuXNngccsW7aMyMhI+vbtW6bXEBERkdKZOXOmaz2MHTt20KxZM3r06EGzZs3YsGEDAO3bt6dZs2YcPHiw0DUwduzYwX333cfVV19NmzZtGDx4MIsXL8YwDNcxBa2BYRgGixYtYvDgwbRs2ZL27dvzt7/9jW3btrkdl78uxMcff8zixYu58cYbufLKK7nmmmt47rnnOHv2rEdvSUlJjBo1inbt2tGhQwfGjBnDjz/+CJz7oKdt27Z069bNrcZ8U6dOpVmzZuzbt69U/z/zzzzNyspybcvIyCA6Otr1/fTp0+nUqRMdOnQo1XOLiEjRNIHhx9q1a0dCQgJff/01GRkZHvvz15LIn+jIyclhzJgxTJ8+HZvNxvDhw+ncuTPr1q1j+PDhHguCPvroozz44IOcPHmSgQMH0q1bN9avX8/w4cPLPIlR1KTL1q1b2bt3L3369PHJeh0iIiLBKDEx0XVpZ82aNZk4cSKjR49m4sSJrjUdxo8fz8SJE11/nF/ou+++4+abb+bLL7+kXbt2DBs2jOzsbB566CFmzpxZ5OtPmTKFxx9/HLvdzrBhw7juuuvYuHEjw4YN47vvvvM4/p133uHxxx/n0ksvZdSoUYSHhzN//nwefvhht+PmzZvH3Xffze7du+nbty/XX38927ZtY8yYMXz77bdUqVKFvn37cuTIETZu3Oj22JycHFatWkXr1q1p2LBhSf9XAucuoatZsyYLFy4kNTWVTZs28f3339O2bVvg3ETM+vXrdfaFiIgXaA0MP2axWBg4cCCzZ89mzZo1DBgwwLXv1KlTrFu3jnbt2rnWkXjttdf46aefGDJkCE8++SQhIef+eX/77TduueUWpk6dSseOHYmOjua7777j/fffp127dsybN8/1qcFf/vIXbrnlFl566SXmzp1b6pr79OnDE088wYoVK7jvvvvc9vl6vQ4RKdyGDRsK/SNk8ODBJCQkuG07dOhQoccnJibqU0eRSlDUuAXo2rUrrVu3pkOHDnTo0IFZs2ZRs2ZNt3UzNmzYwKFDh9zWtLqQw+HgoYcewjAM3n77bdq0aQPA//3f/zF06FDmzZvHiBEjqFGjhsdjV6xYwaeffsoNN9zAc88953pvcscdd3DTTTcxZcoUkpKSCAsLcz1mx44dLFy40PU6d911F3369GHlypU88cQTREVFsXfvXmbMmEHjxo1ZsGABtWrVAuDWW29l0KBBPPvss3z22WcMHDiQjz/+mGXLltG+fXvXa6xZs4aMjAy391YlFRoaytSpU5kyZQqdOnUCoHHjxtxxxx3AubMvunXr5rYIqmEYrrNlRUSk7DSB4ecGDRrE7NmzWb58udsv2RUrVmC3290mA5YsWUKVKlV46KGHXG8QAK644gpuueUW5s+fz6pVqxgyZAiff/45APfff7/bKY9t27Zl0qRJxMbGlqneqKgoevfuzaeffsrWrVtp0aIFAE6nk88//5x69erpDxsRP7RhwwbXqeQXSkxMLHAC4/yFAM83ceJEjXORSlDUuAWIiYkp051ELrR582YOHTrEX//6V9ekAkB4eDj//Oc/2bJlCzk5OQU+dvHixQAe703q16/PsGHDmDt3LuvXr3db/LJ9+/ZurxMTE0ObNm1YvXo1R48epUmTJqxcuZK8vDwmTJjgmrwAaNCgAVOmTCE7Oxu73U6HDh2oV68eX3zxBY888oirhqVLlxIaGkr//v3L9P/khhtuoFmzZqxfv57q1avTu3dvqlSpwtdff83mzZtZsmQJTqeTp59+2nWZzcCBA3n44YfdJmtERKR0NIHh5y6++GKuuuoq1q1bx5kzZ1yfjixbtsx1aiScu/YyOTmZtm3buk1I5LvqqquYP3++69KQHTt2YLPZuPLKKz2Ozf8EoawGDx7Mp59+yvLly10TGD/88APHjx9nwoQJ+gRCxA9NnDixVHczSExM5O233/ZiRSJSnNKO27LKf+9Q0GRI586d6dy5c6GP/e233wgPD2fhwoUe+/bu3QvA9u3b3SYwCrqkIyYmBji3EGlxNQ0bNszt+xtvvJG5c+eybt06unXrxunTp/nmm2+45ppryrV46aWXXsqll17q+t4wDF566SX69OlD8+bNeffdd3nvvfd48MEHiY6O5l//+hc1atTg3nvvLfNriogEO01gBIBBgwbx008/sWrVKm666SYOHTrEpk2bGDBggGuyIjMzE/jzF/yFateuDUB2djYAZ86cITw8nNDQ0Aqvt0OHDlx00UWsXLmSf/zjH1gsFpYuXeq2XoeIyIU+/vhj1+r++Zo3b06vXr18VJGIwLn3DECBH5AUJz09nby8vELP2AJIS0tz+76gMxTyP/zIX4yzNDUNGjSIuXPnsmzZMrp16+Y6i3XgwIEl7qMkvvjiC3bs2MG0adMA+PDDD+nevTu33HILcG4y591339UEhohIOWgCIwD069ePp556ihUrVnDTTTfx+eefYxiG22RA/qKYKSkpBT5H/i/6/NunRUZGkpOTQ15entspnXBu1e4qVaqUuV6r1cqAAQOYN28emzdv5oorruDLL7/kqquu4uKLLy7z84qIuS1ZssTjdPjBgwdrAkPExyIjI4E/Pyw5n91uxzCMQi+LiIyMJCoqqtBbwldETReeRZGdnU1YWBhW67m16hs1akTr1q1ZvXo1ubm5rFixgtjYWLp3715h9TidTmbMmMENN9xAkyZNANi3bx9XX32165iGDRty+vRp0tPTC/3ASUREiqa7kASAmJgYevXqxQ8//MCZM2dYuXIl9erVo2PHjq5joqOjSUhIYN++faSmpno8R/4txS655BIAmjZtisPh8LiFGcCECRNo165dgbcrK6lBgwYBsGrVKtatW0d6errOvhCRIr399tvs3LnT7evZZ5/1dVkiQa9p06YA/PLLLx77VqxYQatWrfjkk08KfGyzZs04evQox48f99i3du1apk+fXqY7nxVV01NPPUWrVq1ITk52bRs4cCBZWVkkJSXx008/cd1111XoWhRLly5l//79bpf0OBwOHA6H6/v8dUJ0Ka2ISNlpAiNADBw4ELvdznvvvcdvv/3GgAEDPH4BDh48mOzsbJ5++mny8vJc23/77TfeeecdqlatSo8ePQBcC4JOnz7ddVkJwM8//8yGDRto06ZNuc7CaNy4MS1btmTNmjV8+eWXVKlSheuuu67MzyciIiK+0b59ey666CI+/fRTtm/f7tqem5vLm2++ic1mc92N40KDBw/GMAyefPJJcnNzXduPHTvGY489xquvvlqmW6vfcMMNWK1W5s6dy6lTp1zbDxw4wIoVK6hfv77rLm0A119/PaGhoUybNo28vLwKvXzEbrczc+ZMhgwZ4namaaNGjdi8ebPr+y1btlCzZs0yXYojIiLn6BKSANGlSxdq1arFK6+8AsCQIUM8jhk/fjzr1q3js88+Y+fOnXTs2JGTJ0+SlJSEYRhMnz7d9UuzS5cu/OUvf+Gjjz5i4MCBdO3alczMTD7//HOioqJ49NFHy13zoEGDeOKJJ0hJSaF37976hS0iIlKBiruNKkD//v1dlzSUVUhICE8//TR33nknw4YNo3fv3tSoUYO1a9eyb98+pk6dSnx8fIGPHTJkCGvWrOGLL75g586ddO3alby8PFasWMHp06e5//773SYaSqpJkyZMnDiRGTNmMHDgQLp3745hGCxfvpycnByPs7diY2Pp0aMHX3zxBfXr1+eqq64q0/+LgixevJhjx44xYcIEt+033XQTTz31FBMmTCAqKorly5d73GJeRERKRxMYAcJms3HjjTcyf/78QteSCA8P58033+T111/ns88+Y9GiRVStWpXu3btz5513cvnll7sd/+9//5sWLVrw/vvv8/777xMeHs4111zDfffdV6Y3Exe6/vrreeaZZzh79myBEy4iIiJSdsXdRhXOLYRb3gkMOHe3kUWLFjFr1iy+/vprzp49yyWXXMJzzz3numy0IBaLhRkzZrBw4UI+/vhjPvzwQyIiIrjkkksYO3Zsuda4ufvuu2nUqBFvvfUWn376KRaLhTZt2vD3v/+dli1behx/3XXX8cUXX7jdlr68cnJymDNnDjfffDMXXXSR276RI0eSmprKu+++i8PhYPTo0dx+++0V9toiIsHIYuQv5yxSCqNGjWLDhg38+OOPrlu7lsc///lPlixZwieffELz5s0roEIR8WdlGfM//PADo0ePZvTo0Tz00ENerlBEzObFF19k3rx5rFq1igYNGpToMd5+f3Lw4EF69uxJz549XWfZiohI4bQGhoiI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QbRp04Zt27aRnJzssf/yyy9n06ZNLFq0qBKqLdiyZcvo378/3377LVdffbXP6hAJVsoRESkv5YiIiPiKJjD81D//+U+mTp0KwDPPPEOzZs3Yv38/AAcOHCA7O7vAxz344IPMmzeP6tWre+x7/PHHCQsL44UXXiAlJcV7xRfh/fffJyIigrlz53LnnXf6pAaRYKEcEZHyUo6IiIg/0QSGn+rVqxc9e/YEoEuXLkycOJGEhASuuOIKjh49yl//+lcWL17MiRMn3B7XsmVLunXrRnR0tMdzNmrUiLvuuouMjAyeeOKJSunjQhMmTGDFihV0797dJ68vEkyUIyJSXsoRERHxJ1oDw0/16tWLM2fOsHr1arp27cqYMWOAc59+jB07lp07d/LQQw8BcMkll9ChQwe6d+9O586dsdlshT7v+PHjWbFiBUlJSaxatYo+ffoUW8v27dvdVgO/UKtWrbjmmmtK1FenTp1KdJyIlJ9yRETKSzkiIiL+RBMYAaZZs2YsW7aMN954g88//5xDhw7xxx9/8Mcff7Bw4UKaNm3Kc889x+WXX17g40NDQ3nyyScZPnw4Tz75JJ06dSImJqbI19yxYwc7duwodP/o0aNL/IZBRHxPOSIi5aUcERERX9AERgCKi4vj/vvv5/7772f37t18//33rFu3jnXr1rFr1y7Gjh3L0qVLiY+PL/DxrVu35pZbbuGdd97hP//5T7Gnbw4ePJhnn33WG62IiI8oR0SkvJQjIiJS2TSBEeCaNGlCkyZNGDFiBCkpKdxzzz1s2bKF999/n7///e+FPu6+++4jKSmJDz74gAEDBlRYPW+++Sbp6elu2xITE+nQoUOFvYaIVCzliIiUl3JEREQqgxbxDCDz5s2jS5cu/PjjjwXuj4+Pd60Unr9CeGGio6N57LHHMAyDRx55hNzc3AqpccGCBcyaNcvta8OGDRXy3CJSfsoRESkv5YiIiPiKzsDwYxaLxe376Ohojh8/zpdffkn79u2LfEzt2rWLff4ePXpw3XXXsXLlSv773/+Wv2BgzZo1FfI8IlIxlCMiUl7KERER8Rc6A8OPhYScm1+y2+0ADBgwgNjYWBYuXMjHH3/scXxGRgYvvvgiVquVgQMHlug1Hn74YapWrcq2bdsqrnAR8RvKEREpL+WIiIj4C52B4cfyF71atGgRaWlpjBo1itmzZ3PXXXcxdepUXn/9dRITE6latSpHjx7l66+/Ji0tjalTp3LZZZeV6DVq1arF5MmTefjhhws9prjblgF07dqV1q1bl7g3EakcyhERKS/liIiI+AtNYPix9u3bM2LECD799FMWLlxI586d6dy5MytWrGDBggWsW7eOzz//nKysLOLi4ujcuTOjR48u9S/um266iaVLlxZ6bWhxty0DiImJ0RsGET+kHBGR8lKOiIiIv7AYhmH4uggRERERERERkaJoDQwRERERERER8XuawBARERERERERv6cJDBERERERERHxe5rAEBERERERERG/pwkMEREREREREfF7msAQEREREREREb+nCQwRERERERER8XuawBARERERERERv6cJDBERERERERHxe/8PGaEAArN6DD4AAAAASUVORK5CYII=\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "df = df_device.iloc[:,2:]\n", - "\n", - "from sklearn.manifold import TSNE\n", - "tsne_2d_model = TSNE(n_components=2, init='random', random_state = 5,\n", - " learning_rate=5, perplexity = 5, n_iter = 1000) #perplexity is the key hyperparameter to tune\n", - "X_tsne_2d = tsne_2d_model.fit_transform(df.iloc[:,:-1].values)\n", - "\n", - "n_col = 4 # num of columns per row in the figure\n", - "column_names = df.columns.values\n", - "for n in np.arange(0, 12, n_col):\n", - " fig,axes = plt.subplots(1, n_col, figsize=(18, 3), sharey = False)\n", - " cmap = plt.get_cmap('rainbow', 20)\n", - " for i in np.arange(n_col):\n", - " if n< len(column_names):\n", - " c = df.iloc[:,n]# Choose which column is shown as the superimposed color\n", - " im = axes[i].scatter(X_tsne_2d[:,0], X_tsne_2d[:,1], \n", - " marker = 'o', c=c, cmap=cmap, alpha =0.6, edgecolors = 'face', s = 10)\n", - " title = column_names[n]\n", - " axes[i].set_title(''+title, fontsize = fs)\n", - " fig.colorbar(im, ax=axes[i], drawedges=False)\n", - " axes[i].set_xlabel('tSNE-1', fontsize = fs)\n", - " axes[i].set_ylabel('tSNE-2', fontsize = fs)\n", - " else:\n", - " axes[i].axis(\"off\") \n", - " n = n+1 \n", - " \n", - " for i in range(len(axes)):\n", - " axes[i].tick_params(direction='in', length=5, width=1, labelsize = fs*.8, grid_alpha = 0.5)\n", - " axes[i].grid(True, linestyle='-.')\n", - " plt.subplots_adjust(wspace = 0.5)\n", - " plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.7.1" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/unsorted/Constrained_MOBO.ipynb b/unsorted/Constrained_MOBO.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/Data Visualization/20221028 - MOBO Vectors.pptx b/unsorted/Data Visualization/20221028 - MOBO Vectors.pptx deleted file mode 100644 index 7d57403..0000000 Binary files a/unsorted/Data Visualization/20221028 - MOBO Vectors.pptx and /dev/null differ diff --git a/unsorted/Data Visualization/round1_constraint_R1.png b/unsorted/Data Visualization/round1_constraint_R1.png deleted file mode 100644 index 5bcd348..0000000 Binary files a/unsorted/Data Visualization/round1_constraint_R1.png and /dev/null differ diff --git a/unsorted/Data Visualization/round2_selectionX_constraints_R1.png b/unsorted/Data Visualization/round2_selectionX_constraints_R1.png deleted file mode 100644 index 4908569..0000000 Binary files a/unsorted/Data Visualization/round2_selectionX_constraints_R1.png and /dev/null differ diff --git a/unsorted/Grid-snapped-LHS.ipynb b/unsorted/Grid-snapped-LHS.ipynb deleted file mode 100644 index e69de29..0000000 diff --git a/unsorted/PerovScaleup_Round0_Initial_Sampling_FOM_MIT_g2g_v2.ipynb b/unsorted/PerovScaleup_Round0_Initial_Sampling_FOM_MIT_g2g_v2.ipynb deleted file mode 100644 index 6ec39bb..0000000 --- a/unsorted/PerovScaleup_Round0_Initial_Sampling_FOM_MIT_g2g_v2.ipynb +++ /dev/null @@ -1,679 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Process Optimization - the `Main` code \n", - "\n", - "### Initial Sampling with Latin Hypercube Sampling\n", - "- UW - MIT collaboration, led by Ethan Schwartz, Nicky Evans, Karen Yang, and Rahul Patidar\n", - "- v1. Initial sampling in Apr 2025" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Load packages and functions" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "import pandas as pd\n", - "import numpy as np\n", - "import matplotlib.pyplot as plt\n", - "import seaborn as sns\n", - "from emukit.core import ParameterSpace, ContinuousParameter, DiscreteParameter\n", - "from emukit.core.initial_designs.random_design import RandomDesign\n", - "from emukit.core.initial_designs.latin_design import LatinDesign" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "def x_normalizer(X, var_array):\n", - " \n", - " def max_min_scaler(x, x_max, x_min):\n", - " return (x-x_min)/(x_max-x_min)\n", - " x_norm = []\n", - " for x in (X):\n", - " x_norm.append([max_min_scaler(x[i], \n", - " max(var_array[i]), \n", - " min(var_array[i])) for i in range(len(x))])\n", - " \n", - " return x_norm\n", - "\n", - "def x_denormalizer(x_norm, var_array):\n", - " \n", - " def max_min_rescaler(x, x_max, x_min):\n", - " return x*(x_max-x_min)+x_min\n", - " x_original = []\n", - " for x in (x_norm):\n", - " x_original.append([max_min_rescaler(x[i], \n", - " max(var_array[i]), \n", - " min(var_array[i])) for i in range(len(x))])\n", - " \n", - " return x_original\n", - "\n", - "def get_closest_value(given_value, array_list):\n", - " absolute_difference_function = lambda list_value : abs(list_value - given_value)\n", - " closest_value = min(array_list, key=absolute_difference_function)\n", - " return closest_value\n", - " \n", - "def get_closest_array(suggested_x, var_list):\n", - " modified_array = []\n", - " for x in suggested_x:\n", - " modified_array.append([get_closest_value(x[i], var_list[i]) for i in range(len(x))])\n", - " return np.array(modified_array)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Parameter Optimiziton Space: \n", - "- Web Speed (Inorganic): 0.25 - 0.55 m/min (0.01 m/min - 31 steps)\n", - "- Web Speed (Organic): 0.25 - 0.55 m/min (0.01 m/min - 31 steps)\n", - "- Solution Pump Rate (Inorganic): 80 - 200 uL/min (1 uL/min - 121 steps)\n", - "- Solution Pump Rate (Organic): 100 - 280 uL/min (1 uL/min - 181 steps)\n", - "- Molar Concentration (Inorganic): 0.8 - 1.6 M (0.05 M - 37 steps)\n", - "- Molar Concentration (Organic): 0.4 - 1.2 M (0.05 M - 37 steps)\n", - "- Relative Humidity: 11% - 44% (1% - 34 steps)\n", - "- Ambient Temperature: 20 - 65 C (5 C - 10 steps)" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "def make_linspace(start, stop, step):\n", - " num_points = int(round((stop - start) / step)) + 1\n", - " return np.round(np.linspace(start, stop, num_points), 4) # round for cleaner floats\n", - "\n", - "speed_inorg_var = make_linspace(0.25, 0.55, 0.01) # m/min\n", - "speed_org_var = make_linspace(0.25, 0.55, 0.01) # m/min\n", - "inkFL_inorg_var = make_linspace(80, 200, 1) # uL/min\n", - "inkFL_org_var = make_linspace(100, 280, 1) # uL/min\n", - "conc_inorg_var = make_linspace(0.8, 1.6, 0.05) # M\n", - "conc_org_var = make_linspace(0.4, 1.2, 0.05) # M\n", - "humidity_var = make_linspace(11, 44, 1) # %RH\n", - "temp_var = make_linspace(20, 65, 5) # °C\n", - "\n", - "# Tracking unique values\n", - "speed_inorg_num = len(speed_inorg_var)\n", - "speed_org_num = len(speed_org_var)\n", - "inkFL_inorg_num = len(inkFL_inorg_var)\n", - "inkFL_org_num = len(inkFL_org_var)\n", - "conc_inorg_num = len(conc_inorg_var)\n", - "conc_org_num = len(conc_org_var)\n", - "humidity_num = len(humidity_var)\n", - "temp_num = len(temp_var)\n", - "\n", - "# Pack into var_array for downstream normalization\n", - "var_array = [speed_inorg_var, speed_org_var, \n", - " inkFL_inorg_var, inkFL_org_var,\n", - " conc_inorg_var, conc_org_var,\n", - " humidity_var, temp_var]\n", - "\n", - "x_labels = ['Speed (Inorg) [m/min]', 'Speed (Org) [m/min]', \n", - " 'inkFL (Inorg) [uL/min]', 'inkFL (Org) [uL/min]',\n", - " 'Conc. (Inorg) [M]', 'Conc. (Org) [M]',\n", - " 'RH [%]', 'Temp [C]']\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Initial Sampling" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [], - "source": [ - "parameter_space = ParameterSpace([ContinuousParameter('x1', 0, 1),\n", - " ContinuousParameter('x2', 0, 1),\n", - " ContinuousParameter('x3', 0, 1),\n", - " ContinuousParameter('x4', 0, 1),\n", - " ContinuousParameter('x5', 0, 1),\n", - " ContinuousParameter('x6', 0, 1),\n", - " ContinuousParameter('x7', 0, 1),\n", - " ContinuousParameter('x8', 0, 1),\n", - " ])\n", - "\n", - "# parameter_space = ParameterSpace([DiscreteParameter('x1', np.linspace(0,1, 51)),\n", - "# DiscreteParameter('x2', np.linspace(0,1, 51)),\n", - "# DiscreteParameter('x3', np.linspace(0,1, 51)),\n", - "# DiscreteParameter('x4', np.linspace(0,1, 51)),\n", - "# DiscreteParameter('x5', np.linspace(0,1, 51)),\n", - "# DiscreteParameter('x6', np.linspace(0,1, 51))\n", - "# ])" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Filtering Functions" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": {}, - "outputs": [], - "source": [ - "def check_clausius_clapeyron(humidity_vals, temp_vals):\n", - " \"\"\"\n", - " Return boolean mask for valid points satisfying Clausius-Clapeyron constraints.\n", - " Simplified: Ensures RH and Temp do not imply >100% saturation.\n", - " \"\"\"\n", - " # Saturation vapor pressure approximation (T in Celsius)\n", - " # Tetens formula: es(T) = 0.6108 * exp((17.27*T)/(T + 237.3)) [kPa]\n", - " # RH = (actual / saturation) * 100\n", - " # Check that vapor pressure does not exceed saturation vapor pressure\n", - " valid_mask = []\n", - " for RH, T in zip(humidity_vals, temp_vals):\n", - " es = 0.6108 * np.exp((17.27 * T) / (T + 237.3))\n", - " e = RH / 100.0 * es # approximate check\n", - " valid_mask.append(e <= es)\n", - " return np.array(valid_mask)\n", - "\n", - "def check_correlation(X_df, threshold=0.45):\n", - " corr = X_df.corr(method='pearson').values\n", - " # Zero out diagonal so we ignore self-correlation\n", - " np.fill_diagonal(corr, 0)\n", - " return (np.abs(corr) < threshold).all()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Create LHS Sampling" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Valid LHS found after 9205 attempts.\n" - ] - } - ], - "source": [ - "# Sampling loop\n", - "max_attempts = 20000\n", - "attempts = 0\n", - "valid = False\n", - "n_samples = 8\n", - "design = LatinDesign(parameter_space)\n", - "\n", - "while not valid and attempts < max_attempts:\n", - " attempts += 1\n", - " x_init = design.get_samples(n_samples)\n", - " x_init_original = get_closest_array(x_denormalizer(x_init, var_array), var_array)\n", - " df = pd.DataFrame(x_init_original, columns=x_labels)\n", - " df_cols = x_labels\n", - "\n", - " # 1. Filter by Clausius-Clapeyron\n", - " rh_vals = df['RH [%]'].values\n", - " temp_vals = df['Temp [C]'].values\n", - " mask_cc = check_clausius_clapeyron(rh_vals, temp_vals)\n", - "\n", - " if not mask_cc.all():\n", - " continue # skip invalid CC samples\n", - "\n", - " # 2. Filter by Pearson correlation\n", - " if not check_correlation(df):\n", - " continue # correlations too high\n", - "\n", - " valid = True # passed all tests\n", - "\n", - "if not valid:\n", - " print(\"No valid LHS found after max attempts.\")\n", - "else:\n", - " print(f\"Valid LHS found after {attempts} attempts.\")" - ] - }, - { - "cell_type": "code", - "execution_count": 38, - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
Speed (Inorg) [m/min]Speed (Org) [m/min]inkFL (Inorg) [uL/min]inkFL (Org) [uL/min]Conc. (Inorg) [M]Conc. (Org) [M]RH [%]Temp [C]
00.530.31147.0179.01.550.7530.025.0
10.340.49177.0134.01.251.1517.030.0
20.420.53132.0224.01.050.4538.045.0
30.490.42162.0269.01.151.0521.050.0
40.310.34192.0156.00.950.8542.060.0
50.460.2787.0201.01.450.9525.055.0
60.380.38117.0111.00.850.5513.040.0
70.270.46102.0246.01.350.6534.035.0
\n", - "
" - ], - "text/plain": [ - " Speed (Inorg) [m/min] Speed (Org) [m/min] inkFL (Inorg) [uL/min] \\\n", - "0 0.53 0.31 147.0 \n", - "1 0.34 0.49 177.0 \n", - "2 0.42 0.53 132.0 \n", - "3 0.49 0.42 162.0 \n", - "4 0.31 0.34 192.0 \n", - "5 0.46 0.27 87.0 \n", - "6 0.38 0.38 117.0 \n", - "7 0.27 0.46 102.0 \n", - "\n", - " inkFL (Org) [uL/min] Conc. (Inorg) [M] Conc. (Org) [M] RH [%] Temp [C] \n", - "0 179.0 1.55 0.75 30.0 25.0 \n", - "1 134.0 1.25 1.15 17.0 30.0 \n", - "2 224.0 1.05 0.45 38.0 45.0 \n", - "3 269.0 1.15 1.05 21.0 50.0 \n", - "4 156.0 0.95 0.85 42.0 60.0 \n", - "5 201.0 1.45 0.95 25.0 55.0 \n", - "6 111.0 0.85 0.55 13.0 40.0 \n", - "7 246.0 1.35 0.65 34.0 35.0 " - ] - }, - "execution_count": 38, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "##### Visualize the distribution of the initial data" - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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Igk48UZERERERERERCSB326uUFtbi4iICOv///zzz/j000/R0NCAgQMHol+/fv5aFRERERERERERUcDz+Yy3bdu24ZprrsFzzz1nfeyrr77CLbfcgkWLFuG9997D3XffjWeeecbXVREREREREREREemGTxNvBw4cwPjx43HkyBGcOHHC+vjzzz+P+vp6tG3bFgMHDoTRaMT777+PLVu2+BwwERERERERERGRHvg08bZ48WLU1dVhyJAheP755wEAP/74I06ePIno6Gh89NFHmDdvHl555RUIIbBixQq/BE2NFEWByWSCoihahxK0mGO5mN/Qxb6Xi/mVi/kl0i8et0StR1EUREVFaR0GkeZ8usZbdnY2TCYTnn/+ecTGxgKA9ay2fv36ITExEQAwZMgQpKamYs+ePb5FS00oigKz2ax1GEGNOZaL+Q1d7Hu5mF+5mF8i/eLEG1HrURQFcXFxWodBpDmfznjLy8tDhw4drJNuALB9+3YoioILL7ywybKpqak4deqUL6ujZoQQKC4uhhBC61CCFnMsF/Mbutj3cjG/cjG/RPrF45ao9QghUFZWpnUYRJrzaeItMjISdXV11v8vLy/HL7/8AgC44IILmixbVFTE00wlMBqNWocQ9JhjuZjf0MW+l4v5lYv5JSIics1g8Pl+jkS659NPTdu3b49Dhw6hpKQECQkJ2LhxI+rr65Geno7OnTtbl/v555/x559/onv37l6vKzs7G/PmzcO+fftQXV2NzMxM3HHHHbj66qs9amft2rX44IMP8Ntvv6Gurg4dO3bE8OHDcfPNN+vu1HOeuisfcyxXqOSX9aulUOl7rTC/coVKflm7KBhxnwt+rF2BQ1EUxMTEaB0GkeZ8mnjr168f9u7di/vuuw9DhgzBW2+9BUVRcNVVVwEAampqsHnzZjz33HNQFAVXXHGFV+tZu3YtJk2ahLCwMGRlZcFoNGLHjh146KGHcPjwYUyYMMGtdqZNm4YVK1YgMjISF154IWpra/H999/jiSeewLFjx/DYY495FZ9WLBYLTp06hTZt2vCbBEmYY7lCIb+sX/aFQt9rifmVKxTyy9pFwcpisQTtcUusXYHGYrGgtLSU10WlkOfTxNvo0aPx2Wef4bvvvsP3338PIQRSU1MxduxYAMCePXvw4IMPQgiBrl274o477vB4HYWFhZg2bRqio6Ox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234U8BNvN954IxYsWICFCxeif//+OP/88wEAR44cwezZs6EoCu68807r8nl5edYiqZ4qHB0djWHDhuG9997DE088gRdffBEREREQQuCll17CiRMncOWVV+L000/XZBttrV27FpMmTUJYWBiysrJgNBqxY8cOPPTQQzh8+DAmTJjgso0ffvgBd9xxB2pqatC1a1ecdtpp2LdvH+bPn49169bhgw8+QFpaWitsTeDxR36b++uvv/DUU09JiFZ//JHfuro6HDhwAPHx8Q4HR75ew6i1hFL9Yu2Si7VLPtavv4VS7VL99NNPeOaZZxw+n5eXh+HDh+PkyZPIyMjApZdeip9//hkLFizA1q1b8f777yM2NrbJa/7v//4Py5YtQ0JCAi6++GIUFBTgq6++wpYtW/DWW2/Z/cBK7nPVZydPnkRxcTHS0tJwwQUX2F1GvSC/in2mb6FYu1obj7uWLBYLHnjgAXz55ZeIiopCz549YTKZ8NNPP2HBggX48ssv8f7771vPtAzF9xNPcxSU+5HQgWXLlomMjAxxzjnniFGjRomxY8eKHj16iIyMDPG///2vybKPPfaYyMjIEI899liTx0tLS8U111wjMjIyxGWXXSbuv/9+cdVVV1n/Pzc3tzU3ya6CggLRs2dPcd5554mffvrJ+vihQ4fExRdfLDIzM8W+ffuctlFfXy/69+8vMjIyxPLly62P19XViccff1xkZGSI//znP9K2IZD5I7/NWSwWcfvtt4uMjAyRkZEhJk6c6O+wdcNf+f31119FRkaGuPfee2WG22pCoX6xdsnF2iUf61dLoVC7VF999ZXo06eP9XjYvn17i2Xuv/9+kZGRIWbOnGl9rKamRjz44IMiIyNDvPDCC02W//rrr0VGRob4xz/+IQoLC62Pf/755+Kcc84R/fv3F9XV1fI2Ksi502dffvmlyMjIEM8995xbbbLPgkMo1a7WxuPOvuXLl4uMjAwxZMgQcfz4cevjZWVlYty4cSIjI0M8+OCD1sdD8f3E0xwF434U0Nd4U918882YP38+evfujT179uD7779H165d8dprr2Hs2LFutREXF4cPPvjA+i3Hxo0bUVdXh1tvvRXLli3z+tbV/rR06VJUV1djxIgR6NGjh/Xxjh074uGHH4YQAosWLXLaxs6dO5Gbm4u+ffvipptusj4eFhaG//znPwCATZs2SYk/0Pkjv829++672LVrF/r27evvcHXHX/lVf6IVCKcE+0Mo1C/WLrlYu+Rj/WopFGpXbm4upkyZgnvvvRd1dXUOr/t0/PhxfPnll2jbti0eeOAB6+MRERF45plnEBMTg2XLljW5oPvbb78NAHj00UeRmJhoffzKK6/Eddddh9zcXHz22WeStix4udtngOfHI/ssOIRC7WptPO6cW7lyJQBg8uTJTc6EjI2NxfPPPw9FUbBhwwZUV1eH7PuJJzkCgnQ/0nLWj5oaOnSoyMjIEHv27Gnx3KlTp0RmZqa46KKLXLaTk5Mj/vjjjxaPHz9+XGRkZIi+ffv6JV698Vd+Vfv37xfdu3cX48aNEytXrgz5s0b8ld+nn35aZGRkiE2bNskIkyRg7ZKLtUs+1q/QpJ7tMmzYMLF//35x22232T2LY8mSJSIjI0M88cQTdtsZP358k34vKysTXbp0Eeedd56oq6trsbz6Tb7tt/vkHnf7TAhhPYvi0KFDLttlnxE5xuPOuXHjxomrrrpKFBUV2X3+ggsuEBkZGeLPP/8M2fcTT3KkLh9s+5EuzngLFerdczp16tTiObPZjOTkZBQWFqKwsNBpO2lpaWjfvn2Tx0pLS/H0008DAK6//no/Rawv/sovANTW1uKRRx5BTEwMnn32Wb/Hqkf+yq/6DUdBQQHuvPNOZGVloVevXhg5ciS2bt3q/8DJZ6xdcrF2ycf6FZrOPvtsvPjii1ixYgUyMzMdLnfgwAEAju9iqO436nKHDh2CxWLB2WefjbCwlpdTVm+uoS5P7nO3z4DG4zEqKgq//PILbrnlFvTp0wd9+/bFuHHj8OOPPzZZln1G5BiPO+fmzZuHzz77DG3atGnx3B9//IHi4mKEh4cjMTExZN9PPMkREJz7ESfeAkRJSQlqamoQExNj904mAKynNefn57vd7ooVK3DnnXdiwIAB2Lp1K4YNG4ZHH33ULzHrib/z+8orr+DAgQN46qmnnJ5uHSr8lV+LxYLffvsNADB16lQUFRWhb9++aNeuHXbt2oUxY8bgnXfe8f8GkNdYu+Ri7ZKP9St0jR07Fv/85z9bXKC5ObXfU1JS7D6vPl5QUODV8uQ+d/ussLAQeXl5qK6uxqOPPgqLxYKsrCyYzWZs2rQJt956K9atW2ddnn1G5BiPO+/Nnj0bAHDppZciMjKS7yd2NM9RsO5HAX9X01BRWVkJoPFOOo6ot8tVl3XHV199hW+++QYAYDQaUVpaipycHJxxxhk+RKs//szvzp07sXDhQlx//fW48sor/Rekjvkrv0ePHkVlZSUiIyMxe/ZsXH755dbn1q1bh0mTJuGll15Cnz590LNnTz9FT75g7ZKLtUs+1i9yRe33qKgou8+rj6vLVVRUAHC8TzVfnvxPPfu0TZs2ePPNN9GrVy8AsF6v8YUXXsCUKVNw/vnnIz09nX1G5Ac87ppasmQJPvnkE0RHR1uvV8z3k6bs5ShY9yOe8RYg1G8QFEVxuIwQosm/7njqqafw888/Y926dbj66quxYcMG3HrrrSgqKvItYJ3xV35LS0sxefJkpKWlYfr06f4NUsf8ld+OHTtix44dWLduXZMPrQBwzTXXYMSIEbBYLPjggw/8EDX5A2uXXKxd8rF+kStGoxGA432k+f7havnmryP/69+/P7Zu3YqPP/7Y+qENaOyTUaNGYdCgQaiursZHH30EgH1G5A887v62ePFiPPfcc1AUBc8995z15458P/mboxwF637EibcAof68xfYOJs3V1tYCAEwmk9vtpqWlISIiAh07dsTLL7+M/v37Iz8/H0uXLvUtYJ3xV36feuop5OTk4IUXXkB8fLx/g9Qxf+6/iYmJLa7zpbrssssAAL/88os3YZIErF1ysXbJx/pFrqj9XlNTY/d59XH123Z1eUf7lPq4s7MsyTeKoiA1NRVpaWl2n29+PLLPiHzH465xcuell17Cc889B4PBgBdeeAH/+Mc/rM/z/cR1joJ1P+JPTQNETEwMTCYTysrKUF1dbff007y8PACOf7/sjuuvvx5bt261nsIZKvyR359//hmffPIJzGYzVq1ahVWrVlmfO3HiBADghx9+wCOPPIKOHTti/PjxErYkMLXW/qu+tqqqyus2yL9Yu+Ri7ZKP9YtccXWNv+bXl1GXd3Q9GVfXoyH5mh+P7DMi+YL9uKuursakSZOwfv16REVF4eWXX8agQYOaLBPq7yfu5MgVve5HPOMtQCiKYr27yeHDh1s8X1xcjIKCAiQmJjq9IPbGjRsxZcoUfPnll3afj4iIAADU19f7IWr98Ed+1d+FFxcXY+3atU3+++GHHwA0fohdu3at9dpUocJf++/69evx8MMPY9myZXaf/+OPPwAA6enpfoia/IG1Sy7WLvlYv8gVZ/sHABw8eBAArHf769SpEwwGA44cOQKLxdJiefUuuo7uake+W7ZsGR566CFs2LDB7vPNj0f2GZHvQvm4Ky8vx6hRo7B+/XokJSVh8eLFdieUQvn9xN0cBet+xIm3ANK/f38AsLuTbdiwAUIIDBw40GkbJ06cwKpVq7B48WK7z2/ZsgUA0L17dx+j1R9f85uVlYXffvvN7n8vvPACAOC6667Db7/9hiVLlsjZiADmj/23rKwMn376Kd577z27v8NfvXo1AKBfv35+iJj8hbVLLtYu+Vi/yBl1/9i4cSMaGhqaPFdWVoZdu3bBZDKhd+/eABp/ztK3b1/rc82pXzC42qfIe7m5ufjss8+wYsWKFs8JIfDxxx8D+Pt4ZJ8R+S5Uj7u6ujqMHTsWP/zwA84880wsW7YM5557rt1lQ/X9xJMcBet+xIm3AHLjjTciOjoaCxcuxO7du62PHzlyBLNnz4aiKLjzzjutj+fl5eHw4cPWn8AAwD/+8Q/ExsYiOzsbCxcubNL+8uXLsWrVKphMJtxyyy3StyfQ+CO/5Jg/8jtkyBC0adMGBw4cwKuvvtrkW4vly5fjiy++QFJSEoYPH946G0VuYe2Si7VLPtYvcua0007DZZddhhMnTuCll16yTqzW1tbiiSeeQEVFBYYPH47Y2Fjra0aMGAEAeOaZZ5r8pGj9+vX45JNPkJqaimuvvbZ1NySEDB06FBEREdi0aROWL19ufdxisWDOnDn46aef0KlTJ1x11VXW59hnRL4J1eNu7ty5+P7775GSkoIlS5bg9NNPd7hsqL6feJKjYN2PFKH17R2oieXLl2P69OkwGo3IyspCREQEduzYgZqaGkycOBFjx461Ljt58mSsXr0aQ4cOxYwZM6yPf/nll/jPf/6Duro6dO7cGWeeeSYOHz6Mo0ePIjo6Gq+++qrmM75a8Ud+7Vm1ahWmTJmC6667DjNnzpS9GQHLH/ndunUr7rvvPtTU1KBDhw7IzMzEsWPH8Ntvv8FkMmHBggXWb4EocLB2ycXaJR/rF91+++3Izs7Gu+++i4svvrjJcydPnsTw4cORl5eHs88+G507d8bPP/+MP//8E926dcOSJUusN+pQPfLII1i7di1iY2Nx4YUX4tSpU9i9ezfCw8OxYMECZGVltebmBSVnfbZy5UpMmzYNFosFmZmZ6NChA/bv34/ff/8dycnJWLJkCc4+++wmr2GfEbnG4+5vJSUlGDhwIKqqqtClSxd07tzZ4bKTJ09GcnJyyL2feJOjYNyPeHOFAHPzzTcjPT0d8+fPx549e2A0GtG1a1fcddddGDJkiFttDB48GCtWrMC8efOQnZ2No0ePIikpCcOGDcPYsWNx1llnSd6KwOWP/JJj/shv//79sXLlSsybNw87d+7E119/jTZt2mDYsGG49957nX5DQtph7ZKLtUs+1i9y5rTTTsNHH32E1157DZs2bcLGjRvRrl073HPPPbj77rtbfEgCgBdffBHnnnsuli9fjq1btyI+Ph6XX3457r//fpxzzjkabEVoufHGG3HWWWdh/vz52L17N44cOYLU1FTcfvvtGD9+PJKSklq8hn1G5JtQO+5+/PFH60X+9+/fj/379ztc9v7770dycnLIvZ94k6Ng3I94xhsREREREREREZEEvMYbERERERERERGRBJx4IyIiIiIiIiIikoATb0RERERERERERBJw4o2IiIiIiIiIiEgCTrwRERERERERERFJwIk3IiIiIiIiIiIiCTjxRkREREREREREJAEn3oiIiIiIiIiIiCTgxBsREREREREREZEEnHjzs/379+PZZ5/FP/7xD/Tp0wc9evTAwIEDMWbMGLz33nuorq7WOkSpbr/9dmRmZmLWrFlevX7//v3o0aMH3njjjSaPX3755cjMzMRrr73mjzADzpQpU5CVlYW8vDyPX7tq1SpkZmY2+a+18vTTTz8hMzMTL7zwgrR1+LpPqezlydc2QwXrmpy6ZqugoABvvvkmRowYgUsuuQTdu3dHnz598K9//QuzZs3CH3/84W34Xqurq8NVV12FW2+9FQ0NDR6/fvLkyS2OuV27dkmItKUFCxYgMzMTX331lbR1qNv0zTff+NSOvTz52mYoUWv7gAED/NKeOt5YsWKF269p3n+u/istLW2xPhnv2xxTBcaYyrZNb3G8FVpY1xzjmMp/Yyq1zdtvv93rNjjecl9Yq60pBMyZMwdvvvkmLBYLYmNjccYZZyA8PBz5+fnYunUrtm7digULFuD1119Ht27dtA434NTV1WHSpElITk7G6NGjtQ6nVU2cOBFffPEFpkyZgrfffturNsLDw9GjRw8AQNu2bf0ZnkNbtmwBAL8NDGRKSkrC+eefDwA4cOAAysvLNY5IH1jXfONOXXvnnXfw2muvobKyEgDQrl07ZGZmIj8/H7/88gt+/vlnLFiwAPfddx/Gjx8PRVFaJfbw8HBMmTIFY8eOxYIFCzBu3Div2klMTESHDh0AAHFxcX6M0LHNmzcjPDwcF154YauszxcdOnSw1qbdu3drHA35okOHDkhMTHS5nNFolB4Lx1T6G1M5w/EWaUVvdY1jqtbF8Zb7OPHmJytXrsTrr78Ok8mEF154AYMHD25SgA4fPoypU6diz549GD16NNatW+dWEQsl77zzDg4cOIAXX3wRkZGRWofTqpKTk3H33Xdj9uzZ+OSTT3Dttdd63EZiYiI++OADCdE5tnnzZphMJvTt21faOl588UVUVVWhTZs2PrUzcOBADBw4EEDjt7rZ2dn+CC+osa75zlVdmz59OpYvXw4AuPHGGzFu3DiceeaZ1uf//PNPvPPOO3jvvffw6quvYt++fXj11VdhMLTOCesDBw7ExRdfjNdffx3XXHMNTj/9dI/buOSSSzBz5kwJ0dlXXl6OH374AX379kVMTIy09axbtw5A46DeF/fccw/uueceAPDpjJhQNXjwYJx77rkIDw/XOhSMGzcOw4YN0zoMABxT6XFM5QzHW6GFdc0+jqlaf0zlDMdbnuFPTf1k3rx5AIBHH30UV111VYtZ/44dO+LNN99EUlISTp06hcWLF2sRZsAqKirCvHnzcOaZZ+L666/XOhxN3H777YiLi8PMmTNRW1urdTguFRUV4ZdffsGFF16IiIgIaetp164dOnbsyAkdDbCu+cZVXVu6dCmWL18ORVHw3//+F88//3yTASLQuP9PmzYNs2bNQlhYGNavX48FCxa01iYAAO677z7U1NTg5ZdfbtX1emv79u2oq6uzfvCTpWPHjujYsSOio6Olroeci4uLQ8eOHXHGGWdoHUrA4JhKf2MqZzjeCj2say1xTBV4ON7yDCfe/KC0tBTHjx8HAJx77rkOl0tMTMSgQYMANF6rgf62YMECVFZW4qabbmq1bx0CTWxsLK677jr89ddf+Oijj7QOx6WtW7fCYrFIL7akDdY13zmra+Xl5Zg9ezYA4I477sANN9zgtK2rr74aI0eOBND481+1b1pDnz590LlzZ3z++ec4ePBgq63XW3r6SRaRDBxT6W9M5QzHW0QcUwUijrc8E5rvxn4WFvb3L3Y3btzodNn7778fn376aYtZbvVifwsXLsThw4cxYcIEZGVloVevXvjnP/+JRYsWoaamxmG73377LR544AH069cP3bt3x8UXX4x7770XO3bscBrPhg0bMHbsWFx00UXo3r07+vfvj4kTJ2Lv3r0OX1NaWoq5c+fiH//4B8477zz069cP06ZNQ35+vtN1OVJdXY2PPvoIiqJ4/M2smrcPPvgAJ06cwJQpUzBgwAB0794dAwYMwOOPP44TJ044fP2OHTtw//33W/N24YUXYsyYMVi/fr3d5dWLhaoXm+/bty969eqFYcOGobi42LrcN998g7Fjx6Jfv34499xzccMNN2Dp0qWwWCxOL7D7z3/+EwDw3nvveZQHR1577TXrRW3z8/Px5JNPYsCAAejRowcGDRqEWbNmWb8J3rVrF0aPHo2+ffuiZ8+eGDp0KNasWeOwbXvFVu2PFStW4NixY5g4cSIuueQS9OzZE9dccw3effddCCEAAOvXr8eIESNw/vnn47zzzsPw4cOxefPmFuuxd7HfEydOIDMzE5dccgmEEFixYgVuuukm9OrVC7169cK///1vrFy50rou8hzrmty69vHHH6O0tBRhYWFuX3/pvvvuQ0REBOrq6vDhhx9aH7c9HnJzczF27Fj07NkTF1xwASZOnGhdrr6+HitWrMDNN9+Mvn37onfv3rjjjjuwbds27Nq1y+kFdq+//noIIfxWm9Tj+ptvvsFPP/2E8ePHIysrC+edd16L2rNixQoMGzYM5513Hnr37o277roLe/bscdj2li1b0L59e5x99tnWx9Ta/fvvv2Pbtm0YNWoU+vTpg/PPPx/Dhw/Hpk2bADTmaMGCBbj22mvRs2dPZGVlYcKECTh8+HCL9di7MK96Qez//Oc/qKysxOzZs3HllVeiR48eyMrKwj333IPvvvvO5/zR3xxdhFzdx7Zs2YL9+/fjwQcfxMUXX4zu3bvjiiuuwPPPP4+ioiK311NUVIRrr70WmZmZuO6661BYWOjvTfELjqn+JmtMdcstt9h9Xq2j/v4JE8dboYd1rSmOqZxzdWMUtXb5chMFezje8gyv8eYHJpMJ559/Pnbv3o3XXnsNx48fx7/+9S+cf/75LX6alZKSgpSUFIdt/fbbb5gzZw4qKyvRuXNn1NfXY9++fdi3bx+++OIL/O9//2txIcWZM2di/vz5AICEhARkZGQgLy8PX331Fb766iuMGTMGkyZNavKa+vp6TJ48GWvXrgXQeCHUzMxMnDhxAp988gk+++wzTJ06FbfddluT1/35558YPXo0jhw5AqPRiM6dO6OmpgYrVqywXn/CU9u2bUNJSQm6du2KtLQ0j18PAL/++itmzpyJyspKnHHGGTjzzDNx6NAhfPTRR/j666+xatWqFhfHfeaZZ6wFz2w2o0uXLsjNzbVeMP7qq6/GSy+9ZPf6Ck899RR2796NTp06oaqqChERETCbzQCAN954A6+++iqAxuuMdOrUCceOHcPTTz+NnTt3Ot2OHj16wGw24/Dhw9i/fz+6dOniVT6a+/3333HDDTfg1KlT6NSpE4xGI/744w/MmzcPf/zxB/r27YunnnoK0dHR6NChA06cOIFff/0Vjz32GKqrqzF8+PAm7VksFmzbtg2dO3e2+3v73bt347nnnkN9fT06duwIRVFw+PBhzJgxA0VFRVAUBf/73/8QHx+PDh064OjRo/jhhx8wbtw4vPXWW25/cyKEwGOPPYaPP/4Y8fHxOOuss/DHH39gz5492LNnD44ePYpHHnnELzkMNaxrcuuaOnjIzMxEamqqW23Gxsbi4osvxqZNm7B582Y8+uijTZ6vra3F6NGjcezYMXTu3Bl//vknTjvtNABATU0NHnzwQesk6plnnomYmBh899132LlzJwYPHux03QMGDMDLL7+Mzz//HE8++aTfzqL5/PPPsXLlSkRERKBDhw74888/rbWnsrISu3fvxtq1a5GUlISzzjoLBw8exPbt2/Htt99ixYoVLWrkvn37kJeXhxEjRthd36JFi7B06VLEx8fj9NNPx++//44ffvgB99xzD1577TUsWbIEu3btQlpaGs466ywcOHAAX375Jb799lv8v//3/9x+jyotLcW///1vHDhwAKmpqejUqRMOHTqEjRs3YsuWLXjjjTdw6aWX+po+csOWLVvw4YcfQgiBDh06ICYmBsePH8eiRYuwadMmrFq1CrGxsU7bOHXqFEaNGoWDBw+ia9eueOedd3y+DpYsHFP9TdaYqjVxvEX2sK41xTFV6+N4y3P66+UANX36dJhMJgghsGbNGtx222244IILMHbsWLz11lv48ccfYbFYXLazatUqmM1mrF69GmvXrsVnn32GDz/8EMnJyfj+++/x0ksvNVn+ww8/xPz58xEfH4+XXnoJ2dnZWLVqFbZu3YpZs2bBZDJhwYIFLW4f/eqrr2Lt2rVIT0/HggUL8M0332DlypX45ptvMG3aNCiKgmeffRbbt29v8rpp06bhyJEjyMzMxBdffIGPP/4Yn3/+OVasWAFFUXDs2DGPc6eevdK7d2+PX6tavnw5OnXqhHXr1uGLL77Ap59+ig8//BAxMTEoKirCO++802R59cKaYWFheOKJJ7Bjxw589NFH2Lp1K2bPng2TyYTPPvsML774ot317d69G7NmzcKnn36Kr7/+2npL6+3bt1sv0jlt2jRs3boVK1euxPbt23Hbbbc5/NZXZTAY0KtXL2tb/vLZZ5/BbDbjs88+w9q1a7Fx40bcd999AIBPP/0UTz/9NEaNGoVdu3Zh9erV2LJlC/r37w8AeOutt1q0t2fPHhQXFzscsK1atQpdu3bFxo0b8fHHH2PLli3WC7MuWLAAb731Fh577DHs2rULq1atwtdff42uXbtCCGGdbHFHYWEhPvnkEzz++OPYuXMnVq1ahW3btlm/DXv33Xc9+uaPmmJdk1fX9u3bBwAefxA855xzAABHjx5tcd2i0tJS5OfnY82aNVi9ejW2bt2KsWPHAgBef/11bNy4EWazGYsXL8b69euxevVqfP311+jbty++/PJLp+vNzMxETEwMiouLnZ456Klly5bhiiuuwJYtW6y1p1+/fgAaP8ivX78e//3vf/HNN99g9erV+OKLL9C2bVvU1tZi0aJFLdpTz+Jw9JOspUuX4vbbb8fWrVuxevVqbNq0CZmZmRBC4IEHHsBvv/2GBQsWYMuWLfj444+xevVq63Y339+c2bZtG06dOoW3337buq6vvvoKmZmZaGhocPitNPnfkiVLcMkll2Djxo349NNP8eWXX+KNN96A0WjE77//7vJniCUlJbjrrrvw22+/oUePHli4cGHAfjgFOKayJWtM1Zo43iJ7WNea4piq9XG85TlOvPlJ165dsWLFiiYFoby8HJs3b8bLL7+Mm2++Gf369cOsWbNQVVXlsB2DwYA33njDWggAoFevXtbByooVK5CbmwugcSb+tddeAwA8//zzTU69VRQF11xzjfWMkNdeew319fUAGt88Fy5cCKDxm0R1ggVovBX07bffjlGjRkEIYf29PAD8+OOP2L59O4xGI+bOndvkTiw9e/b0+iKRu3btAgB07tzZq9cDjbdonjt3Ls466yzrY+rPFYCmtwyuqanBm2++CQB44IEHMGLEiCbfNFx99dV49tlnAQDvv/++3Z9V9OnTB9dcc431/9UL0ar5GjVqFG6//XZru1FRUZg+fbpb1+fIyMgAAJff5HrqhRdesN5+GgDuvvtu65lLvXv3xuTJk60X7Y2Ojra+uZw8eRIlJSVN2nJVbMPCwvDKK69Yz4IyGAzW9iwWC2644Qbcdddd1vy0adPGeq2FX3/91aPtuvXWWzFy5EjrtkRGRmLq1KlQFAX19fW87pgPWNfk1bVTp04BgPWsDnclJycDABoaGlocl0Dj8dCpUycAQEREBGJjY1FaWop3330XQONd67KysqzLp6Wl4c0333R6xiLQmHsZtclsNmPGjBnWMx4jIyNx5513AmisFWPGjGlyrZbTTjsN//rXvwDA7mB18+bNiIyMbLKNtjp16oSpU6ciKioKABAfH2/9ttZiseDRRx9tsu906dIFV155pcP1OfPEE09YJxEBIDU1FRMmTAAA7N+/HxUVFR61R95JSkrCnDlzmpwFccUVV1gnMmzHB82VlZXhzjvvxK+//opevXph4cKFSEhIcLj8lClTrD+Jsfefv3/mYw/HVE3JGlO1Fo63yB7WtaY4pmp9HG95jhNvftSpUye8//77WLNmDSZMmIBevXo1OaW+sLAQ8+bNw/XXX4+cnBy7bVx44YV2Z+v79euH9u3bw2KxWE9r/eGHH1BQUICYmBhcccUVdtu7/vrrYTAYkJuba32D3bx5M2pra9GpUyd069bN7uvUDzo//fST9ff+6m+ye/fubfcuO3379rUWJ0+ogzBf7tzTvXt3u0VO/c15WVmZ9bHvvvvOeh0AR6fHXnPNNUhLS0NDQ4N1u23Z+8YlNzcXP//8M4DGQm2POthxRh3o/vHHHy6XdZfZbG5xgfzo6Gjr4NbeN6m2b+bl5eVNntu6dStiY2Nx/vnn211fZmYm0tPTmzymnp4N2B9Aqutrvi5XLrvsshaPtWnTxrptpaWlHrVHTbGuyalr6jer9n525Yztz3ztXVPHXm1Sc9OuXTu7p9vHxcVZP1A7I6M2ZWVltfgpr7e1orS0FD/++COysrKsA73mBgwY0OInHTJqk9FotFtXO3bsaP3b01pH3rnooosQGRnZ4nG1L2zHB7bKy8sxevRo7N27F+eeey7efvttlz/d6tChA84//3yH/6kftGTimKopGXWrNXG8RfawrjXFMVXr4njLO7zGmwTnnHMOzjnnHNx///2oqqrC7t27sW3bNnz88ccoLCzE8ePH8eCDD2LZsmUtXtuzZ0+H7arXKlJ/9qTeCaWurs7hYAdo3CEtFguOHDmCnj17Wl+Xk5Pj8AKxtsXnyJEjSEpKwtGjRwHAaYHt0qULDh065PD55qqqqqxnysTHx7v9uuYc/Q5cLQbqWTFA4/YAjb/Hd/RmoygKunbtitzcXOt227I3ID148CCEEDCZTE3OmrHVvXt35xsCWM/88Ocp+82vxaJSz3Czd+t424vr2+4P+fn5+PXXXzF48GCHb3D21qeuC4Dd09lt1+cJV33f0NDgVbvUFOuaf+ua2WxGQUGB3W9YnVG/1TUYDHa/2XVUmwA4veC3VrWp+QdGoOnA2VVtsrVt2zY0NDQ4vWaRP9fnTEJCgt3BqO0HJdv3JZLHk/GBrdmzZ6O6uhoA3L7g+Lhx49z6wCULx1QtyahbrYXjLXKEda0pjqlaF8db3uHEm2TR0dG45JJLcMkll+DBBx/E1KlT8emnn2LPnj3Yu3dvizMznJ3qq54VoH6jpH6bUVtb6/SUYlXz15WXl3v0OvVfZxcadxa/PbYF0tGMuTs8+YZDnfVufjH35tQBpL3TU+3FqhbvmJgYl20607yf/SE6Otrp855c1HPLli0QQjgttv5cnyuu+p532vI/1jXn3KlrXbp0wbZt27B//36P2lavY9KhQ4cmH66crU+tTc620Z3apB7Xng5s3WnTEUVR3G7L1U+yAOc5APxXm9x5T2Jtah2engGhqq6uxgUXXICjR4/ixIkTmDlzJp588kk/R+dfHFO1JGNM1Vo43iJHWNea4piqdXG85R1OvPnBE088gZ07d2Lo0KEYP368w+WioqLw9NNPY/369airq8PRo0dbfECtrKx0+Hp1cJOUlATg7wO2W7duWLVqldvxqq+78sorMWfOHLdfp34T4Ox0TfVbFHfZFjlHp0X7mzqIc7U+dZDmbNBnS82rs/y48xtztQDbO4U8EKjF1t07YZE+sa79TUZdu/TSS7Ft2zb88ssvyM3NdevuTRUVFdYLDHtyhyZ/1Sa1JvrygV4WIQS2bduGDh06+PQTOyLVJZdcgjfeeAObNm3Cgw8+iA8++ABXXnklLrzwQq1Dc4hjqpZkjKkcfYhzdq1Tb3C8Rf4WrHWNYyrnnI3DPcXxlvd4jTc/qKmpwe+//44NGza4XDY2NtY66LB3iqV6+qo96iy+er0h9bfhx44dc3gKpRACO3fuxLFjx6y/f1df52xdVVVVyM7Oxh9//GE9dVx9nfrtgD2e/BwLaDyTRJ2pVr9BkE29Rsnvv//usGhaLBbrtaPOPPNMt9pVTzmuqqrC8ePH7S7jzjcxah7UiYhAUl9fj2+++QbnnHOO27d5Jn1iXfubjLp2ww03ICEhAXV1ddY7+LmycOFClJWVISwsDMOHD3c7HvVntAcOHHC4jCe1Sb0YcSD55ZdfUFBQ4NbF1onccfXVVyMqKgpXXXUVBg0aBCEEHn/88YC5SLM9HFO15M8xlXo9qOZ3P1Tl5eX5vA4Vx1skQ7DWtVAfU7VmbeJ4y3ucePMD9a57v/zyi8szNLZt24bi4mK7F7sHGk8rz8/Pb/H4xo0b8ddffyEiIgKXX345gMaLfsfFxaGiosLheteuXYs77rgDV199tfXC5wMHDoTRaMSRI0cc3l594cKFuP3223HDDTdYv8EbMmQIgMZbm9u7E9L+/fs9vqOR0Wi0DsIcXZjd33r37o2EhATU19dj6dKldpf59NNPkZ+fD0VRmtxxxZnTTz/degF5R7fxtnf9q+bUuzuqg9lA8sMPP6CsrIzfvoYA1rVGsupafHw8nnjiCQDAhx9+6PLW6Zs2bbIOJidMmOD2h1eg8Zvc8PBw/PXXX9i2bVuL52tqarBmzRqX7ajbYnunw0DBM0NIpieffBJxcXHWn2YFKo6pWvLnmEq9XtqJEyfsfsD98ssvfV6HiuMtki2Y6lqoj6nU2qRec9OW7Zl9/sDxlvc48eYHl1xyifX2t9OmTcNzzz3X4nbpNTU1WLlyJR566CEAwIMPPmj3dPvKykrce++9+Ouvv6yP7dq1C1OmTAEAjB071nodDZPJZL1l+HPPPYeVK1fCYrFYX7dhwwbr7/avvvpq6+mgp512Gm666SYAwMMPP4yvv/7a+hqLxYIVK1Zg7ty5AIARI0ZYf6eemZmJa6+9FkIITJgwockZIgcPHsQDDzzg1W+o1Ts1/fDDDx6/1hvR0dHWvM2ZMwdLly5tkrcvvvjCWrxvvvlmjwri/fffDwB4++23sXz5cms+6urq8Nprr+HTTz912YZ6fSp7d9LRGott6GBdk1/Xrr32WowaNQpAY46nTZvW4u5WeXl5+O9//4v77rsP9fX1uOyyyzBu3DiPYklOTrbeFXDy5MlNroF36tQpPPTQQy36trna2lrr7d0DsTZt2bIFJpMJF1xwgdahUBBKTU3Fo48+CgD44IMPsHPnTr+vo6qqCkVFRU7/c3Q2gy2OqZry55hKbaOkpASzZs2ynpVdVVWFl19+GVu2bHHZhqs+Vn9+xvEWyRZsdS2Ux1RqG1u3bsX69eutj+fl5eGBBx5weSONuro6l/2k/lyV4y3v8RpvfjJz5kyYTCasWbMGixcvxuLFi9GuXTskJSWhpqbG+pOo8PBwTJw40eGt0Tt06IB9+/Zh0KBByMjIQGVlpfVuf9dee22L4nD33Xfjjz/+wPLlyzF16lS89NJLaN++PXJzc62nlZ5//vl49tlnm7xu6tSpyM3NxcaNGzF+/HikpqYiLS0NJ0+etN5d5corr7R+oFY9+eST+PPPP7F7927885//ROfOnaEoCg4ePIj4+HhccMEFyM7O9ih3AwYMwPLly/H999979DpfjB49GidOnMAHH3yAp59+Gq+99hpOP/105OTkWPN25ZVX4vHHH/eo3UGDBmHMmDFYsGABpk+fjjlz5qBt27b4/fffUVJSgnPPPRc//vhjk9tX26qrq7OeXROIp/Bu3rwZCQkJ6NWrl9ahUCtgXZNf16ZMmYJOnTrhueeew4oVK7BixQq0b98eiYmJKCkpwfHjxyGEQFhYGB566CGMGzfOqwvSPvzww9i3bx+ys7Nxyy23oEOHDoiJicHBgwdRX1+P7t2745dffnFYm3766SfU1tbCbDY7vUutFoqKivDzzz9j4MCBdi+OTOQPN910E9auXYvs7GxMnToVa9eudft6Ze54++238fbbbztd5vXXX8egQYOcLsMx1d/8PabKyMjAddddh7Vr1+Kdd97Bxx9/jPT0dOvPbB966CHMnj3baRsXXXSR0+e7dOmCjz/+mOMtahXBVtdCdUw1dOhQvPfeezh69Cjuv/9+nHHGGTCZTDh8+DCMRiPuuecezJs3z+Hrf/jhB5e1aeTIkRg/fjzHWz7gGW9+EhERgRkzZmDFihW466670K1bN9TW1mL//v3IycnBWWedhdGjR+P//b//Z/1m0J4ePXrggw8+wMUXX4zff/8dp06dwgUXXIBZs2bh5ZdfbrGTK4qCZ555Bm+//TYGDx6MsLAw7Nu3DxUVFTjvvPMwbdo0LFq0qMWdRSIjI/Hmm29i1qxZ6N+/P+rq6rBv3z40NDQgKysLL774ImbPnt2iYMTHx2PRokWYOnUqzjnnHJw8eRJ5eXm48sorsWLFCq8usjhw4ECYzWacOHHC7imyMiiKgv/7v//D22+/jUGDBsFoNFrPdLnsssvw+uuvY86cOV5djHfSpEl4/fXXcdFFF6Gmpgb79+/HaaedhmeeeQaPPfYYAMcX0szOzkZ1dTU6d+6Mrl27er+BEuTm5uLAgQO45JJLHL6RUHBhXWudunbTTTdhw4YN+M9//oPevXujrKwMe/fuRVFREXr06IHx48dj/fr1GD9+vNd3gYqKisI777yDyZMno2vXrsjLy8OxY8fQp08fLFq0yPqTW0e1aevWrQCAf/zjH17fTU2Wbdu2wWKxBOSXFRQ8FEXBs88+i6ioKJw8eRIvvfSS1iHZxTHV32SMqV588UU88cQT6NatGyorK3H8+HH06NED8+fPx9133+2XdXC8Ra0lGOtaKI6pYmJisGzZMowePRpnnnkm/vrrLxQUFODKK6/E6tWrkZWV5fM6AI63fCYoIDz22GMiIyNDTJw4UetQNPHaa6+JjIwMMWPGDK1DkWrjxo0iIyNDDBkyxO7zDzzwgMjIyBCrV692u82VK1eKjIwM0b9/fz9FGfxuu+02kZGRIV555RWtQwlqrGv6qWszZswQGRkZYtq0aS2eq6urE/369RNdu3YVx48fd7vNUO9/b2RkZIiMjAyxfft2rUMhHdNT7fEFx1SBj+Mt8hc91TWOqQKfFuMtnvFGAWHkyJGIi4vDmjVr3Pqtf6C69tpr8e9//9v6u/3m1Gt22PvmtaioCF999RXOPPNMXHfddVLjJCL5AqWuHT16FJdeeilGjRplNw4hhPXbV3u1adOmTcjLy8P111+P008/XXq8ROSbQKk9vuKYiohUgVLXOKYib3HijQJCfHw87rrrLhQVFbl1J5hA1aFDB+zZswczZsxocuvm+vp6LFu2DMuWLYOiKLjllltavHbRokWoq6vDhAkT+NMCoiAQKHXt9NNPR01NDXbs2IGZM2eiurra+lxZWRmefPJJHDx4EImJibjqqqtavP7tt99GREQExo8f35phE5GXAqX2+IpjKiJSBUpd45iKvMWbK1DAuPvuu/Hll19izpw5uPbaa1tcv0kPJk6ciO+//x7Z2dm4/PLLccYZZ1ivm1BcXAyDwYBHH320xZ1gcnJysHDhQlx22WW4/vrrvVp3UVGRdfB544034l//+pfP2xNMNm/ebL2w6IEDBzSOhkJFINS1sLAwPPnkk3j44YexaNEifPTRRzjjjDPQ0NCA48ePo7q6GvHx8Zg1a5b1lvSqL774Art378Zjjz3m1bXuAGD79u3W2jR9+vSAu36l1ubNm2c9c4fIXwKh9viKYyp94niLZAmEusYxlX5pPd7ixBsFjPDwcPz3v//FsGHDsGDBAjzwwANah+Sxs846C+vWrcMHH3yADRs24OTJk6iqqkJKSgouvfRS3HrrrTj33HNbvG7WrFmIjo5ucZdGT9TV1VlvaX3xxRd73U6wKiwsbHLLb6LWECh17aqrrkJGRgYWLlyI77//HsePHwcAtG/fHgMHDsRtt92Gdu3aNXlNXV0dXn75ZfTt2xejRo3yet3qreiBxm+Dqaljx46xNpHfBUrt8QXHVPrE8RbJEih1jWMqfdJ6vKUIIYRmayciIiIiIiIiIgpSvMYbERERERERERGRBJx4IyIiIiIiIiIikoATb0RERERERERERBJw4o2IiIiIiIiIiEgCTrwRERERERERERFJwIk3IiIiIiIiIiIiCTjxRkREREREREREJAEn3oiIiIiIiIiIiCT4/wDBLYoFGiC+cQAAAABJRU5ErkJggg==", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "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IgpmDU0w84RXM986dOiAnJwcfP75507X+9///ofs7Gx06NDBm5ejRiSKIgoLCznVnFSB+agN7CdSG+YkBQLmMSmBeUdEwUx1p53ec889EEURM2fOxPbt22XX2b59O2bNmgVBEHD33Xd783LUiERRRHl5OT9wSRWYj9rAfiK1YU5SIGAekxKYd0QUzPxR+7w67fSmm27CjTfeiF27dmHq1KmYP38+2rdvj+joaJSWluLXX39Fbm4uRFHEjTfeiJtvvtlXcRMREREREREREame1/dPXbx4MYYPHw69Xo+cnBzs3bsX27Ztw969e5GTkwO9Xo/7778fr732mlevc/jwYYwePRo9e/ZEly5dMGzYMHz22Wdetbl161akp6dj2rRpXrVDROQM6xcRaRFrFxFpEWsXEamRVzPfAMBgMGDu3LkYO3Ys9u3bhzNnzqC0tBRRUVFo3bo1+vTpg9TUVK9eY8uWLZg+fToMBgN69OgBvV6PgwcP4oknnkBGRgYmTZrkdpsXL17Es88+61VcREQNYf0iIi1i7SIiLWLtIiK18nrwTdKkSRPcc889do+dP3/e67vk5OXlYc6cOYiIiMDq1avRqVMnAEBGRgZGjhyJpUuX4sYbb0S7du1cblMURcyYMQPFxcVexUZE5AzrFxFpEWsXEWkRaxcRqZnXp53W1NRg0aJF6NevH0wmk92yl19+Gddeey0WLlyIqqoqj9pft24dKisrMXz4cGsBBYA2bdpgypQpEEURq1atcqvN999/H4cOHUL37t09iomIyBWsX0SkRaxdRKRFrF1EpGZeDb5VVVXh4Ycfxttvv43MzEz8/vvvdstzcnJQXl6Od955x6MpvgDw5ZdfAgAGDBhQb1n//v0hCAL27t3rcnsnT560DhYOGTLEo5iCgSAIiImJgSAISodCpNl8DLb6pdV+osDFnPRMsNUutWMekxK0mHesXUTkK/6ofV4Nvq1ZswaHDh1CfHw8XnnlFbRt27be8jfffBNJSUn46quv8PHHH7v9GqdPnwaAem0DgNFoRFJSEvLy8pCXl9dgW1VVVZg2bRqioqLwwgsvuB1LMNHiBy4FLq3mY7DVL632EwUu5qRngq12qR3zmJSgxbxj7SIiX/FH7fPqmm/btm2DTqfDihUr0KFDh/qNGwzo378/kpOTce+99+LTTz+td104Z4qKimAymRAV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iIj8hINvREREREREREREfsLBNyIiIiIiIiIiIj/h4BsREREREREREZGfcPCNiIiIiIiIiIjITzj4RkRERERERERE5CccfCMiIiIiIiIiIvITg9IBkHdKS0uxefNmfPHFFzh58iQKCwsRGhqKFi1aoGfPnhg2bBguvfRSpcNUzFdffYVHHnkEL730Eu644w7r4+np6QCA+fPnY8iQIUqF5zcPPvggMjMzsWnTJkRGRrr13DfeeANLly61e0x6nzZu3IhZs2bZrTtw4MAG2xw3bhy+/PJLAMBtt92GV199FcDf/SBp0qQJ9u3b51a8FJhY25xzVNtsnTt3Dp988gm+/vprnD9/HsXFxYiOjkabNm3Qp08f3HPPPUhMTGzUuIuKijBw4ED06tULCxcudPv5I0aMwOHDh+0e2717N5o3b46ZM2di06ZNAAC9Xo/9+/cjISHBaXvV1dW47rrrUFRUBAB44YUXcM899+DQoUMYOXKk3bq2tYsoENT9DK7LYDAgKioKzZs3R+/evfHwww8jNja23no33HADLly4gEmTJuGxxx5z2qbtviXtuw05f/48+vfvb/fY1VdfjTVr1gAAzGYz3nzzTWzcuBG5ubm49NJL8cgjj+D222+Xba+wsBADBgxAq1at8PHHH7v0eldddRU+/PDDBmMlIvfV/X7hKts6EGgaqnu2qqqqsHXrVuzcuRO//vor8vPzYTAY0LRpU3Tv3h333nsvOnToYPccHucog4NvGrZnzx7MmjULBQUFAACj0Yi0tDQUFRXh9OnTOHnyJNatW4eJEydiwoQJCkfb+IqKijBr1ix06tTJ4QFYoHr66adx5513YsGCBXjuuec8aiM6OhppaWkA4PAL+o4dOxocfCsqKsKBAwdkl1111VUAgPz8fJw9e9ajOCnwsLY511BtM5vNWLhwIVavXo3q6moIgoAWLVrgkksuQWZmJo4cOYIjR45g+fLlmDNnDu6+++5Giz0uLg6TJ0/Gs88+i379+uHWW2/1qJ2mTZuiadOmAICwsLB6y81mM3bu3Ilhw4Y5befAgQPWgTdbMTEx1vr0xx9/IC8vz6M4ibSgVatWsgPVVVVVOHfuHI4dO4Zjx45h48aN2LBhg3XfU0KHDh0QFhZmPT4BgCVLlmD58uWIiIhA69atcebMGUyfPh0VFRW4995767WxbNkyFBcXY/r06bKvERYWZt3/L168iIsXL/pnY4gIQO33DGmfsyXtf6GhoejYsWO95bZ1IJDJ1T3J0aNHMWXKFFy4cAFA7fFLmzZtUFFRgXPnzuHMmTPYsGEDhg8fjlmzZsFgMFjX43FO4+Pgm0a99957eOmllwAAgwYNwsSJE3HZZZdZl2dnZ2PZsmX44IMPsHjxYphMJjzxxBMKRauMhQsXIicnB4sXL4YgCEqH06jS0tIwZMgQbNiwAXfeeafsB5orbTj6lddgMKCmpgZ79uyByWSS/fIr+fzzz1FdXS27TGrf01+8KPCwtjXMWW2rqanBhAkTsG/fPhgMBjz88MN48MEH0aRJE+s6p0+fxrJly7B161Y8/fTTOHXqFGbPnt1o8d97771YvXo15s2bhz59+sjOpGnIHXfcgSeffFJ2mVSfduzY0eDg2/bt22Ufb9++vbU+2c6oIwpE48aNc3gWgMViwZYtW/D0008jOzsbs2bNwsqVKxs3QBuLFi1Cy5Ytrf9fVFSE1atXIykpCZ988gmaNm2K48eP4+6778brr7+Oe+65Bzrd31fZOX/+PD744ANcf/31uPrqq2VfIzk52br/y50NQES+1bdvX/Tt27fe49L+Z7tPBqO6dU+yfft2TJ8+HTU1Nbj22mvx2GOP2X3nKy4uxvvvv4+3334ba9euRXFxMV555RUAPM5RCq/5pkFHjhyxTgl99NFHsXjxYrsvpwCQkpKCf/3rX3j00UcBAG+99RZ++eWXRo9VKb/99hs+/vhjXHPNNejWrZvS4Shi/PjxEAQB8+fP93nbiYmJaNu2LcrLyxs8TfSzzz4DUFvkiZxhbWtYQ7Vt4cKF2LdvH0JDQ/HOO+/gqaeesht4A4C2bdti4cKF1gHvVatWORyE8ge9Xo/x48cjLy8Pb731ls/bv/rqq6HT6XD48GHk5+c7XK+qqgq7d+9GQkICUlNTfR4HUSDQ6XS444478MgjjwAADh48iN9//13hqP52/PhxlJeXo3///tYZeZdffjm6d++OvLy8erEuWrQINTU1mDp1qhLhEhH5xNmzZ/H000+jpqYGQ4YMwbvvvltvskVsbKz1bAMA+O9//4vPP/9ciXDp/+Pgm8aIooi5c+fCbDbjiiuuwOTJk52uP2HCBDRt2hQWiwXvv/9+I0WpvKVLl8JisciebhAspGu0/PTTT9i7d6/P2x80aBCA2lNPHcnPz8c333yDNm3aNHhtGQpurG2ucVbbzp07h9WrVwMApk+fjp49ezpt66GHHsJNN90EAPi///s/lJaW+j5gB2655RYYjUasW7fO6QCZJxITE9GtWzeYzWanB5n79u1DaWkpbrrpJuj1ep/GQBRo+vXrZ/379OnTCkZiLycnBwAQHx9v97h0Gm1hYaH1sWPHjmHbtm248847g+Z0NSIKTC+88ALKy8txySWX4Nlnn7Wb4VvX3XffjSuuuAIA8O677zZWiCSDg28ac+TIEWRkZAAAxo4d2+D6oaGhmDdvHt5//308//zz9ZYXFRVh6dKluPPOO9GlSxdcccUVGDRoEF566SVkZ2fXW3/jxo1IT0/Hk08+ifLycixevBg33XQTOnXqhB49emD8+PH47rvvHMbz559/Yv78+bj55ptxxRVX4KqrrsKwYcOwYcMGWCwWN94Jx7KysrBr1y5ERUXhxhtvdOu5I0aMQHp6Ovbt24cTJ05g8uTJuPbaa9GxY0f0798f8+bNc/pF8X//+x/GjBmDa665Bh07dkSvXr3w2GOP4eDBg7Lrp6enIz09Hbm5uZg2bRq6dOmCrl27YuTIkaipqbGut2PHDowYMQLXXHMNrrzyStx7773YunUrzp8/j/T0dNxwww2y7d95550A4JeLkUqDb9Kpp3J27tyJmpoa/POf//T561NgYW1rWEO17YMPPkBNTQ2MRqPLPzxIp24WFRXhP//5j/XxQ4cOIT09HUOHDkVGRgbuu+8+dOrUCT179rSeFgwAFRUVeP/9963v89VXX43x48fjp59+sr6nM2fOrPe6oaGhuPnmm1FRUYFPPvnE3beiQa78OCDN9vP0unNEwcT2i50oigpGYk86bb3usVlubi6A2muGSl555RWEhoY2+OMOEWnLrl27MHbsWPTs2RMdO3ZE7969MXXqVBw7dkx2fen7V01NDbZt24Zhw4ahS5cu6N69O0aPHo0ffvgBAFBeXo5FixZhwIAB6NixI6699lo89dRTyMrKsmtP+j7WrVs3WCwWrFy5EoMGDULnzp3Rt29fPPnkk/j11199tr1//fUX9u/fD6D2JnuhoaENPmf27NlYvny5X844INdx8E1jvv76awC1p+1cc801Lj3n2muvxbXXXlvvrpcnT57ErbfeijfeeAMnT55Es2bNcOmll+LPP//E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CYLA+kTkgdatW1tn4589exZvvvmmwhHJ27t3Lw4dOoRhw4ahRYsW9ZZHRkZi9erVuOWWW1BaWoq8vDwMGDAAH374od+Pk4jIt8LCwrBs2TIsWrQIvXv3RnV1NY4fPw6z2YwePXrgpZdewuLFix3+COgPixYtwuTJkxEREYGTJ08iMTERI0aMwObNm12+9JA7brnlFuzcuROPPfYYrrrqKoiiiJMnTyIzMxOtW7fG/fffj02bNmH27NmN+j6QAyJRAJo5c6aYlpYmrl27VulQ/Grt2rViWlqaOGrUKNnld911l5iWliYePnzY5TaXLFkipqWlicOGDfNVmA369NNPxbS0NLF3796N9ppEWqSl2vbYY4+JaWlp4rJly+otKywsFDt16iR269ZNLC4udrnNBx54QExLSxNfe+01X4bq1IwZM8S0tDRx6tSpjfaaRPS3c+fOiWlpaWJaWpp49uzZRn1tJY6JiEhb/FGjGrPu8Tin8XDmGwWk8ePHQ6/Xu3RDArUymUzo1asX7r//fmRlZcmus3fvXgC1M33qOnHiBH7++WdcffXV6N69u19jJaLGoZbaduDAAfTv3x+TJ0+WXS7dYQyQr0+bNm2CyWTCgw8+6NWNKIiIiIiItICDbxSQWrZsicGDB+PEiRMO74CjdmFhYUhOTsaRI0ewYMECFBcXW5eZTCYsXboUe/fuRXh4OO666656z3/nnXcgCILDL8dEpD1qqW2XX345srKysGPHDrz77ruoqamxLsvLy8PUqVNRUFCA1q1bo2fPnnbPraqqwpo1axAfH4+RI0c2duhERERERI2ON1yggDVjxgzs378fL7/8MjZt2gSdTntjzU8//TQeeeQRbN++HV988QX+8Y9/QKfT4dy5cygrK0NYWBhefPFFXHrppXbP+/nnn7F161YMHz7cemcvd506dQr33XcfgNrZNn379vV6e+qS2pcuikpEDVNDbUtISMC0adMwf/58vPzyy3j77bfRrFkzVFRU4Ny5c6iurkZqaioWL16MkJAQu+euXbsW58+fx5IlSxAbG+vR6//nP/+xzqxbsmQJkpOTvd4mW7/++iuef/55ALDe5IOIlPfkk08iLCwM7du3x9y5c/3yGjk5OXj88ccBwHrjHCIipfij7vE4RxkcfKOAFRsbi3nz5uHhhx/Gpk2bZGeHqV23bt3w2WefYdWqVThw4AAuXryImpoapKSk4I477sDw4cPRtm3bes97+eWX0bJlS0ybNs3j1y4tLcX3338PoHYmiz9I7ROR69RS2x566CF07doVq1atws8//4wzZ84gJCQEbdu2xY033ojhw4cjPj7e7jmFhYVYvnw5br/9dtx0000ev/bFixetX4pNJpNX2yGnpKSE9YlIhY4dOwYAMBj89xXGZDJx/yci1fBH3eNxjjIEURRFpYMgIiIiIiIiIiIKRNo7D4+IiIiIiIiIiEgjOPhGRERERERERETkJxx8IyIiIiIiIiIi8hMOvhEREREREREREfkJB9+IiIiIiIiIiIj8hINvREREREREREREfsLBNyIiIiIiIiIiIj/h4BsREREREREREZGf/D/zJj/siIxAkgAAAABJRU5ErkJggg==", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "df_cols = x_labels\n", - "sns.set_style(\"ticks\",{'xtick.direction': 'in', # set the style of the plot using seaborn\n", - " 'ytick.direction':'in',\n", - " 'xtick.top': False,'ytick.right': False})\n", - "\n", - "n_col = 4 # num of columns per row in the figure\n", - "for n in np.arange(0, 6, n_col):\n", - " fig,axes = plt.subplots(1, n_col, figsize=(15, 3.5), sharey = False)\n", - " fs = 20\n", - " for i in np.arange(n_col):\n", - " if n< len(df_cols):\n", - " axes[i].hist(df.iloc[:,n], bins = 20)\n", - " axes[i].set_xlabel(df_cols[n], fontsize = 18)\n", - " else:\n", - " axes[i].axis(\"off\")\n", - " n = n+1 \n", - " axes[0].set_ylabel('counts', fontsize = 18)\n", - " for i in range(len(axes)):\n", - " axes[i].tick_params(direction='in', length=5, width=1, labelsize = fs*.8, grid_alpha = 0.5)\n", - " axes[i].grid(True, linestyle='-.')\n", - " plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "metadata": {}, - "outputs": [], - "source": [ - "### df.to_excel(\"output.xlsx\") " - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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4eHo168fatasmeu2yGQyJCQkICEhAcnJybmu578qNTVVuH+pqalF3RwiIiIiKiC5SoRDBc/W1hbDhw/HgQMHcPr0afTt27eom5QvTE1NsXbtWsycORN+fn6YOVNzEpEhQ4Zg8uTJatvj4+Oxfft2lCtXDgsWLMhTW0JDQ9G4cWMAQPPmzbF///481fdfc/DgQaxevbqom0FEREREBYxB42ds9uzZcHV1xe+//45vv/0200yiX5IuXbrg/Pnz2Lt3L+7evYvAQPnSBGXLloWNjQ0GDRqEFi1aaCzr4OCAqKgoODg4qKzdSUREREREBaPIsqcS0X8Ps6fmHLOnEhERUVHjnEYiIiIiIiISxaCRiIiIiIiIRDFoJCIiIiIiIlEMGomIiIiIiEgUg0YiIiIiIiISxaCRiIiIiIiIRDFoJCIiIiIiIlEMGomIiIiIiEgUg0YiIiIiIiISxaCRiIiIiIiIRDFoJCIiIiIiIlEMGomIiIiIiEgUg0YiIiIiIiISxaCRiIiIiIiIRDFoJCIiIiIiIlEMGomIiIiIiEgUg0YiIiIiIiISxaCRiIiIiIiIRDFoJCIiIiIiIlEMGomIiIiIiEgUg0YiIiIiIiISxaCRiIiIiIiIRDFoJCIiIiIiIlEMGomIiIiIiEiUTlE3gIj+OzYNPFjUTfjy6FoXdQu+OHapr4q6CURERF8V9jQSERERERGRKAaNREREREREJIpBIxEREREREYli0EhERERERESiGDQSERERERGRKAaNREREREREJIpBIxEREREREYli0EhERERERESiGDQSERERERGRKAaNREREREREJIpBIxEREREREYli0EhERERERESiGDQSERERERGRKAaNREREREREJIpBIxEREREREYli0EhERERERESiGDQSERERERGRKAaNREREREREJIpBIxEREREREYli0EhERERERESiGDQSERERERGRKAaNREREREREJIpBIxEREREREYli0EhERERERESiGDQSERERERGRKJ2iPHl6ejouXLiAa9eu4enTp4iMjAQAmJiYwMrKCm3btkWfPn1QvHjxomxmkQgMDETnzp0BAPv27YOtrW2u6klISECfPn0gk8lw8eJF6OnpaTwuNTUVFy9ehIuLC54+fYqIiAhIpVIYGxvD2toaHTp0QJ8+fVCyZMlcX1N+2LRpE3bs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bNgwODo6YuDAgahSpQr09PRgaGgIS0tLDB48GMeOHcO0aZqzUiqWTlmzZg10/13ImoiIiIiIig6zpxJRoWH21Jxj9tScY/bUnGP2VCIiygx7GomIiIiIiEgUg0YiIiIiIiISxaCRiIiIiIiIRDFoJCIiIiIiIlEMGomIiIiIiEgUg0YiIiIiIiISxaCRiIiIiIiIRDFoJCIiIiIiIlEMGomIiIiIiEgUg0YiIiIiIiISxaCRiIiIiIiIRDFoJCIiIiIiIlEMGomIiIiIiEgUg0YiIiIiIiISxaCRiIiIiIiIRDFoJCIiIiIiIlEMGomIiIjo/+zdd1hUxxoG8Hd36b0IAgKCBeyKotiNxN6NXaPRqFETE82NKUZTLDEaW4wx1hhL1Kix915iw4ZdsRd677C7sHv/OOwK7h5YkGby/p4nz/XuOTM7Z9j2nZn5hohIFINGIiIiIiIiEsWgkYiIiIiIiEQxaCQiIiIiIiJRDBqJiIiIiIhIFINGIiIiIiIiEsWgkYiIiIiIiEQxaCQiIiIiIiJRRmXdACL67+hUL7asm/DGUUFS1k144+ycdq6sm/DmMfYt6xa8cboqQ8q6CUREpYYjjURERERERCSKQSMRERERERGJYtBIREREREREohg0EhERERERkSgGjURERERERCSKQSMRERERERGJYtBIREREREREohg0EhERERERkSgGjURERERERCSKQSMRERERERGJYtBIREREREREohg0EhERERERkSgGjURERERERCSKQSMRERERERGJYtBIREREREREohg0EhERERERkSgGjURERERERCSKQSMRERERERGJYtBIREREREREohg0EhERERERkSgGjURERERERCSKQSMRERERERGJYtBIREREREREohg0EhERERERkSgGjURERERERCSqXASNoaGh8PX1ha+vL7Zu3Vps9W7fvl1b77NnzwpVVlOuMP+tWbNGWz4oKEj7+Pbt24vtml4VFRWFJk2aYODAgfmel56ejs2bN+Ojjz7C22+/DT8/P9StWxdt27bFuHHj8PfffyMzM7PE2mmoSZMmoU6dOrh9+3aR6xg6dKjO3+arr74qlvblfk0FBQW9Vl3btm2Dr68vZs6cWSxty0/u1+O5c+eKpc6ePXvq9POkSZOKpW4iIiIiKj+MyroB9Hq++uorJCcn4+uvvxY9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JSXMnTsXfX192tvbgfNVeKdNm8acOXMIDw8XI0AClyu0JcYmn3/+ORs3bkRPT4/rr7+eW2+9lYCAALS1tQed++233/L222/T3d3N4cOHsbGxkRYgJCSuAEk0SkhIqBxCFGz//v289957FBcXM3nyZK6//npmzJhBTU0NBQUFZGVlERsbKzYH19DQ4KOPPmLatGkjPIOrR21tLf/973/Ztm0bFhYWrFixggceeOCi55eWlnL06FF+/fVXUlNTgfNN2Tdu3DikYzbW6O3t5ciRI2JxoJKSEioqKqitrVWKYmhqahIREcF1113HlClTVL4R/VCkp6ezadMmmpqaKCsro76+XslGGhoajB8/nunTpzNlyhQ8PT1HcLQSo4H6+nr++c9/ilkkZmZmBAcH4+XlJVbe7erqYteuXezdu5fe3l6WLl3Kiy++OMIjl5AYfUiiUUJCQuUQImBr167l1KlTLFq0iKeffhpzc/NB57a1tXH06FH+97//kZuby/jx4/nggw+GPFcV2L59O6+88go6Ojo8+eSTLFmyBEDcyzhU5FAul5OSksInn3xCTEwMmpqaPPPMM1LFygtoaGigvLycwsJCCgoKKCoqoqSkhMrKShobG5XONTc3Jzg4mHfffVdlWmpcDg0NDVRUVAyyUUVFxSAbmZiYMGnSJCZPnsy0adOkhuwSSghRwu7ubr766it27txJcXExCoUCXV1dFAoFXV1dqKuri8+3hQsX8uyzz2Jubi5FGSUkrhBJNEpISKgkra2thIeHo6GhwaFDh7C1tRUdhKEE0s8//8yrr75Kc3Mz77//Ptdff71KOhV33HEHZ8+e5eGHH+aOO+7A0NDwsudZXV3NPffcQ05ODr6+vnz//fdjSvBcjKHSdHt7e6murqa0tJTi4mIKCgrEdi7V1dW0traio6NDUlKSSl5nA7nY/Pr6+qipqaGsrIyioiLy8/MpKiqivLyciooKMYUVYPny5WzYsEFKieb3662mpgYNDQ2xgNJYRMgqaWtrIy4ujqioKKKjo6mtrUUmk6GpqUljYyOenp7ccsstLFiwQFp8kJD4k0iFcCQkJFQKwaESSqoHBwdjZ2cntjqAoaNpN998M7/88gunTp0SV6tVzZFvbW3l7NmzaGhosHDhQrGS4OXMs6enB2trax5++GGefvppCgoKOHHiBLNnz77aw77mGXg9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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "corr = df.corr(method='pearson')\n", - "#np.fill_diagonal(corr.values, 0)\n", - "\n", - "corr.columns = x_labels\n", - "df_len = len(x_labels)\n", - "fs = 20\n", - "\n", - "fig, ax=plt.subplots(figsize=(df_len,df_len))\n", - "sns.set(font_scale=1.5)\n", - "sns.set_style(\"ticks\",{'xtick.direction': 'in', # set the style of the plot using seaborn\n", - " 'ytick.direction':'in',\n", - " 'xtick.top': False,'ytick.right': False})\n", - "mask = np.triu(np.ones_like(corr, dtype=bool),k=1)\n", - "cmap = plt.get_cmap('coolwarm')\n", - "sns.heatmap(corr, mask = mask, cbar_kws={\"shrink\": .2}, annot=True, fmt='.2f',\n", - " cmap=cmap, cbar=False, ax=ax, square=True)\n", - "ax.set_xlim(0, df_len)\n", - "ax.set_ylim(df_len, 0)\n", - "ax.set_title(\"Pearson Coefficients for Linear Correlation\", fontsize = 20)\n", - "plt.xticks(rotation=75, fontsize = fs)\n", - "plt.yticks(rotation=0, fontsize = fs)\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 28, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjYAAAFtCAYAAAAUFyCcAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAABmjElEQVR4nO3dd3wUdf7H8dfW9J4QQlEOFBRB6YiAeGBBReVQEERQT6oKYjmKiieIIKc/QUEhoHgnIhbgVEDxBMVTQARFQUFEUQSCmE56tszvj9yu2exms5tsssl+P8/Hw4dkZnbyec9MJp9M1WmapiGEEEIIEQL0wS5ACCGEECJQpLERQgghRMiQxkYIIYQQIUMaGyGEEEKEDGlshBBCCBEypLERQgghRMiQxkYIIYQQIUMaGyGEEEKEDGlshBBCCBEyjMEuQAhRsw0bNjBr1izn1waDgeTkZPr27cu0adNITU11mf748eO8/PLL7Nixg1OnTqHT6WjVqhWXX345t9xyi9v0APfccw8ffvgho0eP5tFHHw14hry8PObMmcPnn39OTk4OgwYN4oUXXvA47ZgxY/jiiy8A0Ol0REREkJyczPnnn88111zDlVdeiV5fu7/LNm7cSHZ2Nrfffntto3i0ZMkSli5d6vzaZDLRrFkzBg4cyNSpU4mNjfVrfgMHDqRXr148+eSTftdSXxmFaAqksRGiCVmwYAFt27altLSUvXv3kp6ezhdffMHGjRuJjIwE4OOPP+b+++8nISGB0aNH07FjRwB++OEH1q9fzyeffMLbb7/tMt/s7Gy2b98OVPxSnDFjBmFhYQGt/YUXXuDDDz9k/vz5nHXWWcTFxXmdvnXr1jz99NMAlJSUcOLECbZu3cq9995Ljx49WL58OTExMX7XsWnTJo4cOVJvv/RffPFFYmJiKCoq4pNPPuGVV17hwIEDvP766+h0Op/ns3TpUqKjo2tVQ31nFKIxk8ZGiCbk3HPPpXPnzgBcfPHF2Gw2XnjhBbZu3cr111/P8ePHuf/++2nTpg2vvPKKyy/+Pn36MHbsWD788EO3+b799ttYLBYuu+wytm/fzn/+8x+uu+66gNZ+5MgRzjrrLK6//nqfpg8PD6dLly4uw4YPH8769et56KGHmD17NosXLw5ojYFwwQUXkJiYCEDfvn3Jy8vjnXfe4auvvqJ79+4+z8fRkAoh/CPX2AjRhDl+8WdkZADwz3/+k+LiYv7+9797PJqh0+m48sor3YavX7+e5ORknnzyScLDw1m/fr3PNeTl5fHYY4/Rv39/OnXqxKBBg1i0aBHl5eUAnDhxgg4dOrBz505++uknOnToQIcOHdi9e3ctEsONN97IgAED2LJlCydPnnQOX7NmDaNHj6ZPnz506dKF6667jpUrV2KxWJzTjBkzhu3bt3Py5ElnHR06dHCOX7p0KcOHD6dXr15069aNv/zlL7z11lvU5V3BF110EfDHOqppeTkMHDiQmTNnOr/evXs3HTp0YNOmTSxatIh+/frRrVs3br/9do4ePepzxtdee43rr7+erl270rVrVwYPHswzzzxT63xCNDZyxEaIJuzYsWMAziMEn332GcnJyW5HOrz56quv+Omnn7jzzjtJSEjgyiuvZOPGjRw/fpzWrVt7/WxZWRljx47l+PHjTJkyhQ4dOrB3715WrFjBoUOHWLFiBc2aNeONN95gzpw5FBQUOE8vnXPOObULTcUv/U8++YS9e/fSsmVLAH799VeGDBlCq1atMJlMfP/99yxfvpyjR4+yYMECAP7+978ze/Zsjh8/7nI9jMPJkye5+eabadGiBQBff/018+bN4/Tp09xzzz21qvXXX38FKtaRL8urJs888wzdunXjiSeeoLCwkKeffprJkyfz3nvvYTAYvGbcvHkzc+bMYcyYMcyYMQO9Xs+xY8f48ccfa5VNiMZIGhshmhC73Y7VaqWsrIw9e/awbNkyoqKiGDhwIACnTp3i/PPP92ue69atAyqOhADcdNNNvPvuu2zYsIF7773X62f//e9/c/jwYRYvXszVV18NVJx+iYyM5Omnn2bHjh307duXLl26EB0djcVi8avpqo6j8fj999+dwypfXG232+nRowfx8fE89NBDzJw5k7i4OM455xxiY2Mxm80e63A0QI559OrVC03TeOWVV7j77rt9ukbGsY6Ki4vZvn07r7/+OmlpafTo0cPn5eXNOeec42wOAfR6PdOmTePAgQN06dLFa8avvvqK2NhYHnnkEeewPn361JhJiKZEGhshmpARI0a4fN2+fXsee+wxkpOTazW/oqIi3n//fbp27Uq7du0A6NWrF2eddRb//ve/mTJlite7jz7//HMiIyMZPHiwy/Bhw4bx9NNPs2vXrhp/UdeGp1NDBw8e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mTdvHtdee2191COEEEIIUWt+t3iJiYlER0fXRy1CCCGEEHXid2MzatQo3nrrrfqoRQghhBCiTvw+FTVu3DiefPJJhg0bRv/+/d0e1qfT6bj99tsDVJ6oK5XPu0p2NUl29aiaG9TOXh2dVvW2php88803TJgwodq3eDe1u6IGDRoEwLZt24JciRBCCCE88ed3td9HbObOnUtCQgLz58+Xu6KEEEII0aj43dj8+OOPPPPMM87uSTReFouF3NxcEhISlGtAJbtkl+xqUDU3qJ3dG79PzqWlpbk9lE80XlarNdglBI1kV5NkV4+quUHt7NXxu7GZMGECq1at8vgqBSGEEEKIYPL7VNTBgwc5ffo0l19+Ob1793a7KwrgkUceCURtQgghhBB+8buxefXVV53/3rRpk9t4nU4njY0QQgghgsLvxub777+vjzpEPTAYDCQmJmIwGIJdSoOT7JJdNapmVzU3qJ3dG78bG9F06PV6wsPDg11GUEh2ya4aVbOrmhvUzu5NrRubXbt2sWvXLvLy8khISODiiy+mT58+gaxN1JHNZqO4uJjIyEjlOvqaspeXlzN37lx27txJbm4uqampjBs3jptuuikI1QaWrHfJrlJ2VXOD2tm98buxKS8vZ+rUqXzyySdomobRaMRqtbJixQoGDBjAkiVL5H76RsJut1NQUEB4eLhyG31N2a1WKykpKfzzn/+kdevWfPPNN4wfP57mzZvTr1+/IFQcOLLeJbtK2VXNDWpn98bv272ff/55PvvsMx544AF27tzJt99+y86dO3nwwQf57LPPeP755+ujTr+tWbOGgQMH0rlzZ4YNG8bevXuDXZJoRCIjI7n33ns566yz0Ol0dOnShd69e/Pll18GuzQhhBB14Hdjs3nzZiZOnMi4ceNITEwEIDExkTvvvJOJEyeycePGgBfpr/fee48FCxYwefJk3n77bbp378748ePJyMgIdmmikSorK2P//v106NAh2KUIIYSoA78bm99++40ePXp4HNejRw9Onz5d56Lq6uWXX+bGG29k+PDhtGvXjocffpjmzZuzdu3aYJcmGiFN03j44Yc5++yzufLKK4NdjhBCiDrw+xqbxMREDh8+7PFC4cOHDzuP4gRLeXk53333HRMmTHAZ3rdvX/bt2+fxM6dOnfL67qstW7a4DXNcR2S1Wt1eMWEwGNDr9dhs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sIFZs2Z5HHf48GHnv0MxO1Q8oO+ll17i999/p3379syaNYuePXsGu6yAqvrsFocFCxY4T9NomsbSpUt54403yM/P56KLLuLRRx91awyaujFjxnDeeefx8MMPA6Gd++OPP+aZZ57hl19+oVWrVtxxxx2MGDHCOT6Us9eGNDZCCCGECBlyu7cQQgghQoY0NkIIIYQIGdLYCCGEECJkSGMjhBBCiJAhjY0QQgghQoY0NkIIIYQIGdLYCCGEECJkSGMjhBBCiJAhjY0Q9WzDhg106NDB+V/Hjh259NJLmTVrFqdPn/ZpHjNnzmTgwIH1VuPu3bvp0KEDu3fvrrfvAf7n+Oijj5g0aRKXXHIJnTp1olevXtx22228++67WCyWeqxUDRs3buSf//ynz9Pv3buXhx9+mGHDhtGpUyc6dOjAiRMn6q9AIWpB3hUlRANZsGABbdu2pbS0lL1795Kens4XX3zBxo0bne+0qs5dd93F2LFj6622Cy64gDfeeINzzjmn3r6HPzRN46GHHmLDhg0MGDCAmTNnkpaWRkFBAbt372bOnDnk5uZy2223BbvUJm3Tpk0cOXKE22+/3afpP//8c3bt2sX5559PVFQUX3zxRf0WKEQtSGMjRAM599xz6dy5M1DxtmKbzcYLL7zA1q1buf766z1+pqSkhIiICM4666x6rS06OpouXbrU6/fwx4svvsiGDRuYMmUK99xzj8u4gQMHMm7cOI4dOxak6tR11113OdfHSy+9JI2NaJTkVJQQQeJoJDIyMoCK0zRdu3bl8OHD/PWvf6Vr167Ov6Q9ncLp0KEDc+fO5e233+bqq6/moosu4vrrr+fjjz92+14//fQT999/v/OUzmWXXcb06dMpLy8HPJ+KctRz5MgRbrvtNrp06cLFF1/M3LlzKSkpcZn/mjVrGD16NH369KFLly5cd911rFy5slaniywWCy+++CJt27bl7rvv9jhNSkoKPXr0cH6dl5fHY489Rv/+/enUqRODBg1i0aJFznxVl9n69eu56qqruPDCCxk2bBhff/01mqbx4osvMnDgQLp27crYsWPdmqcxY8YwZMgQ9u7dy4gRI7jwwgvp378/ixcvxmazuUzrb02+rMdffvmFBx54gD59+tCpUyeuvvpq1qxZ4zKNY11u2rSJRYsW0a9fP7p168btt9/O0aNHXbJs376dkydP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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "from sklearn.decomposition import PCA\n", - "\n", - "# make sure your system has required font\n", - "plt.rcParams['font.family'] = 'DejaVu Sans' # Also try Arial\n", - "\n", - "# perform PCA\n", - "pca = PCA(n_components=2) # Reduce to 2 components for visualization\n", - "pca_result = pca.fit_transform(df)\n", - "\n", - "# plot PCA scatterplot\n", - "plt.figure(figsize=(6, 4))\n", - "plt.scatter(pca_result[:, 0], pca_result[:, 1], color='orange')\n", - "for i, (x, y) in enumerate(pca_result):\n", - " plt.text(x, y, str(i), fontsize=9, ha='right', va='bottom') # Add point labels\n", - "plt.xlabel(\"Principal Component 1\", fontsize=12)\n", - "plt.ylabel(\"Principal Component 2\", fontsize=12)\n", - "plt.title(\"PCA of Data Points\", fontsize=12)\n", - "plt.tick_params(axis='both', which='major', labelsize=10)\n", - "plt.grid(True, linestyle='--', alpha=0.5)\n", - "plt.tight_layout()\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 30, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAACSkAAAkqCAYAAADAIQkOAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOz9e7hWdZ0//j/vzZnNUUQERc1DoCGKiEkeSlCz1MQsSgNirKF0qGmsPpGNlpqHmUnTwEPTQc1SwQqxMgVEjb6KpxQC0aQkHEQwhS3HvWFz//7opxMDCLjvxYbN43FdXOVar/V6P+/y4q/ntVapXC6XAwAAAAAAAAAAUJCqxg4AAAAAAAAAAAA0bUpKAAAAAAAAAABAoZSUAAAAAAAAAACAQikpAQAAAAAAAAAAhVJSAgAAAAAAAAAACqWkBAAAAAAAAAAAFEpJCQAAAAAAAAAAKJSSUoGGDRuWYcOGNXYMAAAAAAAAAABoVM0bO0BTtmjRosaOAAAAAAAAAAAAja7BJaXBgwdXIsdGunTpkgkTJhSyGwAAAAAAAAAA2H4aXFJauHBhSqVSyuVyJfK8pba2tqL7AAAAAAAAAACAxlGRz721a9cuF154YSVWpVwuV2wXAAAAAAAAAADQ+CpSUmrVqlXOPPPMSqxKEiUlAAAAAAAAAABoQqoaOwAAAAAAAAAAANC0NfhNSuPGjUurVq0qkaXQnQAAAAAAAAAAQOMolcvlcmOHaKoGDx6cJHnggQcaOQkAAAAAAAAAADQen3sDAAAAAAAAAAAKpaQEAAAAAAAAAAAUqvn2OKS2tjY1NTVZt27d28716NFje8QBAAAAAAAAAAC2o8JKSqtXr84Pf/jD/PrXv86CBQu2OF8qlfLss88WFQcAAAAAAAAAAGgkhZSU3njjjXzqU5/KvHnzUi6Xt+qZrZ0DAAAAAAAAAAB2LoWUlG644Ya88MILad68eYYPH57Bgwdnjz32SLNmzYo4rslZv76cqqpSY8fYrvzmXcOu+JuTXfd3AwAAAAAAAMCbCikpTZ06NaVSKRdeeGHOOeecIo5o0qqqShk/5U95demqxo6yXbx7n845+eh9/eYmblf8zUnStXPbfOKkdzd2DAAAAAAAAABoVIWUlBYvXpyqqqqcddZZRazfJby6dFVe/tvKxo6xXXTt1CaJ39zU7Yq/GQAAAAAAAAD4u0JKSh07dkxdXV1atWpVxHoAAAAAAAAAAGAnUlXE0v79+2f58uVZvHhxEesBAAAAAAAAAICdSCElpX/+539Os2bNcv311xexHgAAAAAAAAAA2IkUUlLq06dPrrrqqtx999258MIL89JLLxVxDAAAAAAAAAAAsBNoXsTSwYMHJ0maNWuWiRMnZuLEienYsWOqq6s3+0ypVMrUqVOLiAMAAAAAAAAAADSiQkpKCxcu3OjasmXLsmzZss0+UyqViogCAAAAAAAAAAA0skJKSldeeWURawEAAAAAAAAAgJ1QISWlM888s4i1AAAAAAAAAADATqiqsQMAAAAAAAAAAABNm5ISAAAAAAAAAABQKCUlAAAAAAAAAACgUM0buuDggw9Okuy///75zW9+s8G1bVEqlfLss882NA4AAAAAAAAAALCDaXBJqVwub/Cf//e/AwAAAAAAAAAAu7YGl5R+8pOfJElat2690TUAAAAAAAAAAIAGl5SOOuqorboGAAAAAAAAAADsmqoaOwAAAAAAAAAAANC0KSkBAAAAAAAAAACFavDn3rakvr4+8+fPzxtvvJF169a97eyAAQOKjgMAAAAAAAAAAGxnhZWUXnnllVxzzTWZMmVK1qxZs8X5UqmUZ599tkFnzpgxIzfffHNmzpyZVatWpUePHjnllFMyatSotG3bdpt2jR07NuPGjXvbmW9961s5++yzGxIZAAAAAAAAAACavEJKSi+99FLOPvvsvPbaaymXy1v1zNbObc5tt92Wyy+/POVyOXvuuWe6d++eefPm5cYbb8zkyZNz++23p1OnTtu8t0uXLtl33303ea9r164NygwAAAAAAAAAALuCQkpK11xzTf72t79lt912y5e//OUce+yx2X333dOsWbMijsvs2bNzxRVXJEkuvfTSDB06NKVSKYsXL855552XOXPm5KKLLsrYsWO3effxxx+fq666qtKRAQAAAAAAAABgl1FVxNJHHnkkpVIp11xzTc4666x069atsIJSktxwww1Zv359zjjjjHziE59IqVRKknTr1i3XXHNNqqqqMnny5Dz33HOFZQAAAAAAAAAAADatkJJSXV1dWrdunaOPPrqI9RtYuXJlpk+fniQZOnToRvf322+/t3Lcd999hecBAAAAAAAAAAA2VMjn3vbee++89NJLRazeyNy5c1NXV5eWLVumb9++m5zp379/HnnkkcycOXOb9z/33HP58pe/nFdffTXV1dXp1atXTj311Bx00EENjQ4AAAAAAAAAALuEQkpKH/rQhzJ27Ng8+uijGThwYBFHvOXFF19MkvTo0SMtWrTY5Mw+++yzwey2mDt3bubOnfvWP0+bNi033XRTRowYka997Wtb/IzdokWLMnjw4M3ef+CBB7Y5EwAAAAAAAAAA7EwK+dzbueeem969e+eiiy4q/I1KNTU1SZKOHTtudubNe2/Obo3dd989n/3sZzNhwoQ8+uij+eMf/5h77rknn/zkJ1Mul3PrrbfmmmuuaVh4AAAAAAAAAADYBRTyJqXWrVvn5ptvzkUXXZSPfOQj+eAHP5hDDz001dXVb/vckCFDtvms2traJNnsW5SSpGXLlhvMbo2zzz57o2u9evXKJZdckr333jvf+c53csstt+Tss8/O3nvvvdk93bt397YkAAAAAAAAAAB2aYWUlJJk4cKF+dvf/pbVq1dn0qRJmTRp0tvOl0qld1RSatWqVZJk7dq1m52pq6vbYLahzj333PzkJz/JkiVL8uCDD2b48OEV2QsAAAAAAAAAAE1RISWl5557LsOHD8/q1auT/P0tR507d06zZs0qftbWfMptaz4Jty2aNWuWww47LFOmTMn8+fMrshMAAAAAAAAAAJqqQkpKY8eOzapVq9KzZ89cdtllOeqoo1JVVVXEUdlvv/2SJC+//HLWrl27yc++LViwYIPZSnjznHXr1lVsJwAAAAAAAAAANEWFNIeefvrplEqlfPe7383RRx9dWEEpSQ455JC0aNEidXV1mTVr1iZnnnrqqSTJ4YcfXrFzX3jhhSTJnnvuWbGdAAAAAAAAAADQFBXSHlq9enXatGmTPn36FLF+A9XV1Tn22GOTJBMmTNjo/vz58zNjxowkySmnnFKRMx966KG3SkrHHHNMRXYCAAAAAAAAAEBTVUhJad999826detSX19fxPqNnH/++SmVSpk0aVLGjx+fcrmcJFmyZEkuuOCCrF+/PieeeGJ69+69wXNnn312Bg0alFtuuWWD6y+88EIuvvjiPPfccxtcX79+fX7961/ny1/+cpLkAx/4QPr27VvcDwMAAAAAAAAAgCageRFLhwwZkquuuirTpk3LSSedVMQRG+jbt2/GjBmTq666KhdffHFuvPHGdO7cOfPmzUtdXV3e9a535bLLLtvoucWLF2fhwoVZvnz5BtfXrVuX8ePHZ/z48enUqVN69OiRZs2aZcGCBampqUmSHHnkkfmv//qvwn8bAAAAAAAAAADs7AopKY0YMSIPP/xwLr744uy+++7p169fEcdsYOTIkenVq1d+/OMfZ9asWXnttdfSo0ePnHLKKRk1alSqq6u3etdee+2VL33pS3nmmWfy5z//OX/9619TV1eXjh075vjjj89pp52W0047Lc2aNSvwFwEAAAAAAAAAQNNQSEnphhtuyGGHHZY5c+bknHPOSf/+/XPooYdusSg0evToBp07cODADBw4cKvnp02btsnrHTp0yHnnndegLAAAAAAAAAAAwN8VUlIaN25cSqVSkqRcLufJJ5/MU089tcXnGlpSAgAAAAAAAAAAdjyFlJQGDBhQxFoAAAAAAAAAAGAnVEhJ6bbbbitiLQAAAAAAAAAAsBOqauwAAAAAAAAAAABA01aRktIJJ5yQb3/723n00UdTX19fiZUAAAAAAAAAAEATUZHPvS1atCg/+9nP8rOf/Szt27fP8ccfn8GDB+f4449PdXV1JY4AAAAAAAAAAAB2UhUpKd19992ZOnVqHnjggcydOze//vWv85vf/CbNmzfP0UcfnUGDBmXQoEHp1q1bJY4DAAAAAAAAAAB2IhUpKfXu3Tu9e/fO6NGj88orr2Tq1KmZOnVqnnzyyUyfPj2///3vc+mll6ZPnz458cQTM2jQoBx00EGVOBoAAAAAAAAAANjBVaSk9I/23HPPDBs2LMOGDcvy5cvz8MMPZ+rUqZk+fXr++Mc/Zvbs2bn22mvTs2fPDB48OIMHD07//v1TKpUqHQUAAAAAAAAAANgBVLyk9I/at2+f0047LaeddlrWrl2bGTNmZOrUqXnwwQezYMGC3HzzzbnlllvSsWPHnHDCCRk8eHCOPfbYtG7dushYAAAAAAAAAADAdlS1vQ5q0aJFjjvuuFxyySX53e9+lwkTJuRzn/tcDjzwwCxbtiwTJ07MF77whQwcODC/+MUvtlcsAAAAAAAAAACgYIW+Sent9O3bN3379s2//du/5aWXXsoDDzyQKVOm5JlnnsmiRYsaKxYAAAAAAAAAAFBhjVZS+kc9e/bMyJEjM3LkyCxdujQ1NTWNHQkAAAAAAAAAAKiQHaKk9I86d+6czp07N3YMAAAAAAAAAACgQgovKf3hD3/I888/nzfeeCNr165929nRo0cXHQcAAAAAAAAAANjOCispPfzww7nkkkuyaNGirX5GSQkAAAAAAAAAAJqeQkpKjz76aM4///zU19cnSfbdd9906dIlzZo1K+I4AAAAAAAAAABgB1ZISen6669PfX19Dj300FxzzTXp2bNnEccAAAAAAAAAAAA7gaoils6ZMyelUilXX321ghIAAAAAAAAAAOziCnmTUosWLdKsWbPss88+RawHAAAAAAAAAAB2IoW8SWn//ffPmjVrUltbW8R6AAAAAAAAAABgJ1JISemTn/xk1q1bl3vuuaeI9QAAAAAAAAAAwE6kkM+9DRkyJDNmzMjll1+etm3b5tRTTy3iGAAAAAAAAAAAYCdQSEkpSa666qrstdde+cpXvpKrr746ffr0SXV19WbnS6VSrrjiiqLiAAAAAAAAAAAAjaSwktL48eNz6623JklefvnlvPzyy5ucK5VKKZfLSkoAAAAAAAAAANBEFVJSmjp1ar75zW8mSdq0aZN+/fqlS5cuad68sE4UAAAAAAAAAACwgyqkNfTDH/4wSXLcccflu9/9btq1a1fEMQAAAAAAAAAAwE6gqoilL7zwQkqlUi6//HIFJQAAAAAAAAAA2MUVUlJq3rx52rdvnz322KOI9QAAAAAAAAAAwE6kkJJS7969s3LlyqxYsaKI9QAAAAAAAAAAwE6kkJLSiBEjUl9fn9tvv72I9QAAAAAAAAAAwE6keRFLBw8enH/5l3/J9773vSR/Ly21bt26iKMAAAAAAAAAAIAdXCElpREjRiRJ2rRpk+9+97u54YYbcsABB6S6unqzz5RKpdx6661FxAEAAAAAAAAAABpRISWlxx9/fIN/XrNmTebMmfO2z5RKpSKiAAAAAAAAAAAAjayQktLo0aOLWAsAAAAAAAAAAOyElJQAAAAAAAAAAIBCVTV2AAAAAAAAAAAAoGlTUgIAAAAAAAAAAAqlpAQAAAAAAAAAABSqwSWlM888M5/5zGcqkaXQnQAAAAAAAAAAQONo3tAFc+fOze67716JLIXuBAAAAAAAAAAAGofPvQEAAAAAAAAAAIVq8JuUkmTp0qUZPHhwJVYBAAAAAAAAAABNTEVKSvX19Vm4cGElVgEAAAAAAAAAAE1Mg0tKV155ZSVybKRVq1aF7AUAAAAAAAAAALavBpeUzjzzzErkAAAAAAAAAAAAmqiqxg4AAAAAAAAAAAA0bUpKAAAAAAAAAABAoZSUAAAAAAAAAACAQikpAQAAAAAAAAAAhVJSAgAAAAAAAAAACqWkBAAAAAAAAAAAFEpJCQAAAAAAAAAAKJSSEgAAAAAAAAAAUCglJQAAAAAAAAAAoFBKSgAAAAAAAAAAQKGaF33AwoULs2TJkrz++uupra1N586d07lz5+y3335p3bp10ccDAAAAAAAAAACNrOIlpdra2tx7772ZPn16Hn/88bz22mubnGvWrFn69OmTAQMG5LTTTkuvXr0qHQUAAAAAAAAAANgBVKyk9NJLL+XWW2/NPffck+XLl6dcLr/t/Lp16/LMM89k5syZ+eEPf5jDDjss55xzTk4//fSUSqVKxQIAAAAAAAAAABpZg0tKNTU1uf7663PHHXdk7dq1SZJ99903AwYMyGGHHZaDDz44nTt3TseOHdO6devU1NRk2bJl+Z//+Z/MmjUrM2fOzBNPPLFBYemrX/1qjjvuuAb/OAAAAAAAAAAAoPE1uKR08sknp6amJp07d87pp5+ej3zkI+nTp89m57t06ZIuXbrkgAMOyPvf//4kyYoVK3L//fdn0qRJefzxxzNq1KhceOGFGT58eEPjAQAAAAAAAAAAjazBJaVSqZQLLrggw4YNS9u2bd/Rjnbt2uWss87KWWedlVmzZmXs2LF54403GhoNAAAAAAAAAADYATS4pDRt2rR3XE7alL59++YHP/hBVq1aVbGdAAAAAAAAAABA46lq6IJKFpS2x14AAAAAAAAAAGD7anBJCQAAAAAAAAAA4O0oKQEAAAAAAAAAAIVqXsTScePGbdN8q1at0qFDhxx44IE59NBD07JlyyJiAQAAAAAAAAAAjaCwklKpVHpHz3bs2DEjR47MqFGjUlXlRU8AAAAAAAAAALCzK6SkNGDAgCTJ888/nzfeeCNJ0r1793Tr1i3lcjlLlizJokWLkvy9lPTud787b7zxRv7yl79k2bJlue666/Lcc8/l2muvLSIeAAAAAAAAAACwHRXyqqLbbrst/fr1yxtvvJEzzzwzU6ZMyYMPPpg777wz48ePz4MPPpgpU6bkox/9aGpqajJgwIBMmjQpjz32WEaPHp0kuf/++zNlypQi4gEAAAAAAAAAANtRISWl+++/Pz/4wQ8yYsSIXHnllenZs+dGMz179swVV1yRT3/607nxxhvzwAMPpG3bthk9enRGjhyZcrmciRMnFhEPAAAAAAAAAADYjgopKf30pz9NqVTK+eefv8XZ8847L8nf3770puHDhydJZs+eXUQ8AAAAAAAAAABgOyqkpPSnP/0p7du3T6dOnbY426lTp3To0CFz585961qPHj1SXV2dpUuXFhEPAAAAAAAAAADYjgopKdXV1WXlypVZvXr1FmdXrVqVFStWpK6ubqN7rVu3LiIeAAAAAAAAAACwHRVSUtp3331TX1+fO+64Y4uzd955Z+rr67Pvvvu+dW358uVZuXJldttttyLiAQAAAAAAAAAA21EhJaUhQ4akXC7n6quvzve///2sWbNmo5k1a9bkv//7v3P11VenVCrljDPOeOveM888kyQ56KCDiogHAAAAAAAAAABsR82LWDps2LBMnTo1Tz75ZK699trcdNNN6dOnT/bYY4+USqUsWbIks2fPzurVq1Mul3PkkUdm+PDhbz0/ceLEJMn73ve+IuIBAAAAAAAAAADbUSElpebNm+cHP/hBrrzyyvz85z/P6tWr88QTT6RUKiVJyuVykqSqqiof//jHM2bMmDRv/r9RLrvsslxyySVp27btNp07Y8aM3HzzzZk5c2ZWrVqVHj165JRTTsmoUaO2edemPPzwwxk1alSSZK+99sq0adMavBMAAAAAAAAAAJq6QkpKSdKmTZtceumlOe+883L//ffn2Wefzeuvv55yuZwuXbrkkEMOycknn5wePXps9Gx1dfU2n3fbbbfl8ssvT7lczp577pnu3btn3rx5ufHGGzN58uTcfvvt6dSp0zv+PStWrMg3v/nNd/w8AAAAAAAAAADsqgopKT3xxBNJkl69eqV79+4ZOXJkEce8Zfbs2bniiiuSJJdeemmGDh2aUqmUxYsX57zzzsucOXNy0UUXZezYse/4jO985ztZtGhRTjzxxEydOrVS0QEAAAAAAAAAoMmrKmLp8OHD8+lPf/qtz7oV7YYbbsj69etzxhln5BOf+MRbn5Xr1q1brrnmmlRVVWXy5Ml57rnn3tH+J598MnfeeWdOOumkDB48uJLRAQAAAAAAAACgySukpNS+ffu0b98+HTt2LGL9BlauXJnp06cnSYYOHbrR/f322y9HH310kuS+++7b5v21tbX593//97Rt2zYXXXRRw8ICAAAAAAAAAMAuqJCS0j777JOVK1emrq6uiPUbmDt3burq6tKyZcv07dt3kzP9+/dPksycOXOb919//fV58cUXc8EFF6Rbt24NygoAAAAAAAAAALui5kUsPfXUUzNnzpzce++9GTJkSBFHvOXFF19MkvTo0SMtWrTY5Mw+++yzwezWmjt3bn70ox+lb9++Oeecc95RvkWLFr3tJ+IeeOCBd7QXAAAAAAAAAAB2FoW8SWnEiBE5/PDDc9lll+Xhhx8u4oi31NTUJMnbflruzXtvzm6N+vr6fOMb30iSXHbZZamqKuR/KgAAAAAAAAAAaPIKeZPSTTfdlAEDBuRPf/pTPv/5z+fAAw/MEUcckS5durxt2Wf06NHbfFZtbW2SbPYtSknSsmXLDWa3xo9+9KPMmTMnn/3sZ9O7d+9tzvWm7t27e1sSAAAAAAAAAAC7tEJKSuPGjUupVEq5XE6SvPDCC5k3b94Wn3snJaVWrVolSdauXbvZmbq6ug1mt2T+/PkZN25c9t5773eUCQAAAAAAAAAA+F+FlJQGDBhQxNpN2ppPuW3NJ+H+0Te/+c3U1tbmW9/6Vtq0adPwkAAAAAAAAAAAsAsrpKR02223FbF2k/bbb78kycsvv5y1a9du8rNvCxYs2GB2S+bMmZNSqZQxY8ZsdG/NmjVJkkWLFuWYY45JkowdOzZHHHHEO0gPAAAAAAAAAABNXyElpe3pkEMOSYsWLVJXV5dZs2alf//+G8089dRTSZLDDz98q/eWy+X87W9/2+z99evXv3X/7T41BwAAAAAAAAAAu7qdvqRUXV2dY489Ng8++GAmTJiwUUlp/vz5mTFjRpLklFNO2aqdTz755Gbv/fKXv8zXv/717LXXXpk2bdo7Dw4AAAAAAAAAALuIwktKdXV1eeSRRzJ79uy89tprKZVK2W233XLooYdm4MCBadmyZYPPOP/88/PQQw9l0qRJOeKIIzJ06NCUSqUsWbIkF1xwQdavX58TTzwxvXv33uC5s88+O4sXL86IESMycuTIBucAAAAAAAAAAAA2VmhJafz48bnuuuuydOnSTd7v3LlzvvSlL2Xo0KENOqdv374ZM2ZMrrrqqlx88cW58cYb07lz58ybNy91dXV517velcsuu2yj5xYvXpyFCxdm+fLlDTofAAAAAAAAAADYvMJKSv/1X/+VH//4xymXy0mSbt26Zc8990ySvPLKK1m8eHFef/31fPOb38yCBQvyla98pUHnjRw5Mr169cqPf/zjzJo1K6+99lp69OiRU045JaNGjUp1dXWDfxMAAAAAAAAAALDtCikpPf744/nRj36UJDn55JPzr//6rznggAM2mPnLX/6S6667Lvfff39+9KMf5QMf+ECOPPLIBp07cODADBw4cKvnp02bts1nfPSjH81HP/rRbX4OAAAAAAAAAAB2VVVFLP3Zz36WJPnYxz6W733vexsVlJJk//33z3XXXZePfexjKZfL+elPf1pEFAAAAAAAAAAAoJEVUlJ6+umnU1VVlX/7t3/b4uyXvvSllEql/OEPfygiCgAAAAAAAAAA0MgKKSktXbo07du3T5cuXbY4u/vuu6dDhw5ZunRpEVEAAAAAAAAAAIBGVkhJqbq6OitXrkxtbe0WZ9esWZOVK1embdu2RUQBAAAAAAAAAAAaWSElpV69eqW+vj4///nPtzj7i1/8IuvWrUvv3r2LiAIAAAAAAAAAADSyQkpKp59+esrlcv7jP/4jd91112bn7rrrrlx11VUplUo544wziogCAAAAAAAAAAA0suZFLP3oRz+aSZMm5YknnsjFF1+cG264Ie9973vTrVu3JMkrr7ySxx57LIsXL065XM5RRx2VM888s4goAAAAAAAAAABAIyukpFRVVZUbb7wxF154YSZPnpxFixZl0qRJG8yUy+Ukycknn5wrrrgipVKpiCgAAAAAAAAAAEAjK6SklCTt2rXL9773vcyaNSv33ntvZs+enddeey1J0qVLl/Tp0ycf/vCH07dv36IiAAAAAAAAAAAAO4DCSkpv6tu3ryISAAAAAAAAAADswqoaOwAAAAAAAAAAANC0KSkBAAAAAAAAAACFKvRzbytWrMhDDz2U559/PjU1NVm7du1mZ0ulUq644ooi4wAAAAAAAAAAAI2gsJLSL3/5y1x++eVZtWrVW9fK5fJGc6VSKeVyWUkJAAAAAAAAAACaqEJKStOnT883vvGNlMvltGrVKocffnj22GOPNG9e6IubAAAAAAAAAACAHVAhraEf/vCHKZfLOfzww3PDDTdkt912K+IYAAAAAAAAAABgJ1BVxNI5c+akVCrlqquuUlACAAAAAAAAAIBdXCElpfr6+rRt2zb77bdfEesBAAAAAAAAAICdSCElpZ49e6auri719fVFrAcAAAAAAAAAAHYihZSUPvKRj2TdunX53e9+V8R6AAAAAAAAAABgJ1JISenTn/50Dj300FxyySWZP39+EUcAAAAAAAAAAAA7ieZFLP3Nb36TM844I9/73vdyxhln5IMf/GAOO+ywVFdXv+1zQ4YMKSIOAAAAAAAAAADQiAopKY0ZMyalUilJUi6X86tf/Sq/+tWv3vaZUqmkpAQAAAAAAAAAAE1QISWlHj16FLEWAAAAAAAAAADYCRVSUpo2bVoRawEAAAAAAAAAgJ1QVWMHAAAAAAAAAAAAmjYlJQAAAAAAAAAAoFBKSgAAAAAAAAAAQKGaV2LJ3XffXYk1GTJkSEX2AAAAAAAAAAAAO46KlJTGjBmTUqnUoB2lUklJCQAAAAAAAAAAmqCKlJSSpFwuV2oVAAAAAAAAAADQhFSkpPTAAw9UYg0AAAAAAAAAANAEVaSktNdee1ViDQAAAAAAAAAA0ARVNXYAAAAAAAAAAACgaVNSAgAAAAAAAAAACtXgktLixYsrkWMjS5YsKWQvAAAAAAAAAACwfTW4pHTSSSfl29/+dsXKSvfdd19OP/30TJgwoSL7AAAAAAAAAACAxtW8oQv22GOP/PSnP81dd92VQYMG5fTTT8/xxx+f5s23fvWCBQtyzz335Fe/+lUWLFiQUqmUvffeu6HRAAAAAAAAAACAHUCDS0q//e1vc9ttt+Wmm27Kb3/729x3333p0KFD+vbtm759+6Z3797p3LlzOnXqlJYtW6ampiY1NTV56aWXMmvWrMyaNSt/+ctfkiTlcjnHHHNMvva1r+Xd7353g38cAAAAAAAAAADQ+BpcUmrRokXOPffcnHXWWRk/fnzuuuuuvPTSS5k+fXp+//vfb/H5crmcFi1a5MQTT8ynPvWpHHnkkQ2NBAAAAAAAAAAA7EAaXFJ6U8eOHTNq1KiMGjUqjzzySKZPn54nnngic+fOTX19/Ubzu+++ewYMGJCjjjoqH/zgB7PbbrtVKgoAAAAAAAAAALADqVhJ6R+9733vy/ve974kydq1a/P666/n9ddfT21tbTp16pTddtstHTp0KOJoAAAAAAAAAABgB1NISekftWjRIt26dUu3bt2KPgoAAAAAAAAAANgBVTV2AAAAAAAAAAAAoGlTUgIAAAAAAAAAAAqlpAQAAAAAAAAAABRKSQkAAAAAAAAAACiUkhIAAAAAAAAAAFAoJSUAAAAAAAAAAKBQSkoAAAAAAAAAAEChlJQAAAAAAAAAAIBCKSkBAAAAAAAAAACFUlICAAAAAAAAAAAK1byhC+6+++4KxPi7IUOGVGwXAAAAAAAAAACwY2hwSWnMmDEplUoNDlIqlZSUAAAAAAAAAACgCWpwSalHjx6bvbd06dKsXr367wc1b55OnTolSZYtW5Z169YlSdq0aZPOnTs3NAYAAAAAAAAAALCDanBJadq0aZu8fscdd+Tyyy9P//79c/7552fAgAFp2bJlkqSuri5PPPFEbrzxxsycOTP//M//nLPPPruhUQAAAAAAAAAAgB1QVRFLH3300Vx22WUZNGhQbrvtthxzzDFvFZSSpGXLljnmmGNy22235YQTTshll12Wxx57rIgoAAAAAAAAAABAIyukpHTzzTenXC7n61//eqqqNn9EqVTKmDFjsn79+vz4xz8uIgoAAAAAAAAAANDICikpzZ49Ox06dEj37t23ONujR4906NAhf/zjH4uIAgAAAAAAAAAANLLmRSxduXJl6uvrU1dXt8Fn3jalrq4uq1atSrNmzYqIAgAAAAAAAAAANLJC3qS09957p76+PpMmTdri7KRJk7Ju3brsvffeRUQBAAAAAAAAAAAaWSElpVNPPTXlcjnf/va3M3HixM3O3X333fn2t7+dUqmUU089tYgoAAAAAAAAAABAIyvkc2+f+cxnMmXKlMydOzcXXnhhxo4dm6OOOirdunVLkixevDiPP/54Fi1alHK5nIMPPjif+cxniogCAAAAAAAAAAA0skJKSq1atcott9ySb3zjG5k6dWpefvnljT79Vi6XkySDBg3KFVdckVatWhURBQAAAAAAAAAAaGSFlJSSpGPHjhk3blxmzZqVe++9N7Nnz85rr72WJOnSpUv69OmTD3/4w+nbt29REQAAAAAAAAAAgB1AYSWlN/Xt21cRCQAAAAAAAAAAdmFVjR0AAAAAAAAAAABo2gp/k9L69esze/bsvPzyy1mzZk2GDBlS9JEAAAAAAAAAAMAOpNCS0m233ZYbb7wxS5cufevaP5aUampq8qlPfSrr1q3LnXfemU6dOhUZBwAAAAAAAAAAaASFfe7t0ksvzRVXXJHXX3891dXVKZVKG8107Ngx73nPe/LXv/41Dz30UFFRAAAAAAAAAACARlRISWn69Om5/fbb07Zt24wbNy5PPvlkdtttt03OnnbaaSmXy5k2bVoRUQAAAAAAAAAAgEZWSEnpzjvvTKlUyhe/+MWceOKJbzvbr1+/JMncuXOLiAIAAAAAAAAAADSyQkpKM2fOTJKcddZZW5xt165d2rVrl1dffbWIKAAAAAAAAAAAQCMrpKS0bNmytG/fPu3atdu6EFVVqa+vLyIKAAAAAAAAAADQyAopKbVr1y4rVqzI2rVrtzj7+uuv54033kjnzp2LiAIAAAAAAAAAADSyQkpKBx10UMrlcv74xz9ucfaee+5JkrznPe8pIgoAAAAAAAAAANDICikpnXTSSSmXyxk3blzWr1+/2bk//OEPue6661IqlfKhD32oiCgAAAAAAAAAAEAja17E0k9+8pP52c9+lkcffTSf/exnM3LkyLfKSq+//nqef/753HvvvZk4cWLWrVuXgw8+OKeddlqDz50xY0ZuvvnmzJw5M6tWrUqPHj1yyimnZNSoUWnbtu027Zo2bVqmT5+eOXPm5JVXXsnSpUvTvHnz7LXXXhk4cGBGjhyZvfbaq8GZAQAAAAAAAACgqSukpNSyZct8//vfz7nnnptHHnkkjz766Fv3jjnmmLf+e7lczj777JNx48alqqphL3W67bbbcvnll6dcLmfPPfdM9+7dM2/evNx4442ZPHlybr/99nTq1Gmr99188815/PHH06JFi3Tt2jXvfve7s3Tp0vz5z3/OCy+8kLvuuivjxo3Lscce26DcAAAAAAAAAADQ1BXyubck2W+//TJp0qT80z/9Uzp06JByubzBn+rq6nz605/OXXfd1eA3Es2ePTtXXHFFkuTSSy/NQw89lIkTJ2bq1Kl5z3vekz//+c+56KKLtmnnWWedlVtuuSV/+MMf8uCDD+YXv/hFpk2blvvvvz8DBgzI6tWr85WvfCWrVq1qUHYAAAAAAAAAAGjqCnmT0pvat2+fr33ta/na176WefPmZcmSJamvr0/Xrl1z0EEHpVmzZhU554Ybbsj69eszZMiQfOITn3jrerdu3XLNNdfkQx/6UCZPnpznnnsuvXv33qqdQ4YM2eT1ffbZJ9dee22OOeaYLF26NE888UTe//73V+JnAAAAAAAAAABAk1RoSekfHXjggTnwwAMrvnflypWZPn16kmTo0KEb3d9vv/1y9NFH55FHHsl999231SWlt7P77runU6dOWbZsWdasWdPgfQAAAAAAAAAA0JQV9rm37WXu3Lmpq6tLy5Yt07dv303O9O/fP0kyc+bMipz55z//OcuWLUtVVVUOOeSQiuwEAAAAAAAAAICmqtA3Ka1evTp33XVXfv/73+fll1/OmjVrMnXq1LfuL1++PA899FBKpVJOO+20d3TGiy++mCTp0aNHWrRoscmZffbZZ4PZd6JcLuf111/PU089le985ztJknPPPTc9e/Z82+cWLVqUwYMHb/b+Aw888I4zAQAAAAAAAADAzqCwktLcuXNz/vnn55VXXkm5XE6SlEqlDWbatWuXm266KX/5y1/Ss2fPHHbYYdt8Tk1NTZKkY8eOm515896bs9ti0qRJ+X//7/9tcG3//ffPd77znZx++unbvA8AAAAAAAAAAHY1hZSUli5dmlGjRuXVV1/Ne97znpx22mm5/vrrs3Llyg3mSqVSPvaxj+U//uM/MnXq1HdUUqqtrU2Szb5FKUlatmy5wey26NKlS4444oiUy+W88sorWbx4cebPn59f/epXGTBgQPbcc8+3fb579+7elgQAAAAAAAAAwC6tqoilt956a1599dUMHDgwd911V/7pn/4prVu33uTs+9///iTJY4899o7OatWqVZJk7dq1m52pq6vbYHZbHHvssbnjjjty55135qGHHsrkyZMzaNCgPPzwwxk6dGiWL1/+jnIDAAAAAAAAAMCuopCS0oMPPphSqZSvfvWrqap6+yP233//NG/ePH/961/f0Vlb8ym3rfkk3Nbq2bNnvve97+Wggw7K4sWL89Of/rTBOwEAAAAAAAAAoCkrpKS0YMGCtGjRIgcffPBWzbdr126jT8Ftrf322y9J8vLLL2/2bUoLFizYYLahmjVrluOOOy5JMnv27IrsBAAAAAAAAACApqqQklK5XE6zZs1SKpW2OLt+/fqsXLkybdq0eUdnHXLIIWnRokXq6uoya9asTc489dRTSZLDDz/8HZ2xKevWrUvy9/wAAAAAAAAAAMDmFVJS6tq1a9asWZPXXntti7PPPPNM1q5dm7322usdnVVdXZ1jjz02STJhwoSN7s+fPz8zZsxIkpxyyinv6Iz/q66uLg899FCSv5ekAAAAAAAAAACAzSukpHTkkUcmSSZOnPi2c+vXr891112XUqmUgQMHvuPzzj///JRKpUyaNCnjx49PuVxOkixZsiQXXHBB1q9fnxNPPDG9e/fe4Lmzzz47gwYNyi233LLB9T/+8Y+59tprM3/+/I3OevHFF3PeeedlwYIFadu2bYYOHfqOcwMAAAAAAAAAwK6geRFLR4wYkbvvvjs33XRTDj300Lz3ve/daGbRokW55JJL8thjj6V58+Y555xz3vF5ffv2zZgxY3LVVVfl4osvzo033pjOnTtn3rx5qaury7ve9a5cdtllGz23ePHiLFy4MMuXL9/g+qpVq3LjjTfmxhtvzG677Zbu3bunefPmefXVV/Pyyy8nSTp16pRrr7023bp1e8e5AQAAAAAAAABgV1BISenggw/OeeedlxtuuCH/9E//lD59+mTFihVJkksuuSR/+tOfMnPmzNTX1ydJLrjggvTs2bNBZ44cOTK9evXKj3/848yaNSuvvfZaevTokVNOOSWjRo1KdXX1Vu/q3bt3/v3f/z2PP/54/vSnP+Wvf/1r1qxZk3bt2qV///457rjj8olPfCK77bZbgzIDAAAAAAAAAMCuoJCSUpJ88YtfTIcOHXLddddl1qxZb12/88473/ocW+vWrfPlL385w4cPr8iZAwcO3KbPxk2bNm2T1zt27Jjhw4dXLBcAAAAAAAAAAOzKCispJX9/u9EZZ5yRX/3qV3nqqaeyZMmSrF+/PrvvvnuOOOKInHHGGdl9992LjAAAAAAAAAAAADSyQktKSdK5c+eMGDEiI0aMKPooAAAAAAAAAABgB1TV2AEAAAAAAAAAAICmrfA3KSXJihUr8uyzz+a1115LknTp0iWHHHJI2rVrtz2OBwAAAAAAAAAAGlGhJaW5c+fmuuuuy/Tp07N+/foN7lVVVeW4447LF7/4xRxyyCFFxgAAAAAAAAAAABpRYZ97+8UvfpGPf/zjefjhh1NfX59yubzBn/r6+jz00EMZOnRofv7znxcVAwAAAAAAAAAAaGSFvElp1qxZueiii7J+/frsu++++exnP5ujjz463bp1S5IsXrw4M2bMyM0335y//OUvufjii/Pud787ffv2LSIOAAAAAAAAAADQiAp5k9L3v//9rF+/PkcddVTuueeefPzjH0/Pnj3TsmXLtGzZMj179szHP/7x3H333Xnve9+b9evX5/vf/34RUQAAAAAAAAAAgEZWSEnpqaeeSqlUyre+9a20atVqs3MtW7bM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cumll2bo0KEplUpZvHhxzjvvvMyZMycXXXRRxo4du827jz/++Fx11VWVjgwAAAAAAAAAALuMBpeUDj744CTJ/vvvn9/85jcbXNsWpVIpzz777DvKcMMNN2T9+vUZMmRIPvGJT7x1vVu3brnmmmvyoQ99KJMnT85zzz2X3r17v6MzAAAAAAAAAACAd6aqoQvK5fJbfzZ1bVv+vBMrV67M9OnTkyRDhw7d6P5+++2Xo48+Okly3333vaMzAAAAAAAAAACAd67Bb1L6yU9+kiRp3br1Rte2h7lz56auri4tW7ZM3759NznTv3//PPLII5k5c+Y273/uuefy5S9/Oa+++mqqq6vTq1evnHrqqTnooIMaGh0AAAAAAAAAAHYJDS4pHXXUUVt1rSgvvvhikqRHjx5p0aLFJmf22WefDWa3xdy5czN37ty3/nnatGm56aabMmLEiHzta19Ls2bN3vb5RYsWZfDgwZu9/8ADD2xzJoB/5O8ZAAAAAAAAAHZ0DS4pNbaampokSceOHTc78+a9N2e3xu67757PfvazOfnkk9OzZ8+0a9cuL774Ym6//fbceeedufXWW9OiRYt89atfbdgPAAAAAAAAAACAJm6nLynV1tYmyWbfopQkLVu23GB2a5x99tkbXevVq1cuueSS7L333vnOd76TW265JWeffXb23nvvze7p3r27t5gAhfL3DAAAAAAAAAA7usJLSvX19Zk/f37eeOONrFu37m1nBwwYsM37W7VqlSRZu3btZmfq6uo2mG2oc889Nz/5yU+yZMmSPPjggxk+fHhF9gIAAAAAAAAAQFNUWEnplVdeyTXXXJMpU6ZkzZo1W5wvlUp59tlnt/mcrfmU29Z8Em5bNGvWLIcddlimTJmS+fPnV2QnAAAAAAAAAAA0VYWUlF566aWcffbZee2111Iul7fqma2d+7/222+/JMnLL7+ctWvXbvKzbwsWLNhgthLePGdLb4cCAAAAAAAAAIBdXSElpWuuuSZ/+9vfsttuu+XLX/5yjj322Oy+++5p1qxZxc865JBD0qJFi9TV1WXWrFnp37//RjNPPfVUkuTwww+v2LkvvPBCkmTPPfes2E4AAAAAAAAAAGiKqopY+sgjj6RUKuWaa67JWWedlW7duhVSUEqS6urqHHvssUmSCRMmbHR//vz5mTFjRpLklFNOqciZDz300FslpWOOOaYiO/lfy1fV5X+WLM/zf309/7NkeZavqmvsSAAAAAAAAAAANEAhJaW6urq0bt06Rx99dBHrN3L++eenVCpl0qRJGT9+/FufjluyZEkuuOCCrF+/PieeeGJ69+69wXNnn312Bg0alFtuuWWD6y+88EIuvvjiPPfccxtcX79+fX7961/ny1/+cpLkAx/4QPr27VvcD9sFvbpsdf7rp0/mvP+Ylq98b3rO+49p+a+fPplXl61u7GgAAAAAAAAAALxDhXzube+9985LL71UxOpN6tu3b8aMGZOrrroqF198cW688cZ07tw58+bNS11dXd71rnflsssu2+i5xYsXZ+HChVm+fPkG19etW5fx48dn/Pjx6dSpU3r06JFmzZplwYIFqampSZIceeSR+a//+q/t8vt2FctX1WXshKfz9POvbnD96edfzdgJT+erw45M+7YtGykdAAAAAAAAAADvVCFvUvrQhz6U2traPProo0Ws36SRI0fm5ptvzvHHH5/Vq1dn3rx56dGjRz7/+c/nF7/4RXbbbbet3rXXXnvlS1/6Uj7wgQ+kffv2+etf/5rnnnsuLVq0yPHHH5///M//zE9+8pN06NChwF+066lZUbtRQelNTz//ampW1G7nRAAAAAAAAAAAVEIhb1I699xzM2XKlFx00UW5+eab07NnzyKO2cjAgQMzcODArZ6fNm3aJq936NAh5513XqVisZVWrl7boPsAAAAAAAAAAOyYCikptW7dOjfffHMuuuiifOQjH8kHP/jBHHrooamurn7b54YMGVJEHHYS1W1aNOg+AAAAAAAAAAA7pkJKSkmycOHC/O1vf8vq1aszadKkTJo06W3nS6WSktIurmO7VunXq+smP/nWr1fXdGzXqhFSAQAAAAAAAADQUIWUlJ577rkMHz48q1evTpK0aNEinTt3TrNmzYo4jiaifduW+cLQfhk74ekNikr9enXNF4f2S/u2LRsxHQAAAAAAAAAA71QhJaWxY8dm1apV6dmzZy677LIcddRRqaqqKuIompiundrkq8OOTM2K2qxcvTbVbVqkY7tWCkoAAAAAAAAAADuxQkpKTz/9dEqlUr773e+mT58+RRxBE9a+bUulJAAAAAAAAACAJqSQ1xutXr06bdq0UVACAAAAAAAAAACKKSntu+++WbduXerr64tYDwAAAAAAAAAA7EQKKSkNGTIkdXV1mTZtWhHrAQAAAAAAAACAnUghJaURI0Zk4MCBufjii/P0008XcQQAAAAAAAAAALCTaF7E0htuuCGHHXZY5syZk3POOSf9+/fPoYcemurq6rd9bvTo0UXEAQAAAAAAAAAAGlEhJaVx48alVColScrlcp588sk89dRTW3xOSQkAAAAAAAAAAJqeQkpKAwYMKGItAAAAAAAAAACwEyqkpHTbbbcVsRYAAAAAAAAAANgJVTV2AAAAAAAAAAAAoGmrSEnphBNOyLe//e08+uijqa+vr8RKAAAAAAAAAACgiajI594WLVqUn/3sZ/nZz36W9u3b5/jjj8/gwYNz/PHHp7q6uhJHAAAAAAAAAAAAO6mKlJTuvvvuTJ06NQ888EDmzp2bX//61/nNb36T5s2b5+ijj86gQYMyaNCgdOvWrRLHAQAAAAAAAAAAO5GKlJR69+6d3r17Z/To0XnllVcyderUTJ06NU8++WSmT5+e3//+97n00kvTp0+fnHjiiRk0aFAOOuigShwNAAAAAAAAAADs4CpSUvpHe+65Z4YNG5Zhw4Zl+fLlefjhhzN16tRMnz49f/zjHzN79uxce+216dmzZwYPHpzBgwenf//+KZVKlY4CAAAAAAAAAADsACpeUvpH7du3z2mnnZbTTjsta9euzYwZMzJ16tQ8+OCDWbBgQW6++ebccsst6dixY0444YQMHjw4xx57bFq3bl1kLAAAAAAAAAAAYDuq2l4HtWjRIscdd1wuueSS/O53v8uECRPyuc99LgceeGCWLVuWiRMn5gtf+EIGDhyYX/ziF9srFgAAAAAAAAAAULBC36T0dvr27Zu+ffvm3/7t3/LSSy/lgQceyJQpU/LMM89k0aJFjRULAAAAAAAAAACosEYrKf2jnj17ZuTIkRk5cmSWLl2ampqaxo4EAAAAAAAAAABUyA5RUvpHnTt3TufOnRs7RqNYvqouNStqs3L12lS3aZGO7VqlfduWjR0LAAAAAAAAAAAapPCS0h/+8Ic8//zzeeONN7J27dq3nR09enTRcXZYry5bnbETns7Tz7/61rV+vbrmC0P7pWunNo2YDAAAAAAAAAAAGqawktLDDz+cSy65JIsWLdrqZ3bVktLyVXUbFZSS5OnnX83YCU/nq8OO9EYlAAAAAAAAAAB2WoWUlB599NGcf/75qa+vT5Lsu+++6dKlS5o1a1bEcTu9mhW1GxWU3vT086+mZkWtkhIAAAAAAAAAADutQkpK119/ferr63PooYfmmmuuSc+ePYs4pslYufrtP4O3pfsAAAAAAAAAALAjqypi6Zw5c1IqlXL11VcrKG2F6jYtGnQfAAAAAAAAAAB2ZIWUlFq0aJHq6urss88+Raxvcjq2a5V+vbpu8l6/Xl3TsV2r7ZwIAAAAAAAAAAAqp5CS0v777581a9aktra2iPVNTvu2LfOFof02Kir169U1XxzaL+3btmykZAAAAAAAAAAA0HDNi1j6yU9+MmPGjMk999yTj3/840Uc0eR07dQmXx12ZGpW1Gbl6rWpbtMiHdu1UlACAAAAAAAAAGCnV0hJaciQIZkxY0Yuv/zytG3bNqeeemoRxzQ57du2VEoCAAAAAAAAAKDJKaSklCRXXXVV9tprr3zlK1/J1VdfnT59+qS6unqz86VSKVdccUVRcQAAAAAAAAAAgEZSWElp/PjxufXWW5MkL7/8cl5++eVNzpVKpZTLZSUlAAAAAAAAAABoogopKU2dOjXf/OY3kyRt2rRJv3790qVLlzRvXlgnCgAAAAAAAAAA2EEV0hr64Q9/mCQ57rjj8t3vfjft2rUr4hgAAAAAAAAAAGAnUFXE0hdeeCGlUimXX365ghIAAAAAAAAAAOziCikpNW/ePO3bt88ee+xRxHoAAAAAAAAAAGAnUkhJqXfv3lm5cmVWrFhRxHoAAAAAAAAAAGAnUkhJacSIEamvr8/tt99exHoAAAAAAAAAAGAn0ryIpYMHD86//Mu/5Hvf+16Sv5eWWrduXcRRAAAAAAAAAADADq6QktKIESOSJG3atMl3v/vd3HDDDTnggANSXV292WdKpVJuvfXWIuIAAAAAAAAAAACNqJCS0uOPP77BP69ZsyZz5sx522dKpVIRUQAAAAAAAAAAgEZWSElp9OjRRawFAAAAAAAAAAB2QkpKAAAAAAAAAABAoaoaOwAAAAAAAAAAANC0KSkBAAAAAAAAAACFUlICAAAAAAAAAAAK1eCS0plnnpnPfOYzlchS6E4AAAAAAAAAAKBxNG/ogrlz52b33XevRJZCdwIAAAAAAAAAAI3D594AAAAAAAAAAIBCNfhNSkmydOnSDB48uBKrAAAAAAAAAACAJqYiJaX6+vosXLiwEqsAAAAAAAAAAIAmpsElpSuvvLISOTbSqlWrQvYCAAAAAAAAAADbV4NLSmeeeWYlcgAAAAAAAAAAAE1UVWMHAAAAAAAAAAAAmjYlJQAAAAAAAAAAoFBKSgAAAAAAAAAAQKGUlAAAAAAAAAAAgEIpKQEAAAAAAAAAAIVSUgIAAAAAAAAAAArVvLEDADTU8lV1qVlRm5Wr16a6TYt0bNcq7du2bOxYAAAAAAAAAMD/n5ISsFN7ddnqjJ3wdJ5+/tW3rvXr1TVfGNovXTu1acRkAAAAAAAAAMCbfO4N2GktX1W3UUEpSZ5+/tWMnfB0lq+qa6RkAAAAAAAAAMA/UlICdlo1K2o3Kii96ennX03NitrtnAgAAAAAAAAA2JTCP/e2cOHCLFmyJK+//npqa2vTuXPndO7cOfvtt19at25d9PFAE7Zy9doG3QcAAAAAAAAAto+Kl5Rqa2tz7733Zvr06Xn88cfz2muvbXKuWbNm6dOnTwYMGJDTTjstvXr1qnQUoImrbtOiQfcBAAAAAAAAgO2jYiWll156KbfeemvuueeeLF++POVy+W3n161bl2eeeSYzZ87MD3/4wxx22GE555xzcvrpp6dUKlUqFtCEdWzXKv16dd3kJ9/69eqaju1aNUIqAAAAAAAAAOD/anBJqaamJtdff33uuOOOrF37908r7bvvvhkwYEAOO+ywHHzwwencuXM6duyY1q1bp6amJsuWLcv//M//ZNasWZk5c2aeeOKJDQpLX/3qV3Pcccc1+McBTVv7ti3zhaH9MnbC0xsUlfr16povDu2X9m1bNmI6AAAAAAAAAOBNDS4pnXzyyampqUnnzp1z+umn5yMf+Uj69Omz2fkuXbqkS5cuOeCAA/L+978/SbJixYrcf//9mTRpUh5//PGMGjUqF154YYYPH97QeEAT17VTm3x12JGpWVGblavXprpNi3Rs10pBCQAAAAAAAAB2IA0uKZVKpVxwwQUZNmxY2rZt+452tGvXLmeddVbOOuuszJo1K2PHjs0bb7zR0GjALqJ925ZKSQAAAAAAAACwA2twSWnatGnvuJy0KX379s0PfvCDrFq1qmI7AQAAAAAAAACAxlPV0AWVLChtj70AAAAAAAAAAMD21eCSEgAAAAAAAAAAwNtRUgIAAAAAAAAAAArVvIil48aN26b5Vq1apUOHDjnwwANz6KGHpmXLlkXEAgAAAAAAAAAAGkFhJaVSqfSOnu3YsWNGjhyZUaNGparKi54AAAAAAAAAAGBnV0hJacCAAUmS559/Pm+88UaSpHv37unWrVvK5XKWLFmSRYsWJfl7Kend73533njjjfzlL3/JsmXLct111+W5557Ltddeu03nzpgxIzfffHNmzpyZVatWpUePHjnllFMyatSotG3btsG/6+GHH86oUaOSJHvttVemTZvW4J0AAAAAAAAAANDUFfKqottuuy39+vXLG2+8kTPPPDNTpkzJgw8+mDvvvDPjx4/Pgw8+mClTpuSjH/1oampqMmDAgEyaNCmPPfZYRo8enSS5//77M2XKlG06c+TIkXnooYfSqlWrHHDAAVm4cGFuvPHGfOxjH8uyZcsa9JtWrFiRb37zmw3aAQAAAAAAAAAAu6JCSkr3339/fvCDH2TEiBG58sor07Nnz41mevbsmSuuuCKf/vSnc+ONN+aBBx5I27ZtM3r06IwcOTLlcjkTJ07cqvNmz56dK664Ikly6aWX5qGHHsrEiRMzderUvOc978mf//znXHTRRQ36Td/5zneyaNGinHjiiQ3aAwAAAAAAAAAAu5pCSko//elPUyqVcv75529x9rzzzkvy9zchvWn48OFJ/l4+2ho33HBD1q9fnzPOOCOf+MQnUiqVkiTdunXLNddck6qqqkyePDnPPffctv6UJMmTTz6ZO++8MyeddFIGDx78jnYAAAAAAAAAAMCuqpCS0p/+9Ke0b98+nTp12uJsp06d0qFDh8ydO/etaz169Eh1dXWWLl26xedXrlyZ6dOnJ0mGDh260f399tsvRx99dJLkvvvu28pf8L9qa2vz7//+72nbtm2D38YEAAAAAAAAAAC7okJKSnV1dVm5cmVWr169xdlVq1ZlxYoVqaur2+he69att/j83LlzU1dXl5YtW6Zv376bnOnfv3+SZObMmVvc939df/31efHFF3PBBRekW7du2/w8AAAAAAAAAADs6poXsXTffffN888/nzvuuCPnnnvu287eeeedqa+vz7777vvWteXLl2flypUbXNucF198Mcnf377UokWLTc7ss88+G8xurblz5+ZHP/pR+vbtm3POOWebnn3TokWL3vYTcQ888MA72gvwJn/PAAAAAAAAALCjK+RNSkOGDEm5XM7VV1+d73//+1mzZs1GM2vWrMl///d/5+qrr06pVMoZZ5zx1r1nnnkmSXLQQQdt8ayampokSceOHTc78+a9N2e3Rn19fb7xjW8kSS677LJUVRXyPxUAAAAAAAAAADR5hbxJadiwYZk6dWqefPLJXHvttbnpppvSp0+f7LHHHimVSlmyZElmz56d1atXp1wu58gjj8zw4cPfen7ixIlJkve9731bPKu2tjZJNvsWpSRp2bLlBrNb40c/+lHmzJmTz372s+ndu/dWP/d/de/e3VtMgEL5ewYAAAAAAACAHV0hJaXmzZvnBz/4Qa688sr8/Oc/z+rVq/PEE0+kVColScrlcpKkqqoqH//4xzNmzJg0b/6/US677LJccskladu27RbPatWqVZJk7dq1m52pq6vbYHZL5s+fn3HjxmXvvffO6NGjt+oZAAAAAAAAAABg0wopKSVJmzZtcumll+a8887L/fffn2effTavv/56yuVyunTpkkMOOSQnn3xyevTosdGz1dXVW33O1nzKbWs+CfePvvnNb6a2tjbf+ta30qZNm63OAgAAAAAAAAAAbKyQktITTzyRJOnVq1e6d++ekSNHFnFMkmS//fZLkrz88stZu3btJj/7tmDBgg1mt2TOnDkplUoZM2bMRvfWrFmTJFm0aFGOOeaYJMnYsWNzxBFHvIP0AAAAAAAAAADQ9BVSUho+fHiqqqry6KOPFrF+A4ccckhatGiRurq6zJo1K/37999o5qmnnkqSHH744Vu9t1wu529/+9tm769fv/6t+2/3qTkAAAAAAAAAANjVFVJSat++faqqqrb682oNUV1dnWOPPTYPPvhgJkyYsFFJaf78+ZkxY0aS5JRTTtmqnU8++eRm7/3yl7/M17/+9ey1116ZNm3aOw8OAAAAAAAAAAC7iKoilu6zzz5ZuXJl6urqili/kfPPPz+lUimTJk3K+PHjUy6XkyRLlizJBRdckPXr1+fEE09M7969N3ju7LPPzqBBg3LLLbdsl5wAAAAAAAAAALArKqSkdOqpp2bdunW59957i1i/kb59+2bMmDFJkosvvjgnnHBCzjzzzAwePDhz5szJu971rlx22WUbPbd48eIsXLgwy5cv3y45AQAAAAAAAABgV1RISWnEiBE5/PDDc9lll+Xhhx8u4oiNjBw5MjfffHOOP/74rF69OvPmzUuPHj3y+c9/Pr/4xS+y2267bZccAAAAAAAAAADAhpoXsfSmm27KgAED8qc//Smf//znc+CBB+aII45Ily5dUlW1+V7U6NGjG3TuwIEDM3DgwK2enzZt2jaf8dGPfjQf/ehHt/k5AAAAAAAAAADYVRVSUho3blxKpVLK5XKS5IUXXsi8efO2+FxDS0oAAAAAAAAAAMCOp5CS0oABA4pYCwAAAAAAAAAA7IQKKSnddtttRawFAAAAAAAAAAB2QlWNHQAAAAAAAAAAAGjaCnmTEjQ1y1fVpWZFbVauXpvqNi3SsV2rtG/bsrFjAQAAAAAAAADsFAorKb322mt5/PHH88ILL2TZsmVJkk6dOuXAAw/Me9/73nTp0qWoo6GiXl22OmMnPJ2nn3/1rWv9enXNF4b2S9dObRoxGQAAAAAAAADAzqHiJaUXX3wx1113XaZMmZL169dvcqaqqionnXRSvvjFL2b//fevdASomOWr6jYqKCXJ08+/mrETns5Xhx3pjUoAAAAAAAAAAFtQVcll9913X84666zcf//9qa+vT7lc3uSf+vr63H///TnrrLNy7733VjICVFTNitqNCkpvevr5V1OzonY7JwIAAAAAAAAA2PlU7E1KjzzySL7yla9k3bp1adWqVU499dScfPLJOfjgg9O5c+ckydKlS/Pss89m8uTJuffee7N69er8v//3/9KxY8ccc8wxlYoCFbNy9doG3QcAAAAAAAAAoEIlpdra2nz961/PunXr0rt371x33XXZd999N5rr1q1bunXrlhNOOCGf+9zn8q//+q95/vnnc+GFF2by5Mlp1apVJeJAxVS3adGg+wAAAAAAAAAAVOhzb7/85S+zePHi9OjRI7feeusmC0r/13777ZdbbrklPXr0yJIlSzJx4sRKRIGK6tiuVfr16rrJe/16dU3Hdop1AAAAAAAAAABbUpGS0kMPPZRSqZQvfvGL6dix41Y/17lz53zhC19IuVzOgw8+WIkoUFHt27bMF4b226io1K9X13xxaL+0b9uykZIBAAAAAAAAAOw8KvK5t+effz5JcuKJJ27zsyeddFK+/vWvv7UDdjRdO7XJV4cdmZoVtVm5em2q27RIx3atFJQAAAAAAAAAALZSRUpKr7/+etq3b5927dpt87Pt2rVLhw4dsnTp0kpEgUK0b9tSKQkAAAAAAAAA4B2qyOfeWrVqldra2nf8fG1tbVq2VAABAAAAAAAAAICmqCIlpa5du6auri5//vOft/nZP//5z6mtrU3Xrl0rEQUAAAAAAAAAANjBVKSkdOSRRyZJxo8fv83P3nnnnRvsAAAAAAAAAAAAmpaKlJROP/30lMvl3H777Xn44Ye3+rmHH344d9xxR0qlUk477bRKRAEAAAAAAAAAAHYwFSkpDRgwIMcff3zWrVuX0aNH54Ybbsjq1as3O79q1apcf/31GT16dOrr63PsscfmqKOOqkQUAAAAAAAAAABgB9O8Uov+8z//M0OHDs2CBQsyduzY/PjHP84xxxyTQw45JJ06dUqpVMrSpUszZ86c/H//3/+XVatWpVwuZ5999sl//ud/VioGAAAAAAAAAACwg6lYSalTp0654447csEFF+Sxxx7LihUrMnny5EyePHmj2XK5nCQ56qijcs0116Rz586VigEAAAAAAAAAAOxgKlZSSpIuXbrk1ltvzbRp03LHHXfkySef3Oizb23atMmRRx6Zs88+O4MGDark8QAAAAAAAAAAwA6ooiWlNw0aNCiDBg1KfX19Fi5cmGXLliX5+9uWevTokebNCzkWAAAAAAAAAADYARXaFmrWrFn22Wef7LPPPkUeAwAAAAAAAAAA7MCqGjsAAAAAAAAAAADQtDW4pLR48eJK5NjIkiVLCtkLAAAAAAAAAABsXw0uKZ100kn59re/XbGy0n333ZfTTz89EyZMqMg+AAAAAAAAAACgcTVv6II99tgjP/3pT3PXXXdl0KBBOf3003P88cenefOtX71gwYLcc889+dWvfpUFCxakVCpl7733bmg0AAAAAAAAAABgB9DgktJvf/vb3Hbbbbnpppvy29/+Nvfdd186dOiQvn37pm/fvundu3c6d+6cTp06pWXLlqmpqUlNTU1eeumlzJo1K7Nmzcpf/vKXJEm5XM4xxxyTr33ta3n3u9/d4B8HAAAAAAAAAAA0vgaXlFq0aJFzzz03Z511VsaPH5+77rorL730UqZPn57f//73W3y+XC6nRYsWOfHEE/OpT30qRx55ZEMjAQAAAAAAAAAAO5AGl5Te1LFjx4waNSqjRo3KI488kunTp+eJJ57I3LlzU19fv9H87rvvngEDBuSoo47KBz/4wey2226VigIAAAAAAAAAAOxAKlZS+kfve9/78r73vS9Jsnbt2rz++ut5/fXXU1tbm06dOmW33XZLhw4dijgaAAAA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6PCcEyZMyL777rtSQClJRo0ale9///tJknfffTfTp08vuW4AAAAAAAAAAOgLyhJSqqury5IlS/LOO++ste/TTz+dtra2jBgxoqS1Fi1alIcffjhJMnHixFXaP/ShD2WfffZJkkybNq2kNf7Wpptu2v7ouCVLlnTJnAAAAAAAAAAA0FuVJaQ0duzYJMmUKVM+sN/y5ctz5ZVXplAoZN999y1prZdeeimtra2prKzMLrvssto+e+yxR5LkmWeeKWmNv/WnP/0p8+fPT79+/bLjjjt2yZwAAAAAAAAAANBbDSjHpMcff3ymTp2aH/7wh9l5552z9957r9Jnzpw5+eY3v5nHH388AwYMyOc+97mS1vrLX/6SJNlyyy1TUVGx2j6jRo1aqW8pisVi5s2bl6eeeipXXHFFkuSkk07KVltt9YHj5syZk/Hjx6+x/YEHHii5JoDEdQYAAAAAAACADV9ZQkof+chHctppp+Waa67JiSeemDFjxmThwoVJkm9+85t5+eWX88wzz2TZsmVJkq9+9atrDfusSVNTU5KktrZ2jX1WtK3ouy7uuuuu/Ou//utKx7bZZptcccUV+fSnP73O8wEAAAAAAAAAQF9TlpBSkpx55pnZaKONcuWVV+bZZ59tP37rrbemWCwmSQYOHJizzjorxx13XMnrtLS0JMkad1FKksrKypX6rotNNtkku+++e4rFYt5888289dZbmTlzZn7+859nzz33zBZbbPGB44cPH24XE6CsXGcAAAAAAAAA2NCVLaSUJCeccEIOPfTQ/PznP89TTz2VuXPnZvny5dl0002z++6759BDD82mm27aqTWqqqqSJG1tbWvs09raulLfdbH//vtn//33b//5tddey+WXX577778/EydOzD333JMhQ4as87wAAAAAAAAAANBXlDWklCTDhg3L8ccfn+OPP74s83fkUW4deSRcR2211Va56qqrcuihh+aVV17JLbfcktNOO63T8wIAAAAAAAAAQG/Vr7sL6KwPfehDSZLZs2evcTelWbNmrdS3s/r375+PfvSjSZLnn3++S+YEAAAAAAAAAIDequw7KSXJwoUL8+KLL+add95JkmyyySbZcccdU1NT0+m5d9xxx1RUVKS1tTXPPvts9thjj1X6PPXUU0mS3XbbrdPrrbB06dIkyfLly7tsTgAAAAAAAAAA6I3KGlJ66aWXcuWVV+bhhx9eJczTr1+/fPSjH82ZZ56ZHXfcseQ1Bg8enP333z+/+c1vcvvtt68SUpo5c2Yee+yxJMlBBx1U8jrv19ramoceeihJOlU7AAAAAAAAAAD0BWV73Nsdd9yRI488Mv/93/+dZcuWpVgsrvRv2bJleeihhzJx4sT813/9V6fWOv3001MoFHLXXXfltttuS7FYTJLMnTs3X/3qV7N8+fJ84hOfyA477LDSuM9+9rMZN25cbrzxxpWOP/fcc/n+97+fmTNnrrLWX/7yl5x22mmZNWtWBg0alIkTJ3aqdgAAAAAAAAAA6O3KspPSs88+mwsvvDDLly/P1ltvnZNPPjn77LNPNt988yTJW2+9lcceeyw33HBD/vznP+cb3/hGtt9+++yyyy4lrbfLLrvk3HPPzeWXX55vfOMbufbaazNs2LD88Y9/TGtra/7u7/4u3/72t1cZ99Zbb+WNN97IggULVjre3Nyca6+9Ntdee2023njjDB8+PAMGDEhjY2Nmz56dJBk6dGi+//3vt58TAAAAAAAAAACwemUJKf3oRz/K8uXLs9dee+XHP/5xqqqqVmrfaqutstVWW+XQQw/NKaeckscffzw/+tGP8oMf/KDkNU844YSMHj06P/nJT/Lss8/mnXfeyZZbbpmDDjoop556agYPHtzhuXbYYYd8/etfzxNPPJGXX345r776apYsWZKamprsscce+ehHP5qjjjoqG2+8ccn1AgAAAAAAAABAX1GWkNJTTz2VQqGQiy++eJWA0vtVVlbmG9/4Rg4++OA89dRTnV533333zb777tvh/g8++OBqj9fW1ua4447Lcccd1+maAAAAAAAAAACgr+tXjkkXL16cmpqabLPNNmvtu+2222bIkCFZsmRJOUoBAAAAAAAAAAC6WVlCSsOHD09LS0uWL1++1r7Lli1LS0tLtthii3KUAgAAAAAAAAAAdLOyhJQOOOCAtLW15f77719r3/vvvz+tra058MADy1EKAAAAAAAAAADQzcoSUvrSl76UrbfeOhdeeGEef/zxNfabPn16vvGNb2SbbbbJqaeeWo5SAAAAAAAAAACAbjagHJPed999Ofroo/ODH/wgJ5xwQnbffffss88+2XzzzVMoFPLmm2/m8ccfz1NPPZUhQ4bkqKOOyn333bfauSZMmFCOEgEAAAAAAAAAgPWk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M27cuC5buFAodNlcAAAAAAAAAADAhqvDIaXu3kGpsbExv/vd7/Lcc8/l+eefz0svvZQlS5Zkp512yp133rnGcePGjcsbb7zxgXM/++yzqaqqWm3ba6+9lmuuuSaPPPJI5s2bl0022ST77bdfTjvttGy11VadOicAAAAAAAAAAOgLOhRSeuCBB8qyeP/+/Tvc95577slll11W8lrbb799ampqVtu2pl2dGhoactJJJ6W5uTm1tbXZfvvt89prr+WOO+7ItGnTcuONN2aXXXYpuSYAAAAAAAAAAOgLOhRSGjFiRLnrWKuampr8/d//fcaMGZMxY8Zk5syZ+e53v9vh8V//+tez9957d7j/4sWLM2nSpDQ3N+czn/lMLrroolRVVaWlpSUXX3xx7rzzzkyaNCm/+tWvMnDgwFJOCQAAAAAAAAAA+oQOP+6tux1xxBE54ogj2n/+oEe8dYXbbrstjY2N2XrrrXPxxRensrIySVJVVZVvfvObefLJJzNr1qz87Gc/y3HHHVfWWgAAAAAAAAAAoCfr190FbKimTZuWJDnssMPaA0orVFZW5vDDD0+S/PKXv1zvtQEAAAAAAAAAQE/SY3ZS6qxbb701P/nJT7JkyZJsuummGTt2bD796U+npqZmlb7Lli3L888/nyQZO3bsaudbcfy5557LsmXL0r9///IVDwAAAAAAAAAAPVifCSnde++9K/38i1/8IldeeWW+853vZL/99lup7Y033khbW1uSZNSoUaudb8Xx1tbWzJ49O1tttdVq+82ZMyfjx49fY10PPPBAh88BYHVcZ4D1wbUGAAAAAACAzuj1j3urr6/Pt7/97dx77715+umnM3369Pzwhz/MjjvumHfffTennXZaXnjhhZXGzJ8/v/310KFDVztvbW1t++umpqZylA4AAAAAAAAAAL1Cr99J6Tvf+c5KP1dXV+fjH/949t1333zuc5/LCy+8kCuuuCI33HBDe5/W1tb21xUVFaudt7Kysv31kiVL1rj+8OHD7SwAlJXrDLA+uNYAAAAAAADQGb1+J6U1GThwYL7yla8kSR577LG899577W3vDyCteOzb33p/kGngwIHlKRIAAAAAAAAAAHqBPhtSSpLdd989SbJ8+fLMmjWr/fj7H+X2/ke/vd/7H/H2/v4AKyxobs3rcxdkxqvz8vrcBVnQ3Lr2QX2Q9wkAAAAAAACg9+v1j3v7IO9/lNuyZcvaX48YMSIVFRVpa2vLrFmzsvnmm68ydkWoqbKyMltuuWX5iwV6lMb5izP59oY0zGhsP1Y/ui6TJtanbmh1N1a2YfE+AQAAAAAAAPQNfXonpZdffrn99fuDSAMGDMiYMWOSJE8++eRqx644vvPOO6d///5lrBLoaRY0t64SvEmShhmNmXx7g52C/i/vEwAAAAAAAEDf0emdlP7yl7/k6aefzty5czNv3ry0tLRk6NCh2XjjjbPttttm9913T3X1hrkbxnXXXZck2W677bLFFlus1HbggQemoaEhU6ZMycknn7zSrkutra258847kyQHHXTQ+isY6BGaFrasErxZoWFGY5oWtmTIoMr1XNWGx/sEAAAAAAAA0HeUFFJqaGjI7bffnocffjjvvPPOB/bt379/dtppp3z605/OoYcemiFDhpRUaCmuv/76VFZW5pBDDsmwYcPaj7/77rv53ve+l2nTpiVJJk2atMrYo446Ktdff31effXVXHTRRbnoootSVVWVlpaWXHzxxZk1a1Y222yzHHnkkevtfICeYdHitk619xXeJwAAAAAAAIC+Y51CSnfddVeuu+66/PGPf0yxWGw/PmjQoAwdOjRDhw5NVVVVmpqa0tTUlHfffTdLly7NM888k2effTbf+c53cvDBB+fLX/5yhg8fvk6FzpkzJxMmTGj/ubX1r48BmjFjRvbee+/24yeffHJOOeWUJMmbb76Z//iP/8gll1ySESNGZOONN86SJUvy5z//OUuXLk2/fv3y1a9+dbW7IQ0aNChXXnllTj755Nxxxx25//77M3LkyLz++utpamrKoEGDMnny5A12lyig+wyuruhUe1/hfQIAAAAAAADoOzoUUnr88cfz7//+73nppZdSLBZTW1ubAw88MGPHjs2uu+6arbfeerXjFi1alOeffz7PPPNMHnzwwTz99NP5r//6r/z85z/P8ccfny9+8YupqanpUKHLli3L/PnzVzm+dOnSlY4vWbKk/fXBBx+cYrGY5557LrNnz84f/vCH9O/fPyNHjsxee+2Vz33uc/nIRz6yxjX32GOP3HXXXbnmmmvyyCOP5OWXX86wYcNy+OGH5/TTT89WW23VodqBvqW2pir1o+tW+yiz+tF1qa2p6oaqNjzeJwAAAAAAAIC+o0Mhpc9//vNJkv333z9HH310Pvaxj6WiYu07XAwePDh777139t5775x66ql5/fXXM3Xq1Nxyyy257rrrMnDgwHz5y1/uUKEjR47MjBkzOtR3hd122y277bbbOo35W6NGjcrll1/eqTmAvmXIoMpMmlifybc3rBTAqR9dlzMn1mfIoMpurG7D4X0CAAAAAAAA6Ds6FFLaf//9M2nSpOy6666dWmzkyJE544wz8oUvfCG33HJLBg0a1Kn5ADZUdUOrc/axY9O0sCWLFrdlcHVFamuqBG/+hvcJAAAAAAAAoG/oUEjpuuuu69JFq6urc8opp3TpnAAbmiGDKoVtOsD7BAAAAAAAAND79evuAgAAAAAAAAAAgN5NSAkAAAAAAAAAACgrISUAAAAAAAAAAKCsBpQ68Pjjj1+n/lVVVdloo42y3Xbb5aMf/WjGjBlT6tIAAAAAAAAAAEAPUnJI6Yknnmh/XSgUkiTFYnGlPqs7XigUctVVV2XffffN5Zdfns0226zUEgAAAAAAAAAAgB6g5JDSGWeckba2ttx6661pamrKlltumT333DObb755isVi5s6dm+nTp2f27Nmpra3N0Ucfnebm5jz//PNpaGjIo48+mi984Qv5r//6r1RVVXXlOQEAAAAAAAAAABuQkkNKX/rSl3LiiSempaUll19+eSZMmLDaflOnTs3FF1+c5557Ltddd1369euXJ554Il/+8pfzxz/+MbfffnuOO+64UssAAAAAAAAAAAA2cP1KHXjjjTfmySefzAUXXLDGgFKSTJgwIRdccEEeffTR/Md//EeSZK+99spZZ52VYrGYX//616WWAAAAAAAAAAAA9AAlh5R+/vOfp3///jn00EPX2vfQQw9N//79M3Xq1PZjBx98cAqFQl555ZVSSwAAAAAAAAAAAHqAkkNKs2bNyuDBg1NZWbnWvpWVlRk8eHBeffXV9mNDhgzJRhttlIULF5ZaAgAAAAAAAAAA0AOUHFIaMGBA3nvvvcydO3etfefOnZv33nsvAwYMWOn44sWLM2TIkFJLAAAAAAAAAAAAeoCSQ0o77bRTkuSKK65Ya9/vfOc7KRaL2XHHHduPvf3222ltbc2mm25aagkAAAAAAAAAAEAPUHJI6dhjj02xWMzPf/7znHzyyXnyySezdOnS9valS5dm+vTpOeWUU3L33XenUCjkuOOOa29/+OGHkyQ777xz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AAAAAAAAAADwV/26u4ByWl1AaYWKiorss88+SZK//OUv66skAAAAAAAAAADoczq9k9Ljjz+ee+65JzNmzMj8+fOzdOnSNfYtFAq5//77O7tkl2ltbU2SVFdXr7HPwoUL841vfCOzZs1KRUVFRo0alXHjxmW//fZbX2UCAAAAAAAAAECPVnJIqVgs5vzzz8/UqVPbf16bQqFQ6nJdbsmSJXnggQeSJHvsscca+zU1NeW2225b6dgtt9ySfffdN9/97nez8cYbf+A6c+bMyfjx49fYvqIGgFK5zgDrg2sNAAAAAAAAnVFySOnmm2/OlClTkiQ77bRTxo0bl8022ywDBnR6c6b14vvf/37efvvtbLzxxjniiCNWaR84cGAmTJiQT3/609luu+2y8cYbp7GxMb/61a8yefLkPProozn99NPzf/7P/0n//v274QwAAAAAAAAAAKBnKDlRdOedd6ZQKOTII4/Mt771ra6sqezuueee3HDDDUmSb3/726mpqVmlz6677ppdd911pWMjRozISSedlPr6+hxzzDFpaGjIPffck3/6p39a41rDhw+3swBQVq4zwPrgWgMAAAAAAEBn9Ct14MyZM5MkZ511VlfVsl488sgjOeecc5Ik//Iv/5JPfOIT6zxHfX19DjzwwCTJfffd16X1AQAAAAAAAABAb1NySKmqqiobbbRRamtru7Kespo+fXq+/OUvp62tLaeeemq+9KUvlTxXfX19kv8X1gIAAAAAAAAAAFav5JDS9ttvn4ULF2bRokVdWU/ZNDQ05NRTT83ixYtz3HHHdXoHqIqKiiTJ0qVLu6I8AAAAAAAAAADotUoOKR1zzDFZtmxZ7rjjjq6spyyef/75nHLKKWlubs7EiRNzwQUXdHrOV155JUmyxRZbdHouAAAAAAAAAADozUoOKR100EE55phjcsUVV2Tq1KldWFLXmjFjRr7whS9kwYIFOfTQQ/PNb34zhUKhU3O+9dZbufvuu5Mk++23X1eUCQAAAAAAAAAAvdaAUgeed955SZLq6uqcd955ueqqqzJmzJgMHjx4jWMKhUIuvfTSUpdcZzNnzsxJJ52U+fPn55Of/GQuu+yy9OvXsVzWP//zP+fII4/MPvvskwED/t/b9Oyzz+bcc8/NggULstlmm+Xoo48uV/kAAAAAAAAAANArlBxSmjJlSgqFQorFYpJk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Tsns2bOz00475fLLL1+n8QMHDsxXvvKVJMljjz2W9957r73t/QGkFY99+1vvDzINHDhwndZekwXNrXl97oLMeHVeXp+7IAuaW9c+CAD6IPdMOsLfCQAAAAAAwIajQzspbWgWLVqUk08+OS+++GI+/OEP5/rrr09NTc06z7P77rsnSZYvX55Zs2ZlzJgxSVZ+lNv8+fOz+eabrzK2qamp/fX7+5eqcf7iTL69IQ0zGtuP1Y+uy6SJ9akbWt3p+QGgt3DPpCP8nQAAAAAAAGxYetxOSosXL84Xv/jFPP300/nQhz6UG264IcOGDStprvc/ym3ZsmXtr0eMGNHeNmvWrNWOXXG8srIyW265ZUnrr7CguXWVL9GSpGFGYybf3uB//QPA/+WeSUf4OwEAAAAAANjwdGonpWKxmDvuuCP33ntvZsyYkaamppXCPn+rUCjkxRdfLHm9lpaWnH766Zk+fXpGjBiRm266KXV1dSXP9/LLL7e/fv9uSQMGDMiYMWPS0NCQJ598MnvuuecqY5988skkyc4775z+/fuXXEOSNC1sWeVLtBUaZjSmaWFLhgyqXG07APQl7pl0hL8TAAAAAACADU/JIaVFixbl1FNPze9///sUi8WurGm12traMmnSpPzud7/LFltskZtuuilbbLFFp+a87rrrkiTbbbfdKnMdeOCBaWhoyJQpU3LyySevtOtSa2tr7rzzziTJQQcd1KkakmTR4rZOtQNAX+GeSUf4OwEAAAAAANjwlBxSuvrqq/PUU0+lf//+OeSQQ7L//vtn00037fSuQquzbNmyfO1rX8t///d/p66uLjfddFO22mqrtY67/vrrU1lZmUMOOWSlR8K9++67+d73vpdp06YlSSZNmrTK2KOOOirXX399Xn311Vx00UW56KKLUlVVlZaWllx88cWZNWtWNttssxx55JGdPr/B1RWdageAvsI9k47wdwIAAAAAALDhKTmkNG3atBQKhZx//vk55phjurKmVfzyl79sDxRVVlbmvPPOW2PfCy+8MDvuuGOS5M0338x//Md/5JJLLsmIESOy8cYbZ8mSJfnzn/+cpUuXpl+/fvnqV7+62t2QBg0alCuvvDInn3xy7rjjjtx///0ZOXJkXn/99TQ1NWXQoEGZPHlyqqurO31+tTVVqR9dt9rHktSPrkttTVWn1wCA3sA9k47wdwIAAAAAALDhKTmk9M4776R///5dspPQ2rS2tra/fuONN/LGG2+sse+CBQvaXx988MEpFot57rnnMnv27PzhD39I//79M3LkyOy111753Oc+l4985CNrnGuPPfbIXXfdlWuuuSaPPPJIXn755QwbNiyHH354Tj/99A7t5tQRQwZVZtLE+ky+vWGlL9PqR9flzIn1GTKoskvWAYCezj2TjvB3AgAAAAAAsOEpOaRUV1eX9957L5WV5f+S5/DDD8/hhx++zuN222237Lbbbp1ae9SoUbn88ss7NUdH1A2tztnHjk3TwpYsWtyWwdUVqa2p8iUaAPwN90w6wt8JAAAAAADAhqXkkNL++++f22+/PX/605+y7bbbdmVNfdaQQZW+OAOADnDPpCP8nQAAAAAAAGw4+pU68Itf/GKGDh2aSy65JG1tbV1ZEwAAAAAAAAAA0IuUvJPSlltumR/96Ef5yle+ksMPPzwnnXRSxowZk8GDB691HAAAAAAAAAAA0HeUHFJKkm222Sbjxo3LLbfckvPPP3+t/QuFQl588cXOLAkAAAAAAAAAAPQwJYeU5s2bl+OPPz5/+tOfkiTFYnGtYzrSBwAAAAAAAAAA6F1KDin94Ac/yB//+MdUV1fnxBNPzP77759NN900/fv378r6AAAAAAAAAACAHq7kkNJvfvObFP5/9v49zMq63h//nwuYGQcGB9ARBQ9lFh7IHM+WuxJ0q2mJpphtLU2xNPDqYDv7lFm5S2uX7cJ0V5qHrO1hS2aZmmJ20DSxMY+btFQUCUhgZGCYGWB9/+gHPwmQYWatWXN4PK7L65q57/fhtU7v9+WsJ/ddKOTLX/5y3vWud5WyJgAAAAAAAAAAoB8Z1NWOL7/8cqqqqnL44YeXsh4AAAAAAAAAAKCf6XJIaZtttsmQIUPc3g0AAAAAAAAAAHhNXQ4pTZgwIa2trXnsscdKWQ8AAAAAAAAAANDPdDmkdNZZZ2WbbbbJBRdckFdeeaWUNQEAAAAAAAAAAP3IkK52fPrpp/Pxj388X/nKV/Kud70rkydPzp577plhw4a9Zr/99tuvq1MCAAAAAAAAAAB9UJdDSqecckoKhcLa3y+//PJN9ikUCnnyySe7OiUAAAAAAAAAANAHdTmklCTFYrGs7QEAAAAAAAAAgL6vyyGl//u//ytlHQAAAAAAAAAAQD81qNIFAAAAAAAAAAAA/VunQ0qf/vSnc9ddd2X58uXlrAcAAAAAAAAAAOhnOn27t5/+9Ke59dZbU11dnYMOOigTJkzIhAkTsvXWW5ezPgAAAAAAAAAAoI/r9JWUzjvvvOy7775ZtWpV7r333lxwwQV5+9vfnve97335/ve/n7/85S/lrBMAAAAAAAAAAOijOn0lpVNPPTWnnnpqmpubc++99+buu+/O7373uzzyyCP505/+lEsuuSQ77rhjDjvssEyYMCF77713OesGAAAAAAAAAAD6iE6HlNaor6/PMccck2OOOSbt7e35/e9/n7vvvju/+tWv8vzzz+eKK67IlVdemVGjRuWQQw7JxIkT87a3vS3V1dXlqB8AAAAAAAAAAOjlNjuk9GrV1dV5xzvekXe84x1JkkceeSR333137rnnnvz1r3/N//7v/+bmm2/OFltskYMPPjgTJkzIO9/5zowcObIkxQMAAAAAAAAAAL1ft0JK/2yvvfbKXnvtlXPPPTfPPfdc7rrrrsycOTOPPvpo7rrrrtx9990ZNGhQ9t5773z0ox/NgQceWMrpAQAAAAAAAACAXmhQuQZ+3etelylTpuT666/P7373u/zHf/xH3vnOd6aqqioPPfRQZs2aVa6pAQAAAAAAAACAXqSkV1LamJEjR2b8+PEZP358vvnNb+Z3v/tdamtre2JqAAAAAAAAAACgwnokpLRkyZJMmjQpgwYNypNPPplDDz20J6YFAAAAAAAAAAB6gR4JKa1RLBZ7cjro95Yub09zS1uWtXZkWG1V6utqMnxodaXLAqCH2AfoCu8bAAAAAACgEno0pASUzsIlrZl+Y1OaZi9ce6xxXEOmTW5Mwwi3UwTo7+wDdIX3DQAAAAAAUCmDKl0AsPmWLm9f7wvGJGmavTDTb2zK0uXtFaoMgJ5gH6ArvG8AAAAAAIBKElKCPqi5pW29LxjXaJq9MM0tbT1cEQA9yT5AV3jfAAAAAAAAlSSkBH3QstaObp0HoG+zD9AV3jcAAAAAAEAlCSlBHzSstqpb5wHo2+wDdIX3DQAAAAAAUElCStAH1dfVpHFcwwbPNY5rSH1dTQ9XBEBPsg/QFd43AAAAAABAJQ3pbMNbbrmly5MsW7asy32B9Q0fWp1pkxsz/camNM1euPZ447iGnDO5McOHVlewOgDKzT5AV3jfAAAAAAAAldTpkNJ5552XQqFQzlqAzdAwojafOnnfNLe0ZVlrR4bVVqW+rsYXjAADhH2ArvC+AQAAAAAAKqXTIaUkKRaL5aoD6ILhQ6t9qQgwgNkH6ArvGwAAAAAAoBI6HVKaOXNmOesAAAAAAAAAAAD6qU6HlMaOHVvOOgAAAAAAAAAAgH5qUKULAAAAAAAAAAAA+jchJQAAAAAAAAAAoKw6fbu3f/bSSy91qd+YMWO6OiUAAAAAAAAAANAHdTmkNHHixM3uUygU8uSTT3Z1SgAAAAAAAAAAoA/qckipWCz2SB8AAAAAAAAAAKBv63JIaebMma95funSpXn00Udz9dVXZ9GiRfna176WN7zhDV2dDgAAAAAAAAAA6KO6HFIaO3bsJtvsuuuuOeaYY/LBD34wn/3sZ3PLLbd0dToAAAAAAAAAAKCPGlTuCWpqavK5z30uCxcuzOWXX17u6QAAAAAAAAAAgF6m7CGlJBk/fnxqa2vzq1/9qiemAwAAAAAAAAAAepEeCSmtXr06q1evzsKFC3tiOgAAAAAAAAAAoBfpkZDSgw8+mLa2tmy55ZY9MR0AAAAAAAAAANCLDCnn4B0dHbnrrrty8cUXp1Ao5IADDijndAAAAAAAAAAAQC/U5ZDSxIkTX/N8W1tbFi1alGKxmGKxmLq6ukydOrWr0wEAAAAAAAAAAH1Ul0NKc+fO7XTbffbZJ+eff35e//rXd3U6AAAAAAAAAACgj+pySOmiiy56zfODBw9OfX19dt1114wePbqr0wAAAAAAAAAAAH1cl0NKxx57bCnrAAAAAAAAAAAA+qlBlS4AAAAAAAAAAADo34SUAAAAAAAAAACAsupUSKmlpSXLli0r6cTlGBMAAAAAAAAAAOh9OhVS2nfffXPEEUeUdOJyjAkAAAAAAAAAAPQ+nb7dW7FYLPnk5RgTAAAAAAAAAADoXYZ0tuHq1aszb948wSIAAAAAAAAAAGCzdDqktHjx4kyYMKFkExcKhU63LRaLaWpqyj333JOHH344f/3rX9PS0pLhw4dn9913z6RJk/Lud797o2N2dHTkmmuuya233po5c+akuro6u+66a04++eT867/+62vO/cILL+Syyy7Lfffdl0WLFmWrrbbK2972tpx11lnZYYcdNusxAwAAAAAAAADAQNTpkFIlr6D0wAMP5NRTT137+w477JCxY8dm7ty5ue+++3Lffffltttuy/Tp01NdXb1O37a2tpx22ml5+OGHM3jw4Oyyyy5pbW3Ngw8+mAcffDBTpkzJueeeu8F5m5qa8qEPfSjLly9PfX193vSmN+WFF17IzTffnDvuuCNXX3119txzz3I+dAAAAAAAAAAA6PM6FVKaOXNmWSYfPHhwp9oVi8Vsv/32+eAHP5ijjjoqW2211dpzt9xyS84///zce++9+fa3v71e4Og///M/8/DDD2f77bfP97///ey8885J/vGYPvaxj+X73/9+9t577/WuEtXa2ppp06Zl+fLlee9735sLLrggNTU1aWtryxe+8IXMmDEj06ZNy5133pktttiim88E9C9Ll7enuaUty1o7Mqy2KvV1NRk+tHrTHSk7rw1QbtYZAAAAAAAANqRTIaWxY8eWu47XtOeee+aOO+5IVVXVeucmTZqUv/3tb/nmN7+Zm266KZ/4xCcyaNCgJMnf//73XH/99UmSL3/5y2sDSkkyceLEnHHGGbnsssty6aWXrhdSuuGGG7Jw4cLstNNO+cIXvrD2Ck01NTX54he/mFmzZmXOnDm56aabcsopp5TroUOfs3BJa6bf2JSm2QvXHmsc15BpkxvTMKK2gpXhtQHKzToDAAAAAADAxgyqdAGdUVdXt8GA0hpvf/vbkyRLlizJokWL1h6/55570tHRkZ122ikHHnjgev3e9773JUmeeOKJzJkzZ51zd9xxR5Lk2GOPXe8WctXV1TnuuOOSJLfffnsXHhH0T0uXt6/35XSSNM1emOk3NmXp8vYKVYbXBig36wwAAAAAAACvpU+ElDalra1t7c+vvvXaI488kiTZZ599Nthv9OjR2X777ddpmySrVq3K448/niTZd999N9h3zfHHHnssq1at6nLt0J80t7St9+X0Gk2zF6a5pW2D5yg/rw1QbtYZAAAAAAAAXkunbvfW2912221Jkl133TV1dXVrjz/33HNJkp122mmjfXfccce8+OKLefbZZ9cemzt3bjo6Otae31i/JGlvb89LL72UHXbYYYPt5s2bl4kTJ250/pkzZ270HPQ1y1o7unWerunMOuO1AbprU2vNZT+46TX7W2eATfH/TgAAAAAA/Vufv5LSE088keuvvz5JcuaZZ65zrrm5OUlSX1+/0f5rzr3yyitrjy1ZsmTtzyNGjHjNfq+eBwa6YbUbvy1jZ85TPl4boNysMwAAAAAAALyWPn0lpb///e+ZOnVqOjo6cthhh+Woo45a5/ya28BVVW38S7Hq6uokyYoVK9Yea29vX/vzxvqu6ffPff/Zdttt51/8MmDU19WkcVzDBm/30ziuIfV1NRWoqv/rzDrjtQG6a1NrzdLl7dYZoFv8vxMAAAAAQP/WZ6+ktHTp0kyZMiUvvfRS9thjj1x88cXrtamp+ceXYWtu3bYhawJJW2yxxdpjrw4gbazvq4NMr+4LA9nwodWZNrkxjeMa1jneOK4h50xuzPCh1RvpSbl5bYBys84AAAAAAADwWvrklZSWLVuWM844I08++WTe+MY35sorr0xdXd167bbccsskr307tjXn1rRN1r2V25IlSzJ69OiN9vvn9jDQNYyozadO3jfNLW1Z1tqRYbVVqa+r8eV0L+C1AcrNOgMAAAAAAMDG9LmQUmtraz784Q/nkUceyete97pcddVVGTly5Abbvu51r8sf//jHPP/88xsdb86cOWvbrjF27NhUVVWlo6Mjc+bM2WBIaU2/6urqjBkzphuPCPqf4UOrfSHdS3ltgHKzzgAAAAAAALAhfep2b21tbTn77LPz0EMPZezYsbnmmmvS0NCw0fZ77bVXkuSPf/zjBs/Pnz8/L7744jptk2TIkCEZP358kmTWrFkb7Lvm+Jvf/OYMHjx4cx8KAAAAAAAAAAAMGH0mpNTR0ZFp06bl/vvvz7bbbptrrrkm22677Wv2mThxYqqqqvLcc8/lgQceWO/89ddfnyTZfffds9NOO61z7vDDD0+S/OQnP0lHR8c659rb2zNjxowkyRFHHNHlxwQAAAAAAAAAAANBt2/39uyzz+aRRx7JggULsmjRorS1tWXEiBEZNWpU3vCGN2TvvfdObW1tt+ZYtWpVzj333Pz6179OQ0NDrrnmmuywww6b7Lf11lvnxBNPzHXXXZfPfvaz+f73v5+dd945SXLPPffkiiuuSJJ89KMfXa/viSeemCuvvDLPP/98LrjgglxwwQWpqalJW1tbvvCFL2TOnDnZZpttcsIJJ3TrsQEAAAAAAAAAQH/XpZBSU1NTbrzxxvz2t7/Nyy+//JptBw8enD322CPvfve7c8wxx2T48OGbPd/tt9+eO+64I0lSXV2dz3zmMxtte/7552f33Xdf+/unPvWpPPHEE2lqasrRRx+dN77xjVm+fHnmzJmTJPnQhz6UQw89dL1xhg4dmm9961s544wzcvPNN+fuu+/O9ttvnxdffDHNzc0ZOnRopk+f3u0AFgAAAAAAAAAA9HebFVL66U9/miuuuCLPPPNMisXi2uNDhw7NiBEjMmLEiNTU1KS5uTnNzc1ZvHhxVq5cmT/96U959NFH841vfCNHHXVUPvrRj2a77bbr9Lzt7e1rf547d27mzp270bZLly5d5/ctttgi1157ba655prceuutee6551JVVZX9998/J5988trbum3IPvvsk5/+9Ke57LLLct999+XPf/5zRo4cmeOOOy5nn312p67mBAAAAAAAAAAAA12nQkoPPvhgvvrVr+app55KsVhMfX19Dj/88Oy77755y1vekp122mmD/ZYtW5bHH388f/rTn3LPPffkkUceyf/+7//mZz/7WT7wgQ/kwx/+cOrq6jY5/3HHHZfjjjtu8x7Zq1RXV2fKlCmZMmXKZvfdcccdc/HFF3d5bgAAAAAAAAAAGOg6FVL64Ac/mCQ5+OCD8773vS/veMc7UlVVtcl+w4YNywEHHJADDjggZ555Zl588cXccsstue6663LFFVdkiy22yEc/+tHuPQIAAAAAAAAAAKBX61RI6eCDD860adPylre8pVuTbb/99pk6dWpOP/30XHfddRk6dGi3xgMAAAAAAAAAAHq/ToWUrrjiipJOWltb26VbrwEAAAAAAAAAAH3PoEoXAAAAAAAAAAAA9G9CSgAAAAAAAAAAQFkJKQEAAAAAAAAAAGU1pKsdP/CBD2xW+5qammy55ZbZZZdd8i//8i8ZP358V6cGAAAAAAAAAAD6kC6HlP7whz+s/blQKCRJisXiOm02dLxQKOTb3/52DjrooFx88cXZZpttuloCAAAAAAAAAADQB3Q5pDR16tR0dHTk+uuvT3Nzc8aMGZP99tsvo0ePTrFYzIIFC/LQQw/lpZdeSn19fd73vvdl+fLlefzxx9PU1JTf//73Of300/O///u/qampKeVjAgAAAAAAAAAAepEuh5Q+8pGP5LTTTktbW1suvvjiTJo0aYPtbrnllnzhC1/IY489liuuuCKDBg3KH/7wh3z0ox/NM888kxtvvDGnnHJKV8sAAAAAAAAAAAB6uUFd7Xj11Vdn1qxZ+exnP7vRgFKSTJo0KZ/97Gfz+9//Ptdee22SZP/9988nP/nJFIvF/PKXv+xqCQAAAAAAAAAAQB/Q5ZDSz372swwePDjHHHPMJtsec8wxGTx4cG655Za1x4466qgUCoU8/fTTXS0BAAAAAAAAAADoA7ocUpozZ06GDRuW6urqTbatrq7OsGHD8vzzz689Nnz48Gy55ZZpaWnpagkAAAAAAAAAAEAf0OWQ0pAhQ/LKK69kwYIFm2y7YMGCvPLKKxkyZMg6x1tbWzN8+PCulgAAAAAAAAAAAPQBXQ4p7bHHHkmSr3/965ts+41vfCPFYjG777772mN///vf097enq233rqrJQAAAAAAAAAAAH1Al0NKJ598corFYn72s5/ljDPOyKxZs7Jy5cq151euXJmHHnooU6ZMya233ppCoZBTTjll7fnf/va3SZI3v/nN3SgfAAAAAAAAAADo7YZsusmGHXrooTnttNNy1VVX5b777st9992XIUOGZMSIESkUClm8ePHa0FKxWMypp56aQw89dG3/xx57LLvuumsOO+yw7j8KAAAAAAAAAACg1+pySClJPv3pT2f8+PH59re/neeffz4dHR1ZuHDhOm122mmnTJs2LUcfffQ6xz//+c93Z2oAAAAAAAAAAKCP6FZIKUmOOuqoHHXUUXnqqafyxBNPZNGiRUmSUaNGZY899shuu+3W7SIB+qKly9vT3NKWZa0dGVZblfq6mgwfWl3psnodzxPAwGUPAAAAAACAgaPLIaVLL700SfLe97432223XXbbbTeBJID/n4VLWjP9xqY0zf7/X12ucVxDpk1uTMOI2gpW1rt4ngAGLnsAAAAAAAAMLIO62vE73/lO/vu//ztbb711KesB6POWLm9f70vXJGmavTDTb2zK0uXtFaqsd/E8AQxc9gAAAAAAABh4uhxSGjlyZIYNG5aqqqpS1gPQ5zW3tK33pesaTbMXprmlrYcr6p08TwADlz0AAAAAAAAGni6HlHbdddcsXbo0ixcvLmU9AH3estaObp0fKDxPAAOXPQAAAAAAAAaeLoeUTjzxxKxevTpXXXVVKesB6POG1b72FeY2dX6g8DwBDFz2AAAAAAAAGHi6HFI6/PDDc9ppp+X73/9+vva1r2XRokWlrAugz6qvq0njuIYNnmsc15D6upoerqh38jwBDFz2AAAAAAAAGHiGdLXjBz7wgSRJbW1trrrqqlxzzTXZcccds9VWW2XQoA1nnwqFQq65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VMoWW2yRj33sY0mSBx54IK+88srac68OIK257ds/e3WQaYsttihPkQAAAAAAAAAA0A8M2JBSkuy9995JktWrV2fOnDlrj7/6Vm6vvvXbqzU3N2+wPQAAAAAAAAAAsK4BHVJ69a3cVq1atfbnsWPHrj336vDSq605Xl1dnTFjxpSxSgAAAAAAAAAA6NsGdEjpz3/+89qfR48evfbnIUOGZPz48UmSWbNmbbDvmuNvfvObM3jw4DJWCQAAAAAAAAAAfduADildccUVSZJddtkl22677TrnDj/88CTJT37yk3R0dKxzrr29PTNmzEiSHHHEET1QKQAAAAAAAAAA9F39OqR05ZVX5oc//GEWL168zvHFixfn85//fO64444kybRp09bre+KJJ6ahoSHPP/98LrjggrS1tSVJ2tracsEFF2TOnDnZZpttcsIJJ5T/gQAAAAAAAAAAQB82pLMNJ06c2OVJisVil/uuMW/evEyaNGnt7+3t7UmS2bNn54ADDlh7/IwzzsiUKVOSJH/7299y7bXX5stf/nLGjh2bUaNGZcWKFfnrX/+alStXZtCgQfnEJz6xwashDR06NN/61rdyxhln5Oabb87dd9+d7bffPi+++GKam5szdOjQTJ8+PbW1td1+bAAAAAAAAAAA0J91OqQ0d+7cctaxSatWrcqSJUvWO75y5cp1jq9YsWLtz0cddVSKxWIee+yxvPTSS/m///u/DB48ONtvv33233//vP/9789uu+220Tn32Wef/PSnP81ll12W++67L3/+858zcuTIHHfccTn77LOzww47lPIhAgAAAAAAAABAv9TpkNKxxx5bzjo2afvtt8/s2bM3q89ee+2Vvfbaq1vz7rjjjrn44ou7NQYAAAAAAAAAAAxknQ4pXXTRReWsAwAAAAAAAAAA6KcGVboAAAAAAAAAAACgfxNSAgAAAAAAAAAAyqrTIaVbbrklt99+ezlrAQAAAAAAAAAA+qEhnW143nnnpaGhIUceeeR656ZPn55ly5blvPPOK2lxAAAAAAAAAABA37dZt3srFosbPH7DDTfkmmuuKUlBAAAAAAAAAABA/7JZISUAAAAAAAAAAIDNJaQEAAAAAAAAAACUlZASAAAAAAAAAABQVkJKAAAAAAAAAABAWQ2pdAHwz5Yub09zS1uWtXZkWG1V6utqMnxodaXLAmAAszcB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X1IsFnP++edn0aJFG2338ssv5/Of/3wKhULe/va3l6MUAAAAAAAAAACgwoaUY9DTTz89N998c5555pkceeSROemkk3LQQQdl9OjRKRQKmTdvXn7/+9/nhhtuyJIlS7Llllvm9NNPL0cpAAAAAAAAAABAhZUlpLT11lvn0ksvzdSpU9Pc3Jzvfve7+e53v7teu2KxmC233DLf+c53stVWW3V5voULF+b+++/PY489lscffzxPPfVUVqxYkT322CMzZszY7PGKxWKamppyzz335OGHH85f//rXtLS0ZPjw4dl9990zadKkvPvd706hUOhyzQAAAAAAAAAAMFCUJaSUJPvvv39uvfXWXH755bnjjjvS3Ny8zvn6+vq8613vykc+8pGMHj26W3Pddtttueiii7o1xqs98MADOfXUU9f+vsMOO2Ts2LGZO3du7rvvvtx333257bbbMn369FRXV5dsXgAAAAAAAAAA6I/KFlJKkm233TZf/OIX88UvfjEvvPBCFi1alCQZNWpUdthhh5LNU1dXl7e+9a0ZP358xo8fn+eeey6XXHJJl8crFovZfvvt88EPfjBHHXXUOld5uuWWW3L++efn3nvvzbe//e2ce+65pXgIAAAAAAAAAADQb5U1pPRqO+ywQ0mDSa92/PHH5/jjj1/7e1du8fZqe+65Z+64445UVVWtd27SpEn529/+lm9+85u56aab8olPfCKDBg3q1nwAAAAAAAAAANCfSddsQF1d3QYDSmu8/e1vT5IsWbJk7dWhAAAAAAAAAACADSv7lZRWr16d5557Ls3NzVm5cuVrtt1vv/3KXU5JtLW1rf15iy22qGAlAAAAAAAAAADQ+5UtpLRgwYJccsklufPOO7NixYpNti8UCnnyySfLVU5J3XbbbUmSXXfdNXV1da/Zdt68eZk4ceJGz8+cObOktQEDj3UG6AnWGqDcrDNAuVlnAAAAACqrLCGl+fPnZ/LkyVmwYEGKxWKn+nS2XaU98cQTuf7665MkZ555ZoWrAQAAAAAAAACA3q8sIaVLL7008+fPz7Bhw/Lxj388EydOzDbbbJPBgweXY7oe8/e//z1Tp05NR0dHDjvssBx11FGb7LPddtv5l3hAWVlngJ5grQHKzToDlJt1BgAAAKCyyhJS+s1vfpNCoZAvf/nLOeKII8oxRY9bunRppkyZkpdeeil7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9a/5t3/7tyxbtizFYjFVVVUZNWpUBg8eXI7pAAAAAAAAAACAXqwsIaVLL700LS0t2WabbfLFL34xb3/728saUFq4cGHuv//+PPbYY3n88cfz1FNPZcWKFdljjz0yY8aMbo39wAMP5Kqrrsqf/vSnLF++PGPGjMkRRxyRM888M0OHDi3RIwAAAAAAAAAAgP6rLCGlBx98MIVCIV/96ldz0EEHlWOKddx222256KKLSj7uD3/4w3z5y19OsVjMtttum+222y7PPPNMLr/88vzyl7/Mj3/844wYMaLk8wIAAAAAAAAAQH9SlpDS0qVLU11dnQMOOKAcw6+nrq4ub33rWzN+/PiMHz8+zz33XC655JJujfn444/nK1/5SpLkS1/6UiZPnpxCoZD58+fnrLPOyhNPPJHzzz8/06dPL8VDAAAAAAAAAACAfqssIaWGhoYsWrQogwYNKsfw6zn++ONz/PHHr/29u7d4S5LLLrssq1evzqRJk3LiiSeuPT569OhccsklOfLII/PLX/4y//d//5ddd9212/MBAAAAAAAAAEB/VZYU0SGHHJIVK1bkySefLMfwZbds2bL89re/TZJMnjx5vfOve93rcuCBByZJ7rjjjh6tDQAAAAAAAAAA+pqyhJTOOuusjBw5Ml/5ylfS3t5ejinK6qmnnkp7e3uqq6uz5557brDNPvvskyT505/+1JOlAQAAAAAAAABAn1OW2721tbXloosuyr//+7/n2GOPzYc+9KHsueeeGTZs2Gv2GzNmTDnK2WzPPvtskn/UU1VVtcE2O+644zptN2bevHmZOHHiRs/PnDmzi1UC/IN1BugJ1hqg3KwzQLlZZwAAAAAqqywhpVf/weeVV17J5z73uU32KRQKveb2cM3NzUmS+vr6jbZZc25NWwAAAAAAAAAAYMPKElIqFos90qdc2trakmSjV1FKkurq6nXabsx2223nX+IBZWWdAXqCtQYoN+sMUG7WGQAAAIDKKktIqa//waempiZJ0tHRsdE27e3t67QFAAAAAAAAAAA2rCwhpbFjx5Zj2B7TmVu5deaWcAAAAAAAAAAAQDKo0gX0Rq973euSJC+99NJGr6Y0Z86cddoCAAAAAAAAAAAbVpYrKW3I3Llz8/LLL6dQKGTUqFG9+mpLu+++e6qqqtLe3p5HH300++yzz3ptHn744STJXnvt1cPVAQAAAAAAAABA31LWkNKCBQvyve99L7fddluWLFmyzrkRI0bk6KOPzpQpU7LNNtuUs4zNNmzYsBx88MH51a9+lRtvvHG9kNJzzz2XBx54IElyxBFHVKJEAAAAAAAAAADoM8p2u7eHH34473nPe/KjH/0oixcvTrFYXOe/xYsX57rrrssxxxyTP/7xj+Uq4zWddNJJmTBhQq6++ur1zp199tkpFAr56U9/mhtuuCHFYjHJP4JXn/jEJ7J69eoceuih2XXXXXu4agAAAAAAAAAA6FvKciWll19+OWeffXaam5tTV1eX973vfXnb296W0aNHJ0nmz5+f+++/PzfccEMWL16cs846K7/4xS+y1VZbdWm+efPmZdKkSWt/b29vT5LMnj07BxxwwNrjZ5xxRqZMmbL29/nz52fu3LlZunTpemPuueeeOe+883LxxRfn85//fC6//PKMHDkyzzzzTNrb2/P6178+F154YZfqBQAAAAAAAACAgaQsIaUf/OAHaW5uzs4775yrrrpqbThpjZ133jkHHXRQTj755Jx22ml59tlnc9VVV+Xcc8/t0nyrVq1a73ZySbJy5cp1jq9YsWKzxj311FMzbty4/OAHP8ijjz6al19+OWPGjMkRRxyRM888M8OGDetSvQAAAAAAAAAAMJCUJaT061//OoVCIRdeeOF6AaVXGz16dC688ML827/9W+69994uh5S23377zJ49e7P73XPPPZtsc9BBB+Wggw7qSlkAAAAAAAAAAECSQeUYdO7cuamtrc0+++yzybb77LNPamtrM3fu3HKUAgAAAAAAAAAAVFhZQkpJUiwWyzU0AAAAAAAAAADQh5QlpDR27NisWLEijzzyyCbbNjU1pbW1NWPHji1HKQAAAAAAAAAAQIWVJaT0L//yLykWizn//POzaNGijbZ7+eWX8/nPfz6FQiFvf/vby1EK3bR0eXteXLA0s59flBcXLM3S5e2VLgmg26xtANA59sy+w2sFAAAAAPR2Q8ox6Omnn56bb745zzzzTI488sicdNJJOeiggzJ69OgUCoXMmzcvv//973PDDTdkyZIl2XLLLXP66aeXoxS6YeGS1ky/sSlNsxeuPdY4riHTJjemYURtBSsD6DprGwB0jj2z7/BaAQAAAAB9QVmupLT11lvn0ksvzfDhw9Pc3Jzvfve7OfXUU3PkkUfmiCOO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zB133LG+KKmuri5Tp07N1KlTM2bMmDQ2NmabbbbJihUr0tramqeffjr3339/nn322Tz00EP5zGc+kwsvvDAf/ehH8773vS+1tbXlCA0AAAAAAAAAAKiwrS5SOuusszJ37tysXbs2Y8eOzeGHH54jjzwy//RP/5Rhw16+++eeey7z5s3L3Llzc/fdd+dLX/pS/vu//ztf/OIXs++++25teAAAAAAAAAAAQIVtdZHSDTfckN133z0nn3xyDj300AwdOnSL9t9uu+0yY8aMzJgxI88880y+8Y1vZM6cOVmwYIEiJQAAAAAAAAAAqAJbXaT05S9/OYcffnhqamq2OpiJEyfms5/9bE466aQ888wzW90fAAAAAAAAAABQeVtdpHTEEUeUI44N7Ljjjtlxxx3L3i8AAAAAAAAAAND3hlQ6AAAAAAAAAAAAoLopUgIAAAAAAAAAAAq11a9725hnn312i7avq6vLqFGjUltbW0Q4AAAAAAAAAABABRVSpDR9+vRe7bfzzjvnda97Xd773vdm8uTJZY4KAAAAAAAAAACohEJe91YqlXr1Z/Hixfn+97+ft73tbfnZz35WRGgAAAAAAAAAAEAfK+RJSvPmzcsf/vCHfOYzn8mQIUNy/PHHZ//998+4ceNSKpWybNmy3Hnnnbnmmmvy0ksv5fzzz8/OO++cP/zhD/nud7+bxx57LP/f//f/ZerUqZ6oBAAAAAAAAAAAA1whT1J66aWXcs4552TChAn56U9/mtNPPz0HHnhgdt111+y222458MADc/rpp+emm27KhAkTcs4552TbbbfNjBkzMmfOnOy9997p6urKd7/73SLCAwAAAAAAAAAA+lAhRUqXXXZZ2tracv7552fMmDGb3G7MmDE577zz0tramq9//etJktra2nz84x9PqVTKggULiggPAAAAAAAAAADoQ4UUKd1+++1paGjIP/7jP77stv/0T/+UhoaG/Pa3v13/2atf/eoMHz48S5YsKSI8AAAAAAAAAACgDxVSpPT8889n7dq1Pd6+VCrlueeeW//vQ4cOTUNDQ1566aUiwgMAAAAAAAAAAPpQIUVKY8eOzZo1a/L73//+Zbf9/e9/n/b29g1eC9fV1ZW//vWvm31VHAAAAAAAAAAAMDAUUqT02te+NqVSKZ/+9Kfz1FNPbXK7p59+Op/+9KdTU1OTgw8+eP3nixYtSqlUyoQJE4oIDwAAAAAAAAAA6EPDiuj01FNPzc9//vMsXrw4b33rW3P44Ydn//33zw477JCamposW7Ysd9xxR26++ea0t7dnxIgROfnkk9fvf9NNNyVJ9t9//yLCAwAAAAAAAAAA+lAhRUoTJkzIt771rZx22mn5y1/+khtuuCE33HBDt+1KpVK23377fPWrX83EiRPXf/6KV7win/rUp/LGN76xiPAAAAAAAAAAAIA+VEiRUpI0NzfnZz/7Wa666qr8/Oc/z2OPPZaXXnopSTJ06NDsvvvuOfTQQzNz5sxsu+22G+x7xBFHFBUWW2jl6s60tnVkVXtXRtQPT+PIuoxqqK10WABbRW4DgJ6xZg4crhVUJ3MbAAAAqCaFFSklyahRo3LyySfn5JNPTldXV1pbW1MqlTJ69OgMHz68yENTBstXtGfWdS1pWbh8/WfNU5py2ozmNI2ur2BkAL0ntwFAz1gzBw7XCqqTuQ0AAABUmyFFdDpt2rQccsghefLJJ9d/Nnz48Gy//fZpampSoDQArFzd2e1GWJK0LFyeWde1ZOXqzgpFBtB7chsA9Iw1c+BwraA6mdsAAABANSqkSGn58uV5/vnnM3ny5CK6pw+0tnV0uxG2TsvC5Wlt6+jjiAC2ntwGAD1jzRw4XCuoTuY2AAAAUI0KKVLaYYcdUiqViuiaPrKqvWur2gH6I7kNAHrGmjlwuFZQncxtAAAAoBoVUqT0mte8JmvWrMlDDz1URPf0gRH1m38l38u1A/RHchsA9Iw1c+BwraA6mdsAAABANSqkSOnEE09MfX19zjvvvLS3txdxCArWOLIuzVOaNtrWPKUpjSPr+jgigK0ntwFAz1gzBw7XCqqTuQ0AAABUo0KKlIYOHZrzzjsvf/zjH3PkkUfmO9/5Tu6777489dRTefbZZzf5h/5jVENtTpvR3O2GWPOUppw+ozmjGmorFBlA78ltANAz1syBw7WC6mRuAwAAANVoWBGdTp8+ff0/t7e354tf/OLL7lNTU+P1cP1M0+j6nDlz37S2dWRVe1dG1A9P48g6N8KAAU1uA4CesWYOHK4VVCdzGwAAAKg2hRQplUqlPtmH4o1qqHXzC6g6chsA9Iw1c+BwraA6mdsAAABANSmkSGnevHlFdAsAAAAAAAAAAAxAhRQpTZw4sYhuAQAAAAAAAACAAWhIpQMAAAAAAAAAAACqWyFPUlpn7dq1eeaZZ7JixYokyejRozNx4sQMGaI2CgAAAAAAAAAABotCipR+9atf5eqrr85dd92V1atXb9DW0NCQfffdN+9+97vz+te/vojDAwAAAAAAAAAA/UhZi5Sef/75fOITn8j//M//JElKpVK3bVatWpVf//rX+fWvf50DDzwwF110UbbbbruyHH/BggW54oorct9992X16tWZMGFCDjvssJx44olpaGjY4v5WrFiRK664IrfddlsWL16crq6ujBkzJnvvvXfe85735MADDyxL3AAAAAAAAAAAUM3KVqS0YsWKHH/88Vm8eHFKpVJGjBiR1772tXnlK1+ZMWPGpFQqZcWKFXn44Yfzu9/9LqtWrcqCBQvy7ne/O9dcc03GjBmzVce/6qqr8rnPfS6lUik77rhjxo8fn8ceeyyXXXZZbrnlllx99dUZPXp0j/tbtGhR3vve92bZsmUZMmRIJk6cmFGjRmXx4sW55ZZbcsstt+Rf//Vfc/LJJ29V3AAAAAAAAAAAUO3KVqT0yU9+Mk8++WSGDx+ek046KSeccMImn160evXqXHHFFfn617+exYsX55Of/GS++c1v9vrYDzzwQC644IIkyXnnnZcZM2akpqYmS5cuzUknnZQHH3ww55xzTmbNmtXjPj/zmc9k2bJl2WWXXTJ79uzsscceSZLOzs5cdtllufTSS3PJJZdk2rRp2XPPPXsdOwAAAAAAAAAAVLsh5ejkjjvuyK9//esMGzYsX/va13LyySdv9vVqDQ0NOeWUUzJ79uwMHTo0v/3tb7NgwYJeH//SSy/N2rVr87a3vS3HHXdcampqkiTjxo3Ll7/85QwZMiS33HJLHnnkkR7119bWljvuuCPJ34qv1hUoJUltbW3+9V//Na985StTKpXy61//utdxAwAAAAAAAADAYFCWIqWbbropSTJz5sy87nWv6/F+r3/96/Oe97wnpVJpfR9batWqVfnNb36TJJkxY0a39l122SUHHnhgkuTmm2/uUZ+dnZ0plUpJkp133nmj26z7vKura4tjBgAAAAAAAACAwaQsRUp33XVXampqctxxx23xvscff/z6Pnrj4YcfTmdnZ2pra7PXXnttdJt99tknSXLffff1qM+xY8dm/PjxSZJ77rmnW3tHR0ceeOCBJMk///M/9yZsAAAAAAAAAAAYNIaVo5Nly5altrY2u+666xbvu8suu6Suri7Lly/v1bGfeOKJJMmECRMyfPjwjW4zadKkDbbtiTPPPDMf//jHc+GFF2bIkCF54xvfmJEjR+bxxx/PV7/61Tz77LM59NBDc/DBB2+2nyVLlmT69OmbbJ83b16PYwLYGHkG6AtyDVA0eQYomjwDFE2eAQAA2LyyFCl1dXWltra21/vX1tams7OzV/u2trYmSRobGze5zbq2ddv2xFve8paMGDEis2fPzjnnnLNB25gxY3LuueeufwoUAAAAAAAAAACwaWUpUho7dmz+/Oc/Z+XKlRk1atQW7bty5cqsXLkyO+64Y6+O3dHRkSSbfIpSkvUFVOu27anFixentbU1NTU1GT9+fLbddtssXrw4L7zwQq699tpMnTo1zc3Nm+1j/Pjx/oYMUCh5BugLcg1QNHkGKJo8AxRNngEAANi8IeXoZMqUKUmSX/ziF1u87y233LJBH1uqrq4uyd+e5rQp657StG7bnvjsZz+bz33uc2loaMiNN96YW2+9NTfeeGPuvPPOfPzjH8/ChQvz/ve/Pw8++GCv4gYAAAAAAAAAgMGiLEVKb3jDG1IqlXLJJZdkxYoVPd7vhRdeyKxZs1JTU5M3vvGNvTp2T17l1pNXwv29Rx55JP/93/+dYcOGZdasWRsUUA0fPjwnnnhijjnmmHR0dOTiiy/uVdwAAAAAAAAAADBYlKVI6dhjj824ceOydOnSnHDCCXnyySdfdp9FixblhBNOyJ///OfssMMOOfbYY3t17F122SVJ8uyzz27yaUqLFy/eYNuXc/fdd6dUKmXy5MmZNGnSRrd53etelyS5//77tyxgAAAAAAAAAAAYZMpSpFRbW5sLLrggQ4cOzcKFC3PUUUfl7LPPzq9+9assW7YsnZ2d6ezszLJly3LbbbflU5/6VN72trdl4cKFGTZsWD73uc+ltra2V8eeOnVqhg8fns7Ozk0WDN19991Jkr333rtHfa5atSpJUlNT87LbrnuVHAAAAAAAAAAAsHHDytXRa1/72vznf/5nzj777LS3t2fOnDmZM2fOJrcvlUrZZptt8h//8R85+OCDe33cESNG5OCDD86tt96a6667Lvvss88G7YsWLcqCBQuSJIcddliP+tx1113X7/vUU09l55137rbNb37zmw22BQAAAAAAAAAANq4sT1Ja54gjjsgPf/jDvOlNb0pNTU1KpdJG/9TU1ORNb3pTfvCDH+TII4/c6uOefPLJqampyY033phrr702pVIpSbJs2bKcccYZWbt2bQ455JDsueeeG+x3/PHHZ9q0afnOd76zwecHH3xwtt9++7z44os5/fTT8+ijj65v6+rqyre+9a31BVhHH330VscPAAAAAAAAAADVrGxPUlpnt912y6xZs7J8+fLceeedefTRR7NixYokyejRo7P77rvngAMOSFNTU9mOuddee+Wss87KF77whZx77rm57LLLMmbMmDz22GPp7OzMrrvumvPPP7/bfkuXLs0zzzyTlStXbvB5fX19Lrroopx88sl56KGH8ta3vjUTJkzItttum8WLF69/Hdyb3/zmvOc97ynb9wAAAAAAAAAAgGpU9iKldZqamvKWt7ylqO67OeGEEzJlypRcfvnluf/++/Pcc89lwoQJOeyww3LiiSdmxIgRW9TfQQcdlJ/85Ce58sorc/vtt+fpp5/O0qVL09jYmFe/+tU55phj+vT7AQAAAAAAAADAQFVYkVIlHHTQQTnooIN6vP38+fM32z5x4sT8n//zf7Y2LAAAAAAAAAAAGNSGVDoAAAAAAAAAAACgum11kdJHP/rRPPDAA+WIJUmyZs2afPvb387VV19dtj4BAAAAAAAAAIDK2erXvf3qV7/Kr3/967zhDW/I8ccfn4MPPjhDhmx57dMzzzyTG2+8Md///vfz/PPP59RTT93a0AAqauXqzrS2dWRVe1dG1A9P48i6jGqorXRY/Y7zBDB4WQOgepnfAPQX1iQAAID+Y6uLlK644op84QtfyK233prbbrstY8eOzWGHHZZ99tkne+21V3baaaeN7tfe3p4HHngg9913X+bPn5+WlpYkyfDhw/OBD3wg73vf+7Y2NICKWb6iPbOua0nLwuXrP2ue0pTTZjSnaXR9BSPrX5wngMHLGgDVy/wGoL+wJgEAAPQvW12kdNBBB+WGG27InDlz8q1vfStPPPFErr766vWvaxsxYkTGjBmTxsbG1NbW5q9//WtWrFiRF154IWvXrk2SlEql1NXV5S1veUtOOeWUTJw4cWvDAqiYlas7u90AS5KWhcsz67qWnDlzX39jL84TwGBmDYDqZX4D0F9YkwAAAPqfrS5SSpKampq8/e1vz9vf/vbceeedufbaa/O73/0u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AAAADBCFPEmpo6Mjn//85/PJT34yxxxzTD74wQ9mr732yogRIza734QJE4oIBwAAAAAAAAAAqKBCipSmT5++/p//+te/5tOf/vTL7lNTU5OHHnqoiHAAAAAAAAAAAIAKKqRIqVQq9ck+AAAAAAAAAABA/1dIkdK8efOK6BYAAAAAAAAAABiACilSmjhxYhHdvqwFCxbkiiuuyH333ZfVq1dnwoQJOeyww3LiiSemoaGh1/3+6le/yvXXX5977703K1asyLbbbptJkyblgAMOyGmnnZZhwwo5jQAAAAAAAAAAUBWGVDqAcrnqqqtywgkn5LbbbktdXV3+4R/+Ic8880wuu+yyvOMd78iKFSu2uM8XX3wxZ555Zk488cT84he/yNChQ7PnnnumoaEhDzzwQL7+9a+no6Oj/F8GAAAAAAAAAACqSJ89AuiZZ57Jc889l5qamowdO7asT1t64IEHcsEFFyRJzjvvvMyYMSM1NTVZunRpTjrppDz44IM555xzMmvWrC3q99///d8zd+7c7Lnnnjn//POz1157rW9rb2/P7bffntra2rJ9DwAAAAAAAAAAqEaFFiktW7Ys3/jGN3LTTTd1e5LR6NGjc+SRR+YjH/lIdthhh606zqWXXpq1a9fm6KOPznHHHbf+83HjxuXLX/5yDj/88Nxyyy155JFHsueee/aozwULFuT666/PDjvskCuvvDKjR4/eoL2+vj7Tp0/fqrgBAAAAAAAAAGAwKOx1b3fffXeOOuqofP/7388LL7yQUqm0wZ8XXngh3/ve9/K2t70t99xzT6+Ps2rVqvzmN79JksyYMaNb+y677JIDDzwwSXLzzTf3uN/vfOc7SZIPfehD3QqUAAAAAAAAAACAnivkSUrPPfdcTj755LS2tmbkyJF517velde+9rUZN25ckmTp0qW5/fbbc+211+aFF17ISSedlJ/+9KfZbrvttvhYDz/8cDo7O1NbW7vB69j+3j777JPbb7899913X4/67OjoyG9/+9skyfTp03P//fdnzpw5efLJJ1NXV5d//Md/zDve8Y7suOOOWxwvAAAAAAAAAAAMNoUUKV1++eVpbW3NbrvtliuuuGJ9cdI6u+22Ww466KDMnDkzH/jAB/LEE0/kiiuuyCc+8YktPtYTTzyRJJkwYUKGDx++0W0mTZq0wbYv55FHHklXV1caGhry85//PF/60peydu3a9e233nprvvnNb+YLX/hCDj/88M32tWTJks2+Fm7evHk9iglgU+QZoC/INUDR5BmgaPIMUDR5BgAAYPMKed3br371q9TU1OT888/vVqD098aNG5fzzz8/pVIpt912W6+O1dramiRpbGzc5Dbr2tZt+3KWL1+eJOns7MyFF16Y5ubmzJkzJ3/4wx/y85//PIcddljWrFmTM888M4888kiv4gYAAAAAAAAAgMGikCcpPfPMM6mvr88+++zzstvus88+qa+vzzPPPNOrY3V0dCTJJp+ilCS1tbUbbPtyVq1alSR58cUXM2bMmHzjG9/IyJEjkyS77LJLvvKVr+TJJ5/Mww8/nMsuuyxf/epXN9nX+PHj/Q0ZoFDyDNAX5BqgaPIMUDR5BiiaPAMAALB5hTxJKUlKpVJRXW+grq4uSdLV1bXJbTo7OzfYtqd9Jslxxx23vkBpnSFDhuSEE05Ikvz2t7/d4FVwAAAAAAAAAADAhgopUpo4cWLWrFmTe++992W3bWlpSXt7eyZOnNirY/XkVW49eSXcxvpMkt12222j26z7vK2tLStWrOhRvwAAAAAAAAAAMBgVUqT0L//yLymVSjnnnHPy/PPPb3K75557Lueee25qamryute9rlfH2mWXXZIkzz777CafprR48eINtn05f1+YtKmnL/39556kBAAAAAAAAAAAmzasiE4/9KEP5Yc//GEee+yxHH744Tn++ONz0EEHZdy4campqcmSJUvyP//zP7n22muzYsWKbLvttvnQhz7Uq2NNnTo1w4cPT2dnZ+6///7ss88+3ba5++67kyR77713j/ocN25cJk6cmGeeeWZ9gdP/9tRTTyVJamtrM3r06F7FDgAAAAAAAAAAg0EhT1LafvvtM3v27IwaNSqtra35r//6r5xwwgk5/PDDc9hhh+UDH/hAvvGNb6wvUPra176W7bbbrlfHGjFiRA4++OAkyXXXXdetfdGiRVmwYEGS5LDDDutxv4cffniS5IYbbtjok5J+8IMfJEn233//DBtWSK0XAAAAAAAAAABUhUKKlJK/Fe/MnTs3xx13XLbddtuUSqUN/my77bY5/vjj8+Mf/zj77bffVh3r5JNPTk1NTW688cZce+21KZVKSZJly5bljDPOyNq1a3PIIYdkzz333GC/448/PtOmTct3vvOdbn1+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yZIccsghm93uwgsvzN13352ddtopP/nJTzJ37tz84he/yKWXXpra2tp885vfzPz58/soagAAAAAAAAAAGLgGVZHSXXfdlWuuuSZvetObMn369E1u95e//CXXXHNNkuRzn/tcdtttt/Vt06dPz4c//OEkyezZs4sNGAAAAAAAAAAAqsCgKVLq6OjIpz/96TQ0NOScc87Z7Lbz589PV1dXJk+enAMPPLBb+7ve9a4kyYMPPpjFixcXEi8AAAAAAAAAAFSLQVOk9LWvfS1PPPFEzjjjjIwbN26z2957771Jkn322Wej7ePGjctOO+20wbYAAAAAAAAAAMDGDat0AH3h4Ycfzre//e3stddeefe73/2y2y9atChJMnny5E1uM2nSpDz99NN54oknNtvXkiVLNvtquXnz5r1sPACbI88AfUGuAYomzwBFk2eAoskzQNHkGQBgoKv6Jym99NJLOfvss5Mk559/foYMefmv3NramiRpbGzc5Dbr2v7617+WIUoAAAAAAAAAAKheVf8kpW9/+9t58MEH8+EPfzh77rlnj/bp6OhIkgwfPnyT29TW1iZJ1qxZs9m+xo8fr3IdKJQ8A/QFuQYomjwDFE2eAYomzwBFk2cAgIGuqp+ktGjRosyePTs77bRTTj311B7vV1dXlyTp6ura5DadnZ1Jkm222WbrggQAAAAAAAAAgCpX1UVKn/nMZ9LR0ZF///d/T319fY/323bbbZP8v9e+bcy6tnXbAgAAAAAAAAAAG1fVr3t78MEHU1NTk7POOqtb27rXtC1ZsiSvfe1rkySzZs3Kq1/96uyyyy6555578uSTT26y78WLFydJdtlll/IHDgAAAAAAAAAAVaSqi5SSpFQq5S9/+csm29euXbu+fd3r3fbee+/MmTMn99xzz0b3Wbp0aZ5++un12wIAAAAAAAAAAJtW1UVKd9111ybb5syZk0996lOZOHFi5s+fv0Hb9OnTc/7552fRokVZsGBBDjzwwA3ar7nmmiTJ1KlTM3ny5PIHDgAAAAAAAAAAVWRIpQPoj7bffvscd9xxSZKzzz47f/rTn9a3zZ8/P9/61reSJKecckpF4gMAAAAAAAAAgIGkqp+ktDXOPPPMPPjgg2lpacmRRx6ZPfbYI6tXr87ixYuTJB/84AdzyCGHVDhKAAAAAAAAAADo/xQpbcI222yT7373u7ny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6A3jBgAAitVnT1JaZ+zYsbnwwgtzzDHH5DOf+UzOP//8zJ07N+edd15e8YpX5Etf+lJKpVKee+659dsPGVLeWqpSqZTnn38+d999dy666KIkyQc/+MHsvPPO67dZtGhRkmTy5Mmb7GfSpEl5+umn88QTT2xymyVLlmT69OmbbJ83b94WRg/0d61tHd1uZKzTsnB5Wts6MqqhdqPtvTFQ80xfnydg6wzUXEP/ZA1gY+SZ6mB+05/JMzC4VGJNkmcGPv8tQ38nzwAAA12fFymt85rXvCY33XRTZs+encsvvzzHHntsPvShD+WUU05JbW1ttt9++7If88Ybb8wnP/nJDT7bbbfdctFFF+Wtb33rBp+3trYmSRobGzfZ37q2v/71r2WOFBjIVrV3bVX7YOE8AQxe1gCoXuY3AP2FNYneMG4AAKBYFStSSpLa2tqcccYZOfLII3Puuefmv/7rv/Kzn/0s//7v/57XvOY1ZT/edtttl1e/+tUplUr585//nKVLl2bRokX58Y9/nP322y877rjj+m07OjqSJMOHD99s/EmyZs2aTW4zfvx4leswyIyo33Te6En7lhqoeaavzxOwdQZqrqF/sgawMfJMdTC/6c/kGRhcKrEmyTMDn/+Wob+TZwCAga6871HrpVe84hW55pprcu655+aFF17Ihz70oXzyk5/M888/X9bjHHzwwfnv//7vXHPNNbnttttyyy23ZNq0afnVr36VGTNmZOXKleu3raurS5J0dW36b0Z0dv7t/dPbbLNNWeMEBrbGkXVpntK00bbmKU1pHFnXxxH1T84TwOBlDYDqZX4D0F9Yk+gN4wYAAIrVp0VKbW1tmT17do4//vgceuihOf744zN79uz1xUHvfve787Of/SxvfvObM3fu3Bx++OGZM2dOYfHsvPPOueSSS7LHHntk6dKl+d73vre+bdttt03y/177tjHr2tZtC5Akoxpqc9qM5m43NJqnNOX0Gc3eW/9/OU8Ag5c1AKqX+Q1Af2FNojeMGwAAKFafve7t+eefz/HHH5/FixenVColSZ588snce++9+fGPf5yrr7462223Xbbffvt89atfzW233ZbzzjsvZ599dm644Yacd9552WWXXcoe19ChQ/Mv//IvefTRR/PAAw+s/3yXXXbJPffckyeffHKT+y5evHj9tgB/r2l0fc6cuW9a2zqy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fO655/KNb3wjzzzzzDrTV69enfvuuy9f+9rXkiQf/OAHs88++9TP32677XLSSSfV7++FF16onzdu3LjceOONSZKzzjpri44FAAAAAAAAAADaujb/ureVK1fmV7/6VX71q1+lV69e2WmnndKxY8fMnj07CxcuTJIMGjQoV111VYN1zz///EyfPj1Tp07Ncccdlz322CNLly7N7NmzkySnnnpqjjzyyBY9HgAAAAAAAAAAKDdtPkmpT58+OffcczNt2rQ8//zzmTVrVmpra9OzZ88cfvjhOe6443LcccelY8eODdbt0qVLbr311owaNSpjxozJzJkzU1lZmQMPPDBDhgzJ0UcfXYIjAgAAAAAAAACA8tJiSUp33XVXxo0blx122CGXXnrpRpcvFAq5/PLL88orr+Too4/O8ccf36T9brXVVjnjjDOatG6SVFVVZejQoRk6dGiTtwEAAAAAAAAAAO1ZiyQpLVq0KFdccUUWL16cn//855u0TkVFRY455pgMGTIkkydPzuDBg9OtW7cilxQAAAAAAAAAAGhuHVpiJ7/73e+yaNGifOhDH8qgQYM2eb39998/gwcPzoIFC/LAAw8UsYQAAAAAAAAAAECxtEiS0iOPPJKKior893//92av+/GPfzyFQiEPPfRQEUoGAAAAAAAAAAAUW4skKc2YMSNJcvDBB2/2ugcccECS5Omnn27WMgEAAAAAAAAAAC2jRZKU3njjjXTv3j3du3ff7HXXrDd//vwilAwAAAAAAAAAACi2FklSWr16dQqFwhZvAwAAAAAAAAAAKD8tkqTUq1evLF26NIsXL97sdRcvXpwlS5akV69ezV8wAAAAAAAAAACg6FokSenf/u3fkiQTJkzY7HXXrLPbbrs1a5kAAAAAAAAAAICW0SJJSgcffHAKhUJuvPHGzV73xhtvTEVFRQ455JAilAwAAAAAAAAAACi2FklS+vjHP56uXbvmySefzPe+971NXu973/tepk2bli5duuS///u/i1hCAAAAAAAAAACgWFokSWmbbbbJF77whRQKhdxyyy057bTTMnXq1EaXnzJlSk499dTccsstqaioyNChQ7Ptttu2RFEBAAAAAAAAAIBm1qmldvTFL34x//jHP3Lfffdl/PjxGT9+fLbeeuv8+7//e3r16pUkWbBgQZ555pnMnz8/SVIoFHLsscfmzDPP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ylx00UUZM2ZMkmSHHXZI7969s2DBgrzyyiupq6vLlClT0r179/p1itZmCpS9p556qjBgwIBCTU1NYfTo0YXVq1cXCoVC4ZVXXin813/9V6F///6Fs88+e7O2effddxfGjx9fWLFixTrTZ82aVfjMZz5T6N+/f+Gggw4qLFmypNmOo9wUo97f6Zvf/Gahf//+hTPPPLPQv3//woc+9KHmKHrZK0bdX3vttYX+/fsXLrjggmIUueyJM6UhzpSOONOyfv3rXxc+//nPF66++urCAw88ULj++usL/fv3L/zXf/3XBtf7zne+U+jfv3/hiCOOKDz//PP10//4xz8W9tprr0L//v0LY8eObbDe0qVLC+9///sL/fv3L1x00UWF5cuXFwqFQmH58uWFCy+8sNC/f//C4YcfXli2bFnzHmg70tRz+qEPfajQv3//woQJEzZrf84prZW+sPn+8pe/FI444ojCqFGjCq+99to68+6+++76+H7VVVc1WLe9jgtbUmftua2Vq6bGlQ1pifsOKEbbbUv3WO5BKVfGJYqpFPcGsCU29bN27ZPmpu2xKS655JJC//79Cx/96EcLTz755Drzli5dWvjjH/9YqK2tXWd6sdqMJKU24Iwzzij079+/8PWvf73BvBdffLEwYMCAQv/+/QszZsxolv3Nmzev0L9//0L//v0LDz/8cLNssxwVu94ff/zxQk1NTeGss84q/OY3v5E8sJZi1L0PbjZMnCkNcaZ0xJnSWtMeN/Sh5rx58wrvec97Cv379y889thjDeZfc801jW7j5ptvLvTv37/w4Q9/uEGi5IoVKwpHHnlkoX///oVbb711yw+GQqGwaee0UGj6l+XOKeVCX9i4RYsWNfhAZG3XXXddoX///oUDDzywsGrVqvrp7XlcaGqdFQrtu621FZsaVzakpe/3oFBonrbblu6x3IPSVhiXaE6luDeAptrUz9q1T5qbtsemeOyxxwr9+/cvHHrooYU33nhjk9YpZpvp0CzPhqJklixZkj//+c9JkhNPPLHB/F133TUHH3xwkuSBBx5oln1ut9129Y9SXb58ebNss9wUu95XrFiRSy+9NN26dctll122ZYVtY0rR5ts7caY0xJnSEWfKw7hx41JXV5d+/frVn4+1ffKTn0ySTJ8+vf7Ro2usOW//9V//1eBx4FVVVTnhhBOSJL/73e+KUXSKwDmFt7SFvlBdXZ3KyspG5x9++OFJkgULFmT+/Pn109vzuNDUOtsS5V5nvM21L5Sefghv0x9YWynuDaApNuezdu2T5qTtsaluueWWJG+97nlTX5lbzDbTabOWptWZMWNGamtrU1VVlX322We9y+y///4ZP358nnzyyWbZ5/PPP58FCxakQ4cO2XPPPZtlm+Wm2PX+4x//OC+++GIuu+yybL/99lta3Dal2HX/zDPP5Gtf+1rmzZuX7t27p6amJscee2z22GOPLS162RJnSkOcKR1xpjxMmzYtyVvnYn2233779O3bN3PmzMm0adOyyy67JElWrVqVv/3tb0mSQYMGrXfdNdOfeuqprFq1Kh07dmzm0rMxo0ePzs9+9rMsX7482223XQYNGpTjjz8+1dXVDZZ1TmnL9IWGVqxYUf//Ll261P/fuNC4xupsbdpa+1WK+z1obuV+j+UeFN5mXGJzNPe9ATTV5nzWrn3SnLQ9NsWKFSvy6KOPJkkGDx6cv/71r7nrrrsya9asdO7cOXvttVc+/vGPZ4cddlhnvWK2GUlKZe7FF19Mkuy0006NZpSvaRBrlm2KQqGQ+fPnZ/Lkybn66quTJKeeemp23nnnJm+znBWz3mfMmJGbbrop++yzTz796U9vWUHboGK3+RkzZmTGjBn1f48bNy7XX399PvvZz+aCCy5olx+uizOlIc6UjjhTHmbOnJkk6devX6PL7LLLLpkzZ8465+mll15KXV1d/fzG1kuS2travPzyy+02DpXS/fffv87f9913X370ox/lBz/4Qd7//vevM885pS3TFxr67W9/myQZMGDAOgk0xoXGNVZna9PW2q+Wut+DYir3eyz3oPA24xKbo7nvDaApNvezdu2T5qLtsameeeaZ1NXVpVu3bnnwwQfzgx/8IKtXr66f/9BDD+WGG27IlVdemWOOOaZ+ejHbjCSlMrdw4cIkSc+ePRtdZs28NctujnvvvTdf//rX15m222675eqrr87xxx+/2dtrK4pV76tWrcoll1ySJPnOd76TDh28kfGdilX32223XU4//fQcddRR2XnnnVNdXZ0XX3wxt99+e0aPHp1Ro0alsrIy559//pYdQBkSZ0pDnCkdcaY8bM55evPNN+unLViwoP7/jT3WdO1tLly40BerLWjgwIH54he/mP333z877bRT6urqMnny5Fx77bV5+umnc8YZZ+SXv/xl3vOe99Sv45zSFukL6zd9+vSMHj06STJs2LB15hkX1m9DdZZoaxT/fg+Kqa3cY7kHhbcZl9hUxbg3gM3VlM/atU+ag7bH5pg3b16St35IdtVVV2X//ffPJZdckj322CMvv/xyfvjDH+aBBx7I+eefn3e/+90ZMGBAkuK2GUlKZW7N4yw39F7eqqqqdZbdHNtuu23222+/FAqFvPLKK3n11Vczc+bM/N///V8OOOCABo/9ai+KVe833XRTpk+fntNPP70+ALCuYtX9pz71qQbTampq8u1vfzt9+/bN1VdfnVtuuSWf+tSn0rdv380sdXkTZ0pDnCkdcaY8bM55Wr58ef202tra+v83tu6a9d65LsX3gx/8YJ2/u3btmg996EM55JBD8ulPfzrTp0/P1VdfnZtvvrl+GeeUtkhfaOi1117L2Wefnbq6unz4wx/Oscceu85840JDG6uzRFuj+Pd7UExt5R7LPSi8zbjEpijWvQFsrqZ81q590hy0PTbHkiVLkiQrV67M1ltvnZ/+9Kf1TyDcdddd88Mf/jCzZs3KjBkzct111+VHP/pRkuK2GY9PKHOdO3dOkvpHra/Pmg8Q1yy7OQ499ND88pe/zOjRo/Pwww/n97//fY444og88sgjOfHEE7No0aKmFbzMFaPeZ86cmREjRqRv3745++yzt7yQbVSx2/z6nHrqqXnXu96VlStX5qGHHmqWbZYTcaY0xJnSEWfKw+acpy5dutRPW/tL08bWXfvL17XXpXS6dOmSc889N0kyYcKEdX6Z4ZzSnrTXvrBo0aIMHTo0L7/8ct7znvfkyiuvbLCMcWFdm1JnG9Je21p7VIprX2gJ5XSP5R4U3mZcYmOKeW8Am6Opn7Vrn2wpbY/NtfY100knnbTOK1KTpEOHDvn85z+fJHn00UfrXwVXzDYjSanMbcqjTTflUVybauedd861116bPfbYI6+++mp+8YtfbPE2y1Ex6v2b3/xmVqxYkW9961vp2rXrlheyjWrpNp8kHTt2zHvf+94kb79/sz0RZ0pDnCkdcaY8bLXVVkk27TytWTZZ95yt/bqa9a33zuUprf322y9Jsnr16syePbt+unNKe9Pe+sKSJUty+umn5+mnn84ee+yRm266qcGHKYlxYW2bWmcb097aWntVimtfaAnldI/lHhTeZlxiQ4p9bwCbo6mftWufbCltj8219jXTbrvttt5l1kxfvHhx/Wc9xWwzXvdW5nbdddckycsvv5y6urr1Pm5rzYeJa5bdUh07dsxhhx2W5557Ln/729+aZZvlphj1Pn369FRUVOTCCy9sMG/NI9Lmzp2b97///UmS4cOH139o3J6Uos0nbz/KbuXKlc22zXIhzpSGOFM64kx52HXXXTNlypTMmjWr0WXWd5769OmTysrK1NXVZfbs2dl+++0bXa+qqio77bRT8xacJlu7L65atar+/84p7U176gvLli3LF77whUybNi277rprbr755my99dbrXda48JbNqbONaU9trT0r1bUvtIRyucdyDwpvMy7RmJa4N4DN0dTP2rVPtpS2x+ZaOzGpsSdRrj19zZOUitlmPEmpzO25556prKxMbW1t/vrXv653mcmTJydJ9t1332bb75qb1zWNtL0pVr0XCoW89tprDf4tXrw4yVv1vWbahh6t1paVqs0/99xzSZIddtih2bZZLsSZ0hBnSkecKQ9r6n7KlCnrnf/qq69mzpw56yybJJ06dcpee+2VJHniiSfWu+6a6XvvvXc6duzYTCVmS/3973+v///aX4g7p7Q37aUvrFixImeeeWYef/zx9OnTJ6NGjUrv3r0bXd64sPl1tjHtpa21d6W69oWWUC73WO5B4W3GJdanpe4NYHM15bN27ZPmoO2xObbffvv06dMnSdZ5Uvba/vnPfyZ568dmvXr1SlLcNiNJqcx17949hx56aJLkjjvuaDB/5syZmTBhQpLkIx/5SLPss7a2Ng8//HCSt24a2qNi1PsTTzyRZ599dr3/rrjiiiRv/WJ1zbSDDjqomY6mvJSizT/88MP1H9ysyTxuT8SZ0hBnSkecKQ+DBw9OZWXlOudjbaNHj07yVgzp16/fOvOOPvroJMndd9/dIBmvtrY2d911V5LmO780jxtvvDFJsvvuuzf4IsU5pT1pD32hrq4u55xzTsaPH58ddtgho0aN2ugXqO19XGhKnW1Me2hrlObaF1pCOd1juQeFtxmXeKeWvjeATdXUz9q1T7aUtkdTHHPMMUmSe+65Z70Ph7jzzjuTJAceeGA6dXrrZWzFbDOSlNqAM888MxUVFbn33nvzq1/9KoVCIUnyr3/9K1/96lezevXqHHnkkRkwYMA6633qU5/KEUcckVtuuWWd6U899VSuueaa9b6P/MUXX8wZZ5yR2bNnp1u3bjnxxBOLdVitXnPXO5uuuev+ueeeyze+8Y0888wz60xfvXp17rvvvnzta19Lknzwgx/MPvvsU7wDa8XEmdIQZ0pHnGn9tttuu5x00klJkksuuSQvvPBC/bxx48bVf7F61llnNVj3pJNOSu/evTNr1qz6d3gnb/0y75vf/GZmz56dd73rXfnEJz7RAkfCGjfddFN+/vOf54033lhn+htvvJFvfOMbeeCBB5Ik55xzToN1nVPakvbeF1atWpXzzjsvjzzySHr37p1Ro0Zl55133uh67XlcaGqdtfe21t5s6B6hqde+0BLayz2We1DaG+MSm6IU9wZQbNonpaLttW+nnXZaevTokeeffz7/8z//k9ra2iRvPZVr1KhReeihh1JRUZFhw4bVr1PMNlNRWHOFR1m75ZZbcuWVV6ZQKGTHHXfM1ltvnX/84x+pra3Nu9/97tx+++3ZZptt1lnniCOOyEsvvZSzzz57nQ8dJ06cmM9+9rNJkm222SY77rhjOnXqlHnz5uXll19OkvTq1SvXXHNNDjnkkJY7yFaoOet9Q+66665cdNFF6dOnT8aNG1eMQyk7zVn3M2bMyMc+9rEkb7XtnXbaKR07dszs2bOzcOHCJMmgQYNy3XXXZauttmqxY2xtxJnSEGdKR5xpOXPnzq2vn+StJzAsXbo0nTp1SnV1df30008/PUOHDq3/e/ny5fn85z+fqVOnpmPHjtljjz2ydOnS+keWnnrqqbngggvWu8/Jkyfn9NNPz9KlS9OzZ8/07ds3c+bMycKFC9OtW7fcfPPNHmu7BZpyTi+//PLceuutqaioSJ8+fbLNNttk+fLleeGFF7Jy5cp06NAhX/3qV9dpA2tzTmmN9IXNt/YXp3369FnnNWPvdNlll63z1M32Oi40tc7ae1srV029btrYPUJTrn1hczR3222L91juQSlHxiWKqVT3BrClNvZZu/ZJsWh7bMj48eNzxhlnZPny5enZs2f69euXuXPnZt68eamoqMj555+f0047bZ11itVmOjXLEVFyn//851NTU5Of/exn+etf/5rXX389O+20Uz7ykY9k2LBh6d69+yZva8CAAbn00kszadKk/P3vf8+sWbOyfPnyVFdXZ//9989hhx2Wk046yU1Amrfe2TzNWfd9+vTJueeem2nTpuX555/PrFmzUltbm549e+bwww/Pcccdl+OOOy4dO3Ys4hG1fuJMaYgzpSPOtJxVq1ZlwYIFDaavXLlynenLly9fZ36XLl1y6623ZtSoURkzZkxmzpyZysrKHHjggRkyZEj9q2jWZ//998+9996bkSNH5i9/+Uv+/ve/Z+utt84JJ5yQM888c5N+mUfjmnJOjz322BQKhTz11FN5+eWX88wzz6Rjx47p27dvDjzwwHz605/Ov//7vze6T+eU1khf2HxrfsmVJC+99FJeeumlRpddtGjROn+313GhqXXW3ttauWrqddPGuO+g2Jq77bbFeyz3oJQj4xLFVKp7Ayg27ZNS0fbat/e97325995785Of/CTjx4/PjBkzUl1dnSOOOCKnnHJKDjzwwAbrFKvNeJISAAAAAAAAAABQVB1KXQAAAAAAAAAAAKBtk6QEAAAAAAAAAAAUlSQlAAAAAAAAAACgqCQpAQAAAAAAAAAARSVJCQAAAAAAAAAAKCpJSgAAAAAAAAAAQFFJUgIAAAAAAAAAAIpKkhIAAAAAAAAAAFBUkpQAAAAAAAAAAICikqQEAAAAAAAAAAAUlSQlSuKII45ITU1N7rrrriZvo7a2NkceeWT22muvzJ07txlLV/6WLFmSgw8+OAcccEDeeOONJm/nrrvuSk1NzTr//vM//7MZS1o+Jk6cWF8Hze2Pf/xjg3o+4ogjmn0/rJ941JAY0vzaSgy58MILU1NTkwsvvLDZt90cfbE1Ou+881JTU5P777+/1EVpdU477bTU1NTksccea/I25syZ06D919TU5M0332zGkpa34cOHp6amJieffHKzb/vyyy9vUPfFiA/rIx5tvrYWj8SQltFWYwjl6bHHHktNTU2GDh1a6qK0Or/97W9TU1OTr3/961u0nZNPPrlBv7zllluap5Ab8eabb643Js+ZM6dF9g+bo63FIzEEACiV9Y3fM2bMKMq+fA7RenQqdQHaskKhkAceeCD33Xdfnn766bz++uvp2LFjtt122/Tu3Tv77LNPBg0alEMOOSTV1dWlLm7Z+cUvfpF//vOf+fSnP50dd9xxnXlz5szJ4MGDkyRXXHFFTjjhhFIUsWS6d++eU089NT/4wQ/y4x//OJdeeukWba9Dhw7ZZpttkiRbb711cxRxi8yYMSMf+9jHssMOO+SRRx4pdXG2WFVVVbbbbrskyfLly7N48eJm34d4VFwbikfvNGnSpNx33315/PHHM2/evKxYsSLbbLNNampq8sEPfjAnnHBCunTp0kIlXz8xpLy0RAxpzdYe8zfmndcEF154Ye6+++706dMn48aNa/ayPfXUU7nvvvvSv3//HHPMMQ3mH3HEEXnppZfyX//1X7nyyiubff+t3TnnnJNHH3003//+9/Ob3/wmHTps2e8ntt5663Ts2DFJtnhb79Rc15a33HJLrrjiinz0ox/NVVdd1ZxFLInq6ur6+LNo0aKsWLGixCUqrXKOR2ubNWtW7rzzzowfPz4vvfRSFi9enJ49e2bnnXfOoYcemk984hPZfvvtm72Mm0sMEUPK2apVq/Lggw/m4YcfzpNPPpnXX389y5cvT48ePbLrrrtm0KBBOf7449O/f/9SF7XVWL16df310jnnnNNg/vDhwzNixIgkybPPPtuiZWsNjjnmmIwcOTJjxozJ5z73ubznPe/Zou1169Yt3bp1q///O60Zt9b41a9+lX333XeD2zzuuOPy3HPP1f/9zvPUoUOH+piwatWqLfrBDJtOPNp8G4tHa1uxYkXuvvvuPPzww3nmmWcyf/78VFZW5l3velcGDRqUY489NgcffHBLFHuDxBBoW97Zx5Lks5/9bC655JIWLcd//ud/5plnnllnWnv8vo72ZUt+pNze+0fPnj1TWVmZJOnUacMpLK+//np+85vf5LHHHsvzzz+fBQsW1H8PNWDAgHzgAx/IMccck6222mqd9drz5xCtjSSlInnzzTdz1llnZdKkSfXTOnXqlK5du2bu3Ln55z//mSlTptR/yNieg05TLFiwINddd12qqqryhS98odTFaZU+85nP5Gc/+1lGjx6dk08+Of369WvytnbccceifFnSVGPHjk2STf4SqDl07do17373u4uy7cMPPzx/+ctfkrz15JmLLrqoWbcvHhXXpsajN954IxdccME6STFVVVXp3LlzXnnllbzyyit55JFHcv311+eKK67I+9///pYofqPEkOZVzjFkbb1798673/3u9O7du2j72BLV1dUbTPJr6QTA733veykUCjnrrLNSUVHRovsuB/vuu28OPfTQPProoxkzZkw+9rGPbdH27rzzzvTt27d5ClckpYg/W2+9dd797ndvNIm2Kb785S/ny1/+cpL1fwhaTOLR5tmUeLRq1apcffXVufXWW7Ny5cokSceOHVNdXZ358+fntddey9SpU3PjjTfmnHPOyWmnndaSh9CAGNIy2moMKaVp06blggsuyMyZM+unVVZWpnv37lmwYEGmTJmSKVOm5Kc//WmOOuqo/OAHP0hVVVXpCtxK3H333XnmmWfywQ9+MPvss0+pi9PqdOjQIWeeeWa++tWv5nvf+15uvfXWLdreqaeeutHki7XdddddG0wwePLJJ9dJLlif6urq+vuazUn8penEo6bZ1Hj0l7/8JRdffHFeeeWV+mnV1dWpra3NCy+8kBdeeCF33HFHDj/88Hz/+98v6Y+6xBDYsLWToddWWVmZXr16paamJh/5yEfysY99rP4L9nfa3B8unHzyyZk0aVIOPPDA/PznP29SuTt37pwePXokyXp/mFxXV5ef/OQnuffeezN37tz06tUrH/zgB/OVr3wl22677QbL9sQTT+RXv/rVBuPg1ltvXZ8MMH/+/KxevbpJxwHlZE2bf6elS5dm6dKlG1ym1D9eL7Xhw4fnoIMO2uAyhUIhP/nJT3L99ddn2bJl9dO7deuWioqKvPTSS3nppZcyduzYXHXVVbnwwgvz8Y9/vH659vo5RGskSalIvv71r2fSpEnp2LFjPve5z+Wkk07KLrvskg4dOmTlypX5xz/+kT//+c+57777Sl3UsnTHHXfkzTffzNFHH50ddtih1MVplbp3757jjz8+t956a0aNGpVvfOMbpS5SsynFh/P77LNPHnjggRbbX3MSj4prU+LRa6+9lk9/+tOZNWtWOnbsmE9/+tP55Cc/md133z3JW4lkf/zjH3Pttddm7ty5GTZsWK666qr8x3/8R0seyjrEkOZVzjFkbV/72tfyta99rdTFaNQll1zSahItp02blscffzzbbbddPvzhD5e6OK3Wpz71qTz66KO58cYbtzjBoLVb80VPVVVVDj300Bbb75AhQzJkyJAW219LEY823abEo9WrV+ecc86pHyMPO+ywfOELX8h+++2Xjh07pra2NhMnTsyPf/zjTJ06Nd///vczd+7cLX7a4pYSQ4qvrcaQUhk3bly+/OUvp7a2Nr169cppp52Wo446KrvuumuSt5IFn3766fz+97/P7bffnt///vdZvny5pIAkN954Y5K3+j3rd/TRR2ebbbbJxIkT89RTT2Xvvfcu+j532mmnzJ07N7/97W9z8cUXN/rFym9+85skSZ8+ffLSSy8VvVxsnHjUdJsSj+6///6cf/75WblyZbbffvucc845Oeqoo9KzZ88kyfPPP59f/epXue222/KnP/0pJ510Un75y19uMCmg2MQQ2DRrJxcsWbIk8+bNy7x58/Loo49m9OjR+dnPflbf10vtP/7jPzb45O6vfOUr+cMf/pDkrS/4582bl1//+teZNGlSfvOb39QnOK3tN7/5TSZNmpSTTz55o4nja7/ucc3TxKGtW5Mw+05rJzs2tgwbVigUcv755+f//u//kiTvfe97c/rpp+fggw+uf2LS4sWLM2HChNx555156KGHMm7cuHWSlGg9mvcZ5iRJZs6cmYceeihJcu655+aCCy7IrrvuWv/I+E6dOmXAgAEZOnRo7r333pJ+CV2OCoVC7rjjjiTJRz/60RKXpnVbUz9jxoxZJ6O0nM2dOzdPP/10evTokQMPPLDUxWn1xKPi2pR4VCgU8rWvfS2zZs1KZWVlRowYkUsvvbQ+QSlJttpqq5xwwgm5++67M2DAgKxcuTKXXHJJnn/++RY5jsaIIdB0o0ePTpIce+yx9a8PoqHDDz88vXr1ynPPPZfJkyeXujhF9fDDD2flypU5+OCDvVqVFrUp8WjkyJH1CUrDhg3LjTfemAMOOKB++aqqqhx22GG5/fbb65OBfv7zn+eee+4pevk3RAyhnMycOTPnn39+amtrs/vuu+fee+/NsGHD6hMCkreeXrb33nvna1/7WsaOHespEP+/iRMn5oUXXsg222zTokl65aZTp071r/T81a9+1SL77Nu3bw444IAsXrw4Dz744HqXWb58ee6///5UVFS0+YTSciEeNd2mxKPnn38+l1xySVauXJn+/fvnnnvuySc+8Yl1khb+7d/+LRdffHFGjhyZysrKzJo1q+QJ+GIIbJq//OUv9f+mTZuWhx56KCeeeGKS5G9/+1u++93vlriEm+axxx7LH/7wh1RXV+e2227L1KlT88c//jF77LFHZs2atU6C0Rrz58/P97///eywww4599xzW7zMQPt2ww031Ccofe5zn8uvfvWrHHXUUeu80q26ujpHHnlkrr/++vziF7/woJNWTJJSEcyYMaP+/5tyA7e+XwgcccQRqampyV133ZXFixfnBz/4QY4++ujss88+Oeigg3LmmWfmySef3Oi2n3766Vx00UU58sgj8973vjcDBw7MRz/60fzwhz/M/PnzN7juihUrcuutt2bIkCE56KCDstdee+X9739/zjzzzPzpT3/a4LrLly/PyJEj8x//8R/ZZ599csghh2To0KF57LHHNlrmjRk/fnz++c9/Zquttsrhhx/epG2sXb+1tbW58cYb89GPfjT77rtv9t9//3z2s5/d6DGuWrUqd955Zz772c/W189hhx2WL33pS5k4cWKj65188smpqanJ8OHDU1dXl5/97Gc54YQTMmjQoNTU1Kyzbl1dXW655Zb853/+Z/bdd98ceOCBOfnkk+ufxrH2ttZn7733zq677ppFixbl/vvvb0JNbZrhw4enpqYmJ598cpK3nlLyuc99LgcddFD222+/fPKTn8wf//jHdda555578slPfjIHHHBABg4cmM985jOb1D7WbOfwww9f59Gpa9fFqlWrcsstt+RjH/tYBg4cmEMOOSRnnnnmOu8/XrZsWUaOHJnjjjsu++67bw466KCce+65mT179nr3O3HixNTU1Kz3fbJ33XVXampqcsQRRyR562bky1/+cg499NDstddeGTx4cK644oosXLhwo8fX3MSj0sejhx56KBMmTEiSfPGLX6xvJ+uz9dZb50c/+lE6d+6cpUuX5kc/+lGDZcSQt4ghpXHhhRempqYmF154YYN5a9fhmgS+T3ziE9lvv/0ycODAnHTSSbn33nubvO/rr78+NTU1+fd///f88pe/3JLDKLrFixfnd7/7XZLkuOOOa9I23lnXDzzwQE4++eQceOCBee9735v//M//zKhRozb6qOyJEyfmS1/6Ug477LDstddeOeigg/K5z30uv/nNb7Jq1ar1rvPOPvnggw/m1FNPzSGHHJIBAwY0iBl//OMf87nPfS6DBg2qj+033HBD6urqGmzrnaqqqnLUUUclSX3SZzG8s5+tz5w5c+r76Zw5c5q9DI09xW3NPidOnJg33ngjV1xxRY488sjss88++dCHPpT/9//+3zrj5EsvvZRvfvObOeKII7L33nvngx/8YK688sosXrx4vfvd0DlornZWCuLRptmUeDR//vzccMMNSZKDDjooX/3qVxvdXocOHfKd73wn//Zv/5Yk+d///d/U1taus4wYIoaUQwwphWuuuSaLFy9O586dM2LEiI1+WNqrV6+MHDlyvb9gnzdvXr73ve/l2GOPzcCBA7Pvvvvm2GOPzfe///289tpr693eO9voa6+9lu9+97v1beF973tfvvKVr2z0hxKrV6/O/fffnzPPPLP++uLggw/OCSeckKuvvjp///vfN71SNtGa/v2Rj3wknTpt/kPh33ktPmvWrFx00UX5wAc+kL322iuHH354Lr300rz66qsb3E5z1Pvs2bNz2WWX5Ygjjshee+3VIK689NJLufjii3P44YfXl+2iiy7KrFmzNinOrIn1v/3tb7NkyZLNraomWfPkwLvuumu98x988MEsWrQoBx54YKt/rWZ7IR413abEo2uuuSZLly5NVVVVfvSjH2WbbbZpdHsf+MAHcsYZZyR5K2Hg4YcfXme+GCKG0PrttNNO+c53vpNDDjkkSfK73/2uxfrPlhg/fnyS5MQTT8ygQYOSJDvvvHO+8pWvJFn/k16uuOKKLFiwIJdddpkfTkARLFq0KNddd10+8YlP5IADDshee+2VD3zgA/nqV7+aadOmrXedd47vL730Ui699NJ88IMfzN57750jjzwyP/zhD+tfNZckf//733PeeeflAx/4QPbee+8cddRRGTlyZOrq6ta7j7U/Z6utrc1Pf/rTHH/88dl3331zwAEH5JRTTskjjzxSjCqpN3/+/Fx33XVJkkMOOSQXXXRRKioqNrjOAQccUPIngNM4r3srsldeeaX+A9ymePPNN/Pxj388L774YiorK9O5c+csWLAgY8eOzUMPPZTvfOc7jT6m7Nprr83IkSNTKBSSJF27dk1dXV2effbZPPvss/nNb36Tn/70p9lzzz0brDtz5sx84QtfqH8neUVFRaqrq/Paa69l7NixGTt2bD71qU/lW9/6VoN1FyxYkFNOOSVPP/10krd+hbFy5cr86U9/yp///OctfmXQn//85yRvvTpnSx8xvHTp0gwZMiRPPvlkKisrU1lZmcWLF2fixImZNGlSvvvd7663fhctWpQzzzwzkyZNSvLWr4u6d++eefPm5cEHH6z/IP6CCy5odN8rVqzIySefnKlTp6ZTp07p3r17g7INGzYsjz/+eP0+qqqq8vjjj2fSpEkZNmzYJh3joEGDMnPmzPz5z3/Of//3f29q1TTZtddemx//+Mfp0KFDunfvniVLlmTq1Kk566yz8q1vfSuf/OQnc9FFF+Xuu+9Op06d6pMxnnjiiZx22mkZOXJkPvjBDza6/TUfzh955JHrnb9y5cqcfvrpGT9+fP05nT9/fsaOHZvHHnsst956a/r27ZtTTz01Tz/9dDp37pyKioosWLAgv/vd7zJp0qTceeed2WmnnZp0/P/3f/+Xiy66KHV1denRo0dWrVqVOXPm5JZbbslf/vKX/OpXv2pwrluKeFSaeHT77bcneev1aaeccspGt7nrrrvm2GOPzV133ZU//OEPmTdvXnr37t1gOTFEDGmtVq1albPOOitjx45Np06d0qVLlyxZsiTTpk3LtGnTMmvWrHzpS1/a5O2tXr063/3ud3Pbbbelc+fO+cEPftDqX582adKkLF++PN26dct73vOeLd7e//t//y+33XZbOnTokOrq6ixfvjzPPPNM/ud//idPP/10vve97613vSuuuKL+l28VFRXp0aNHFi1alAkTJmTChAkZM2ZMfvzjH2/wg6Urr7wyN998cyoqKrLVVlvVP4lvje9973v52c9+Vv/3Vlttleeffz5XX311Hnnkkey///4bPb5BgwbljjvuqI+pbdGKFSvy6KOPpqKiotEkh7lz5+brX/96XnnllXTr1i2rV6/Oyy+/nNtuuy0TJkzI6NGjM3PmzAwbNixvvPFGqqurs3r16sydOzc333xznnzyyfziF79o8pO7mtrOWjPxaNPi0Z133pnly5cnSc4+++yNfshTVVWVYcOG5YILLsirr76aP/7xj40+jVMMaR5iSPl77bXX6p8Qcfzxx+fd7373Jq/7zj45adKknHXWWXnzzTeTvHVvVVFRkX/84x/5xz/+kTvvvDMjR46s/7Jpff7xj3/k4osvzuuvv56uXbsmSV5//fXcf//9+dOf/pTbbrstAwYMaLDe/Pnz86Uvfan+HiNJevTokcWLF2f69OmZPn16XnjhhYwcOXKTj29jCoVCHn300STZ4DFtqgkTJuSMM87I0qVL07179xQKhbz66qv59a9/nUceeSR33nlntt9++wbrNUe9T506Nd/4xjeydOnSdO3adZ0fTayZf9ppp9V/sdmlS5csWrQod911V37/+99v0pMZ9t5773XukT7wgQ9sTvU0ydFHH53vfOc7mThxYubMmdMgiWBN4sEJJ5wgabEVEI+ablPi0b/+9a/6H0cdd9xx2W233Ta63c9//vO56aabsmTJktx2222NfqYhhoghtG6HHnpoHnvssdTV1WXWrFnr/Zy7NXnjjTeSJLvssss609c8Ve+dPyoeP358xowZkw9/+MONfq4KNN2TTz6ZM888sz7Ju2PHjunSpUteeeWV/Pa3v83999+fr3zlK/nCF77Q6DaefvrpXHLJJXnzzTdTXV2dVatW5Z///Geuv/76PPHEE/XfMZx77rlZtmxZevToUR+zfvSjH+W5557LD3/4w0a3X1dXl1NOOSVPPPFEOnXqlG7duuXNN9/M+PHjM378+Jx99tk555xzmr1ukreuB9YkWm3KZ1drvPNzKFoPZ6YI9t577/rOceWVV+bFF19s8rZGjBiR+fPn55prrsm0adMyefLk3H///TnwwAOzevXqfPOb38z06dMbrHfLLbfkxz/+cbp165avfe1refTRRzNt2rQ8+eST+c1vfpODDz448+bNyxlnnNEgq/vNN9/MqaeempkzZ+bggw/Obbfdlr/+9a954okn8sQTT+Siiy5Kt27d8stf/jKjRo1qsO9LL700Tz/9dKqqqvLtb387U6ZMyeOPP55x48blyCOPzP/8z/9s9KkpG7Lm5nNj77vdFNdee21eeeWV/PjHP87UqVMzderU/O53v8u+++6bQqGQyy+/PIsWLWqw3iWXXJJJkyalsrIyl156aSZPnpzHH398nS/xf/azn23wl9W33XZbnn322VxxxRWZPHlyJk2alAkTJtT/su/KK6/M448/ng4dOuS8886rTywYP358Tj755Pz0pz9d56kejXnve9+bJOvctBfLjBkzcv311+fcc8/NpEmT8sQTT+RPf/pT/eOPr7rqqgwfPjz3339/vv3tb+eJJ57IlClT8uCDD2avvfbKqlWr8u1vf7vRG84333wzTzzxRCorKxt9as3tt9+ep59+Oj/60Y8yderUTJkyJb/+9a+z8847Z+nSpbn88stz2WWXZeHChbnpppsybdq0TJ06Nbfccku22WabvP766/nf//3fJh3//Pnzc/HFF+djH/tYHn744frj+8Y3vpHKyso899xz9e+tbyniUWnj0cqVK+tfPXLooYducnLJmicCrF69utG+K4aIIa3V7bffnkmTJuXKK6/M5MmTM3ny5DzyyCP50Ic+lCS57rrr6pMON6a2tjbnnntubrvttmy11Va56aabWn1CQPJ2f9lzzz23+FVv48aNyx133JGL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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "fig= sns.pairplot(df, height= 3)\n", - "\n", - "for i, j in zip(*np.triu_indices_from(fig.axes, 1)):\n", - " fig.axes[i, j].set_visible(False)" - ] - }, - { - "cell_type": "code", - "execution_count": 31, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAB60AAAS3CAYAAAC9lu8uAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOzde1zM2f8H8NeUEkqlC6VcorIVCUWRXHNZFkVYK3frltu6X1rXrVhyCRtiRSwrWsq6Ze26LMVmicjuupU21ZKKLmp+f/RrvkalmZqaZno9H499rDnnfD6f90wzZz7zeZ/POQKhUCgEERERERERERERERERERGRHKjIOwAiIiIiIiIiIiIiIiIiIqq5mLQmIiIiIiIiIiIiIiIiIiK5YdKaiIiIiIiIiIiIiIiIiIjkhklrIiIiIiIiIiIiIiIiIiKSGyatiYiIiIiIiIiIiIiIiIhIbpi0JiIiIiIiIiIiIiIiIiIiuWHSmoiIiIiIiIiIiIiIiIiI5IZJayIiIiIiIiIiIiIiIiIikhsmrYmIiIiIiIiIiIiIiIiISG6YtCYiIiIiIqJKc+zYMVhaWuLYsWPyDqVM33//Pfr164c2bdooTMxU+bZu3QpLS0tcv35d3qEQEREREREprVryDoCIiIiIiKg6evv2Lfbt24czZ87g8ePHePfuHXR1dWFiYoL27dtj2LBhaNKkSaXHcf36dXh6emLGjBnw8vIqVr9o0SIcP34ckZGRMDExqfR45MXS0hIODg7Yv39/pez/5MmT8PHxgbW1NcaOHQs1NTV88sknlXKs0vTo0QOJiYml1gcHB6Njx46VHkdlv9aVISEhAT179hQrU1NTg56eHjp06IBJkyahVatWcoqOiIiIiIiIysKkNRERERER0QcyMzPx+eef48GDB2jatCkGDhyI+vXrIykpCX/99Rd27tyJJk2aVEnSmqrGr7/+CgAIDAyEgYGB3OJQVVXF1KlTS6xr3LhxFUejeJo0aYLPPvsMAPDmzRvcunUL4eHhOHv2LPbt24d27dpJvc9Ro0ahf//+MDY2lnW4RERERERE9P+YtCYiIiIiIvrAvn378ODBAwwdOhRr1qyBQCAQq3/27Blyc3PlFB1VhhcvXgCAXBPWQGHSuqQ76kkyTZo0Kfb6+fv747vvvoO/v3+57h5v0KABGjRoIKsQiYiIiIiIqARc05qIiIiIiOgDt27dAgB88cUXxRLWAGBqaooWLVoUK09LS4Ofnx/69OmD1q1bw8HBAR4eHtizZ49Yu6NHj2Lq1Kno0aOHqN2ECRNw7do1sXZbt26Fp6cnACAgIACWlpai/xISEtCjRw8cP34cANCzZ09R3ejRo8X28+zZMyxduhTdunWDjY0NunTpgkWLFpU4FXXR9snJyVi0aBE6d+6MVq1aidbz7dGjB3r06IH09HQsW7YMTk5OaNOmDYYOHYrIyEgJX+FCf/zxByZPngwHBwe0bt0affv2xdatW/H27VtRm+vXr8PS0hIAEBUVJfYaFK05XVBQgB9//BFDhw6Fg4MDbG1t0aNHD0ybNg3R0dEfjaFoze2i51e07x49eoi1O378ODw8PGBnZwc7Ozt4eHiIXvv3FcW7detWxMTEYMKECejQoYPoOchKWloavvnmG/Tu3Rs2Njbo2LEjvLy8EB8fX6zttWvXsHjxYvTp00cUv5ubGw4fPlxi7EDpr/XH1ncuaf3yhIQEWFpaYtGiRfj7778xY8YMdOzYUfQeLnL+/HmMGTMG9vb2aN26NQYMGICgoCDk5+dX+LUq+jzExsYCAJKTk7FlyxZ4eHjA0dERNjY26NGjB1asWIG0tLRi25f0nCV5Xnfv3sXMmTNFnzsnJycMHz4cO3furPBzIiIiIiIiUja805qIiIiIiOgD2traAIAnT55IvK7x48eP4enpieTkZLRv3x69evXC27dv8fDhQ3z33XcYP368qO2qVavQqlUrODo6okGDBkhOTsb58+cxbtw4bN26Fb169QIAODg4YMiQITh+/DgcHBzg4OAg2kf9+vXh6emJ48eP4/79+/D09ET9+vUBiE8j/eeff2LChAl4+/YtunfvjiZNmiAxMREnT57Eb7/9hsOHD8PU1FTsubx69QrDhw+HtrY2+vXrh7y8PGhqaorqc3NzMW7cOGRnZ2Pw4MHIyMhAREQEpk+fjnXr1ommZ/6YM2fOYO7cuVBTU0O/fv2gp6eHq1evIiAgAFeuXEFwcDDU1dXRuHFjzJgxAwEBAWjcuDGGDBki2kfR32bDhg3YvXs3mjRpggEDBqBevXpITk7GjRs3cO3aNdjb25caxyeffIIZM2bg+PHjSExMxIwZMwAAWlpaojbffPMN9u3bh4YNG8Ld3R0CgQBnz57FokWLcP/+fSxevLjYfmNiYhAYGIiOHTvCw8MDSUlJZb4mknr69KloYEHnzp3Rq1cvpKWl4ezZs7h8+TK+//572Nraitrv2rULT58+ha2tLRo1aoTXr1/j8uXL8Pb2xqNHj7Bo0SIAkOi1Lq8nT55g+PDhaNmyJYYMGYL09HSoqakBADZu3IjAwEA0atQIrq6u0NTURHR0NNatW4c///wTW7ZsqdCxPxx4cuPGDezduxedOnVCmzZtoKamhnv37uHQoUO4fPkyjh8/Lvb3L8/ziouLw4gRI6CqqoqePXvC2NgYr1+/xl9//YUjR45g8uTJFXpOREREREREyoZJayIiIiIiog/07dsXJ0+exNKlSxEbG4vOnTvDyspKlMwuyfz585GcnIzVq1fDw8NDrO7ff/8VexwREVEsUfzixQu4u7tj/fr1oqR1x44dAUCUtP5w2uOxY8fi/v37uH//PsaMGQMTExOx+ry8PMyZMwcFBQUIDQ1Fq1atRHU3btyAp6cn1q5di++++05su/j4eLi5uWHNmjVQVVUt9lxTUlLQokULHD58WJR4HDt2LNzc3LB69Wr06NFDLMn9oczMTCxbtgwqKir44YcfRHEJhULMmzcP4eHh2L17N6ZNmwYTExN4eXmJEqklTZ199OhRNGzYECdOnECdOnVE5UKhEOnp6aXGARQmYz/55BNERUUhMTGx2P5v3LiBffv2iZ5vUTJz5syZ8PDwwPfff4/evXujQ4cOYttduXIFa9euxdChQz96/A/l5+dj69atxcrNzMzw6aefAgAWLFiA1NRUBAUFoXPnzqI2U6dOhbu7O5YtW4aTJ0+KylesWFHs/fbu3TtMnjwZwcHB8PT0hLGxsUSvdXn98ccfmDZtGmbNmiVWfuXKFQQGBqJr167YsmWL6O8nFAqxYsUK/PDDDzhz5gz69OlT7mMHBwcDAGxsbAAAnTp1wuXLl1GvXj2xdmFhYVi4cCEOHDhQ6rrikj6vvXv3Ijc3F9u3b0fPnj3F6l6+fFnep0JERERERKS0OD04ERERERHRB3r16oX58+ejoKAAu3btwtixY+Hg4IDevXtj1apVePz4sVj727dv4/bt27C3ty+WsAaARo0aiT3+MIEIAIaGhujTpw8eP35c4rTd5XHx4kUkJiZi4sSJYglrAOjQoQN69uyJX3/9FZmZmWJ1ampqmD9/fokJ6yKzZs0SJawBoEWLFnB3d8fr16/LnCb8/PnzeP36Ndzd3cXiEggEmDdvHmrVqlXi1Nsfo6amVixegUAAHR0dqfbzoaKprmfMmCF2962WlpboruySYrWyspI6YQ0UJq0DAgKK/RcREQEAuHfvHmJiYjB48GCxhDUANG/eHB4eHoiPjxebJryk91utWrUwYsQI5OfnlzjVt6wZGBiUmAg+cOAAgMLZB94fcFD0XhAIBKLnLomnT59i69at2Lp1K/z8/DBy5Eh89913qF27NubOnQsA0NPTK5awBoBBgwZBU1MTV69erfDzKqKhoVGsTFdXV+L9ExERERER1RS805qIiIiIiKgEEydOxIgRI3Dp0iXExMQgNjYWt2/fRkhICI4ePQp/f3/RHZR37twBgGJJxNI8e/YMgYGBuHbtGpKTk5GbmytW/+LFC7EpvsuraG3uf/75p8S7d1NSUlBQUIBHjx6hdevWonITExM0aNCg1P2qqamhbdu2xco7dOiAkJAQ3L9/H4MGDSp1+7i4OAAQm+68iJGREUxNTfHo0SNkZmZ+9I7tIn379sUPP/yAgQMHol+/fnBwcEDbtm1Rt27dMrctS1GsRXe9v68o/qI273v/9ZSGurq66P1UkqK/aWpqaol/03/++Uf0fwsLCwCFd7bv2bMH58+fx7Nnz/DmzRuxbV68eFGuWKVhaWkJdXX1YuV//vkn6tati6NHj5a4nYaGhug5SeLp06cICAgAUPg+1dPTw4ABAzB58mSxdcXPnj2Lw4cP4+7du3j9+rXY2tnSvB6lPa8+ffpg3759mD59Ovr16wcnJye0b98exsbGEu+biIiIiIioJmHSmoiIiIiIqBSampro168f+vXrBwDIyMjAxo0bcfDgQSxduhTOzs5QV1fH69evAQANGzYsc59PnjzBsGHDkJmZiY4dO6J79+7Q1NSEiooKoqKiEBUVVSyJXV5FU2O/P1V0Sd6+fSv2WF9f/6PtdXR0oKJSfOIuPT09AIWv08cU3dld2nEMDAzw6NEjZGVlSZS0XrZsGUxNTXH8+HHs2LEDO3bsQO3atdGvXz8sXLjwown4smRmZkJFRaXEfejr60NFRaXYnepFdZWh6G968eJFXLx4sdR2RX/T3NxceHp64u7du7CyssJnn30GHR0d1KpVC4mJiTh+/LjM3m8fU9rrkZ6ejnfv3okSzSX5MMn+MV26dEFQUNBH2+zZswd+fn5o0KABOnfujEaNGonuiN63bx/y8vIkPl5pz8vOzg779u1DYGAgwsPDRXfsW1tbY8GCBejUqZPExyAiIiIiIqoJmLQmIiIiIiKSkJaWFry9vfHrr78iMTER8fHxsLGxQf369QEAycnJZe7j+++/R3p6OtavX4/PPvtMrM7b2xtRUVEyi7co4fvdd9+he/fuEm8nEAg+Wv/q1SsUFBQUS1ynpaUBgNg02h+LKzU1tcT6ovKSpnAuiZqaGiZOnIiJEyciOTkZ0dHROHbsGMLCwkRrP5eXpqYmCgoK8N9//4mS8kXS0tJQUFBQYmK9rNewIvEAwPLly/HFF1+U2T4yMhJ3797FsGHDsGbNGrG6iIgIqadhL3pe79+ZXORjgxVKez2Knk9VTFEOFK7lvX37dhgaGuKnn34SG4wgFAqxe/duqfb3sb+zg4MDHBwckJ2djT///BO//PILDh48iC+//BInT55EkyZNyv08iIiIiIiIlA3XtCYiIiIiIpKCQCAotk5t0VTQV65cKXP7p0+fAgB69OghVl5QUICYmJhi7YvWaS4pSQhAlDguKCgoVtemTRsA/5tSWlby8vJK3OeNGzcAoNj62R/65JNPAKDEBH1ycjKePXsGU1NTsWSwiopKqa/B+xo2bIgBAwZg9+7daNasGa5evYrs7Owytysr1pKSqkXxl/V8ZcnW1hYASnyvlOTZs2cAir/fgP/9vT70sddaW1sbQMkDNEqaJr0sbdq0watXr4qtE19ZXr58iYyMDLRt27bY3fN37typ0HulNBoaGujYsSMWLVqEL7/8EtnZ2VKtm01ERERERFQTMGlNRERERET0gR9++AG3b98use7s2bP4559/UL9+fdGawW3atEGbNm0QHR2NI0eOFNvm/QRf0VrVN2/eFGuza9cuxMfHF9v2Y0nC9+v//fffYnW9evWCsbEx9u7di+jo6GL1eXl5pSYuy7J582axaZT//vtvhIaGQktLS7TWd2l69eoFLS0tHDt2DA8fPhSVC4VCbNiwAXl5eRgyZIjYNtra2iU+x9zcXPz+++8QCoVi5W/evEFWVhZq1apV4lTmkiqKY9u2bWLTgGdmZmLbtm1ibapCmzZtYGtri4iICJw6dapYfUFBgdhggKI1lD98v0VFReHHH38s8RilvdYAYGNjAwAICwsTGygRExNT5jT0JRk9ejQAYMmSJXj58mWx+pSUFPz9999S77c0enp60NDQwN27d8WmxU9PTy92J3pF3Lhxo8Rp44tmI/hw4AsREREREVFNx+nBiYiIiIiIPvDbb7/h66+/RtOmTdGuXTsYGhrizZs3iIuLw40bN6CiooKvv/4a6urqom3Wr1+P0aNHY/ny5fjpp5/Qtm1b5OTk4K+//kJcXJzoTt0RI0bg2LFj8PLyQv/+/aGjo4Nbt27h3r176NatW7F1is3MzGBoaIiIiAhoaGigUaNGEAgEGDlyJLS0tNCpUyfs2bMH3t7e6Nu3L+rWrQsjIyMMHDgQ6urq2Lx5MyZNmoQvvvgCjo6OMDc3BwAkJSXhxo0b0NHRwenTp6V6fQwMDJCRkYHBgwfDxcUFmZmZCA8PR05ODlavXl3mOtSamppYvXo1vvrqK3h4eKBfv35o0KABfv/9d8TGxqJNmzaYOHGi2DadOnXCzz//jJkzZ+KTTz6BqqoqXFxcYGRkhLFjx8LU1BS2trYwMjLCmzdvcPHiRaSkpGDSpElifydp2dvbY/To0di/fz8GDBgAV1dXCIVCnDt3DklJSRg9ejTs7e3Lvf/y2LBhA8aMGYM5c+Zg3759sLa2Ru3atfH8+XPcunUL//33H+7cuQMA6N69Oxo3bozdu3fj4cOHMDc3x6NHj3Dx4kX06tULZ86cKbb/0l5rS0tLtG3bFnZ2drh27RqGDx+ODh064Pnz57hw4QK6d++Oc+fOSfVcunbtimnTpmH79u1wdXWFs7MzjI2N8erVKzx58gQ3b97E7Nmz0aJFC5m8dioqKvj888+xZ88eDBo0CN27d0dmZiZ+++03NG7cGIaGhjI5zp49e3D16lV07NgRpqamUFdXx7179/D777+jadOm6NWrl0yOQ0REREREpCyYtCYiIiIiIvrAvHnz0K5dO1y9ehXR0dFISUkBUDj19JAhQ/DFF1+I7jgt0qxZMxw/fhyBgYH45ZdfsG/fPtSrVw9NmzbF1KlTRe2srKwQFBSETZs24ezZs1BVVYWdnR0OHTqECxcuFEtaq6qqIiAgAN9++y1++uknZGVlAQD69+8PLS0tuLi4YP78+fjxxx+xe/du5OXlwcHBAQMHDgRQeGfuiRMnsHv3bvz222+4efMm1NXV0bBhQ/Tq1Quffvqp1K+Puro69uzZg2+//RZhYWHIyMiAhYUFpk2bVuZd1kX69esHAwMDBAYG4ty5c3j79i0aN26MadOmYdKkSahdu7ZY+6VLlwIArl27hnPnzqGgoAD6+vowMzPDvHnzcO3aNdy4cQNpaWnQ1tYWlffv31/q5/ehZcuW4ZNPPsGhQ4dEd9K3bNkSXl5ecHd3r/D+pWVqaorjx49j7969iIyMRGhoKFRUVGBoaIgOHTqgb9++orb16tXDvn37sH79ekRHRyMqKgotW7bEt99+Cz09vRKT1qW91paWlhAIBNi+fTt8fX3x66+/Ij4+Hq1atcKOHTvw4sULqZPWADBr1izY29sjODgYv//+OzIyMqCjowMTExPMmDFD9F6Wlblz50JbWxvHjx/HwYMHoa+vj08//RReXl4yO1bRoJI///wT0dHREAqFMDY2xtSpUzFmzJgyB3YQERERERHVNALhh3OoEREREREREZWiaG3kCxcuyDkSIiIiIiIiIlIWXNOaiIiIiIiIiIiIiIiIiIjkhklrIiIiIiIiIiIiIiIiIiKSGyatiYiIiIiIiIiIiIiIiIhIbrimNRERERERERERERERERERyQ3vtCYiIiIiIiIiIiIiIiIiIrlh0pqIiIiIiIiIiIiIiIiIiOSGSWsiIiIiIiIiIiIiIiIiIpIbJq2JiIiIiIiIiIiIiIiIiEhumLQmIiIiIiIiIiIiIiIiIiK5YdKaiIiIiIiIiIiIiIiIiIjkhklrIiIiIiIiIiIiIiIiIiKSGyatiYiIiIiIiIiIiIiIiIhIbpi0JiIiIiIiIiIiIiIiIiIiuWHSmoiIiIiIiIiIiIiIiIiI5IZJayIiIiIiIiIiIiIiIiIikhsmrYmIiIiIiIiIiIiIiIiISG6YtCYiIiIiIiIiIiIiIiIiIrlh0pqIiIiIiIiIiIiIiIiIiOSGSWsiIiIiIiIiIiIiIiIiIpIbJq2JiIiIiIiIiIiIiIiIiEhumLQmIiIiIiIiIiIiIiIiIiK5YdKaiIiIiIiIiIiIiIiIiIjkhklrIiIiIiIiIiIiIiIiIiKSGyatiYiIiIiIiIiIiIiIiIhIbpi0JiIiIiIiIiIiIiIiIiIiuWHSmoiIiIiIiIiIiIiIiIiI5IZJayIiIiIiIiIiIiIiIiIikhsmrYmIiIiIiIiIiIiIiIiISG6YtCYiIiIiIiIiIiIiIiIiIrlh0pqIiIiIiIiIiIiIiIiIiOSGSWsiIiIiIiIiIiIiIiIiIpIbJq2JiIiIiIiIiIiIiIiIiEhumLQmIiIiIiIiIiIiIiIiIiK5YdKaiIiIiIiIiIiIiIiIiIjkhklrIiIiIiIiIiIiIiIiIiKSGyatiYiIiIiIiIiIiIiIiIhIbpi0JiIiIiIiIiIiIiIiIiIiuWHSmoiIiIiIiIiIiIiIiIiI5IZJayIiIiIiIiIiIiIiIiIikhsmrYmIiIiIiIiIiIiIiIiISG6YtCYiIiIiIiIiIiIiIiIiIrlh0pqIiIiIiIiIiIiIiIiIiOSGSWsiIiIiIiIiIiIiIiIiIpIbJq2JiIiIiIiIiIiIiIiIiEhumLQmIiIiIiIiIiIiIiIiIiK5YdKaiIiIiIiIiIiIiIiIiIjkhklrIiIiIiIiIiIiIiIiIiKSGyatiYiIiIiIiIiIiIiIiIhIbpi0JiIiIiIiIiIiIiIiIiIiuWHSmoiIiIiIiIiIiIiIiIiI5IZJayIiIiIiIiIiIiIiIiIikhsmrYmIiIiIiIiIiIiIiIiISG5qyTsAIiIiImUSGBiIs2fP4p9//oGGhgbs7Owwb948mJmZidpYWlqWuO38+fMxceJEAMDo0aMRFRUlVt+/f3/4+/tXXvBEREREREREREREcsCkNYAOHTogNzcXBgYG8g6FqNpJSUmBuro6bty4Ie9QFBr7GaLSKVs/ExUVhVGjRqF169bIz8+Hv78/JkyYgIiICNStWxcAcPnyZbFtfvvtNyxduhR9+vQRK/fw8MDMmTNFjzU0NEo9LvsZotIpWz8jD+xjiErHPkY22M8QlY79TMWxjyEqHfsY2WA/Q1Q6SfsZJq0B5OTkID8/X95hEFVL7969g1AolHcYCo/9DFHplK2fCQoKEnvs4+MDR0dH3L17F/b29gBQ7AdMZGQkOnbsCFNTU7FyDQ0NiX/ssJ8hKp2y9TPywD6GqHTsY2SD/QxR6djPVBz7GKLSsY+RDfYzRKWTtJ9h0hqAoaEhgMILxkQkrmfPnvIOQSmwnyEqnbL3MxkZGQAAbW3tEutTU1Px66+/wtfXt1jdyZMnceLECejr66Nr166YPn06NDU1S9yPoaEhkpKSPhoL+yCqqZS9n6kKPJchKh37GNlgP0NUOvYzFcc+hqh07GNkg/0MUekk7WeYtCYiIiKqJEKhED4+Pmjfvj0sLCxKbHP8+HHUq1cPrq6uYuUDBw6EiYkJ9PX18fDhQ2zYsAH379/H3r17qyJ0IiIiIiIiIiIioirDpDURERFRJVm1ahXi4+Nx8ODBUtuEhoZi4MCBqF27tli5h4eH6N8WFhZo2rQp3N3dcffuXVhbW5e4LyMjI47oJSIiIiIiIiIiIoXDpDUph4J8IOUS8DYJqGMEGDgDKqryjoqo+uJnhqjSrV69GhcuXMCBAwfQqFGjEtvcuHEDjx49wqZNm8rcn7W1NdTU1PDkyZNSk9ZEcsfvFyJSJuzTSNb4niKi97FPIKLKxD6GFBCT1qT4nh0Dbs4C3iT8r6yuCdB+M2DqJr+4iKorfmaIKpVQKMTq1atx7tw57N+/H6ampqW2PXr0KKytrdGqVasy9/vw4UPk5eXBwMBAluESyQ6/X4hImbBPI1nje4qI3sc+gUjukpOTsX79ely6dAnZ2dlo1qwZ1q5dCxsbGwCF13cCAgJw+PBhvH79Gra2tvD29oa5ubmcI5cA+xhSUCryDoCoQp4dAy4NFe98AeBNYmH5s2PyiYuouuJnhqjSrVy5EidOnMCGDRtQr149pKSkICUlBdnZ2WLtMjMzcfr0aQwbNqzYPp4+fYqAgADcuXMHCQkJ+PXXXzFr1ixYWVmhXbt2VfVUiCTH7xciUibs00jW+J4iovexTyCSu/T0dIwcORJqamrYtWsXIiIisGjRItSvX1/UZteuXdi7dy+8vb1x9OhR6OvrY9y4ccjMzJRj5BJgH0MKjElrUlwF+YWjhSAsofL/y27OLmxHRPzMEFWRQ4cOISMjA6NHj0aXLl1E/506dUqsXUREBIRCIQYMGFBsH2pqarh27RomTpyIvn37Ys2aNejcuTP27t0LVVVO5UTVDL9fiEiZsE8jWeN7iojexz6BqFrYtWsXGjVqBB8fH7Rp0wYmJiZwdHREkyZNABTeZR0cHIwpU6bA1dUVFhYW8PPzQ3Z2NsLDw+Uc/UewjyEFx+nBSXGlXCo+WkiMEHjzrLBdw25VFRVR9cXPDFGVePDggUTthg8fjuHDh5dYZ2RkhAMHDsgyLKLKw+8XIlIm7NNI1vieIqL3sU8gqhYuXLiALl26YObMmYiOjkbDhg3x+eefw8PDAwCQkJCAlJQUdOnSRbSNuro67O3tERMTgxEjRpS436SkJPTs2bPU40ZGRsr2iXyIfQwpON5pTYrrbZJs2xEpO35miIioMvD7hYiUCfs0kjW+p4jofewTiKqFZ8+e4dChQ2jWrBmCgoIwYsQIrFmzBmFhYQCAlJQUAICenp7Ydvr6+khNTa3qcCXHPoYUHO+0JsVVx0i27YiUHT8zRERUGfj9QkTKhH0ayRrfU0T0PvYJRNWCUCiEjY0N5s6dCwCwsrLCX3/9hUOHDmHw4MGidgKBoNh2H2NkZFT5d1N/DPsYUnC805oUl4EzUNcEgKCUBgKgrmlhOyLiZ4aIiCoHv1+ISJmwTyNZ43uKiN7HPoGoWjAwMECLFi3EyszMzPD8+XNRPYBid1WnpaVBX1+/aoIsD/YxpOCYtCbFpaIKtN/8/w8+7IT//3H7TYXtiIifGSIiqhz8fiEiZcI+jWSN7ykieh/7BKJqoV27dnj06JFY2ePHj9G4cWMAgImJCQwMDHDlyhVRfW5uLqKjo2FnZ1elsUqFfQwpOCatSbGZugHOR4G6jcXL65oUlpu6yScuouqKnxkiIqoM/H4hImXCPo1kje8pInof+wQiuRszZgz+/PNPfPfdd3jy5AlOnjyJI0eO4PPPPwdQOC24p6cnAgMDce7cOcTHx2Px4sXQ0NDAgAED5Bx9GdjHkALjmtak+EzdgMaDgJRLwNukwvUYDJw5WoioNPzMEBFRZeD3CxEpE/ZpJGt8TxHR+9gnEMlVmzZtEBAQgI0bN2Lbtm0wMTHBkiVL8Nlnn4naTJo0CTk5OVi5ciXS09Nha2uLPXv2QFNTU46RS4h9DCkoJq1JOaioAg27yTsKIsXBzwwREVUGfr8QkTJhn0ayxvcUEb2PfQKRXHXv3h3du3cvtV4gEMDLywteXl5VGJUMsY8hBcTpwYmIiIiIiIiIiIiIiIiISG6YtCYiIiIiIiIiIiIiIiIiIrnh9OBEVC2dPXu2XNs5OjpCS0tLxtEQERERERERERERERFRZWHSmoiqpZkzZ0IgEEAoFEq8jUAgwNGjR2FtbV2JkRERERERERERUVUKDAzExo0b4enpiaVLlwIAhEIhAgICcPjwYbx+/Rq2trbw9vaGubm5nKMlIiKi8mDSmoiqLW9vb7Ro0UKitvn5+Rg/fnwlR0RERERERERERFXp9u3bOHz4MCwtLcXKd+3ahb1798LX1xfNmjXDjh07MG7cOJw+fRqamppyipaIiIjKi0lrIqq2rK2t0aZNG4na5ufnS3VXNhERERERERERVW9ZWVmYP38+1qxZgx07dojKhUIhgoODMWXKFLi6ugIA/Pz84OTkhPDwcIwYMUJeIRMREVE5MWlNRNVSWFgYmjdvLnF7VVVVhIWFwczMrBKjIiIiIiIiIiKiqrJq1Sq4uLjAyclJLGmdkJCAlJQUdOnSRVSmrq4Oe3t7xMTElJi0TkpKQs+ePUs9VmRkpGyDJyIiIqlIlLQeOHCg1DsWCATw9/eXeGpfIqL3tWrVqkq2ISIiIiIiIiKi6iciIgL37t3D0aNHi9WlpKQAAPT09MTK9fX18fz58yqJj4iIiGRLoqT1w4cPYWVlhXr16km84xs3biA7O7vcgRERERERERERERFRzZOUlIS1a9diz549qF27dqntBAKB2OOPLR1nZGTEu6mJiIiqMYmnB1+xYoXEa8u+e/cONjY25Q6KiOhDN27cQHh4OJ4/f15sQIxAIMC+ffvkFBkRKSrOJENERERERFQ93b17F2lpaXBzcxOV5efnIzo6GiEhITh9+jQAIDU1FYaGhqI2aWlp0NfXr/J4iYiIqOIkSlr37NkT2traEu9UVVUVPXv2RP369csdGBFRkdDQUCxduhTa2tpo3rw51NTUxOo/NoqWiKg0nEmGiIiIiIioeurUqRNOnjwpVrZ48WKYmZlh0qRJMDU1hYGBAa5cuQIrKysAQG5uLqKjozFv3jx5hExEREQVJFHSetu2bVLtVCAQSL0NEVFpdu/ejX79+sHPzw/q6uoV3l90dDSCgoIQGxuLlJQUbNu2Db169RLVZ2VlYcOGDTh//jxevXqFxo0bY/To0fj8889FbXJzc+Hn54fw8HDk5OSgU6dOWLFiBRo1alTh+Iio6nAmGSIiIiIioupHU1MTFhYWYmV169aFjo6OqNzT0xOBgYFo1qwZmjZtisDAQGhoaGDAgAHyCJmIiIgqSEXeAQBASEgIevTogdatW8PNzQ03btwote3169dhaWlZ7L+///5brN2ZM2fQv39/2NjYoH///jh37lxlPw0iqiTPnz/HsGHDZJKwBoA3b97A0tIS3t7eJdb7+Pjg0qVLWL9+PU6dOoWxY8dizZo1OH/+vKjN2rVrce7cOfj7++PgwYN48+YNvvzyS+Tn58skRiKqfJxJhoiIiIiISHFNmjQJY8aMwcqVK+Hu7o7k5GTs2bMHmpqa8g6NiIiIykHiNa0/lJaWhsTEROTk5BSrs7e3l3g/p06dgo+PD77++mu0a9cOP/zwAyZNmoSIiAgYGxuXut3p06fFTkAaNGgg+ndMTAzmzJmDWbNmoVevXjh//jxmz56NgwcPwtbWVuLYiKh6aNGiBVJTU2W2PxcXF7i4uJRaf+vWLQwePBgdO3YEAAwfPhyHDx9GbGwsevXqhYyMDISGhmLdunVwcnICAKxfvx7dunXD1atX4ezsLLNYiajyVNZMMoGBgTh79iz++ecfaGhowM7ODvPmzYOZmZmozaJFi3D8+HGx7WxtbXHkyBHRY87oQERERERE9D/79+8XeywQCODl5QUvLy85RURERESyJHXS+sWLF1iwYAGuX79erE4oFEIgECAuLk7i/e3duxfu7u4YNmwYAGDp0qW4fPkyDh06hK+++qrU7fT09Eq902nfvn1wcnLCl19+CaAw4RUVFYV9+/Zh48aNEsdGRNXDnDlzsG7dOnTs2BENGzas9OO1a9cOFy5cwNChQ2FoaIjr16/j0aNHWLJkCQAgNjYWeXl56Ny5s2ibhg0bwtzcHDExMUxaE9VwUVFRGDVqFFq3bo38/Hz4+/tjwoQJiIiIQN26dUXtnJ2d4ePjI3qspqYmtp+1a9fil19+gb+/P3R0dODr64svv/wSx44dg6qqapU9HyIiIiIiIiIiIqLKJnXSevXq1YiLi8O8efNgaWlZoel6c3NzcffuXUyePFmsvHPnzoiJifnotoMHD0Zubi5atGiBqVOnolOnTqK6W7duYezYsWLtnZ2dsW/fvlL3l5SUhJ49e5ZaHxkZ+dF4iKjyhISEICMjA3369EGrVq2go6MjVi8QCLBjxw6ZHW/ZsmVYvnw5unbtilq1akEgEGDNmjXo0KEDACA1NRVqamrFphXW19f/6B3h7GeIqj9ZzCQTFBQk9tjHxweOjo64e/eu2D7U1dVhYGBQ4j44owMRERERERHRewrygZRLwNskoI4RYOAMqHBANxGRMpE6aR0VFYUFCxbA3d29wgd/+fIl8vPzoaenJ1aur6+PlJSUErcxMDDA6tWrYW1tjdzcXPz0008YO3Ys9u/fL7oQnJqaWmyfenp6pe6TiKq3+Ph4qKiooEGDBnjx4gVevHghVi8QCGR6vP379+PWrVvYsWMHjI2NcePGDaxcuRKGhoai5FFJhEKhTONQOPzxQApM1jPJvC8jIwMAig10iYqKgqOjI+rXrw97e3vMmTNHdP5S3hkdODiGiIiqDZ4bEpEiYt9FVD09OwbcnAW8SfhfWV0ToP1mwNRNfnEREVUEzzuKkTppLRAIYGRkJNMgPkw4FV0cLomZmZnYmpB2dnb4999/ERQUJHb3kjT7BAAjIyNeyCWqpi5cuFBlx8rOzoa/vz8CAgLQrVs3AECrVq0QFxeHoKAgODk5QV9fH3l5eUhPTxdLQqWlpcHOzq7UfSt1P8MfD6TgZDmTzPuEQiF8fHzQvn17WFhYiMq7du2Kvn37wtjYGAkJCdi8eTPGjBmDY8eOQV1dvdwzOnyMikAIJF/kiTAREVU+nhsSkSJi30VUPT07BlwaCuCDm0XeJBaWOx/lZ5SIFA/PO0okddK6b9+++OWXXz56t6GkdHV1oaqqWuzia1paGvT19SXej62tLU6cOCF6XNIF3f/++0+qfSodjtggksi7d++Ql5dXbJCLqqqq6E5qGxsbqKmp4cqVK+jfvz+Awrs0Hz58iPnz51d5zHLHHw+kBGQ5k8z7Vq1ahfj4eBw8eFCsvKjvAAALCwvY2NigR48euHjxIlxdXUvdX1kzOpQ6OKboRDiy+//KeCJMRESVgeeGRKSI2HcRVU8F+YW/ZT/8bAL/XyYAbs4GGg/itW4iUhw87yiVirQb9OvXD7/++ivWrFmDK1eu4O7du8X+k5S6ujqsra1x5coVsfKrV69+9G7FD8XFxYmtCdm2bdti+7x8+bJU+1Qqz44BJ5oVXqi++nnh/080KywnqoGysrIQFxcnmuo3ISEBcXFxeP78OTQ1NeHg4ID169fj+vXrePbsGY4dO4awsDD06tULAKClpQV3d3f4+fnh999/x7179zB//nxYWFjIZECPQinzxwMKfzwU5FdhUETSq4yZZFavXo0LFy5g3759aNSo0UfbGhoawtjYGI8fPwYAsRkd3iftwD4A/zsRfn/kJvC/E2GeDxARkazw3JCIFBH7LqLqK+VS8d+yYoTAm2eF7YiIFAHPOz5K6jutx4wZAwA4cOAAQkJCxOrKs+bjuHHjsGDBAtjY2MDOzg6HDx9GUlISRowYAQDYsGEDkpOTsW7dOgDA999/DxMTE7Rs2RJ5eXk4ceIEzpw5g61bt4r26enpiS+++AI7d+5Ez549ERkZid9//73YXU41AkdskIL65JNPcPjwYbRp0watWrX66PT+AoEA9+7dk3jfsbGx8PT0FD328fEBAAwZMgS+vr7YuHEjNm7ciHnz5iE9PR3GxsaYM2cORo4cKdpmyZIlqFWrFmbPno3s7Gw4OjrC19cXqqo1bFSnND8eGnarqqiIpCbLmWSEQiFWr16Nc+fOYf/+/TA1NS1zm5cvXyIpKQmGhoYAZDijA0elExFRVeK5IREpIvZdRNXX2yTZtiMikjeed3yU1EnrouSOrPTv3x8vX77E9u3b8eLFC1hYWGDnzp1o3LgxACAlJQVJSf/70snLy4Ofnx+Sk5OhoaGBli1bYufOnXBxcRG1adeuHTZu3IhNmzZhy5YtMDU1hb+/P2xtbWUae7XHC9WkwKZPn46GDRuK/v2xpLW0OnbsiAcPHpRab2BgUGZfV7t2bSxfvhzLly+XWVwKiT8eSEn069cPy5cvh1AoRPfu3aGjo1OsjbW1tUT7WrlyJcLDw7F9+3bUq1cPKSkpAApnadDQ0EBWVhYCAgLg6uoKAwMDJCYmwt/fH7q6uiXO6KCrqwttbW34+flJP6MDT4SJiKgq8dyQiBQR+y6i6quOhDOiSdqOiEjeeN7xUVInrYcMGSLzIEaNGoVRo0aVWOfr6yv2eNKkSZg0aVKZ++zbty/69u0rk/gUFi9UkwKbMWOG6N9eXl5yjIQ+ij8eSEnIciaZQ4cOAQBGjx4tVu7j4wM3NzeoqqoiPj4eYWFhyMjIgIGBATp27Ah/f39oamqK2stkRgeeCBMRUVXiuSERKSL2XUTVl4EzUNekcNbQEm/MEhTWGzhXdWREROXD846PkjppTQqEF6qJqLLxxwMpCVnOJPOxmRwAQENDA0FBQWXuRyYzOvBEmKhamTJlSrm2W7p0qURLDRDJHc8NiUgRse8iqr5UVIH2m/9/+UsBxD+j/z8rY/tNnEWUiBQHzzs+SqKkdUBAAIYNG4aGDRsiICDgo20FAgGmT58uk+CognihmpRIbm4url69isTEROTm5orVCQQCjB07Vj6B1XT88UBKojJmkqkWeCJMVK1cvHgR+vr6UFdXl3ibpKQkzjpDioPnhkSkiNh3EVVvpm6A89HCZTDfn1W0rknhZ9PUTW6hERFJjecdHyVx0rpr165MWisaXqgmJREbG4spU6YgLS0NQmHx9zKT1nLGHw9E1RdPhImqne3bt6NNmzYStX337h1sbGwqOSIiGeO5IREpIvZdRNWbqRvQeFDhMpdvkwpvwjJw5m9ZIlJMPO8olURJ6/v375f4b6rmeKGalMTKlSuhqamJlStXokWLFlBTU5N3SPQh/nggBVRjZpLhiTBRtdGqVSvUqVNH4vYqKipo1aoVNDQ0KjEqokrAc0MiUkTsu4iqNxVVoGE3eUdBRCQbPO8oEde0Vna8UE1K4K+//sK3336Lnj17yjsU+hj+eCAFU6NmkuGJMFG1EBYWJlV7FRUVqbchqjZ4bkhEioh9FxEREVUVnncUw6R1TcAL1aTgjIy47joRyV6Nm0mGJ8JERERK7e7du+XarmXLlqhdu7aMoyEiIiIiIpKO1Enr/Px87N+/HydPnsTz58+Rk5MjVi8QCHDz5k2ZBUgywgvVpMAmTpyIPXv2wNnZGerq6vIOh4iIiIiIqNpxd3eHQCCQerujR4/C2tq6EiIiIiIiIiKSnNRJ62+//RZ79+7FJ598AicnJyaQiKjSubm5ITExEb169YKDgwN0dHSKtVm2bFnVB0ZEREQkBWmWOhEIBDh//nwlRkNEymjKlClo0qSJRG3z8/OxfPnySo6IiIiIiIhIMlInrU+ePInJkydj7ty5lREPEVExFy9eRGBgIN69e4fw8PBi9QKBgElrIqoQziRDRFUhMTERWlpaaNeuXbnuhvyY6OhoBAUFITY2FikpKdi2bRt69eol0bY3b97E6NGjYW5ujp9++kmmcRFR1erevTvatGkjUdv8/HyJf0eVp4+JioqCr68vHj58CENDQ0ycOBEjR46U6HhERERERFTzSJ20zsnJgaOjY2XEQkRUonXr1sHa2horV65EixYtoKamJu+QiEjJcCYZIqoKDg4OiI6ORnx8PAYPHgw3NzeYmprKZN9v3ryBpaUl3Nzc4OXlJfF2GRkZWLhwIRwdHZGamiqTWIhIPgICAtC8eXOJ26uqqiIgIABNmzYts620fcyzZ88wefJkDBs2DOvXr8cff/yBlStXokGDBujTp4/EMRIRERERUc0hddK6c+fOuH37NhPXRFRlEhMTERAQgFatWsk7FCJSUpxJhoiqQnBwMBISEnD06FGEhYUhMDAQ9vb2GDp0KPr06VOhATMuLi5wcXGRejtvb28MGDAAqqqqnI6cSMFJOrtCebaRto/54YcfYGRkhKVLlwIAWrRogTt37mDPnj1MWhMRERERUYmkTlovW7YMkydPRp06deDi4gJtbe1ibUpab5aIqLzMzMyQmZkp7zCISIlxJhkiqiomJiaYPXs2Zs2ahUuXLiE0NBRLlizB6tWr8emnn2LEiBGwtLSsklhCQ0Px9OlTrF+/Hjt27CizfVJS0kfX5Y6MjJRleESkwG7duoXOnTuLlTk7OyM0NBR5eXmlzp7FfoaIiIiIqOaSOmmtqamJ5s2bw8fHBz4+PiW2iYuLq3BgRERFZs2ahY0bN6JDhw4wMDCQdzhEpIQ4kwwRVTWBQICuXbuia9euePXqFfbu3Yvdu3cjJSUFAQEBlX78x48fY8OGDQgJCUGtWlL/LCSiaigsLEyq9oMHD66UOAAgNTUV+vr6YmV6enp49+4dXr58CUNDw0o7NhERERERKSapr058/fXXOH36NHr27Mm1ZYmoSvzwww94/fo1XF1d0apVq2IzPAgEAonuDiIiKg1nkiEieRAKhfjtt98QGhqKCxcuoG7duujQoUOlHzc/Px9fffUVvLy8pFr/1sjIiHc5ElVjixYtgkAgAFDYv3yMQCCo1KR10THeVxTTh+XvYz9DREUOHjyIQ4cOITExEQBgbm6OadOmiZYqWLRoEY4fPy62ja2tLY4cOVLlsRIREZFsSJ20Pn/+PObOnYsJEyZURjxERMXEx8dDRUUFurq6SE5ORnJyslj9xy56EBFJgjPJEFFVevr0KUJDQxEWFoYXL17AwcEBPj4+cHV1Re3atSv9+FlZWYiNjUVcXBxWr14NACgoKIBQKISVlRWCgoI48wSRgtLU1ES/fv3w6aefol69enKLQ19fHykpKWJl//33H2rVqsWBgEQkkUaNGmHevHlo0qQJgMLZJKZPn47jx4/D3NwcQOGyA+//fuPNVURERIpN6qR1rVq1YGVlVRmxEBGV6MKFC/IOgYiUHGeSIaKqEBYWhqNHj+LmzZto1KgR3N3d4ebmBhMTkyqNQ1NTEydPnhQrO3jwIK5du4YtW7ZUeTxEJBsHDhxAaGgoTp48iZMnT6Jv375wd3evkhkcPtS2bVv88ssvYmWXL1+GjY0Nz7OISCI9evQQezxnzhwcOnQIt27dEiWt1dXVuYwcERGREpE6ae3q6opLly5x5D0RVap9+/ahT58+aNSokbxDIaIagDPJEFFVWLRoETQ1NTFs2DA4OTlBRUUF9+7dw71790ps7+rqKvG+s7Ky8PTpU9HjhIQExMXFQVtbG8bGxtiwYQOSk5Oxbt06qKiowMLCQmx7PT091K5du1g5ESmODh06oEOHDli2bBkiIiIQGhqKL774Ak2bNoW7uzsGDx5c7rWkpeljAGDEiBEICQmBj48PPDw8EBMTg9DQUGzYsEEmz5WIapb8/HycPn0ab968gZ2dnag8KioKjo6OqF+/Puzt7TFnzhzo6emVup+kpCT07Nmz1HouT0BERCRfUietP/30Uyxfvhzv3r1Dt27dSlzz0draWibBEVHNFRgYCF9fX9jY2KBPnz5wdXUVTQlFRCRrnEmGiKpKZmYmjhw5gh9//BFA6evOCgQCqZYliI2Nhaenp+hx0VSZQ4YMga+vL1JSUpCUlFSByIlIUdSrVw8eHh7w8PDAX3/9haNHj+L777/Hli1bMGHCBMyZM0fqfUrbx5iammLnzp3w8fFBSEgIDA0NsXTpUvTp06fiT5CIaowHDx5gxIgRyMnJQd26dbFt2za0bNkSANC1a1f07dsXxsbGSEhIwObNmzFmzBgcO3YM6urqco6ciIiIykPqpPXYsWMBAMHBwdi/f79YnVAolPriisIoyAdSLgFvk4A6RoCBM6CiKu+oiJTW5cuXERUVhbNnz2Lfvn3YsGEDLCwsRAnsoh8pRESywJlkiKgqBAcHV9q+O3bsiAcPHpRa7+vr+9Htvby84OXlJeuwiEjOWrZsCXd3d+Tl5eHQoUP4+++/y7Wf8vQxDg4OOH78eLmOR0QEAM2bN0dYWBhev36Ns2fPYuHChThw4ABatmyJ/v37i9pZWFjAxsYGPXr0wMWLF0udrcbIyIh3UxMREVVjUieti0bT1ijPjgE3ZwFvEv5XVtcEaL8ZMHWTX1xESkxFRQWdOnVCp06d4O3tjZs3b+LMmTM4evQotm7dimbNmsHV1RV9+vTh3ZFEVGGcSYaIqoKDg4O8QyCiGiIzMxPh4eEIDQ1FbGwsmjZtitmzZ2Pw4MHyDo2ISGLq6upo2rQpAKB169a4c+cOgoODsWrVqmJtDQ0NYWxsjMePH5f/gLxpiYiISK4kSloXFBRARUUFQOHUTzXKs2PApaEAPpi2701iYbnzUSauiapA+/bt0b59eyxZsgS3b9/GmTNn8PPPPyMwMBCNGzdGnz59sGDBAnmHSUQKSpYzyQQGBuLs2bP4559/oKGhATs7O8ybNw9mZmYAgLy8PGzatAm//fYbnj17Bk1NTTg5OeGrr75Cw4YNRfsZPXo0oqKixPbdv39/+Pv7V+CZEhERkTK7du0aQkNDcfbsWaioqKBv375YuHAhOnToIO/QiIgqTCgUIjc3t8S6ly9fIikpCYaGhuXbOW9aIiIikjuJktaOjo7o3bs3XF1d4ejoCDU1tcqOq3ooyC88WfkwYQ38f5kAuDkbaDyIo+6IqlCbNm3Qpk0bzJ8/H/fv38eZM2dw9uxZJq2JysJR46WS5UwyUVFRGDVqFFq3bo38/Hz4+/tjwoQJiIiIQN26dZGdnY179+5h6tSpaNWqFV6/fo1vvvkGU6dOxbFjx8T25eHhgZkzZ4oea2hoyCxOqob4GVV6e/fuxcCBA6Gvry/VNoMGDUKDBg0qMTIZ4PuXSO569+6NhIQE2NraYtmyZfj0009Rt25deYdFVHn43aPUNm7ciK5du6JRo0bIysrCqVOnEBUVhd27dyMrKwsBAQFwdXWFgYEBEhMT4e/vD11dXfTq1Uv6g/GmJSIiUhYKfn4kUdJ6+vTpOHfuHKZMmYJ69eqhW7du6NOnD5ydnVG7du3KjlF+Ui6Jj64rRgi8eVbYrmG3qoqKiN7z4sULnDhxgmsSEZWFo8aLqayZZIKCgsQe+/j4wNHREXfv3oW9vT20tLSwd+9esTbLli3DsGHD8Pz5cxgbG4vKNTQ0YGBgILPYqBrjZ7RGWLduHdq3by9x0jo/Px/r1q2Dg4ND9U5a8/1LVC0UzeCSlZWF4OBgBAcHl9pWIBDgxIkTVRgdkYzxu0fppaamYsGCBXjx4gW0tLRgaWmJ3bt3o3PnzsjOzkZ8fDzCwsKQkZEBAwMDdOzYEf7+/tDU1JTuQLxpiajG2rp1KwICAsTK9PX1ceXKFQCFszsEBATg8OHDeP36NWxtbeHt7Q1zc3N5hEtUNiU4P5Ioae3p6QlPT0+kpaXh7NmzOHfuHGbPng01NTV07doVffr0Qbdu3ZRvBO/bJNm2IyKZe/v2LZ4/fy7vMIiqN44aL1FVzSSTkZEBACWuk10kMzMTAoEA9evXFys/efIkTpw4AX19fXTt2hXTp0//6EWYpKQk9OzZs9R6DvCppvgZrTGEQiGOHj2K3377TeL21R7fv0TVhr29vbxDIKoa/O6pEb755ptS6zQ0NIoNFi433rREVKOZm5uL3Vigqvq/wSm7du3C3r174evri2bNmmHHjh0YN24cTp8+Lf0AGaLKpiTnRxIlrYvo6elh5MiRGDlyJF69eoXz58/j7NmzWLhwIVRUVODk5IQ+ffqgR48exS66KqQ6RrJtR0REVNU4arxUVTGTjFAohI+PD9q3bw8LC4sS2+Tk5ODbb7/FgAEDxH70DBw4ECYmJtDX18fDhw+xYcMG3L9/v9hd2qTg+BmtcY4cOSLvEGSH71+iamX//v3yDoGo8vG7h2SNNy0R1WiqqqolznAnFAoRHByMKVOmwNXVFQDg5+cHJycnhIeHY8SIEVUdKlHplOj8SKqk9ft0dHQwdOhQDB06FJmZmYiMjMTZs2fx9ddfY9myZYiNjZVlnPJh4Fx46/ybRJT8xxYU1hs4V3VkRFQB0dHRCAoKQmxsLFJSUrBt27Ziax79/fffWL9+PaKjo1FQUABzc3Ns2rRJNG1vbm4u/Pz8EB4ejpycHHTq1AkrVqxAo0aN5PGUiErHUeOlqoqZZFatWoX4+HgcPHiwxPq8vDzMmTMHQqEQK1asEKvz8PAQ/dvCwgJNmzaFu7s77t69C2tr6xL3Z2RkxLupFQ0/ozXK/fv35R2CbPH9S0REVY3fPSRrvGmJqEZ78uQJunTpAnV1ddja2mLu3LkwNTVFQkICUlJS0KVLF1FbdXV12NvbIyYmptSkNWfAI7lQovMjFVnsRFNTE4MGDcK2bdvw+++/Y/369VJtHxISgh49eqB169Zwc3PDjRs3JNru5s2bsLKywqBBg8TKjx07BktLy2L/5eTkSBUXVFQL53oHAAg+qPz/x+03VfuRCUQk7s2bN7C0tIS3t3eJ9U+fPsXnn38OMzMz7N+/HydOnMC0adPE7rxcu3Ytzp07B39/fxw8eBBv3rzBl19+ifz8/Kp6GkSS4ajxMhXNJLNnzx5cvnwZS5cuxdu3b7Fw4UI4Ojpi6tSpCAsLw+vXr6Xa7+rVq3HhwgXs27evxAEteXl5mD17NhISErBnz54yp5aytraGmpoanjx5IlUcVM3xM0qKjO9fIiKqavzuIVkrummp2LXfIgKgrilvWiJSQm3atIGfnx+CgoKwZs0apKamYsSIEXj58iVSUlIAFF4zep++vj5SU1PlES5R6ZTo/Kjcd1oDhWvJlpQI7tevn8T7OHXqFHx8fPD111+jXbt2+OGHHzBp0iRERESI7mgsSUZGhuhickmdhKamJk6fPi1WVq6pPk3dCud6L3Hx8k0KMQc8EYlzcXGBi4tLqfX+/v7o2rUrFixYICozNTUV/TsjIwOhoaFYt24dnJycAADr169Ht27dcPXqVTg784cMVSMcNS4VWcwkIxQKsXr1apw7dw779+8X6z+KFCWsnzx5guDgYOjq6pa534cPHyIvL6/EaatIgfEzSoqM71+iaqVdu3YIDg6GjY2NRO0LCgrQoUMHHDx4EK1atark6IhkhN89JGtFNy1dGorCxPX7s23ypiUiZfbh9eG2bduid+/eCAsLg62tLQBAIBAf0CIUljQj7/9wBjySCyU6P5I6af327Vts3LgRJ0+eRHp6eolt4uLiJN7f3r174e7ujmHDhgEAli5disuXL+PQoUP46quvSt3O29sbAwYMgKqqKs6fP1+sXiAQyO6irqlb4VzvKZcKRyLUMSocXceTFaJKY2dnV+ykoCQFBQUyPW5BQQEuXryIiRMnYsKECbh37x5MTEzw5ZdfiqYQj42NRV5eHjp37izarmHDhjA3N0dMTEypSWtOD0NywaUuyq1oJhl7e3v88ssvaNCggUTbrVy5EuHh4di+fTvq1asnGp2rpaUFDQ0NvHv3DjNnzsS9e/cQGBiI/Px8URttbW2oq6vj6dOnOHHiBFxcXKCrq4u///4bvr6+sLKyQrt27SrtOZMc8DNKiozvX6Jq5c2bN1L9PhIKhXjz5g1niyLFwu8eqgy8aYmIANStWxcWFhZ4/Pix6DpwamoqDA0NRW3S0tKgr68vrxCJSqZE50dSJ61XrVqFEydOoHv37mjRogXU1NTKffDc3FzcvXsXkydPFivv3LkzYmJiSt0uNDQUT58+xfr167Fjx44S27x58wbdu3dHfn4+PvnkE8yaNQtWVlal7lOiZFI1n+udSJn06dNHoqS1rKWlpeHNmzfYtWsXZs+ejXnz5uHSpUuYMWMGgoOD4eDggNTUVKipqUFbW1tsW04PQ9USR41X2J07d7BmzRqJB+UdOnQIADB69Gixch8fH7i5ueHff//FhQsXAKDYEifBwcHo2LEj1NTUcO3aNezfvx9ZWVkwMjKCi4sLZsyYAVVV/q2UCj+jpMj4/iWqdoYPHy5Ve3n85iKqEH73UGXhTUtENV5ubi7+/vtvtG/fHiYmJjAwMMCVK1dEeaXc3FxER0dj3rx5co6U6ANKdH4kddL6l19+wdy5czFhwoQKH/zly5fIz88vcV2AojuOPvT48WNs2LABISEhqFWr5PDNzMzg4+MDS0tLZGZmIjg4GCNHjsRPP/2EZs2aVThuIqp8vr6+cjlu0Z0JPXv2xNixYwEAn3zyCf744w/88MMPcHBwKHVbTg9D1RZHjVepBw8efLTexMSkzDZGRkY4cOCALMOi6oyfUVJkfP8SVRszZswo13bv3z1EpBD43UOVRUWVNy0R1SB+fn7o3r07jIyM8N9//2HHjh3IzMzEkCFDIBAI4OnpicDAQDRr1gxNmzZFYGAgNDQ0MGDAAHmHTlSckpwflWtN64/dsVweJa0LUNJo3/z8fHz11Vfw8vJC8+bNS91f27Zt0bZtW9Hjdu3aYciQIThw4ACWLVtW4jZMJhERAOjq6qJWrVpo0aKFWHmLFi1w8+ZNAIUDa/Ly8pCeni52t3VaWhrs7OyqNF4iiXHUOFH1xs8oKTK+f4mqhfImrYkUEr97iIiogv7991/MnTsXr169gq6uLtq2bYsjR46gcePGAIBJkyYhJycHK1euRHp6OmxtbbFnzx5oamrKOXKiUijB+ZHUSWtXV1dcvnwZjo6OFT64rq4uVFVVi02nW9q6AFlZWYiNjUVcXBxWr14NoPCuSKFQCCsrKwQFBZUYl4qKClq3bo3Hjx9XOGYiqnrR0dFltrG3t5fJsdTV1dG6dWs8evRIrPzx48eiExYbGxuoqanhypUr6N+/PwDgxYsXePjwIebPny+TOIgqBUeNE1Vv/IySIuP7l4iIqhq/e4iIlN6rV6/KtV39+vWhoqLy0Tb+/v4frRcIBPDy8oKXl1e5YiCSCwU/P5I6ab1w4ULMnDkTPj4+cHFxKbamKwBYW1tLtC91dXVYW1vjypUr6N27t6j86tWrJa4vrampiZMnT4qVHTx4ENeuXcOWLVtgYmJS4nGEQiHi4uJgYWEhUVxEVL2MHj26zLXWJF1rFigcAPP06VPR44SEBMTFxUFbWxvGxsaYMGEC5syZA3t7e3Ts2BGXLl3CL7/8guDgYACAlpYW3N3d4efnB11dXWhra8PPzw8WFhZwcnIq35MkIiKiGuX69eu4ePEiYmJikJycjOzsbOjq6qJly5bo2LEj+vXrhwYNGsg7TCIiIiIiIrnp1KlTmdeFPyQQCPDjjz9KnKcioupD6qR1Tk4O3r17h3379okSOEWKpvWWJnk0btw4LFiwADY2NrCzs8Phw4eRlJSEESNGAAA2bNiA5ORkrFu3DioqKsUSz3p6eqhdu7ZYeUBAAGxtbdGsWTPRmtb379/H119/Le3TJaJq4MO+BgBevnyJyMhI/PHHH/D29pZqf7GxsfD09BQ99vHxAQAMGTIEvr6+6N27N1asWIGdO3dizZo1aN68ObZs2YIOHTqItlmyZAlq1aqF2bNnIzs7G46OjvD19YWqquJMtUFU0+3du1eidvHx8ZUcCRHVJMePH8fOnTvx6NEj1K1bF61atYKNjQ3U1dWRnp6OBw8e4OzZs/D19UW/fv0wa9Ys0WwvRERERERENY27uzsaNWokUdv8/Hx89913lRwREVUWqZPWS5YswZ07dzBmzBi0aNECampqFQqgf//+ePnyJbZv344XL17AwsICO3fuFF2YSUlJQVJSklT7fP36Nby9vZGSkgItLS1YWVnhwIEDaNOmTYViJSL5cHBwKLG8T58+8Pb2xqVLl9C1a1eJ99exY0c8ePDgo22GDh2KoUOHllpfu3ZtLF++HMuXL5f4uERUvfj5+UncVtpRvUREJXFzc0NCQgI+++wz+Pn5wcbGpsQp6169eoXz58/j+PHj6N+/P/z8/NC3b185RExERERERCRfHh4eEud28vPzsWPHjkqOiIgqi9RJ6+vXr2Px4sXw8PCQWRCjRo3CqFGjSqzz9fX96LYlrSmwZMkSLFmyRGbxEVH11bt3byxatAhLly6VdyhEpGAiIyPlHQIR1TAuLi6YMGECNDU1P9pOR0dHNIAuOjoa6enpVRQhERERERFR9bF48WKpZp5SVVXF4sWLYWRkVIlREVFlkTppXa9ePU5PR0TVxuvXr5GbmyvvMIhIAfF8hoiq2qxZs6Text7evhIiISIiIiIiqv7GjBlTJdsQUfUgddJ68ODBiIiIQOfOnSsjHiKiYp4/f16sLDc3Fw8ePMCGDRtga2srh6iIiIiIiIiqr8zMTNy6dQvJycnIzs6Grq4uWrZsCQsLC3mHRkREREREVIzUSetWrVrB398f06dPR7du3aCtrV2sjaurq0yCIyICgB49epS4nqxQKETz5s3h7e0th6iISJl4enp+tF4gEGDfvn1VFA0R1QRhYWGl1gkEAtSvXx9WVlZo2LBh1QVFRArv3bt3OHPmDH744Qf88ccfKCgogFAoFNULBALo6Ohg4MCB+Pzzz9GsWTP5BUtERERUhidPnsDDwwOrV68uNe907tw5LFu2DCEhIWjZsmUVR0hEsiR10nrevHkAgMTExBLXghQIBIiLi6t4ZERE/++bb74plrSuXbs2GjdujNatW0NFRUVOkRGRsnj/Ym6RV69e4dGjR2jQoAEv6BKRzC1atEh0fvNhQqmoTEVFBYMGDcLq1atRq5bUP92IqIaJjIzEunXrkJiYCCcnJ8yZMwfW1tZo0KABateujfT0dDx79gy3bt3C+fPnERISAnd3d8yePRsNGjSQd/hERERExRw4cAAtW7b86I2SvXv3RnBwMA4cOIAVK1ZUXXBEJHNSX/kIDg6ujDiIiErl5uYm7xCISMnt37+/xPJHjx5h2rRpmDFjRhVHRETK7scff8ScOXPQuXNnDBgwAHp6ekhNTUV4eDiuXLmCFStW4O7du9i2bRsaN27MfoiIyrRo0SKMGTMGI0eOhJ6eXoltbG1tMWDAACxbtgy///47duzYgYMHD7KPIaJyGThwoNTbCAQC+Pv7o0WLFpUQEREpm99++w0TJkwos91nn32G3bt3V0FERFSZpE5aOzg4VEYcRERERNVO8+bNMWHCBKxfvx4//vijvMMhIiUSHByM3r17Y+HChaIyMzMzODg4wNfXF6Ghodi0aRPS09Nx8uRJJpSIqEyRkZGoX7++xO0dHR3h6OiI169fV2JURKTMHj58CCsrK9SrV0/ibW7cuIHs7OxKjIqIlElSUhKaN29eZrtmzZrh33//rYKIiKgycY45IqqW1qxZg/Hjx8PY2Fii9kKhEGvXrsXEiRPRqFGjSo6OiGqSxo0b4+HDh/IOg4iUzMWLF7F58+YS67p27YqZM2cCADp16oQDBw5UZWhEpKCkSVjLYjsiIgBYsWIF2rRpI1Hbd+/ewcbGppIjIiJloqqqiry8vDLb5eXlcQlJIiUg0ad4zZo1eP78ucQ7FQqFWLNmDUe2EFG5hYSEIDU1VeL2BQUFCAkJQVpaWiVGRUQ10dmzZ2FoaCjvMIhIyeTn5+Pp06cl1j158kS0zrW6ujrU1dWrMjQiIiIiifTs2RPa2toSt1dVVUXPnj05WIaIJGZiYoJbt26V2S4mJgYmJiaVHxARVSqJ7rQOCQnBZ599JvEdj0XJoyFDhvCORyIqF6FQiO3bt0NXV1feoRBRDbB48eJiZbm5uYiPj8dff/2F+fPnyyEqIlJmXbp0waZNm2BqaorOnTuLyi9duoTNmzeLyv755x80btxYXmESkYLq0aMHBAJBiXUqKirQ0tJC69at4enpyXVliajctm3bJlV7gUAg9TZEVAEF+UDKJeBtElDHCDBwBlRU5R2VVFxcXHDgwAEMHTq01BsKkpOTERISgqFDh1ZxdEQkaxIlrZk8IqKqZmxsjPj4eKm2MTIy4p1IRFQu169fL1ZWu3ZtNG7cGJMnT8bAgQPlEBURKbOlS5di9OjRmDhxIurVqwc9PT2kpaUhKysLTZo0wdKlS0Vtx4wZI8dIiUgROTg4ICoqCi9evEC7du2gr6+PlJQUxMTEwNDQEEZGRjh37hx++ukn7N+/H61bt5Z3yEREYg4ePIhDhw4hMTERAGBubo5p06bBxcUFQOH16oCAABw+fBivX7+Gra0tvL29YW5uLs+wiaqPZ8eAm7OANwn/K6trArTfDJi6yS8uKY0bNw6hoaEYMWIE5s+fj+7du0NDQwMAkJ2djQsXLuDbb7+FQCDA2LFj5RssEVWYRElrJo+IqoASjHyTpQsXLsg7BCKqQdjnEFFVa9iwIX766SccO3YMN27cwKtXr2BlZQV7e3sMGTIEderUAQCMGjVKzpESkSLq0qULbt26hXPnzsHIyEhU/vz5c4wfPx69evWCr68vRo8eja1bt2Lnzp1yjJaIlNHx48fx66+/QigUomvXrnB3d5dq+0aNGmHevHlo0qQJACAsLAzTp0/H8ePHYW5ujl27dmHv3r3w9fVFs2bNsGPHDowbNw6nT5+GpqZmZTwlIsXx7BhwaSgAoXj5m8TCcuejCpO41tPTQ2BgIKZNm4a5c+dCVVVVdHPly5cvkZ+fDz09PezYsQMNGjSQc7REVFESJa15IZeokinJyDciIjEcjENEVKLs7GwsXboUI0eOxKhRo5iYri74vUVK5LvvvoOXl5dYwhoovClh+vTp2L59O4YMGYKxY8fim2++kVOU1Rz7BKJyCwgIwI8//oh+/frhzZs3WLNmDZ4+fYo5c+ZIvI8ePXqIPZ4zZw4OHTqEW7duoWXLlggODsaUKVPg6uoKAPDz84OTkxPCw8MxYsQImT4fIoVSkF94nfnDhDXw/2UC4OZsoPEghflea9OmDU6fPo0jR47gypUrSEpKAgBYWFjA2dkZQ4cO5WAVkg2e/8mdRElrIqpESjTyjYhIRAEG47x69Qr169eHiopKpW5DVG3xx5jcaGhoIDIykhdUqxMF+N4iksaTJ09KvXhbv3590XS7jRs3xtu3b6syNMXAPoFIIrm5uSXOtHns2DEEBwejadOmAAA7Ozts2LBBqqT1+/Lz83H69Gm8efMGdnZ2SEhIQEpKCrp06SJqo66uDnt7e8TExJR6jpWUlISePXuWepzIyMhyxUdUraRcEv/+KkYIvHlW2K5ht6qKqsI0NTUxfvx4jB8/Xt6hkLLi+V+1wCuuRPJU5sg3FI58K8ivwqCIiCqoaDDOhz+SigbjPDsmn7g+4OjoiNjYWInb5+fnw9HREXFxcZUYFVEVeXYMONEMiOwOXP288P8nmlWbz2dN0KpVK6mXYKJKoiDfW0TSMDY2xvHjx0usCw0NFd2B/erVK2hra1dlaNUf+wQiiX322WeIiooqVp6bmwstLS3R43r16iE/X/prWw8ePICdnR1at26Nr7/+Gtu2bUPLli2RkpICoHDa4Pfp6+sjNTVV6uMQKZW3SbJtR1QT8Pyv2uCd1kTypKQj34ioBlOgaaiEQiFSUlLw/Plzidrn5+dDKCzpeYkLDAzE2bNn8c8//0BDQwN2dnaYN28ezMzMxI4dEBCAw4cP4/Xr17C1tYW3tzfMzc1FbXJzc+Hn54fw8HDk5OSgU6dOWLFiBRo1aiT9kyV6H2d5qRbmzZuHBQsWwNzcHA4ODvIOp+ZSoO8tImlMmDAB3t7eGDFiBPr27StK5Jw+fRp//vknVq1aBQC4fv06bGxs5BxtNcI+gUgqQ4cOxZdffol+/fph4cKFokEwrq6umDhxItzd3fHmzRvs378fvXv3lnr/zZs3R1hYGF6/fo2zZ89i4cKFOHDggKheIBCItS/r95qRkRHvpiblV8eo7DbStJOzNWvWYPz48TA2NpaovVAoxNq1azFx4kRePyHJ8PyvWmHSmkieOPKNiJSNgg3GmTFjhlTtP7woUpKoqCiMGjUKrVu3Rn5+Pvz9/TFhwgRERESgbt26AIBdu3Zh79698PX1RbNmzbBjxw6MGzcOp0+fFk3luXbtWvzyyy/w9/eHjo4OfH198eWXX+LYsWNQVeVJMpUTf4xVGytXrkRWVhbGjBmD+vXrw9DQUKxeIBDgxIkTcoquBlGw7y0iSXl4eEAoFGLr1q3w9fUVlevr62PlypUYNmwYAGDKlCklTu1bY7FPIJLKxIkT0bdvX6xYsQL9+vXD4sWLMXDgQCxcuBA7duzAsWPHIBQKMWTIEEyZMkXq/aurq4umGG/dujXu3LmD4OBgTJo0CQCQmpoqdg6VlpYGfX192Tw5IkVl4Fw4pfGbRJT8u09QWG/gXNWRlUtISAg+++wziZPWBQUFCAkJwZAhQ5i0Jsnw/K9aYdKaSJ6UbOQbEZEiDcbx8fEp13YmJiYfrQ8KCip2HEdHR9y9exf29vYQCoUIDg7GlClT4OrqCgDw8/ODk5MTwsPDMWLECGRkZCA0NBTr1q2Dk5MTAGD9+vXo1q0brl69CmdnxfhxSdUQf4xVGzo6OtDR0ZF3GKRA31tEksrPz8fTp0/Rr18/eHh44J9//sGrV6+go6MDMzMzsUF4TO58gH0CkdRMTEywe/duhIeHw9fXF2FhYVi5ciVmz56N2bNny/RYQqEQubm5MDExgYGBAa5cuQIrKysAhTNVRUdHY968eTI9JpHCUVEtXIP30lAAAognrv//HKD9JoUZpCwUCrF9+3bo6urKOxRSVjz/q1aYtCaSJyUb+VYVCgoKkJOTgzp16sg7FCIqiQINxhkyZEiVHCcjIwMARFPlJSQkICUlBV26dBG1UVdXh729PWJiYjBixAjExsYiLy8PnTt3FrVp2LAhzM3NERMTU2rSOikpCT179iw1Fk6FR/wxVn3s379f3iEQoFDfW0SSEgqF+PTTT7Fjxw64uLigRYsW8g5JcbBPICq3AQMGwNnZGevWrcPAgQMxbdo0jB8/vtyzRG3cuBFdu3ZFo0aNkJWVhVOnTiEqKgq7d++GQCCAp6cnAgMD0axZMzRt2hSBgYHQ0NDAgAEDZPzMiBSQqVvhsk83Z4kPWq5rUpiwVqDloIyNjREfHy/VNkZGRpxJhiTH879qpUJJayaPiCpIyUa+VYacnBxERETg4sWL+OOPP/Dff/9BKBRCXV0dLVq0QKdOnfDZZ5+hVatW8g6ViAAOxvmAUCiEj48P2rdvDwsLCwBASkoKAEBPT0+srb6+vmh97dTUVKipqYkS3e+3SU1NrYLISWnxxxiROH5vkRKqVasW9PX1y1zblUrAPoFIapmZmbh16xZycnLQunVrrF27FoMHD8bXX3+NkydPYs2aNWjTpo3U+01NTcWCBQvw4sULaGlpwdLSErt37xYN7J00aRJycnKwcuVKpKenw9bWFnv27BEtt0RU45m6FS77lHKpcFByHaPC7y8Fu8584cIFeYdAyo7nf9WKVElrJo+IKoESjXyTpezsbOzevRvBwcHIyMiAmZkZHB0doaenh9q1a+PVq1dISEjAjz/+iL1798LOzg7z58+HnZ2dvEMnqtk4GEfMqlWrEB8fj4MHDxar+3B9bEkuLJfVxsjIiHdT08fxx1i18urVK3z//fe4du0aXr58CV1dXTg5OWHMmDHFBq1QJeH3FimpTz/9FGFhYejWrZu8Q1Es7BOIpPLHH39g2rRpyMnJgbq6Ot6+fYvly5dj2LBh+Omnn/Ddd9/B09MTQ4cOxZw5c1CvXj2J9/3NN998tF4gEMDLywteXl4VfRpEyktFlcs+EZWF53/VikRJayaPiCqZkox8kyVXV1fUqVMHU6dOxcCBA0tda00oFOLatWs4duwYPD094e3tjWHDhlVxtEQkhoNxAACrV6/GhQsXcODAATRq1EhUbmBgAKDwzgFDQ0NReVpamqiv09fXR15eHtLT08USV2lpaTy/oorhj7FqIzk5GSNHjsTz58/RokULGBsb48WLF9i+fTvCwsJw6NAhNGzYUN5h1gz83iIl1KpVK5w6dQqenp5wdXWFgYFBsQFzrq6ucoqummOfQCSxNWvWoGfPnli5ciVq1aqFkJAQrFmzBgMHDoSGhga8vLzw6aefwtvbG/3798evv/4q75CJiIiK4/lftSFR0prJI6IqwJFvYmbOnIkhQ4aUufaRQCCAo6MjHB0dMXPmTNHUukQkZzV4MI5QKMTq1atx7tw57N+/H6ampmL1JiYmMDAwwJUrV2BlZQUAyM3NRXR0NObNmwcAsLGxgZqaGq5cuYL+/fsDAF68eIGHDx9i/vz5VfuESPnwx1i1sHHjRmRnZ+PIkSNiU2bevn0bU6dOhb+/P3x9feUYYQ1Tg7+3SDktXLgQQOEAmaioqGL1AoEAcXFxVR2W4mCfQCSRx48fY/78+ahVq/AS82effYbVq1cjKSkJzZs3BwCYmZnhwIEDOHr0qDxDJSIi+jie/1ULEiWtmTwioqo2dOhQqbcxNTUtlhwiIjmqoYNxVq5cifDwcGzfvh316tUTrWGtpaUFDQ0NCAQCeHp6IjAwEM2aNUPTpk0RGBgIDQ0NDBgwQNTW3d0dfn5+0NXVhba2Nvz8/GBhYQEnJyd5Pj1SFvwxJneXLl3C7Nmzi63x2KZNG8ycORObN2+WU2Q1WA393iLlFBwcLO8QFB/7BKIytWrVCgcPHoSFhQU0NDSwe/duaGlpwcTEpFjb8lznISIiqlI8/5M7iZLWTB4RERERSebQoUMAgNGjR4uV+/j4wM2t8A7WSZMmIScnBytXrkR6ejpsbW2xZ88eaGpqitovWbIEtWrVwuzZs5GdnQ1HR0f4+vqWOYiQSGL8MSZXGRkZaNy4cYl1JiYmyMjIqOKIiEiZODg4yDsEIqoBVq5ciRkzZqBLly4AAG1tbfj4+EBNTU3OkREREZEikihpTUQkTwEBAaXWqaiooH79+rCxsUHbtm2rLigiUjoPHz7EH3/8geTkZGRnZ0NXVxctW7aEvb29WDK5LA8ePCizjUAggJeXF7y8vEptU7t2bSxfvhzLly+X+NhEpDhMTExw8eJFdO7cuVjdb7/9VuIdSkREksrPz8fdu3fx/PlzCAQCGBkZwdramoPfiEimzM3NcerUKTx+/Bi5ubkwMzND7dq15R0WERERKSipk9ZMHhFRVQsICIBAIIBQKCxWV1QuEAhgb2+PHTt2oF69eh/dX3R0NIKCghAbG4uUlBRs27YNvXr1KrGtt7c3Dh8+jMWLF2Ps2LGi8tzcXPj5+SE8PBw5OTno1KkTVqxYgUaNGlXouRJR1UpPT8fhw4dx+PBhPH/+vMR+platWujatStGjx4NR0dHOURJRMrIzc0NGzZsgFAoxODBg2FgYICUlBScOHECBw4cwFdffSXvEIlIQe3atQtBQUFIT08XK9fW1sakSZMwYcIEOUVGRMpIVVUVLVq0kHcYRFSDxMTE4Pr163j16hV0dHTg4OCAdu3ayTssIpKBciWtZZk8AoCQkBAEBQUhJSUF5ubmWLJkCTp06FDmdjdv3sTo0aNhbm6On376SazuzJkz2Lx5M54+fYomTZpgzpw56N27t+RPlIiqjXPnzmHixIlwd3fHgAEDoK+vj5SUFERERCA0NBTr16/HkydPsGLFCmzevBlLliz56P7evHkDS0tLuLm5ffQux/Pnz+PPP/+EoaFhsbq1a9fil19+gb+/P3R0dODr64svv/wSx44d490LRAoiODgY27ZtAwD0798fDg4OsLa2RoMGDVC7dm2kp6fj2bNnuHXrFiIjIzF+/Hg4OTnB29sbTZs2lXP0RKToJk6ciGfPnuHAgQMICQkRlQuFQnh4eEidVJJmUB4AnD17FocOHUJcXBxyc3Nhbm6OGTNmwNnZudzPiYjk76uvvkJERATMzMwwdOhQmJiYQCgUIjExEZGRkfj2229x7949bNiwoVz7l/b6zYkTJ7B79248efIEWlpacHZ2xoIFC6Crq1vep0hE1cjdu3fRokULaGhoSLVNy5YteUc2EUktOzsbc+bMwcWLF8XyUwKBAC4uLti0aZNU/RERVT8q0m5w7tw5NGnSBHPnzsWFCxdw+/ZtREZGYu7cuWjSpAmOHDmCdevW4e7du9i8eXOZ+zt16hR8fHwwdepUhIWFoX379pg0aRKeP3/+0e0yMjKwcOHCEu94iomJwZw5czBo0CD89NNPGDRoEGbPno0///xT2qdLRNXA2rVrMWjQIEyePBnGxsZQV1dH48aNMXnyZAwaNAhbtmzBwIEDMX78eJw/f77M/bm4uGDOnDlwdXUttU1ycjJWrVqFb7/9tthaTBkZGQgNDcWiRYvg5OQEKysrrF+/HvHx8bh69WqFny8RVY39+/dj8eLFuHz5Mr7++mv069cPTZo0gaamJtTU1KCvrw87OzuMGzcOBw4cwJkzZ2BoaIiff/5Z3qETkRIQCARYtWoVTp06BW9vb8ycORPe3t74+eefsWrVKqn3VzQoz9vbW6L20dHRcHJyws6dO3Hs2DF07NgRU6dOxb1796Q+NhFVDydPnkRERAS++uornDp1CvPmzcOIESMwcuRIzJs3Dz///DPmzJmDU6dOISIiQur9S3v95saNG1i4cCGGDh2K8PBwbNq0CXfu3MGyZcsq+lSJqJoYOnQo4uPjJW6fn5+PoUOH4q+//qrEqIhIWa1fvx6XLl3C7NmzERkZKcpNzZo1C5cvX8b69evlHSIRVZDUd1q/nzwqUpQ8evfuHbZs2YLdu3fj6dOnCA0NLfOOx71798Ld3R3Dhg0DACxduhSXL1/GoUOHPjolnre3NwYMGABVVdViSap9+/bByckJX375JQCgRYsWiIqKwr59+7Bx40ZpnzIRydn169cxZsyYEuvs7Oywa9cu0b937NhR4eMVFBRg/vz5mDBhAszNzYvVx8bGIi8vT2wNyoYNG8Lc3BwxMTGl3qGUlJSEnj17lnrcyMjICsdORJL7+eefUauW5KdCTZo0gY+PD/Lz8ysxKiKqaczMzGBmZlbh/bi4uMDFxUXi9kuXLhV7PHfuXERGRuLChQuwsrKqcDxEVPWOHDmCgQMHYtKkSaW2mTx5MuLj4/HDDz/g008/lWr/0l6/+fPPP9G4cWN4enoCAExNTTF8+HDs3r1bquMSUfUlFApx79495OTkSNQ+Pz+/xNk7iYgkcerUKUydOlWU9wEKc1NTpkzBu3fvcODAASxfvlyOERJRRUmdtJZl8ig3Nxd3794VS4ADQOfOnRETE1PqdqGhoXj69CnWr19f4jFu3boltvYsADg7O2Pfvn2l7pPJJKLqS11dHffu3StxZoXY2Fioq6sDKEw2161bt8LH27VrF2rVqiW6uPKh1NRUqKmpQVtbW6xcX18fqampFT4+EVUNaRLW7+MSAEQkS2lpaUhMTCzxYq+9vX2VxVFQUICsrCzo6OiU2oa/mYiqtwcPHmDcuHFltvv000+xcOFCqfZdnus3dnZ28Pf3x6+//oquXbsiLS0NZ86c+egAG/YzRIpn5cqVErctWlaSiKg8srOzS127ul27dggKCqriiIhI1qS+WivL5NHLly+Rn58PPT09sfKi9WpL8vjxY2zYsAEhISGlXmxOTU0ttk89Pb1S90lE1VvPnj2xdetWaGlpoW/fvqhfvz5ev36NU6dOYfv27ejfvz8AID4+Hk2aNKnQsWJjYxEcHIxjx45J/UOqrNHCRkZGvMhCREREAIAXL15gwYIFuH79OoD/nUcIBALRBd24uLgqi2fPnj14+/Yt+vXrV2XHJCLZys7OhpaWVpntNDU1kZ2dLdW+y3P9pl27dvj2228xe/Zs5Obm4t27d+jRowfvgCJSIsHBweXarnnz5jKOhIhqAltbW9y5c6fE3NSdO3fQunVrOURFRLIkddK6MpJHHyaGSht1l5+fj6+++gpeXl5lntxIus8iTCYRVV+LFy/G48eP4e3tja+//hqqqqqiKaXatWuHRYsWASiconvGjBkVOtaNGzeQlpaG7t27i8ry8/Ph5+eH4OBgXLhwAfr6+sjLy0N6errY3dZpaWmws7Or0PGJSD5atWpV6nmCQCCAlpYWbGxsMGnSJHTq1KmKoyMiZbR69WrExcVh3rx5sLS0FA3+lYfw8HAEBARg+/btxRJS7+NvJqLqrWHDhoiPjy9zlob79++jUaNG5TqGNNda/vrrL6xZswbTp09Hly5dkJKSgnXr1uHrr7/GN998U+I27GeIFIuDg4O8QyCiGmTZsmWYPHky6tWrhwEDBkBbWxvp6ek4efIkDh8+jMDAQHmHSEQVJHXSWpbJI11dXaiqqhabTjctLQ36+vrF2mdlZSE2NhZxcXFYvXo1gMI7uoVCIaysrBAUFARHR8cSp+j977//StwnEVV/WlpaCAkJwW+//Ybo6Gi8evUKOjo6sLe3R9euXUUXSaRdk60kgwYNgpOTk1jZhAkTMGjQILi5uQEAbGxsoKamhitXrogG6rx48QIPHz7E/PnzKxwDEVW96dOnIywsDFlZWejevbvorqGLFy+iXr166NGjB37//XeMHz8eu3btElvTnoioPKKiorBgwQK4u7vLNY5Tp05h6dKl2Lx5c7FzICJSLM7Ozti9ezf69euHBg0alNjmv//+Q1BQEHr16iXVvqW9fgMAgYGBaNeuHSZOnAigcJBgnTp1MGrUKMyePRuGhoZSxUBEREQ127Bhw/Du3TusWbMGa9asEeWmgMIl4IYPHy5qKxAIcPPmTXmFSkTlJHXSWpbJI3V1dVhbW+PKlSvo3bu3qPzq1aslrmGkqamJkydPipUdPHgQ165dw5YtW2BiYgIAaNu2La5cuSK2rvXly5d5BySRAsrOzsbYsWMxc+ZMuLi4fHT9M0llZWXh6dOnoscJCQmIi4uDtrY2jI2NoaurK9ZeTU0N+vr6MDMzA1DYD7q7u8PPzw+6urrQ1taGn58fLCwseLGXSEFpa2vDwMAAJ0+eFFveJCsrC+PHj0fDhg0RFhaGcePG4bvvvmPSmogqTCAQwMjISK4xhIeHY8mSJdi4cSO6desm11iIqOImT56M8PBweHh44KuvvkL37t2hoaEBoPB31YULF7Bx40a8ffsWkyZNkmrf0l6/KTqmqqqqWFnR47KWViIiIiL6UJ8+faRezpGIFItUSevKSB6NGzcOCxYsgI2NDezs7HD48GEkJSVhxIgRAIANGzYgOTkZ69atg4qKCiwsLMS219PTQ+3atcXKPT098cUXX2Dnzp3o2bMnIiMj8fvvv+PgwYMVjpeIqpaGhgbi4+OLXeyoiNjYWHh6eooe+/j4AACGDBkCX19fifaxZMkS1KpVC7Nnz0Z2djYcHR3h6+sr0ziJqOrs378f8+fPF0tYA0C9evUwfvx4+Pn5YeLEiRg5ciSWLVsmpyiJSJn07dsXv/zyi8wGvJU1KO/931VAYcJ64cKFWLJkCWxtbUVr0mpoaEi0Ji4RVT+NGjXCjh074OXlhblz50JVVVU0ILdoTeoGDRpgx44daNiwodT7l+b6DQB0794dy5cvx8GDB+Hs7IwXL17gm2++QZs2bcp1fCIiIqrZJL1uS0SKS6qkdWUkj/r374+XL19i+/btePHiBSwsLLBz5040btwYAJCSkoKkpCSp9tmuXTts3LgRmzZtwpYtW2Bqagp/f3/Y2trKLG4iqjp2dna4ffs2OnbsKJP9dezYEQ8ePJC4/YULF4qV1a5dG8uXL8fy5ctlEhMRyde///6LWrVKPi16fypMAwMDvHv3ripDIyIl1a9fPyxfvhxCoRDdu3eHjo5OsTbW1tYS76+sQXkf/q46fPgw3r17h1WrVmHVqlWicmkG8RFR9dO+fXucOXMGR44cwdWrV0WfewsLC3Tu3BnDhg0r98AUaa/fuLm5ISsrCyEhIfDz84OWlhY6derEJZWIiIiIiKhEUk8PLuvkEQCMGjUKo0aNKrGurAsmXl5e8PLyKlbet29f9O3bVybxEZF8LVy4ENOmTYOBgQF69+6NevXqyTskIlIyzZs3R3BwMLp27SqWvH737h2Cg4PRvHlzAIUXY0tbI5KISBpjxowBABw4cAAhISFidUKhEAKBAHFxcRLvr6xBeR/+rtq/f78U0RKRItHS0sKECRMwYcIEme9b2us3o0ePxujRo2UeBxEREdVMubm5uHr1KhITE5GbmytWJxAIxJaMJSLFI3XSmskjIqpqw4cPR15eHhYvXozFixdDQ0NDbP0SgUCAmzdvyjFCIlJ0M2fOxMyZM+Hq6oqePXtCX18fqampiIyMRHJyMrZs2QIAuHLlCtq2bSvfYIlIKRTdCU1ERERERERli42NxZQpU5CWlgahUFisnklrIsUnddKaySMiqmp9+vQR62eIiGStV69e+O6777BlyxYcOHBAdJejjY0NVq5cCWdnZwDA2rVr5RwpESmLIUOGyDsEIlIykyZNwpw5c2BlZSVR+9zcXISEhEBdXb3Uu6eJiD4mICBA4rYCgQDTp0+vxGiISNmtXLkSmpqaWLlyJVq0aAE1NTV5h0REMiZ10prJIyKqalxXkaqFgnwg5RLwNgmoYwQYOAMqqvKOimQgNzcXUVFRaNmyJY4ePYq3b9/i9evXqF+/PurUqSPv8IioNOyXiWoefu4/Sl9fH0OHDkWbNm0wePBgODg4wMzMTKxNZmYmbt++jcjISISHh0NLSwvr1q2TU8REpOj9WklJa4FAUOodkExaE1FF/PXXX/j222/Rs2dPeYdCVUXBvydJelInrZk8IomwMyEiZfLsGHBzFvAm4X9ldU2A9psBUzf5xUUyUatWLUyZMgW7du2CsbEx6tSpw2Q1UXWnoP3y3r17JW7Lqe2IPqCgn/uq5OPjg9GjR2Pnzp1Ys2YN8vPzoaGhAV1dXdSuXRvp6el49eoVhEIhjI2NMWXKFIwaNQrq6uryDp2oZlKCfu33338Xe5yfn48uXbrg+++/h6WlpZyiIiJlZWRkVGn7DgwMxMaNG+Hp6YmlS5cCAIRCIQICAnD48GG8fv0atra28Pb2hrm5eaXFQe9Rgu9Jkp7USWuiMrEzoUrw9OlTbN26Fb///jtevXoFXV1dODk5Yfr06WjSpIm8wyNl9uwYcGkogA9Gir9JLCx3Psq+TcGpqKigYcOGyMzMlHcoRCQJBe6X/fz8JG7LpDXRexT4c1/VrKyssGnTJqSlpeHSpUv4888/8eLFC2RnZ8Pa2hpmZmZwcHBA+/btOYsekTwpSb+mq6sr9jg/Px8AoKWlVayOiKiiJk6ciD179sDZ2Vmmg+5u376Nw4cPFxtss2vXLuzduxe+vr5o1qwZduzYgXHjxuH06dPQ1NSU2fGpBEryPUnSkyppnZCQgO+//x5Xr15FUlISgMLRLZ07d8aYMWNgYmJSKUGSAmFnQpXg77//xogRI5CTk4NOnTrB0NAQL168wM8//4yLFy/i4MGDaNGihbzDJGVUkF84COfDPg34/zIBcHM20HgQZ5NQcEOHDkVISAh69OgBVVX+LYmqLQXvlyMjI+UdApHiUfDPvbzo6elh8ODBGDx4sLxDIaIPsV8jUgycSbTacXNzQ2JiInr16gUHBwfo6OgUa7Ns2TKp9pmVlYX58+djzZo12LFjh6hcKBQiODgYU6ZMgaurK4DCQchOTk4IDw/HiBEjKvRc6CP4PVmjSZy0Pn/+PObPn4+3b99CT08PFhYWEAqFeP78Ofbv34+jR49i/fr16NWrV2XGS9UZOxOqJP7+/tDR0cH+/fvRqFEjUfm///6LMWPGYNOmTdi6dascIySllXJJfNaIYoTAm2eF7Rp2q6qoqBKoqanh0aNH6N+/P3r06AEDAwOxu494xyNRNaHg/XLjxo3lHQKR4lHwzz0RUTHs1yQSGBiIs2fP4p9//oGGhgbs7Owwb948mJmZidosWrQIx48fF9vO1tYWR44cqepwSdlwJtFq6eLFiwgMDMS7d+8QHh5erF4gEEidtF61ahVcXFzg5OQklrROSEhASkoKunTpIipTV1eHvb09YmJiSk1aJyUlfXTNbQ5klgC/J2s0iZLWf//9N+bOnQtzc3MsX74cbdu2FauPiYnBmjVr8NVXX+HYsWO847GmYmdClSQ6OhpLly4VS1gDQKNGjTBt2jSsXbtWTpGR0nubJNt2VG19++23on+XtOastEnr6OhoBAUFITY2FikpKdi2bZvYwL7S1nebP38+Jk6cCAAYPXo0oqKixOr79+8Pf39/ieMgUjrsl4lqHn7uiUjZsF+TSFRUFEaNGoXWrVsjPz8f/v7+mDBhAiIiIlC3bl1RO2dnZ/j4+Igeq6mpySNcUiacSbTaWrduHaytrbFy5Uq0aNGiwp/3iIgI3Lt3D0ePHi1Wl5KSAqBw9pr36evr4/nz5xU6LpWB35M1mkRJ6127dqFJkyYICQmBhoZGsXo7OzscOHAAw4YNw+7du8VOFKgGYWdCleTt27clTvcCFK6flJ2dXbUBUc1Rx0i27ajakvVI1zdv3sDS0hJubm7w8vIqVn/58mWxx7/99huWLl2KPn36iJV7eHhg5syZosclnYcR1SgK3i9PmjQJc+bMgZWVlUTtc3NzERISAnV1dYwaNaqSoyOqphT8c09EVEwN6Nfen7WqvIKCgsQe+/j4wNHREXfv3oW9vb2oXF1dHQYGBhU+HhEAziRazSUmJiIgIACtWrWq8L6SkpKwdu1a7NmzB7Vr1y613Yf9mVBY0nvjf4yMjHg3dUXVgO9JKp1ESetr165hxowZH71QWqdOHYwZMwbbtm2TWXCkYNiZUCVp3rw5Tp48ia5duxari4iIEJsaikimDJwLp396k4iSf7AICusNnKs6MpIxWU/Z6+LiAhcXl1LrP7yoEhkZiY4dO8LU1FSsXENDgxdgiN6n4P2yvr4+hg4dijZt2mDw4MFwcHAodh6TmZmJ27dvIzIyEuHh4dDS0sK6devkFDFRNaDgn3siomKUqF8bOHBgieVz5syBurq6WJlAIMCJEyfKfayMjAwAgLa2tlh5VFQUHB0dUb9+fdjb22POnDnF7owswml7qUycSbRaMzMzQ2Zmpkz2dffuXaSlpcHN7X93zefn5yM6OhohISE4ffo0ACA1NRWGhoaiNmlpadDX15dJDFQKJfqeJOlJlLROS0tDs2bNymzXvHlzpKWlVTQmUlTsTKiSjB49GsuWLUNGRgaGDBkCAwMDpKSk4MSJE7hw4QLWrFkj7xBJWamoFq5XdGkoAAHE+7b/H2nZfhNH1yqBtLQ0XL9+HUlJSRAIBGjUqBE6duxY6sUOWUpNTcWvv/4KX1/fYnUnT57EiRMnoK+vj65du2L69OnQ1NSs9JiIqi0F75d9fHwwevRo7Ny5E2vWrEF+fj40NDSgq6uL2rVrIz09Ha9evYJQKISxsTGmTJmCUaNGFbvoS1SjKPjnnoioGCXq10qaFe/9u6BlRSgUwsfHB+3bt4eFhYWovGvXrujbty+MjY2RkJCAzZs3Y8yYMTh27BjPn6h8OJNotTZr1ixs3LgRHTp0qPAA/06dOuHkyZNiZYsXL4aZmRkmTZoEU1NTGBgY4MqVK6KZsnJzcxEdHY158+ZV6NhUBiX6niTpSZS01tTUlCgZnZaWxgupNRk7E6okQ4cORVpaGnbs2IFff/0VQOEPFg0NDcyZMwfu7u5yjpCUmqlb4XpFN2eJj7ata1LYp3EdI4WWk5MDHx8fHD16FPn5+aJpngQCAVRVVeHh4YGFCxd+dKqoijp+/Djq1asHV1dXsfKBAwfCxMQE+vr6ePjwITZs2ID79++XuOZ2Ed45QDWCgvfLVlZW2LRpE9LS0nDp0iX8+eefePHiBbKzs2FtbQ0zMzM4ODigffv2Mplak0gpKPjnnoioGCXp1/bv318lx1m1ahXi4+Nx8OBBsfL+/fuL/m1hYQEbGxv06NEDFy9eLPb7CuC0vSQBziRarf3www94/fo1XF1d0apVq2IzLwgEAuzYsUOifWlqaooNggGAunXrQkdHR1Tu6emJwMBANGvWDE2bNkVgYCA0NDQwYMAA2TwhKp2SfE+S9CRKWrdp0wahoaHF1ln8UGhoKGxtbWUSGCkodiZUSb788kt8/vnniImJwatXr6CjowM7OztoaWnJOzSqCUzdCtcrSrlUOJq2jlHhrBEchKPQ3r17h0mTJiEqKgqdO3dGr169YGJiAqFQiMTERJw/fx4HDx7E33//jT179kBVtXL+3qGhoRg4cGCxxLiHh4fo3xYWFmjatCnc3d1x9+5dWFtbV0osRApDCfplPT09DB48GIMHD5Z3KESKQQk+90REYmpYvyYUCss1IG/16tW4cOECDhw4gEaNGn20raGhIYyNjfH48eNyRkk1HmcSrdbi4+OhoqICXV1dJCcnIzk5Waxe1oN+J02ahJycHKxcuRLp6emwtbXFnj17eONmValh35NUSKKk9ahRozB58mSsXr0aCxYsKHZRNScnB35+frh06RJ27txZKYGSAmFnQpVES0urxHWtiaqEiirXK1Iyhw8fxs2bN+Hv749+/foVqx85ciROnTqFBQsW4MiRIxg5cqTMY7hx4wYePXqETZs2ldnW2toaampqePLkSalJa945QDUK+2Wimoefe5lwdXVFQUEBzp8/L+9QiKiG9GsnTpzA9u3bRWvESkIoFGL16tU4d+4c9u/fD1NT0zK3efnyJZKSksTWnyWSCmcSrdYuXLhQqfv/cPYIgUAALy8veHl5Vepx6SNqyPck/Y9ESeuuXbti/Pjx2LNnD8LDw9GlSxeYmJgAABISEnD58mW8fv0a48aNg7MzRxkR2JmQzAmFQty5cweJiYnIyckpVs+7lIhIWidOnMDw4cNLTFgX6d+/P6KjoxEWFlYpSeujR4/C2toarVq1KrPtw4cPkZeXV+F1m4iIiKhmEwqFKCgokHcYRKQkMjIycP78eaSmpqJ58+bo0aMHVFRUAABnz57Fli1b8Ndff8HY2Fiq/a5cuRLh4eHYvn076tWrh5SUFACFNzRoaGggKysLAQEBcHV1hYGBARITE+Hv7w9dXV306tVL5s+TahDOJEpEJDcSJa0BYMGCBbCyssJ3332HiIgIsTpzc3N4e3vj008/lXmARESPHj3C1KlT8eTJE9F6s+8TCARMWhOR1P766y9Mnz69zHY9evTAiRMnpNp3VlYWnj59KnqckJCAuLg4aGtriy7WZGZm4vTp01i4cGGx7Z8+fYoTJ07AxcUFurq6+Pvvv+Hr6wsrKyu0a9dOqliIiIiI3nfu3Dl5h0BESuLJkycYNWoU0tLSRNN/29vbY/v27Zg7dy4uXbqE+vXrY/78+Rg9erRU+z506BAAFNvOx8cHbm5uUFVVRXx8PMLCwpCRkQEDAwN07NgR/v7+nLqXKo4ziVZbubm5OHbsGKKiovDy5Ut8/fXXaNasGc6fPw9LS0uJZmUgoupL4qQ1AAwYMAADBgxASkoKkpKSABROQ8k7foioMq1atQq5ubnw9/eHpaUl1NXV5R0SESmB/Px8aGholNlOXV0d+fn5Uu07NjYWnp6eosc+Pj4AgCFDhsDX1xcAEBERAaFQiAEDBhTbXk1NDdeuXcP+/fuRlZUFIyMjuLi4YMaMGZW2tjYREREREZE0Nm/ejMzMTMyYMQM2NjZISEjAjh07MGLECPz1118YNmwY5s+fj/r160u97wcPHny0XkNDA0FBQeUNnahsnEm02vnvv/8wZswYPHz4EPr6+khLS0NWVhYAIDIyEpcvX8aKFSvkGyQRVYhUSesiBgYGTFQTUZW5ffs2Vq9ejb59+8o7FCJSIo0bN8atW7fg4ODw0Xa3bt1C48aNpdp3x44dy7zIMnz4cAwfPrzEOiMjIxw4cECqYxIREREVefToEVJSUko8z4mKioKhoSGaNWtW9YERkVKJiorC1KlT8eWXX4rKmjRpgkmTJmHEiBFMHhGRTK1fvx6vX79GaGgoLC0tYWNjI6rr2LEjdu3aJcfoiEgWVCRpdOrUKal3nJycjJs3b0q9HRHRh+rWrcupnYhI5nr06IGgoCCxabw/9PjxY+zdu5drohEREZFC8fX1RWRkZIl1v/zyi2jmFyKiinj58mWx5Yvat28PAOjfv788QiIiJXbx4kXMnDkT1tbWEAgEYnUNGzbEv//+K6fIiEhWJEpar1q1CoMGDcKPP/6IzMzMj7aNjY3FypUr0adPH9y/f18mQRJRzebm5obw8HB5h0FESmbChAmoU6cOhg4dit27d+PRo0fIy8tDXl4eHj16hF27dsHDwwN16tTB+PHj5R0uERERkcTu3LkDe3v7Euvs7e0RGxtbxRERkTLKz89H7dq1xcqKHterV08eIRGREsvMzISxsXGJde/evZN6aTciqn4kmh783Llz2Lp1K9auXYtVq1bBysoKVlZW0NPTg7q6OtLT0/Hs2TPcunULKSkpMDc3x9atW+Hs7FzZ8RNRDWBhYYGIiAhMmTIFPXr0gI6OTrE2rq6uVR8YESk0HR0dBAUFYebMmfj222+xYcMGsXqhUIiWLVtiy5Yt0NbWllOURFQTubq6oqCgAOfPn5d3KESkoDIyMlC3bt0S6zQ0NJCenl7FERGRsvrnn3+gqqoqelyUNPrnn3+KtbW2tq6yuIhI+ZiYmODWrVtwdHQsVnf79m00b95cDlERkSxJlLTW0tLCkiVLMH36dBw7dgy//vorwsLC8PbtW1EbU1NTODs7Y+DAgejUqVOlBUxENc9XX30FAEhISMDFixeL1QsEAsTFxVVxVESkDFq0aIGTJ0/i7NmzuHr1KpKSkgAUrinduXNn9O7dGyoqEk1MQ0QkM0KhEAUFBfIOg4gUWMOGDXH79m04OTkVq7t9+zYMDAzkEBURKaPFixeXWL5gwQLRv4VCIa/dEFG5REdHw8rKCvXq1cPAgQOxa9cumJubo1u3bgAKrwvfvn0bwcHBmDp1qnyDJaIKkyhpXURbWxvjxo3DuHHjABSO3M3OzoaOjg7U1NQqJUCi/2Pv7uNrrv8/jj/PZnM1ZtnGmFxvsrm+amOGCYnKXERCUlKuv6EiQmobId9chJDrCMlFX7n+hS5IVGMuKsVYbK5ZmDm/P/Z1vh074+zsbGc7e9xvN7d23p/353Ne53Q+z885n/fnAli0aJGjSwDgxFxcXNSmTRu1adPG0aUAgKS0K10BQFa0bNlSc+bMUe3atc1OLPj+++81d+5cderUyYHVAXAWUVFRji4BgJPr2bOnVqxYoZo1a+qll17Sjz/+qAEDBpiuiNenTx9dunRJYWFh6tmzp4OrBZBVmRq0vlexYsVUrFgxe9UCABY1bNjQ0SUAAAAAQJ7Rv39/7d69W71791aFChVUunRp/fXXX/rjjz9UpUoVDRw40NElAnACHTp0cHQJAJyc0Wg0/e3m5qa5c+fqyy+/1M6dO3X+/Hl5eXmpWbNmeuKJJ7hSHuAEsjRoDQAAkFe1a9dOgwcP1mOPPWZV/3PnzmnOnDny9fVV3759s7k6AM7uxIkTSkxMtHhw3t69e+Xr66sKFSrkfGEAnEKxYsW0YsUKffLJJ9q1a5fOnDkjLy8vDRw4UL169VLRokUdXSIAAECmGQwGPfHEE3riiSccXQqAbMCgNYBcacKECZnq/9Zbb1ndd9++fZo3b55iY2OVmJioGTNmqGXLlpKklJQUffDBB/r666916tQpeXh4KDQ0VK+99ppKlSplWsatW7cUExOjDRs26ObNm3r00Uc1duxYlS5dOlN1A3CcNm3aaMSIEfL09FT79u3VsGFDBQUFycvLSwaDQTdu3NDJkyf1008/adu2bdq9e7eCg4MznU8AYEl0dLQqVKhgcdB6x44dOnHihD766CMHVAbAWRQtWlT9+/dX//79HV0KAAAAADxQrhi0Xrp0qebNm6fExERVrVpVI0eOVP369S32/eGHH/T+++/rxIkT+vvvv1WmTBl17dpVzz//vKnPmjVr9Oabb6ab9+eff1bBggWz62UAsKMlS5ZY3ddgMGRq0Do5OVmBgYGKjIxMd1m8Gzdu6PDhw3rllVdUrVo1XblyRe+9955eeeUVrVmzxtTv3Xff1Y4dOzR16lSVKFFC0dHRevnll7VmzRq5urpaXQsAxxkwYIC6dOmihQsX6rPPPtPcuXNlMBhkMBhUoEABpaSkSEq7FFX9+vU1ZcoUtWrVysFVA3AWv/zyizp37mxxWoMGDbR+/focrggAAAAAcp9evXrJYDA8sJ/BYND+/ftzoCIA2cXhg9ZffvmloqKi9Pbbb6tu3br69NNP9dJLL2njxo0qU6ZMuv5FihTRc889p8DAQBUuXFj79+/X22+/rcKFC+uZZ54x9fPw8NCmTZvM5mXAGsg7jhw5km3LDg8PV3h4uMVpxYoV04IFC8za3nrrLXXu3FlnzpxRmTJldPXqVa1evVoTJ05UaGioJGnSpElq1qyZvvnmG4WFhWVb7QDsy9fXV8OHD9eQIUP0008/6eDBgzp37pxu3LghLy8vVapUSY0aNeIqCgDs7urVqypSpIjFaYUKFdLly5dzuCIAzuaLL77Qhg0bdObMGd24ccNsmsFg0NatWx1UGQAAgPUaNmyohx56yNFlAMgBDh+0XrBggTp27Gg6y2DUqFHavXu3li9frtdeey1d/+rVq6t69eqmx/7+/tqyZYt++OEHs0Frg8EgHx+f7H8BAJzetWvXZDAYVLx4cUlSbGysUlJS1LhxY1OfUqVKqWrVqjpw4ECGg9YJCQmKiIjI8Hm2bdtm38IBWM3NzU3169fP8EovAGBvpUqV0s8//2w6AO6ffv75Z37LAMiSOXPmaMqUKapSpYqqVasmd3d3R5cEAABgk/79+6tmzZqOLgNADnDooPWtW7d06NAh9e3b16y9cePGOnDggFXLOHz4sA4cOKAhQ4aYtScnJ6t58+ZKTU3VI488osGDB5sNdt+LwSQAlty8eVPvv/++2rVrJw8PD0lSUlKS3Nzc5OnpadbX29tbSUlJjigTAADkMS1bttScOXNUu3ZtPfroo6b277//XnPnzlWnTp0cWB2AvG7lypXq3r27Ro8e7ehSAAAAAMAqVg1a9+zZ0+oFGgwGLVy40Kq+Fy9eVGpqqkqWLGnW7u3trcTExPvO27RpU124cEGpqakaMGCA2f3gKlWqpKioKAUGBuratWtatGiRunXrpi+++EIVKlSw+rUAcJx27dpp8ODBeuyxx6zqf+7cOc2ZM0e+vr7pDoSxVUpKioYOHSqj0aixY8c+sL/RaLzvdD8/Pw6Asac7qVLiLunvBKmwn+QTJrlwP3Eg12AdBe6rf//+2r17t3r37q0KFSqodOnS+uuvv/THH3+oSpUqGjhwoKNLhLMhl/OVpKQktWzZ0tFl2BefYQAAkJfxXQZ4IKsGre8diDlx4oSSkpJUpkwZ+fj4KDExUWfOnJGPj48qVqyY6SIMBkO657u37V5Lly5VcnKyfvrpJ02ePFnly5dXu3btJEm1a9dW7dq1TX3r1q2rDh06aMmSJXrrrbcsLo/BJCB3adOmjUaMGCFPT0+1b99eDRs2VFBQkLy8vGQwGHTjxg2dPHlSP/30k7Zt26bdu3crODhYEyZMsMvzp6SkaMiQIYqPj9fChQtNZ1lLaQfWpKSk6PLly2ZnW58/f1516tSxy/PjAU6tkfYPlpLj/9dWxF+qN00qF+m4ugCkYR0FHqhYsWJasWKFPvnkE+3atUtnzpyRl5eXBg4cqF69eqlo0aKOLhHOhFzOd4KCgnTq1CmFhIQ4uhT74DMMAADyMr7LAFaxatB68eLFpr+//vprjR49WsuXLzcbnPnxxx81dOhQ9enTx+on9/Lykqura7rL6Z4/f17e3t73nbdcuXKSpMDAQCUlJenDDz80DVrfy8XFRTVq1NAff/xhdW0AHGvAgAHq0qWLFi5cqM8++0xz586VwWCQwWBQgQIFlJKSIintIJf69etrypQpatWqlV2e++6A9Z9//qlFixbJy8vLbHpwcLDc3Ny0Z88etW3bVlLamd7Hjx/X8OHD7VID7uPUGmlXJ0n3nNmefDqtPWwVX/YAR2IdBaxWtGhR9e/fX/3793d0KXBm5HK+9MYbb2j48OGqXr26goODHV1O1vAZBgA4C860zbQjR444uoSs47sMYLVM39P6gw8+0IABA9KdTVi3bl0NGDBAU6dOVXh4uFXLcnd3V1BQkPbs2WN2CeBvvvnmvveXvpfRaDQNYGU0PS4uTgEBAVYv06HYeAGSJF9fXw0fPlxDhgzRTz/9pIMHD+rcuXO6ceOGvLy8VKlSJTVq1EilS5fO1HKvX7+ukydPmh7Hx8crLi5Onp6e8vX11aBBg3T48GHNnj1bqampptsVeHp6yt3dXcWKFVPHjh0VExMjLy8veXp6KiYmRgEBAQoNDbXre4B73ElNOyrx3i950n/bDNL+IVLZp8hNwBFYRwEgdyGX862RI0fq0qVL6ty5s7y9vVWiRAmz6QaDQevWrXNMcZnBZxgA4Cw40zZ/4rsMkCmZHrT+9ddf5efnZ3Gan5+ffv/990wtr3fv3hoxYoSCg4NVp04drVixQgkJCerataskafLkyTp79qwmTpwoKe2y4H5+fqpUqZIkaf/+/Zo/f76ee+450zKnT5+uWrVqqUKFCqZ7Wh85ckRvv/12Zl9uzmPjBaTj5uam+vXrq379+nZZXmxsrHr27Gl6HBUVJUnq0KGDBgwYoO3bt0uSnnrqKbP5Fi1apEaNGklK2wlUoEABDRkyRDdu3FBISIiio6Pl6sqXi2yVuMs8H9MxSsmn0vqVapZTVcHJxcbGaunSpaaswH2wjgKZ8sUXX2jDhg06c+aMbty4YTbNYDBo69atDqoMToNczrdKlCiRbqA6T+IzDABwBpxpm3/xXQbIlEwPWnt7e2vz5s1q0qRJummbNm164GW979W2bVtdvHhRM2fO1Llz5xQQEKA5c+aobNmykqTExEQlJCSY+t+5c0dTpkxRfHy8XF1d9fDDD+u1114zDXJL0pUrVzRmzBglJiaqWLFiql69upYsWaKaNWtm9uXmLDZeQI5o1KiRjh49muH0+027q2DBgho9erRGjx5tz9LwIH8nPLhPZvoBVjh9+rTWrl3LoLU1WEcBq82ZM0dTpkxRlSpVVK1aNbm7uzu6JDgjcjnf+udt3vI0PsMAgLyOM23zN77LAJmS6UHrbt26afLkybp06ZLat28vb29vJSUlaf369dqyZYv+9a9/ZbqI7t27q3v37hanRUdHmz3u0aOHevTocd/ljRw5UiNHjsx0HQ7FxgsAHqyw5St92NwPgH2xjgJWW7lypbp3784BcMhe5DLyOj7DAIC8jjNt8ze+ywCZkulB65deekk3btzQxx9/rC1btkhKu2d0wYIF1a9fP7300kt2LzJfYOMFAA/mE5Z2y4Tk07J8kI8hbbpPWE5XBkBiHQUyISkpSS1btnR0GXB25HK+dunSJX3yySf67rvvdPHiRXl5eSk0NFS9evWSp6eno8uzDp9hAEBex5m2+RvfZYBMcbFlpoEDB2r37t366KOPFBMTozlz5mjXrl0aPHiwvevLP9h4AcCDubhK9ab994Hhnon/fVzvA65IAYfbt2+f+vXrpyZNmigwMDDdfWnfeOMNBQYGmv3r0qWLWZ9bt27pnXfeUaNGjVS7dm3169dPf/31V06+jMxjHQWsFhQUpFOnTjm6DDg7cjnfOnv2rCIjI/XRRx/p6tWrKlOmjK5evaqZM2eqQ4cOOnv2rKNLtA6fYQBAXseZtvkb32WATLFp0FqSihUrpvDwcD355JNq2rSpihcvbs+68h82XgBgnXKRUtgqqUhZ8/Yi/mnt5SIdUxfwD8nJyQoMDNSYMWMy7BMWFqbdu3eb/s2ZM8ds+rvvvqstW7Zo6tSpWrZsmZKTk/Xyyy8rNTU1u8vPGtZRwCpvvPGG5s+fr9jYWEeXAmdHLudLU6ZM0Y0bN7Ry5Upt3LhRCxYs0MaNG7Vy5UrdvHlTU6dOdXSJ1uMzDORLs2fPVseOHVWnTh2FhITo1Vdf1e+//27Wx2g06sMPP1STJk1Us2ZN9ejRQ8ePH3dQxUAG7p5pm27A8i6DVKQcZ9o6M77LAFbL9OXBJenChQtasGCB9u7dq0uXLmn69OmqWrWqPv30U9WsWVPVq1e3d53Oj8tEADaJjY3V0qVLFRUV5ehSkJPKRUpln0q7ZcLfCWkH9PiEcVQiMqVfv35W9UtKSsr0ssPDwxUeHn7fPu7u7vLx8bE47erVq1q9erUmTpyo0NBQSdKkSZPUrFkzffPNNwoLy+XfB1hHgQcaOXKkLl26pM6dO8vb21slSpQwm24wGLRu3TrHFAfnQy7nO7t27dKQIUNUs2ZNs/aaNWtq0KBBmjZtWgZz5lJ8hoF8Z+/everevbtq1Kih1NRUTZ06VX369NHGjRtVpEgRSdLcuXO1YMECRUdHq0KFCpo1a5Z69+6tTZs2ycPDw8GvAPivu2fa7uqktIHrf+7750zbfIPvMoBVMj1oferUKXXr1k3Xrl1TtWrVdPLkSd26dUuSdPToUf30008MHtmCjRdgk9OnT2vt2rXkTn7k4iqVauboKpCHHTt2zOq+fn72v9LJ3r17FRISouLFi6tBgwYaOnSoSpYsKSntgJyUlBQ1btzY1L9UqVKqWrWqDhw4kOGgdUJCgiIiIjJ8zm3bttn3RdwP6yhwXyVKlEg3UA1kK3I5X7l69arKli1rcZq/v7+uXr2awxXZAZ9hIF+ZN2+e2eOoqCiFhITo0KFDatCggYxGoxYtWqR+/fqpVatWkqSYmBiFhoZqw4YN6tq1qyPKBiy7e6bt/sFScvz/2ov4p+3z50zb/IHvMsADZXrQetKkSSpevLhWr16tkiVLKjg42DStXr16+vDDD+1aYL7CxgsAgByzfft2hz1306ZN1aZNG5UpU0bx8fGaNm2aevXqpTVr1sjd3V1JSUlyc3OTp6en2Xze3t42nfkNIPdZvHixo0sA4MT8/f21c+dOswPg7vr666/l7+/vgKoAwHZ3D7a5+xspPj5eiYmJatKkiamPu7u7GjRooAMHDlgctM5VB/ki/+FMWwB4oEwPWn/33XcaO3asSpUqle6eij4+Pjp37pzdisuX2HgBAOD02rZta/o7ICBAwcHBatGihXbu3Gk6S8ASo9HSLUT+x8/Pjx0tAABAkZGRmjx5soxGo55++mn5+PgoMTFR69at05IlS/Taa685ukQAsJrRaFRUVJTq1aungIAASVJiYqIkma5WdZe3t7fOnDmT4zUCVuFMWwC4r0wPWt+8eTPdWT93/f333zIYDFkuKt9j4wUAQLabOHGievbsqdKlS5va7ty5IxcXlxyvxdfXV2XKlNEff/whKW1HS0pKii5fvmz2vev8+fOqU6dOjtcHIHtcunRJn3zyib777jtdvHhRXl5eCg0NVa9evTL8zZWRffv2ad68eYqNjVViYqJmzJihli1b3neevXv3Kjo6WsePH5evr69efPFFdevWLSsvCUAu8eKLL+rUqVNasmSJli5damo3Go3q0qWL+vTpY9Nyly5dqnnz5ikxMVFVq1bVyJEjVb9+/Qz737p1SzNmzNC6deuUmJio0qVLq1+/furUqZNNzw8gfxo/fryOHTumZcuWpZt2777o+x3oy0G+AADkbpneK1uxYkV98803Fqft27fPdLQbAABAbrZgwQKzK8SkpqYqKChIhw4dyvFaLl68qISEBPn6+kqSgoOD5ebmpj179pj6nDt3TsePH2fQGnASZ8+eVWRkpD766CNdvXpVZcqU0dWrVzVz5kx16NBBZ8+ezdTykpOTFRgYqDFjxljV/9SpU+rbt6/q1auntWvXql+/fnr33Xf11Vdf2fJyAOQyBoNB48eP15dffqkxY8Zo0KBBGjNmjP7zn/9o/PjxNi3zyy+/VFRUlF555RWtXbtW9erV00svvXTfMxoHDx6sb7/9Vu+++642bdqkKVOmqFKlSra+LAD50DvvvKPt27dr4cKFZgcc+/j4SFK62yedP39e3t7eOVojAACwj0yfad25c2dFR0fL19dX7du3lySlpKRo06ZNWrZsmdU7SQDgfvr162dVP+7tCsBWlo7Af9Dlt611/fp1nTx50vQ4Pj5ecXFx8vT0lKenp6ZPn65WrVrJx8dHp0+f1tSpU+Xl5WU6K7JYsWLq2LGjYmJi5OXlJU9PT8XExCggIEChoaF2qRGAY02ZMkU3btzQypUrVbNmTVP7zz//rFdeeUVTp05VdHS01csLDw9XeHi41f0//fRT+fn5adSoUZKkypUr65dfftH8+fPVunVr618IgFytUqVKdhskXrBggTp27KjOnTtLkkaNGqXdu3dr+fLlFi83/vXXX2vfvn3aunWrSpQoIUncSxuA1YxGo9555x1t2bJFixcvVrly5cym+/v7y8fHR3v27FH16tUlpV3dYd++fRo2bJgjSgYAAFmU6UHr7t2768iRI4qKilJMTIwk6dlnn5XRaFTnzp3VoUMHuxcJIP85duyY1X39/PyysRIAyLzY2Fj17NnT9DgqKkqS1KFDB40dO1bHjh3T2rVrdfXqVfn4+KhRo0aaOnWqPDw8TPOMHDlSBQoU0JAhQ3Tjxg2FhIQoOjparq6uOf56ANjfrl27NGTIELMBa0mqWbOmBg0apGnTpmXr8x88eFCNGzc2awsLC9Pq1auVkpIiNze3dPMkJCQoIiIiw2VyuU3AsS5fvqy33npLkZGRat68ucU+O3bs0Jo1azR+/Hh5eXlZvexbt27p0KFD6tu3r1l748aNdeDAAYvzbN++XcHBwfr444/1xRdfqEiRImrRooUGDx6sQoUKWZyHnAFw17hx47RhwwbNnDlTRYsWNd3DulixYipUqJAMBoN69uyp2bNnq0KFCipfvrxmz56tQoUKqV27dg6uHgAA2CLTg9ZS2mVZOnbsqB07dujChQvy8vJSs2bNVLduXXvXByCf2r59u6NLAACbNWrUSEePHs1w+rx58x64jIIFC2r06NEaPXq0PUsDkEtcvXpVZcuWtTjN399fV69ezdbnT0pKSnfpzJIlS+r27du6ePGi6XYFAPKOzz77TEeOHFFYWFiGfcLCwhQdHa2lS5dqwIABVi/74sWLSk1NVcmSJc3avb29TQNJ9zp16pT279+vggULasaMGbp48aLGjRunS5cumQ7oA4CMLF++XJLUo0cPs/aoqChFRkZKkl566SXdvHlT48aN0+XLl1WrVi3Nnz/f7GBgAACQd9g0aC1JtWvXVu3ate1YCgAAQM76/fffTWcup6ammtosCQoKyrG6ADg/f39/7dy5M93ZzlLaJXVz4hK6BoPB7PHdWyTc236Xn58fZzkCudiXX36pzp07q0CBjHf1FChQQJ07d9aXX36ZqUHruyzlRkaZcXfa+++/r2LFikmS3njjDQ0aNEhvv/22xbOtyRkAd93vIOC7DAaDBg4cqIEDB+ZARQAAILvZPGi9a9cu7d27VxcvXtSrr76qMmXK6Oeff5a/v78eeughe9YIIB+aOHGievbsqdKlS5va7ty5IxcXFwdWBcDZvPnmm+naRowYYfb47g7XuLi4nCoLQD4QGRmpyZMny2g06umnn5aPj48SExO1bt06LVmyxOL9Ye3J0tmRFy5cUIECBUz3ngWQt5w4cUI1atR4YL+goCDNnDkzU8v28vKSq6urkpKSzNrPnz+f7qoNd/n4+KhUqVKmAWtJqly5soxGo/766y9VqFAhUzUAAAAAcG6ZHrT++++/9eqrr+rbb781HU3brVs3lSlTRvPnz5efn59ef/11uxcKIH9ZsGCB2rRpYxq0Tk1NVXBwsFatWsXZjgDsgstSAnCkF198UadOndKSJUu0dOlSU7vRaFSXLl3Up0+fbH3+2rVra8eOHWZtu3fvVnBwsMX7WQPI/VJTU+97lvVdBQoU0O3btzO1bHd3dwUFBWnPnj167LHHTO3ffPNNhvegrlu3rjZt2qTr16+raNGiktIG1l1cXMwOTgYAAAAAyYZB66lTpyo2NlYffvihQkNDVa9ePdO0xo0ba8mSJXYtEED+dPfylA9qAwBbdejQwdElAMjHDAaDxo8fr+eff17ff/+9Ll26pBIlSujRRx9VxYoVM72869ev6+TJk6bH8fHxiouLk6enp8qUKaPJkyfr7NmzmjhxoiSpa9euWrp0qaKiotSlSxcdOHBAq1ev1uTJk+32GgHkLB8fH/36669q0KDBffsdP348w7Oj76d3794aMWKEgoODVadOHa1YsUIJCQnq2rWrJKXLmXbt2mnmzJl68803NWjQIF28eFGTJk1Sx44dLV4aHAAAAED+lulB602bNmnw4MF67LHHTPd+vKtMmTJKSEiwW3EAAAA54Zdffrnv5TRXrVqlTp065WBFAPKLSpUqqVKlSlleTmxsrHr27Gl6fPdqEh06dFB0dLQSExPNfquVK1dOc+bMUVRUlJYuXSpfX1+NGjVKrVu3znItAByjYcOGWrZsmTp16pThFRNSUlK0fPlyNWrUKNPLb9u2rS5evKiZM2fq3LlzCggI0Jw5c1S2bFlJSpczRYsW1fz58zVhwgR17NhRJUqU0OOPP64hQ4bY9PoAAAAAOLdMD1pfuHBBVapUsTjNxcVFN27cyHJRAAAAOemVV17RihUrTDtd/2njxo16++23GbQGkGWXL1/WW2+9pcjISDVv3txinx07dmjNmjUaP368vLy8rF52o0aNdPTo0QynR0dHp2tr2LChPv/8c6ufA0Du1qtXL3Xq1EkDBgzQ+PHjVapUKbPpZ8+e1ejRo3XixAm9//77Nj1H9+7d1b17d4vTLOVM5cqVtWDBApueCwAAAED+kulB61KlSunYsWN69NFH0007evSo/P397VIYgGx2J1VK3CX9nSAV9pN8wiQXV0dXZeb333+Xq2taTXev7PD7779b7Mt9rgFkRWBgoPr27avly5erePHipvZt27bp9ddf17PPPuvA6gA4i88++0xHjhxRWFhYhn3CwsIUHR2tpUuXasCAATlYHYC8rlq1ahozZozGjRuniIgIBQcHmw7IO336tGJjY2U0GjV27FgFBgY6uFoAAAAAMJfpQetWrVrpo48+Uv369U0/cgwGg06fPq1PPvlEkZGRdi8SgJ2dWiPtHywlx/+vrYi/VG+aVC73rMNvvvlmurYRI0aYPTYajTIYDIqL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6pNPPlH//v21efNmlS1bNsPyP/74o8aMGaOxY8eqZcuWOn/+vCZPnqwJEyaYzsEA5H3dunVTTEyMatasadXyqamp6tatm9asWaPq1avbuToArmTmzJnatWuXhg8fro4dO5pGq9q4caPmzZunmTNnMrwuADO0/wK4V447radPn66nnnpKAwYMME0rV66cBgwYoNu3b+udd97RBx98oJMnT2rt2rUP7LQOCwtTWFjYfZc5f/68pk6dqsWLF+ull14ym5eYmKi1a9dqxowZatKkiaS0k6YWLVrou+++4w4lII9Yvny5xo4dqw4dOsjNzS3DfB8fH/n4+KhOnTp6/vnndfLkSS1YsED//ve/NXDgQAdUDMAVVapUSZUqVcrxeqw5v7nb+PHjzV7/61//0vbt27Vjxw46rYE8bOPGjdq8ebNeffVV9e/fP8P8kSNHauHChZozZ45atWqlDh06WL3upUuXqmvXrurevbuktBz59ttvtWrVKouPNPj5559Vrlw59enTR5JUvnx5Pf300/rggw+y+dMBcEZGo1G//PKLbt26ZdXyqampFu9wAoAH2bJliwYNGmTWVluuXDkNHDhQt2/f1ooVK+i0BmCG9l8A98pxp/WePXvUt29fi/Pq1KmjRYsWmf69YMGCnG5Od+7c0ahRo9SvXz9VqVIlw/zY2FilpKSoadOmpmmlS5dWlSpVdODAgUw7rePi4tS6detMt7t9+/Yc1w7Aev/+979VqJD1EfXwww8rIiJCqampdqwKQH5z8eJFnTlzxmJDb4MGDXKtjjt37uj69evy8vK673KczwDObfXq1erUqZPFDut0AwYM0PHjx/XJJ59Y3WmdnJysI0eOmF1ILElNmzbVgQMHLL6nTp06mjNnjv7zn/+oefPmunjxor788sv7XmBDxgB505QpU6xeNv0RbwCQVUlJSZk+u7pu3bpavHhxLlcEwNnR/gvgXjnutHZ3d9cvv/xi8VkCsbGxcnd3l5TW2Fq0aNGcbk6LFi1SoUKFTHcE3CshIUFubm7y9PQ0m+7j46OEhIQcbx9A7sjKCcvdeAQAAFu4cOGCRo8erT179kiS6Y4jg8Fgasw9evRortWzZMkS3bx5U0888USubROA7R07dkzPP//8A5fr0KGDxowZY/V6L126pNTUVJUqVcpsevqjmyypW7eu3nrrLQ0fPlzJycm6ffu2WrVqxR1QgItZtmxZtt6XncehAMjfatWqpcOHD1tsIz58+LBq1KjhgKoAODPafwHcK8ed1q1bt9a7776rEiVKqF27dipZsqSuXr2qLVu2aP78+Wrfvr0k6fjx43r44YdztK3Y2FgtW7ZM69aty/KVvw8a3srf3587AwAAgCTpjTfe0NGjRzVy5EgFBQWZLsJzhE2bNmnevHmaP39+hg6pe3E+Azi3pKQklShR4oHLFS9eXElJSVle/72/I93vjsnffvtN06ZN0+DBg9WsWTPFx8drxowZev311/Xmm29afA8ZA+Q9DRs2dHQJAPKJCRMmaMCAASpWrJg6duwoT09PXblyRRs3blRMTIyio6MdXSIAAHByOe60Hjt2rP78809NmjRJr7/+ugoWLGh6BlLdunX12muvSUobonvIkCE52taPP/6oixcvqmXLlqZpqampioqK0rJly7Rjxw75+PgoJSVFV65cMbvb+uLFi6pTp06Otg/AMapWrZppg6vBYFCJEiUUHBys/v37q1GjRrlcHQBXtHfvXo0ePVpdu3Z1aB1btmzR+PHjNXfuXDVp0sShtQDIudKlS+v48eMPfLzAr7/+qjJlyli9Xm9vbxUsWDDDyFIXL16Uj4+PxfdER0erbt26evHFFyWlnW8VKVJEvXr10vDhw+Xn52f19gEAALp3767bt29r2rRpmjZtmqmNWEq7m/Lpp582LWswGLR//35HlQrACdH+C0CyQad1iRIltHLlSn3zzTfat2+fLl++LC8vLzVo0EDNmzc3BY21z2O7n6eeeipDg22/fv301FNPKTw8XJIUHBwsNzc37d6923SX94ULF3TixAmNGjUqxzUAyH2DBw/W+vXrdf36dbVs2dI01OXOnTtVrFgxtWrVSt9//71eeOEFLVq0yOyZ9gCQHQaDQf7+/g6tYdOmTRo3bpxmz56tFi1aOLQWALYRGhqqDz74QE888YQeeughi8v8/fffWrx4sdq0aWP1et3d3VW9enXt3r1bjz32mGn6d999l+kzqJOSkjIMq5f++kGjVAEAANzr8ccfz/LImACQjvZfAFIOO62TkpL03HPPadiwYQoLC1NYWFiOC7p+/bpOnjxpen369GkdPXpUnp6eKlu2rLy9vc2Wd3Nzk4+PjypVqiQprRO9a9euioqKkre3tzw9PRUVFaXAwEDuUALy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YblFixapZcuWqlGjhrp06aL//Oc/Vv+cn332mVJTUzNcZff+++/L09NTY8aMyfCeokWLauLEibp586bZ8AXpwx8cO3ZML7zwgurUqaPnnntOUtoQNOPGjVPDhg1Vp04dDRgwQKdOnbI4VEqnTp107do1i8PVWCt9KI2NGzdq5syZatasmerUqaOBAwcqISFB165d08SJExUSEqKQkBCNHTtW169fz7Cer776SkWLFlWTJk0k/W/oje+//14TJkxQSEiI6tatq9GjR+vGjRuKj4/XK6+8ovr166tZs2aKiopSSkqK2Trv/ZnT1/nDDz/o9ddfN9U0ZMgQnT9/PtufAZwfOUPOSOQM7IeMIWMkMgb2Rc6QMxI5A/siZ8gZiZyB/ZAxZIxExsC2LA0P3rt3b3Xs2FGHDh3Ss88+q1q1aql169ZauHCh2dDblly7dk39+vVTkyZNdOjQIXuXL0lKTk7WmjVr1LFjxwfeZZ2+H27atElz5sxRs2bNVLduXT333HP6/fffMyy/Zs0aPfnkk6pRo4YaNmyowYMH67///a/ZMs6UM5kN957+c+/ZsyfL6zQajfrqq6/0+OOPm6alH2O+/vprde7cWTVr1tQTTzyhr7/+2lTHE088odq1a6tbt246fPiw2TotDQ+evs5vvvlGXbp0Uc2aNdWuXTutWbMmyzXnlG3GsMjnateurU8//VTTpk1Tp06dVK1aNbm5uWW6/NGjR/Xmm29qyJAh8vHx0caNGzV9+nSlpKSoX79+kqSbN2/qn//8p86dO6eBAwcqKChIJ06c0DvvvKPjx4/rww8/lMFgkCRNmjRJn332mXr37q2RI0fqypUreu+999SzZ099/vnn8vHxkZQ2tMu8efPUrVs3Pf744zp37pwmTpyoO3fuqGLFig/8OXfu3Kl//OMfKlmypGnahQsXdOLECbVv315FihSx+L46deqoVKlS2r17t9n0lJQUDRo0SD179lT//v2VmpqqO3fuaODAgYqNjdWQIUNUvXp1HThwQC+++KLFdfv6+qpSpUr6z3/+o27duj3wZ7ifOXPmKCQkRBERETpz5oyioqL0r3/9S4UKFVJQUJBmz56tX375RXPmzFGxYsU0YcIEs/dv3bpVLVq0kLu7u9n0CRMmqG3btmbvT01N1R9//KHHHntMTz/9tL777jstWrRIfn5+ev755x9Y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/36Ly6emplr8vIsUKSI/Pz+dPHnS4tDgro5OaxsYNGiQtm/fro8++kgXLlxQSkqKfH191aRJE7300kumh52n+8c//qHw8HC9++67+vPPP+Xn56exY8eaXYlWtGhRrVy5UgsXLlRMTIxOnz4tDw8P+fv7q0mTJmbDuUydOlW1atVSTEyMVq1apTt37sjPz09169ZVzZo1Tcu98sorKlq0qD7++GN9/vnnqlSpkqZMmaIlS5ZY9XN26tRJa9eu1aFDh8zWK0ljxoxRo0aNtHLlSr3++uu6fv26HnroITVq1Ehz5861+gqVAgUK6P3331dkZKQWLVqklJQU1a1bVzNnzlSPHj1UsmRJs+X37NmjxMREderUyar129qNGzf07bffavLkyQ7ZPvIPcoacIWdgT2QMGUPGwN7IGXKGnIG9kTPkDDkDeyJjyBgyBs6sQIECevPNN1WsWDGNGjVKN2/eVPfu3bO0jiNHjuiVV17JML1Lly6ZDo3v7++v+vXra/v27Xr66aezVbslL730kh566CEtX75cW7ZskYeHhxo2bKh//etfqlChwgPf74icKViwoN5//3298cYbev311+Xu7q4OHTpo0qRJGjBgQIblb926ZfHzLleunJ588kmVK1dOwcHB2a4nzzIiV7Vs2dI4YMAAR5eRbR07djROmjQp17e7YcMGY2BgoHH//v1m00eOHGl8+umnrVpHy5YtjaNHjzampKQYU1NTbVLX5s2bjdWqVTNeunTJJuuzpdTUVGNKSoqxV69exg4dOji6HOQiciZ7yJmsI2fyJzIme8iYrCNj8i9yJnvImawjZ/IvciZ7yJmsI2fyJzIme8iYrCNj4Ay++OIL4z/+8Q/juXPnHF3KAzlrztzriSeeMEZERNhl3TmVkpJi/O6774yBgYHGf//73zZfP3daI0tGjRqlIUOGaNCgQSpTpoxdtrFp0yadP39egYGBKlCggA4ePKjFixerQYMGqlu3rmm5kydP6t///rc+/PBDq9e9fv16rV+/Xr169dKkSZNyXGv79u3Vvn37HK/HHoYMGaLt27dLkqpUqeLgagDrkTPmyBnAtsgYc2QMYHvkjDlyBrA9csYcOQPYFhljjowB7q9t27aqUaOGoqOjbbLP2Upeypl73e/Z24509OhRde7c2a7bMBiNRqNdtwAzrVq1UpUqVRQdHe3oUrJtxYoVqlq1qurXr2+X9X/99dd69913dfLkSd28eVO+vr5q06aNhg8fbvbckB9++EF//fWX1cNOHDt2TMnJyZKkUqVKqWzZsnap31mcPHlSV65ckSR5eHhw4pKPkDMPRs7YBjmTP5ExD0bG2AYZk3+RMw9GztgGOZN/kTMPRs7YBjmTP5ExD0bG2AYZA2dx/Phx7dixQwMGDFCBAgUcXY4kcsYekpKSdOLECdPrhx9+WJ6enjbdBp3WAAAAAAAAAAAAAACHcY5LHgAAAAAAAAAAAAAA+RKd1gAAAAAAAAAAAAAAh6HTGgAAAAAAAAAAAADgMHRaAwAAAAAAAAAAAAAchk5rAAAAAAAAAAAAAIDD0GkNAAAAAAAAAAAAAHAYOq0BAAAAAAAAAAAAAA5DpzUAAAAAAAAAAAAAwGHotAYAAAAAAAAAAAAAOAyd1gAAAAAAAAAAAAAAh6HTGgAAAAAAAAAAAADgMHRaAwAAAAAAAAAAAAAchk5rAAAAAAAAAAAAAIDD0GkNAAAAAAAAAAAAAHAYOq0BAAAAAAAAAAAAAA5DpzUAAAAAAAAAAAAAwGHotAYAAAAAAAAAAAAAOAyd1gAAAAAAAAAAAAAAh6HTGgAAAAAAAAAAAADgMHRaAwAAAAAAAAAAAAAchk5rAAAAAAAAAAAAAIDD0GkNAAAAAAAAAAAAAHAYOq0BAAAAAAAAAAAAAA5DpzUAAAAAAAAAAAAAwGHotAYAAAAAAAAAAAAAOAyd1gAAAAAAAAAAAAAAh6HTGgAAAAAAAAAAAADgMHRaAwAAAAAAAAAAAAAchk5rAAAAAAAAAAAAAIDD0GkNAAAAAAAAAAAAAHAYOq0BAAAAAAAAAAAAAA5DpzUAAAAAAAAAAAAAwGHotAYAAAAAAAAAAAAAOEwhRxfgDOrXr6/k5GT5+vo6uhTA6cTHx8vd3V0//vijo0vJ08gZIHPkjG2QM0DmyJmcI2OAzJExtkHOAJkjZ3KOjAEyR8bYBjkDZM7anKHTWtKtW7eUmprq6DIAp3T79m0ZjUZHl5HnkTNA5sgZ2yBngMyRMzlHxgCZI2Nsg5wBMkfO5BwZA2SOjLENcgbInLU5Q6e1JD8/P0nS9u3bHVwJ4Hxat27t6BJcAjkDZI6csQ1yBsgcOZNzZAyQOTLGNsgZIHPkTM6RMUDmyBjbIGeAzFmbMzzTGgAAAAAAAAAAAADgMHRaAwAAAAAAAAAAAAAchk5rAAAAAAAAAAAAAIDD8Exr5H13UqX4XdLNOKmIv+QbKhUo6OiqAMB5kZsAXAFZBsDeyBm4Ar7HgPNhvwTgCsgy2AGd1sjbTq2T9r8i3Tj9v2lFA6R6c6Xy4Y6rCwCcFbkJwBWQZVY5f/68Zs6cqV27dikpKUkVKlTQ9OnTFRwcLEkyGo2aN2+eYmJidPXqVdWqVUuTJk1SlSpVHFw54ATIGbgCvseA82G/BOAKyDLYCcODI+86tU7a1c08GCXpxpm06afWOaYuAHBW5CYAV0CWWeXKlSt65pln5ObmpkWLFmnz5s167bXXVLJkSdMyixYt0tKlSzVp0iStWbNGPj4+ev7553Xt2jUHVg44AXIGroDvMeB82C8BuAKyDHZEpzXypjupaVfyyGhh5v9P2z88bTkAALkJwDWQZVZbtGiRypQpo4iICNWsWVMBAQFq3LixHn74YUlpd1kvW7ZMAwcOVNu2bRUYGKioqCglJSVp06ZNDq4ecCByBq6A7zHgfNgvAbgCsgx2Rqc18qb4XRmv5DFjlG6cSlsOAEBuAnANZJnVduzYoeDgYA0bNkyNGzdW586dtXr1atP806dPKz4+Xs2aNTNNc3d3V4MGDXTgwAGL64yLi1Pr1q0z/QO4BHIGroDvMeB82C8BuAKyDHZGpzXypptxtl0OAFwduQnAFZBlVjt16pRWrVqlChUqaPHixerZs6emTZum9evXS5Li4+MlSaVKlTJ7n4+PjxISEnK7XMB5kDNwBXyPAefDfgnAFZBlsLNCji4AyJYi/rZdDgBcHbkJwBWQZVYzGo0KDg7Wv/71L0lStWrV9Ntvv2nVqlXq3LmzaTmDwZDhfZnx9/fX9u3b7VIv4DTIGbgCvseA82G/BOAKyDLYGXdaI2/yDZWKBkgyZLKAQSpaPm05AAC5CcA1kGVW8/X11aOPPmo2rVKlSjp79qxpvqQMd1VfvHhRPj4+uVMk4IzIGbgCvseA82G/BOAKyDLYGZ3WyJsKFJTqzf3/F/cG5P+/rvd22nIAAHITgGsgy6xWt25d/fHHH2bT/vzzT5UrV06SFBAQIF9fX+3evds0Pzk5Wfv27VOdOnVytVbAqZAzcAV8jwHnw34JwBWQZbAzOq2Rd5UPl0LXSEXLmU8vGpA2vXy4Y+oCAGdFbgJwBWSZVfr27auff/5Z77//vv766y9t3LhRq1ev1rPPPispbVjwPn36KDo6Wl999ZWOHz+usWPHysPDQx07dnRw9YCDkTNwBXyPAefDfgnAFZBlsCOeaY28rXy4VO4pKX6XdDMu7VkJvqFcyQMAmSE3AbgCsuyBatasqXnz5mn27Nl67733FBAQoHHjxunJJ580LdO/f3/dunVLU6ZM0ZUrV1SrVi0tWbJExYsXd2DlgJMgZ+AK+B4Dzof9EoArIMtgJ3RaI+8rUFAq3cLRVQBA3kFuAnAFZNkDtWzZUi1btsx0vsFg0NChQzV06NBcrArIQ8gZuAK+x4DzYb8E4ArIMtgBw4MDAAAAAAAAAAAAAByGTmsAAAAAAAAAAAAAgMPQaQ0AAOBg0dHRCgoK0vTp003TjEaj3n33XTVr1kw1a9ZU7969deLECQdWCQAAAAAA4HpolwGcA53WAAAADnTo0CHFxMQoKCjIbPqiRYu0dOlSTZo0SWvWrJGPj4+ef/55Xbt2zUGVAgAAAAAAuBbaZQDnQac1AACAg1y/fl2jRo3StGnT5OnpaZpuNBq1bNkyDRw4UG3btlVgYKCioqKUlJSkTZs2ObBiAAAAAAAA10C7DOBcCjm6AAAAgPxq6tSpCgsLU5MmTbRgwQLT9NOnTys+Pl7NmjUzTXN3d1eDBg104MAB9ezZ0+L64uLi1Lp160y3t337dtsVDwAAAAAAkIfRLgM4FzqtAQAAHGDz5s365ZdftGbNmgzz4uPjJUmlSpUym+7j46OzZ8/mSn0AAAAAAACuinYZwPnQaQ0AAJDL4uLiNH36dC1ZskSFCxfOdDmDwWD22mg03ne9/v7+XLULAAAAAABwH7TLAM6JTmsAAIBcduTIEV28eFHh4eGmaampqdq3b59WrlypL774QpKUkJAgPz8/0zIXL16Uj49PrtcLAAAAAADgKmiXAZwTndYAAAC5rFGjRtq4caPZtLFjx6pSpUrq37+/ypcvL19fX+3evVvVqlWTJCUnJ2vfvn0aOXKkI0oGAAAAAABwCbTLAM6JTmsAAIBcVrx4cQUGBppNK1q0qLy8vEzT+/Tpo+joaFWoUEGPPPKIoqOj5eHhoY4dOzqiZAAAAAAAAJdAuwzgnOi0BgAAcEL9+/fXrVu3NGXKFF25ckW1atXSkiVLVLx4cUeXBgAAAAAA4NJolwFyH53WAAAATmD58uVmrw0Gg4YOHaqhQ4c6qCIAAAAAAID8gXYZwPEKOLoAAAAAAAAAAAAAAED+Rac1AAAAAAAAAAAAAMBhGB4cAAAAyMydVCl+l3QzTiriL/mGSgUKOroqAAAAAAAA5DW0M90XndbW4osEALA3jjWAczm1Ttr/inTj9P+mFQ2Q6s2Vyoc7ri7AGXEMA4D8i2MAADgn8hlwLrQzPRCd1tbgiwQAsDeONYBzObVO2tVNktF8+o0zadND17BvAuk4hgFA/sUxAACcE/kMOBfamazCM60fJP2LdHe4S//7Ip1a55i6AACug2MN4FzupKb9cn/vLxLS/6btH562HJDfcQwDgPyLYwAAOCfyGXAutDNZjU7r++GLBACwN441gPOJ35Xxl3szRunGqbTlgPyMYxgA5F8cAwDAOZHPgPOhnclqdFrfD18kAIC9cawBnM/NONsuB7gqjmEAkH9xDAAA50Q+A86Hdiar0Wl9P3yRAAD2xrEGcD5F/G27HOCqOIYBQP7FMQAAnBP5DDgf2pmsVshWKxo4cGC23jd+/HiVL1/eVmXYFl8kAIC9cawBnI9vqFQ0IO15XxaHVDOkzfcNze3KAOfCMQxwKi7ZLgPnxTEAAJwT+Qw4H9qZrGazTuudO3fKx8dH7u7uVr8nLi5OQ4cOtVUJtscXCQBgbxxrAOdToKBUb660q5skg8z3TUPaX/XeTlsOyM84hgFOxSXbZeC8OAYAgHMinwHnQzuT1WzWaS1J8+fPV82aNa1a9vbt2woODrbl5m2PLxIAwN441gDOqXy4FLpG2v+K+fPAigak7ZPlwx1WGuA0OIYBTsfl2mXgvDgGAIBzIp8B50Q7k1Vs9kzrqlWrqkiRItZvuEABVa1aVR4eHrYqwT7Sv0hFy5lPLxqQNp0vEgAgpzjWAM6pfLj05J9S66+lJh+n/f3kH+yTwN04hgFOw2XbZeC8OAYAgHMinwHnRDvTA9nsTuv169dnafkCBQpk+T0OUz5cKveUFL9LuhmX9rwH31CuRgIA2A7HGsA5FSgolW7h6CoA58YxDHAKLt0uA+fFMQAAnBP5DDgn2pnuy6bDg7s0vkgAAHvjWAMAyKs4hgFA/sUxAACcE/kMII+h0xpAnnDkyJFsva9y5coqXLiwjasBAAAAAADIX2ibAQAA9mSzTuvWrVtbvazBYNC2bdtstWkA+UDXrl1lMBiy/L41a9aoevXqdqgIAAAAAJwH7TIA7I22GQAAYE8267Q+c+aMSpQoobp162br5OV+9u3bp8WLFys2Nlbx8fF677331KZNG6veu3//fvXu3VtVqlTR559/btO6AOSugQMH6uGHH7Zq2dTUVE2cONHqdWcnZ/bu3avIyEidOHFCfn5+evHFF/XMM89YvU0AAAAAsBXaZQDkBnu1zdAuAwAAbNZp3bBhQ+3bt0/Hjx9X586dFR4ervLly9tk3Tdu3FBQUJDCw8M1dOhQq9+XmJioMWPGqHHjxkpISLBJLQAcp2XLlqpZs6ZVy6ampmrChAlWrzurOXPq1CkNGDBA3bt318yZM/XTTz9pypQpeuihh/T4449bvV0AAAAAsAXaZQDkBnu1zdAuAwAAbNZpvWzZMp0+fVpr1qzR+vXrFR0drQYNGqhbt256/PHH5e7unu11h4WFKSwsLMvvmzRpkjp27KiCBQsy7BWQx82bN08VK1a0evmCBQtq3rx5euSRR6xaPqs588knn8jf31/jx4+XJD366KM6fPiwlixZwi9HAAAAAHId7TIA7M2ebTO0ywAAgAK2XFlAQICGDx+uHTt26P3335eXl5fGjRunZs2aacqUKTp27JgtN3dfa9eu1cmTJzVkyJBc2yYA+2nTpo1KlCiR5fcUL17cLvUcPHhQTZs2NZsWGhqq2NhYpaSk2GWbAAAAAHA/tMsAsCdnapuhXQYAANdjszut72YwGNS8eXM1b95cly9f1tKlS/XBBx8oPj5e8+bNs8cmzfz555+aNWuWVq5cqUKFrPsR4+Li1Lp160znb9++3VblAXABCQkJ8vHxMZtWqlQp3b59W5cuXZKfn1+G95AzAAAAAHJDXmyXkfidCYD1aJcBAMD12KXTWpKMRqO++eYbrV27Vjt27FDRokVVv359e23OJDU1Va+++qqGDh2apeFqADi39evXZ2n5zp0726WOuxkMBrPXRqPR4nQAAAAAyG20ywCwNWdrm6FdBgAA12LzTuuTJ09q7dq1Wr9+vS5cuKCGDRsqIiJCbdu2VeHChW29uQyuX7+u2NhYHT16VG+88YYk6c6dOzIajapWrZoWL16sxo0bZ3ifv78/V9MBTuy1114z/dKR/ktIZgwGg91/MfLx8VF8fLzZtL///luFChWSl5eXxfeQMwDSffzxx1q1apXOnDkjSapSpYpefvll0zPcXnvtNX322Wdm76lVq5ZWr16d67UCAIC8Ja+2y0j8zgQ4O2dqm6FdBkBO0TYDOB+bdVqvX79ea9as0f79+1WmTBl17dpV4eHhCggIsNUmrFK8eHFt3LjRbNrHH3+sH374Qe+8806u1wPAdooXL64nnnhCHTp0ULFixRxaS+3atfX111+bTfv2228VHBwsNzc3B1UFIK8oU6aMRo4cqYcfflhS2nnU4MGD9dlnn6lKlSqS0p7HFhERYXoP2QIAAO6HdhkAucFZ2mZolwGQU7TNAM7HZp3Wr732mooXL67u3burSZMmKlCggH755Rf98ssvFpdv27at1eu+fv26Tp48aXp9+vRpHT16VJ6enipbtqxmzZql8+fPa8aMGSpQoIACAwPN3l+qVCkVLlw4w3QAeceKFSu0du1abdy4URs3blS7du3UtWtXmw1vl5WckaSePXtq5cqVioiIUI8ePXTgwAGtXbtWs2bNskk9AFxbq1atzF6PGDFCq1at0sGDB02/GLm7u8vX19cR5QEAgDyIdhkA9mbPthnaZQDkNtpmAOdj0+HBr127ptWrV+vTTz+VlPkwMQaDQUePHrV6vbGxserTp4/pdfqVLV26dFFkZKTi4+MVFxeXg8oBOLv69eurfv36mjBhgjZv3qy1a9fqn//8px555BF17dpVnTt3lp+fX7bXn9WcKV++vBYuXKiIiAitXLlSfn5+Gj9+vB5//PHs/5AA8qXU1P9j707Doyizv4//OoEYwhpIgIQgYQsMS8JiQJaAEETZlF0cHkFEFIUIOCggiIAoQQVENsPqIIsgIELgjyzKiDgKw8AgDgM6I0ogQkD2EAKhnxeRaEwCnaSrq7v6+7kuLu2qu6tOd+o+VXXu6qoMbdmyRampqWrYsGHW9D179qhZs2YqVaqUoqOjNWLECJUrV+62y0pOTlZsbGye87kVHgAA1kZdBoCRjKzNUJcBYCZn1WaoywCFY7Pf6QEkDtqzZ0++2jdp0sQZq3WKW0mEhAHk5M794/vvv9eaNWu0YcMGXbx4UQMHDtSIESPMDitX7vw9Ambz1v5x5MgR9enTR9euXVNAQICmTZuW9dykzZs3KyAgQKGhoUpKStLMmTOVkZGhdevWyc/PL9flxcbGKjk5WSEhIXmu09u+Y+AWb80zzsR3COTNXfqHJ9dlJPf5HgF35M79w1NqM+78HQJm8+b+4czaDHUZIG+O5hmn/dLa3U52AFhfjRo11KNHD12/fl0rV67Uf//7X7NDAgCHVa1aVevXr9fFixe1detWjRo1SsuWLVONGjXUsWPHrHYRERGqV6+e2rZtq507d972Vp4hISHmnwDdzJBSdklXk6ViIVJwjOTja25MAAB4AeoyAMxAbQaAJ3N2bcYt6jKAB9fmnHp7cABwhcuXLysxMVFr167VoUOHVKVKFQ0fPlxdu3Y1OzQAcJifn5+qVKkiSapfv76++eYbLV26VJMmTcrRtnz58goNDdWxY8dcHGU+HV8n7RsmpSb9Ni0gTGo8U6rc3by4AAAAADgVtRkAVmDJ2gy8m4fX5pw2aL1kyRJ16dJFQUFB+XrPww8/rLJlyzorDM/mwVc/AK7w1Vdfae3atdq6dat8fHz04IMPatSoUbrnnnvMDq3w6P+A17Pb7UpPT8913rlz55ScnFzg58O5xPF10q6ekv7w5JnUE5nTY9Z4xMExAKAQOKY1FXUZwEnIZbdl6doM3Bf9Ei7i8bUZeDcL1OacNmj9xhtvqHHjxg6fHGVkZOiNN95QkyZNODmSPP7qB8Bo999/v5KSkhQVFaVx48apU6dOCggIMDss56D/A15n+vTpatWqlSpWrKgrV65o8+bN2rNnjxYuXKgrV65o9uzZat++vYKDg3XixAnNmDFDgYGBateundmh5+5mRmYe++NBsfTrNJu0b7hU6WEKC4CLzJo1S7Nnz842LSgoSLt375aUWYyZPXu2Vq1apYsXLyoqKkrjx49XzZo1zQgXVsAxremoywBOQC67LUvXZuC+6JcwiOVqM/BuFqnNOW3Q2m63a82aNfr8888dbo9fWeDqB8Box48fV4kSJXTlyhUtXbpUS5cuzbOtzWbThg0bXBhdIdD/Aa905swZvfjiizp9+rRKliypWrVqaeHChWrRooXS0tJ09OhRrV+/XpcuXVJwcLCaNm2qGTNmqESJEmaHnruUXdkLCDnYpdTjme0q3OeqqACvV7NmTS1ZsiTrta/vbyemCxYs0JIlSxQfH6/w8HDNmzdPAwYM0JYtW9w318B9cUzrFqjLAIVELrsjy9Zm4L7olzCQ5Woz8G4Wqc059ZnWq1evdubivINFrn4AjBYdHW12CM5H/we81uuvv57nPH9/fy1atMiF0TjB1WTntgPgFL6+vgoODs4x3W63a+nSpRo8eLDat28vSZo6daqaN2+uxMRE9enTx9WhwpNxTOtWqMsABUQuc4glazNwX/RLGMxytRl4N4vU5pw2aP2f//zHWYvyLha5+gEw2vvvv292CM5H/wdgFcVCnNsOgFP8+OOPatmypfz8/BQVFaXnn39elStXVlJSklJSUtSyZcustn5+foqOjtb+/fvzHLROTk5WbGxsnuvbsWOH0z8DPADHtG6DugxQCOQyh1iyNgP3Rb8EAMdZpDbnY3YAXs8iVz8AKAD6PwCrCI7JfKaYbHk0sEkBlTPbAXCJyMhITZ06VYsWLdLkyZN15swZ9enTR+fOnVNKSookqVy5ctneExQUpDNnzpgRLjwZx7QArIBcBrgf+iUAOM4itTmn3h4cBWCRqx8AFAD9H4BV+PhKjWf++qwxm7Lfvu3Xg+XGb3PLNsCFWrdune11gwYNdP/992v9+vWKioqSlPmsyd+70/NtQ0JC+DU1cuKYFoAVkMsA90O/BADHWaQ2xy+tzWaRqx8AozVq1EiHDh1yuP3NmzfVqFEj975FHv0fgJVU7i7FrJECKmWfHhCWOb1yd3PiAiBJCggIUEREhI4dO5b1nOs//qr67NmzCgoKMiM8eDKOaQFYAbnMIZaszcB90S8BIH8sUJtj0Npst65+kJRzB+w5Vz8ARktNTdXNmzcdbm+325WamqqMjAwDoyok+j8Aq6ncXXromBT7mdR8ReZ/H/rBIw6KAatLT0/Xf//7XwUHByssLEzBwcHavXt3tvl79+5Vw4YNTYwSHoljWgBWQC5ziCVrM3Bf9EsAyD8Pr81xe3B3cOvqh33DpNSk36YHhGXueD1kYwKM9sgjj+Sr/R9veemW6P8ArMbHV6pwn9lRAF5v6tSpatOmjUJCQvTLL79o3rx5unz5srp16yabzaZ+/fopISFB4eHhqlKlihISEuTv76/OnTubHTo8Ece0AKyAXOYQS9Zm4L7olwCQfx5cm2PQ2l1U7i5VelhK2SVdTc58FkdwDFeKAb8aOnRogd5Xvnx5J0diAPo/AABwsp9//lnPP/+8zp8/r8DAQDVo0ECrV69WpUqZtwkbNGiQrl27pokTJ+rChQuKiorS4sWLVaJECZMjh8fimBaAFZDLbsvStRm4L/olAHgNBq3diQdf/QAYraAnRh6D/g8AAJxoxowZt51vs9kUFxenuLg4F0UEr8AxLQArIJflyfK1Gbgv+iUAeAVDBq2//vpr7dy5U/v379epU6eUlpamwMBA1ahRQ02bNlWHDh1UtmxZI1YNAAAAAB7h/PnzBXpfqVKl5OPj49xgAFgKdRkAAAAAnsapg9YfffSR5s+frx9++EEBAQGqXbu26tWrJz8/P124cEFHjhzR1q1bFR8frw4dOmjYsGFZt6cDAAAAAG9y77335vs5jzabTR9++KHq1q1rUFQAPBl1GQAAAACeymmD1t27d1dSUpIeeughTZ06VfXq1cv16v/z589r+/bt+uijj9SxY0dNnTpVDz74oLPCAAAAAACP0aNHD1WsWNGhthkZGXr33XcNjgiAp6IuAwAAAMCTOW3QunXr1ho4cKBKlChx23ZlypRRz5491bNnT+3du1cXLlxwVggAAAAA4FF69+6tyMhIh9pmZGRo3rx5BkcEwFNRlwEAAADgyZw2aD1s2LB8vyc6OtpZqwcAAAAAjzJmzJh83ZbX19dXY8aMUUhIiIFRAfBU1GUAAAAAeDKnPtMaAAAAAOCY/v37u+Q9AAAAAAAA7s6QQev169fnOc9ms6lUqVKqU6eOKlSoYMTqAXiBy5cv68CBAzp16pTS0tIUGBioGjVqKCIiwuzQAAAAAMBU1GUAuAK1GQAA4EyGDFqPHj1aNptNkmS327Om/36aj4+PHn74Yb366qsqUoQffAO4sxs3buiTTz7RBx98oH/+85+6efNmjhxTpkwZdenSRX/+858VHh5uXrAAAAB38OOPP6p379569dVX1b59+1zbbNu2TePGjdPy5ctVo0YNF0cIwFNRlwFgFGozAADAKIaclXz44YcaMWKEWrRooc6dO6tcuXI6c+aMEhMTtXv3bk2YMEHffvut5syZo0qVKmno0KFGhAHAQnbs2KE33nhDJ06cUPPmzTVixAjVrVtXZcuW1V133aULFy7o+PHjOnDggLZv367ly5erR48eGj58uMqWLWt2+AA8VJcuXfL9HpvNphkzZqh69eoGRATASpYtW6YaNWrkOWAtSffff7+WLl2qZcuWacKECa4LDoBHoy4DwAjUZgC4GnUZwLsYMmi9dOlS3X///Ro1alTWtGrVqqlJkyaKj4/X2rVr9fbbb+vChQvauHEjJ0cA7mj06NHq37+/Hn30UZUrVy7XNlFRUercubPGjRunv//975o3b55WrFhBjgFQYN99953q1Kmj4sWLO/yef/zjH0pLSzMwKgBW8fnnn2vgwIF3bPfQQw9p4cKFLogIgFVQlwFgBGozAFyNugzgXQwZtN65c6dmzpyZ67xWrVrpueeekyTde++9WrZsmREhALCYHTt2qFSpUg63b9asmZo1a6aLFy8aGBUAbzBhwgRFRkY61PbGjRuqV6+ewREBsIrk5GRVrVr1ju3Cw8P1888/uyAiAFZBXQaAEajNADADdRnAe/gYsdCMjAz99NNPuc778ccfs55z4ufnJz8/PyNCAGAx+Tkpcsb7AECSYmNjVbp0aYfb+/r6KjY2ltwDwCG+vr66fv36Hdtdv35dPj6GnLoBsCjqMgCMQG0GgKtRlwG8iyG/tG7ZsqXefvttVa5cWS1atMiavmvXLs2cOTNr2v/+9z9VqlTJiBAAAAAKbc6cOflqb7PZ8v0eAN4rLCxMBw4cUPPmzW/bbv/+/QoLC3NRVACsgLoMAACwAuoygHcxZNB67Nixeuyxx/Tkk0+qePHiKleunM6ePasrV67o7rvv1tixY7Pa9u/f34gQAFhY27ZtZbPZcp3n4+OjkiVLqn79+urXr5+qV6/u4ugAACiAmxlSyi7parJULEQKjpF8fM2OCgZr3bq1li1bpp49e6p8+fK5tjl16pSWL1+unj17ujg6AJ6MugwAo1GbAQC4BPUSr2LIoHWFChX08ccfa926dfrHP/6h8+fPq06dOoqOjla3bt1UrFgxSVLfvn2NWD0Ai2vSpIn27Nmj06dPq1GjRgoKClJKSor279+v8uXLKyQkRNu2bdPHH3+s999/X/Xr1zc7ZAAW9NFHH+lvf/ub7Ha7WrVqpR49ejj83hUrVmjlypU6ceKEJKlmzZp69tln1bp1a0mS3W7X7NmztWrVKl28eFFRUVEaP368atasachngcmOr5P2DZNSk36bFhAmNZ4pVe5uXlww3IABA7R27Vr16dNHL7zwgtq0aSN/f39JUlpamj799FO99dZbstlsevzxx80NFoBHoS4DwGjUZgCYrTB1GYnajEegXuJ1nD5onZaWprFjx+rRRx9V3759OQEC4HQtW7bUgQMHtG3bNoWEhGRNP3nypJ544gm1a9dO8fHxeuyxxzRr1izNnz/fxGgBWNHs2bP14YcfqkOHDkpNTdXkyZP1008/acSIEQ69v2LFiho5cqTuvvtuSdL69es1ZMgQffTRR6pZs6YWLFigJUuWKD4+XuHh4Zo3b54GDBigLVu2qESJEkZ+NLja8XXSrp6S7Nmnp57InB6zhhMxCytXrpwSEhL07LPP6vnnn5evr68CAwMlSefOnVNGRobKlSunefPmqWzZsiZHC8BTUJcB4ArUZgCYqbB1GYnajNujXuKVfJy9QH9/f+3YsUN2u/3OjQHkdDNDOrVTOrYy8783M8yOyO28++67iouLy3ZSJEmhoaEaMmSI5s+fr5IlS+rxxx/XgQMHzAkSgCXyWXp6eq7T161bp6VLl2r06NGaNGmSxo8fr7Vr1zq83LZt26p169aqWrWqqlatqhEjRiggIEAHDhyQ3W7X0qVLNXjwYLVv314RERGaOnWq0tLSlJiY6KyPBndwMyPziuE/noBJv03bN9wj+w4cFxkZqS1btuiFF15Q06ZNVbJkSZUsWVJNmzbVqFGjtGXLFkVGRpodJoxigX0l3A91mUKiXwIOoTbj5shlsAij6jIStRm3Rr3Eaxlye/DatWvr6NGjio6ONmLxgHVxuwuH/Pjjj3lezVaqVKmsW7pUqlRJV69edWVoAG6xSD576KGHNGnSJDVp0iTb9PT0dJUsWTLrdfHixZWRUbAD5YyMDG3ZskWpqalq2LChkpKSlJKSopYtW2a18fPzU3R0tPbv368+ffrkuazk5GTFxsbmOX/Hjh0FihEGSdmVvY/kYJdSj2e2q3Cfq6KCCUqUKKEnnnhCTzzxhNmhwJUssq+Ee6IuU0D0S8Bh1GbcGLkMFuKKuozkvNoMdRknoV7itZz+S2tJGjlypBYtWqQ9e/YYsXjAmm7d7uKPyfjW7S6OrzMnLjcUGhqqjz76KNd5a9euzbrK9/z58ypdurQrQwMgWSqf9ezZU08//bReeuklXbhwIWt6+/bt9eSTT2r58uVasGCBJk+erPvvvz9fyz5y5IgaNmyo+vXr65VXXtGcOXNUo0YNpaSkSMq8bfDvBQUF6cyZM4X/UHAfV5Od2w6A57DQvhLuibpMAdAvgXyhNuOmyGWwGCPrMhK1GbdFvcRrGfJL64kTJ+rKlSvq37+/SpUqpfLly2ebb7PZtGHDBiNWDXimO97uwpZ5u4tKD0s+vq6NzQ0NHDhQ48ePV58+ffTggw9mHSxs2bJF//rXvzRp0iRJ0tdff6169eqZHC3gZSyWz5588kk9+OCDmjBhgjp06KAxY8aoS5cuGjVqlObNm6d169bJbrerW7duGjx4cL6WXbVqVa1fv14XL17U1q1bNWrUKC1btixrvs1my9bekVt8hoSEcNWuJykWcuc2+WkHjzN58mQ98cQTCg0Ndai93W7Xa6+9pieffFIVK1Y0ODoYxmL7Srgn6jL5RL8E8o3ajBsil8GCjKzLSM6vzVCXcRLqJV7LkEHrMmXKqEyZMkYsGrAmbneRL71795bdbtesWbMUHx+fNT0oKEgTJ05Ur169JEmDBw+Wn5+fWWEC3smC+SwsLEwLFy5UYmKi4uPjtX79ek2cOFHDhw/X8OHDC7xcPz8/ValSRZJUv359ffPNN1q6dKkGDRokSTpz5ky2AvPZs2cVFBRUqM8CNxMck3mbvtQTyr2wZMucHxzj6sjgIsuXL9dDDz3k8KD1zZs3tXz5cnXr1o1Ba09mwX0l3A91mXyiXwL5Rm3GDZHLYFFG1WUkajNui3qJ1zJk0Pr99983YrGAdXG7C4dlZGTop59+UocOHdS7d2/973//0/nz51WmTBlVq1Yt29VvHEAAJrBwPuvcubNiYmL0xhtvqEuXLnr22Wf1xBNPyNfXOVeo2+12paenKywsTMHBwdq9e7fq1KkjKfNZTXv37tXIkSOdsi64CR/fzOfK7eopyabsJ2K/7s8av82vICzMbrdr7ty5CgwMNDsUuJKF95VwH9Rl8ol+CeQLtRk3RS6DxRldl5GozbgN6iVey5BBawD5xO0uHGa329WpUyfNmzdPrVu3VvXq1c0OCcDvWTCfXb58WQcOHNC1a9dUv359vfbaa+ratateeeUVbdy4UZMnT1ZkZGS+ljl9+nS1atVKFStW1JUrV7R582bt2bNHCxculM1mU79+/ZSQkKDw8HBVqVJFCQkJ8vf3V+fOnQ36lDBN5e5SzJrM2/j9/lcRAWGZJ2CVu5sWGowXGhqqo0eP5us9ISEh/FrJ01lwXwl4PPolkC/UZtwUuQwWZURdRqI24/aol3glwwatz58/r/fee09fffWVzp07p8DAQDVv3lz9+/dX6dKljVot4Jm43YXDihQpoqCgIIee7QrABBbLZ//85z/17LPP6tq1a/Lz89PVq1f18ssvq1evXvr444/17rvvql+/furZs6dGjBih4sWLO7TcM2fO6MUXX9Tp06dVsmRJ1apVSwsXLlSLFi0kSYMGDdK1a9c0ceJEXbhwQVFRUVq8eLFKlChh5MeFWSp3z3yuXMquzF89FAvJ7CNcMWx5n376qdkhwAwW21fCfVGXyQf6JZAv1GbcFLkMFmRUXUaiNuMRqJd4HUMGrU+dOqVHH31UJ0+eVPXq1RUaGqrTp09r7ty5Wr9+vVauXKkKFSoYsWrAM3G7i3zp1KmT1q9fr/vuu8/sUAD8kcXy2eTJkxUbG6uJEyeqSJEiWr58uSZPnqwuXbrI399fcXFx6tSpk8aPH6+OHTvqb3/7m0PLff31128732azKS4uTnFxcc74GPAEPr48Vw7wFhbbV8I9UZfJJ/olkG/UZtwQuQwWZFRdRqI24zGol3gVHyMWOn36dKWlpWn16tXatGmTlixZok2bNmn16tW6du2aZsyYYcRqAc9263YXAZWyTw8Iy5zO7S6y1K5dW/v371e/fv20bNkyffLJJ9q6dWu2fwBMZKF8duzYMXXu3FlFimRe5/fQQw/p2rVrSk7+7Rlg1apV07JlyziJAQA4zkL7Srgn6jIFQL8E8oXajJsil8FiqMsA3sWQX1rv2rVLw4cPz/EcgcjISD333HOaOXOmEasFPB+3u3DIqFGjJGX+emDPnj055ttsNh0+fNjVYQH4PYvks9q1a2vFihWKiIiQv7+/Fi5cqJIlSyosLCxH2549e5oQIQDAY1lkXwn3RF2mgOiXgMOozbgxchkshLoM4F0MGbS+dOmSKlWqlOu8sLAwXbp0yYjVAtbA7S7uaOnSpWaHAMARFshnEydO1NChQ9WyZUtJUunSpTVlyhQVLVrU5MgAAJZggX0l3BN1mUKgXwIOoTbj5shlsAjqMoB3MWTQOiwsTDt37sx6YP3vff7557leBQMAjmrSpInZIQDwEjVr1tTmzZt17Ngxpaenq1q1arrrrrvMDgsAAOC2qMsAMBq1GQCuQF0G8C6GDFp3795d06ZNk91uV9euXRUcHKyUlBRt2LBBy5Yt01/+8hcjVgvAS2RkZOjbb7/VyZMnZbPZFBISorp168rXl9scAXA+X19fVa9e3ewwAAAAHEZdBoDRqM0AcBXqMoD3MGTQ+sknn9Tx48e1bNkyLV++PGu63W5X7969NXDgQCNWC8ALLFiwQIsWLdKFCxeyTS9durQGDRpEfgHgVN9++62qV68uf3//fL2nRo0aXPkLoED279+vr7/+WufPn1eZMmXUpEkTNWrUyOywAHgY6jIAjERtBoCrUJcBvIshg9Y2m02TJk3S448/nq3gcu+996pq1ar5Xt7evXu1aNEiHTp0SCkpKZozZ47atWuXZ/utW7dq5cqVOnz4sNLT01WzZk0NHTpUMTExhflYAEz2l7/8RZs2bVK1atXUs2dPhYWFyW6368SJE9qxY4feeust/fvf/9a0adMKtPzly5dr0aJFSklJUc2aNfXSSy/pnnvuybP9hg0btHDhQv34448qWbKkYmJi9OKLLyowMLCgHxGAm+nZs6dWrVqlyMhIh9pnZGSoZ8+eWrNmjerWrWtwdACsJC0tTSNGjNDOnTtlt9uzpttsNrVu3Vpvv/12vgo1ALwbdRkARjGyNkNdBsAfUZcBvIshg9a3VKtWTdWqVSv0clJTU1WrVi11795dcXFxd2y/d+9eNW/eXCNGjFCpUqW0bt06PfPMM1q9erXq1KlT6HgAuN7GjRu1adMm/eUvf9GgQYNyzB85cqTmz5+vGTNmqG3bturUqVO+lr9582ZNmTJFr7zyiho1aqQPPvhAgwYN0qZNmxQaGpqj/T/+8Q+NGjVKY8aMUZs2bXTq1ClNmDBB48aN05w5cwr8OQG4F7vdrn//+9+6du2aQ+0zMjKyDTYBgKPefPNN7dq1S8OHD1fnzp2zbuW7ceNGzZ49W2+++aZefvlls8ME4GGoywBwJiNrM9RlAOSGugzgXQwdtD579qxOnDiRa0KJjo52eDmtW7dW69atHW4/duzYbK+ff/557dixQ59++iknR4CHWr16tbp06ZLrSdEtTz31lI4ePaoPPvgg34PWS5YsUY8ePdSrVy9JmXnkiy++0MqVK3N93tu//vUvVapUSf369ZMkVa5cWY888ogWLlyYr/UCcH8TJ050uK3dbpfNZjMwGgBWtXnzZj3zzDN6+umns6ZVqlRJgwcP1o0bN7Rs2TIGrQHkG3UZAM5kZG2GugyAvFCXAbyHIYPWp0+f1osvvqivv/5akrKubLHZbFlJ4/Dhw0asOlc3b97UlStXVKZMmTzbJCcnKzY2Ns/5O3bsMCAyAI46cuSIBgwYcMd2nTp10qhRo/K17PT0dH377bd66qmnsk1v0aKF9u/fn+t7GjZsqBkzZuhvf/ubWrVqpbNnz+qTTz65bSGHPAN4nqVLlxbofQW57SYA75aWlpbns6sbNWqkRYsWuTgiAJ7ME+syEudMgLszqjZDXQZAXqjLAN7FkEHrV199VYcPH9bIkSNVq1Yt+fn5GbEahy1evFhXr15Vhw4dTI0DQMGlpaWpZMmSd2xXokQJpaWl5WvZ586dU0ZGhsqVK5dtelBQkFJSUnJ9T6NGjfTWW29p+PDhSk9P140bN9S2bVt+AQVYTJMmTcwOAYCXiIqK0jfffKNmzZrlmPfNN9+ofv36JkQFwFNRlwFgBKNqM9RlAOSFugzgXQwZtN6zZ49efPFF9ejRw4jF50tiYqJmz56tuXPn5jjw+b2QkBCupgPcWIUKFXT06NE73sLuP//5jypWrFigdfzx1jG3u53M999/r8mTJ2vIkCFq2bKlUlJS9MYbb+iVV17R66+/nut7yDMAACAv48aN01NPPaXixYurc+fOKl26tC5cuKCNGzdq1apVSkhIMDtEAB7EE+syEudMgLszujZDXQYAAO9myKC1zWZTSEiIEYvOl82bN2vs2LGaOXOmmjdvbnY4AAohJiZGCxcuVIcOHVS2bNlc2/zyyy9atGiR2rVrl69lBwYGytfXV2fOnMk2/ezZswoKCsr1PQkJCWrUqJGefPJJSVLt2rVVrFgx9e3bV8OHD1f58uXzFQMAAPBuvXr10o0bNzR58mRNnjxZvr6+ysjIkCQVKVJEjzzySFZbm82mffv2mRUqAA9AXQaAEYyqzVCXAQAAkkGD1g8++KA+++wzU09IEhMT9dJLL2n69Om67777TIsDgHM89dRTSkxMVO/evfWXv/xFbdq0kb+/v6TM21N9+umnmj59uq5evapBgwbla9l+fn6qW7eudu/erfvvvz9r+pdffpnns47S0tLk6+ubbdqt17eeFwcAAOCoBx54IM9fEgFAflGXAWAEo2oz1GUAAIBk0KB1hw4d9PLLL8tut6tNmzYqU6ZMjjZ169Z1eHlXrlzRTz/9lPU6KSlJhw8fVunSpRUaGqpp06bp1KlTeuONNyRlnhiNGjVKL730kqKiorKefeLv7+/Qc1cAuJ+KFStq3rx5iouL0/PPPy9fX18FBgZK+u3ZR2XLltW8efNUoUKFfC9/wIABevHFF1WvXj01bNhQq1atUnJysvr06SNJOfJMmzZt9PLLL2vFihWKiYnR6dOn9frrrysyMrJA6wcAAN4tPj7e7BAAWAh1GQBGMLI2Q10GAAAYMmjdv39/SdKyZcu0fPnybPNuPYvk8OHDDi/v0KFD6tevX9brKVOmSJK6deum+Ph4paSkKDk5OWv+qlWrdOPGDU2aNEmTJk3Kmn6rPQDP1LhxY33yySdavXq1vvzyy6x+HxERoRYtWqhXr14FLoB07NhR586d09y5c3X69GlFRERo/vz5qlSpkiTlyDPdu3fXlStXtHz5ck2dOlUlS5bUvffeqxdeeKHwHxQAAAAACoG6DACjGFWboS4DAABsdgPul/LRRx/dsU23bt2cvdoCu3WbmR07dpgcCeB+6B/OwfcI5I3+4Rx8j0DePKV/pKen68svv9SJEyeUnp6ebZ7NZtPjjz9uTmDynO8QMIM79g9Pq8tI7vk9Au6C/lF4fIdA3ugfzsH3COTN0f5hyC+t3e3EBwAAoCBmz57tcFubzaYhQ4YYGA0AKzt06JAGDx6ss2fP5vocRrMHrQF4FuoyAADACqjLAN7FkEFrAHC2QYMGacSIEapTp45D7dPT07V8+XL5+fmpb9++BkcHwKpyOzmy2Wx5DihxcgSgoCZOnKgSJUpo4sSJql69uooWLWp2SAAAANlQmwHgatRlAO/itEHrJUuWONyWXwnAI93MkFJ2SVeTpWIhUnCM5ONrdlReIygoSD179lRkZKS6du2qJk2aqFq1atnaXL58WQcPHtSOHTuUmJiokiVL6o033jApYlgK/d9r/f3vf8/2OiMjQy1bttR7772nWrVqmRQVACv6/vvv9dZbb2XdMgsmY98PD0RdBvBAHra/oTaDXHnYdgzPQl0G8C5OG7SeOnWqw205OYLHOb5O2jdMSk36bVpAmNR4plS5u3lxeZEpU6boscce0/z58zV58mRlZGTI399fgYGBuuuuu3ThwgWdP39edrtdoaGhGjx4sPr27Ss/Pz+zQ4eno/97tcDAwGyvMzIyJEklS5bMMQ8ACiMkJMSwZSckJGj69Onq16+fxo4dK0my2+2aPXu2Vq1apYsXLyoqKkrjx49XzZo1DYvDY7Dvh4eiLgN4GA/c31CbQQ4euB3Ds1CXAbyL0watebg8LOv4OmlXT0l/uOVI6onM6TFrOAhzkTp16ujtt9/W2bNntWvXLv3rX//S6dOnlZaWprp166patWpq0qSJGjduLJvNZna4sAL6P6yEq98Bt/bkk09q8eLFiomJcWph9+DBg1q1alWOXyEsWLBAS5YsUXx8vMLDwzVv3jwNGDBAW7ZsUYkSJZy2fo/Dvh8ejLoM4EE8eH9DbQZZPHg7BoAcqJu5BacNWleqVMlZiwLcx82MzKsF/3jwJf06zSbtGy5VepgE5kLlypVT165d1bVrV7NDgZXR/2GghIQEbd26Vf/73//k7++vhg0bauTIkdlurTd69Gh99NFH2d4XFRWl1atX53+FXP0OuL3u3bvrxIkTateunZo0aaIyZcrkaDNu3Lh8LfPKlSt64YUXNHnyZM2bNy9rut1u19KlSzV48GC1b99eUuYvNJs3b67ExET16dOnUJ/FY7Hvh4ejLgN4CIvsb6jNeDmLbMfwXi6vy8C9UTdzG04btAYsKWVX9kSVg11KPZ7ZrsJ9rooKgCvQ/2GgPXv2qG/fvqpfv74yMjI0Y8YMDRw4UJs2bVJAQEBWu5iYGE2ZMiXrddGiRfO/Mq5+BzzCzp07lZCQoBs3bigxMTHHfJvNlu9B60mTJql169Zq3rx5tkHrpKQkpaSkqGXLllnT/Pz8FB0drf379+c5aJ2cnHzbZ257/K882fcDAFyB/Q2sgO0YHs6ldRm4N+pmbsVpg9aDBg3SiBEjVKdOHYfap6ena/ny5fLz81Pfvn2dFQbgXFeTndsOgOeg/+M2Cnubu0WLFmV7PWXKFDVr1kzffvutoqOjs6b7+fkpODi44Cvi6nfAY7zxxhuqW7euJk6cqOrVqxe6GLJp0yb9+9//1po1a3LMS0lJkZT5C6nfCwoK0smTJwu1Xo/Gvh8ejroM4CHY38AK2I5hMo+py8C9UTdzO04btA4KClLPnj0VGRmprl27qkmTJtlupSBJly9f1sGDB7Vjxw4lJiaqZMmSeuONN5wVAuB8xUKc2w6A56D/Q1KXLl1ynT5ixIgcz5y12WzasGFDgdZz6dIlSVLp0qWzTd+zZ4+aNWumUqVKKTo6WiNGjMgxyPR7f/wVZFTFc5reiavfAU9w4sQJzZ49W7Vr1y70spKTk/Xaa69p8eLFuuuuu/Js98dCj92e24n6b0JCQjz/19S3w74fHo66DOAh2N/ACtiO4SKeXpf5I0ufT3ki7hrhdpw2aD1lyhQ99thjmj9/viZPnqyMjAz5+/srMDBQd911ly5cuKDz58/LbrcrNDRUgwcPVt++fXMkFsCtBMdkPrsg9YRyv9rGljk/OMbVkQEwGv0fUq7PlP39FbfOYLfbNWXKFDVu3FgRERFZ01u1aqUHH3xQoaGhSkpK0syZM9W/f3+tW7fO4eOnsgHXHAuCq98B01WrVk2XL192yrK+/fZbnT17Vt27/3YLs4yMDO3du1fLly/Xli1bJElnzpxR+fLls9qcPXtWQUFBTonBI7Hvh4ejLgN4CPY3sAK2Y7iIp9dl4Oa4a4TbceozrevUqaO3335bZ8+e1a5du/Svf/1Lp0+fVlpamurWratq1aqpSZMmaty4caFv3wC4hI+v1Hjmr880sCn7Qdiv23Djt7k1BGBF9H9Iev/99w1fx6RJk3T06FGtWLEi2/SOHTtm/X9ERITq1auntm3baufOnWrfvn2uy8rxK8hTO6Udbe4cBFe/A6YbNmyYpk+frnvuuafQt5+79957tXHjxmzTxowZo2rVqmnQoEGqXLmygoODtXv37qzbCKenp2vv3r0aOXJkodbt0dj3wwKoywAegP0NrIDtGC7i8XUZuDfuGuF2nDpofUu5cuXUtWtXde3a1YjFA65VubsUsybz2Qa/v1VEQFjmwVfl7nm+FYCHo/8jH+x2e76Lv6+++qo+/fRTLVu2TBUrVrxt2/Llyys0NFTHjh1zfAVc/Q54jA8++EAXL15U+/btVbt27Ry3pbPZbJo3b55DyypRokS2XwhIUkBAgMqUKZM1vV+/fkpISFB4eLiqVKmihIQE+fv7q3Pnzs75QJ6KfT8sgroM4ObY38AK2I7hZtyyLgP3Rt3M7RgyaA1YTuXuUqWHM59dcDU588qa4BiuFgS8Af0fDtiwYYPmzp2bdcvdO7Hb7Xr11Ve1bds2vf/++6pcufId33Pu3DklJydnu5XvHXH1O+Axjh49Kh8fHwUGBurUqVM6depUtvnO/kXkoEGDdO3aNU2cOFEXLlxQVFSUFi9erBIlSjh1PR6JfT8AwBXY38AK2I7hJty2LgP3Rt3M7TBoDTjKx1eqcJ/ZUeAO2rdvr5s3b2r79u1mhwIrof97tUuXLmn79u06c+aMqlatqrZt28rHx0eStHXrVr3zzjv6/vvvFRoa6vAyJ06cqMTERM2dO1fFixdXSkqKJKlkyZLy9/fXlStXNHv2bLVv317BwcE6ceKEZsyYocDAQLVr1y5/H4Cr3wGP8Omnnxq6/D/eVs9msykuLk5xcXGGrtdjse8HALiCRfc31Ga8jEW3Y7gPj6/LwL1RN3MrDFoDsBS73a6bN2+aHQYAi/jxxx/Vt29fnT17Nus2U9HR0Zo7d66ef/557dq1S6VKldILL7ygxx57zOHlrly5UpJyvGfKlCnq3r27fH19dfToUa1fv16XLl1ScHCwmjZtqhkzZhTsV5Bc/Q4AAADARajNAHAWy9Rl4N6om7kNBq0BWMq2bdvMDgGAhcycOVOXL1/W0KFDVa9ePSUlJWnevHnq06ePvv/+e/Xq1UsvvPCCSpUqla/lHjly5Lbz/f39tWjRosKEnhNXvwNuLz09XevWrdOePXt07tw5vfLKKwoPD9f27dtVq1Yth25ZBwAAYDZqMwCcxVJ1Gbg36mZugUFrAACAPOzZs0fPPPOMnn766axpd999twYNGqQ+ffpowoQJ5gUHwFJ++eUX9e/fX999952CgoJ09uxZXblyRZK0Y8cOffHFF+QcAAAAAF6FugzgXXzMDgAA8uuHH37Qnj17cp23Z88eHTt2zLUBAbCsc+fOqVGjRtmmNW7cWJLUsWNHM0ICYFFvvvmmLl68qLVr12rnzp2y2+1Z85o2baq9e/eaGB0AAEB21GYAuAJ1GcC7MGgNwOPEx8drx44duc777LPPFB8f7+KIAFhVRkaG7rrrrmzTbr0uXry4GSEBsKidO3fqueeeU926dWWz2bLNq1Chgn7++WeTIgMAAMiJ2gwAV6AuA3gXl98evH379rp586a2b9/u6lUDsIhvvvlGvXr1ynVedHS0Nm7c6OKIAFjZ//73P/n6+ma9zsjIyJr+R3Xr1nVZXACs5fLlywoNDc113o0bN7JyDwAUFnUZAM5AbQaAq1CXAbyHywet7Xa7bt686erVArCQS5cuKSAgINd5/v7+unDhgosjAmBlY8aMyXX6iy++mPX/drtdNptNhw8fdlVYACwmLCxMBw4cULNmzXLMO3jwoKpWrWpCVACsiLoMAGegNgPAVajLAN7D5YPW27Ztc/UqAVhMhQoVdPDgQTVv3jzHvIMHDyo4ONiEqABY0ZQpU8wOAYCF7d27V3Xq1FHx4sXVpUsXLViwQDVr1tR9990nSbLZbDp48KCWLl2qZ555xtxgAVgGdRkAzkBtBoArUJcBvIvLB60BoLDatWun+fPnq0GDBrr33nuzpn/99ddasGCBevbsaWJ0AKykW7duZocAwML69eunVatWKTIyUoMGDdI///lPDR06VKVLl5YkDRw4UOfPn1dMTIz69etncrQAAAC/oTYDwBWoywDexZBB6x9++EEpKSlq0qRJjnl79uxR+fLlFR4ebsSqAXiBIUOG6IsvvtCAAQMUHh6uihUr6ueff9axY8dUo0YNxcXFmR0iAADAHdnt9qz/L1q0qBYsWKDNmzdr586dOnv2rAIDA3XfffepU6dO8vHxMTFSAJ6GugwAo1GbAQAAzmbIoHV8fLzCw8NzPTn67LPP9MMPP+jdd981YtUAvEDJkiW1atUqvffee9q1a5dOnjypwMBAxcXFqX///ipevLjZIQIAAOSbzWZTp06d1KlTJ7NDAeDhqMsAMBq1GQAA4GyGDFp/88036tWrV67zoqOjtXHjRiNWC8CLFC9eXEOGDNGQIUPMDgUAAAAA3Ap1GQCuQG0GAAA4kyGD1pcuXVJAQECu8/z9/XXhwgUjVgsAAAAAHqV///6y2Wx3bGez2bRv3z4XRATACqjLAAAAAPA0hgxaV6hQQQcPHlTz5s1zzDt48KCCg4ONWC0AL/Lxxx8rMTFRJ0+eVFpaWrZ5NptN27dvNykyAAAAxzVp0kRly5Y1OwwAFkNdBoArUJsBAADOZMigdbt27TR//nw1aNBA9957b9b0r7/+WgsWLFDPnj2NWC0ALzF//nxNnz5dNWrUUO3ateXn52d2SAAAAAUyZMgQRUZGmh0GAIuhLgPAaNRmAACAsxkyaD1kyBB98cUXGjBggMLDw1WxYkX9/PPPOnbsmGrUqKG4uDgjVgt3czNDStklXU2WioVIwTGSj6/ZUcECVq9erb59++rll182OxTPQ78EAACejGMZwCHUZWBZ7AfcBrUZoBDIZQCQK0MGrUuWLKlVq1bpvffe065du3Ty5EkFBgYqLi5O/fv3V/HixY1YLdzJ8XXSvmFSatJv0wLCpMYzpcrdzYsLlnDmzBm1a9fO7DA8D/0SAAB4Mo5lAIdRl4ElsR9wK9RmgAIilwFAngwZtJak4sWLa8iQIRoyZIhRq4C7Or5O2tVTkj379NQTmdNj1rADRqHUrVtXx48fV7NmzcwOxXPQLwEAElf0w3NxLAPkG3UZWAr7AbdDbQYoAHIZAKtxcp3Jx4mhAZkb6L5hyrHjlX6btm94ZjuggEaPHq3Fixfr0KFDZofiGeiXAAAps0CyIVza0Ub68s+Z/90QnjkdpvjPf/7D86wdwbEMAHg39gNuidoMkE/kMgBWY0CdybBfWn/88cdKTEzUyZMnlZaWlm2ezWbT9u3bjVo1zJSyK/utTXKwS6nHM9tVuM9VUcFiXnrpJZ0/f169evVSUFCQypQpk22+zWbThg0bzAnOHdEvAQBc0Q9PxrEMUCDUZWAZ7AfcErUZIJ/IZQCsxKA6kyGD1vPnz9f06dNVo0YN1a5dW35+fkasBu7oarJz2wG5KFOmTI6TIdwG/RIAvNsdr+i3ZV7RX+lhbhUO98SxDJBv1GVgKewH3BK1GSCfyGUArMLAOpMhg9arV69W37599fLLLxuxeLizYiHObQfk4v333zc7BM9CvwQA78YV/fB0HMsA+UZdBpbCfsAtUZsB8olcBsAqDKwzGfJM6zNnzqhdu3ZGLBruLjhGCgiTZMujgU0KqJzZDoBr0C8BwLtxRT88HccyQL5Rl4GlsB8AYAXkMgBWYWCdyZBB67p16+r48eNGLBruzsdXajzz1xd/3AH/+rrx29x6EoV2/vx5vf322+rTp48eeOAB9enTR++8844uXLhgdmjuh34JAN6NK/rh6TiWAfKNugwshf2A26I2A+QDuQyAVRhYZzJk0Hr06NFavHixDh06ZMTi4e4qd898yHpApezTA8IK/PB14PdOnTql7t27691339WlS5cUGhqqS5cuae7cuerWrZtOnTpldojuh34JAN6LK/phBRzLAPlCXQaWw37A7VCbAQqAXAbACgysMxnyTOuXXnpJ58+fV69evRQUFKQyZcpkm2+z2bRhwwYjVg13Ubl75kPWU3Zl3gKgWEjmBsqVYnCC6dOnKy0tTatXr1ZkZGTW9IMHD+qZZ57RjBkzFB8fb2KEbop+CbiNhIQEbd26Vf/73//k7++vhg0bauTIkapWrVpWG7vdrtmzZ2vVqlW6ePGioqKiNH78eNWsWdPEyOGRbl3Rv6unMk8o7L+byRX98CAcywAOoy4DS2I/4FaozQAFRC5zC9RlgEIwsM5kyKB1mTJlcpwQwQv5+Ob7IeuAI3bt2qXhw4dnOymSpMjISD333HOaOXNmHu8E/RJwD3v27FHfvn1Vv359ZWRkaMaMGRo4cKA2bdqkgIAASdKCBQu0ZMkSxcfHKzw8XPPmzdOAAQO0ZcsWlShRwuRPAI9z64r+fcOk1KTfpgeEZZ5IcEU/PAXHMoBDqMvAstgPuA1qM0AhkMtMR10GKCSD6kyGDFq///77RiwWACRJly5dUqVKlXKdFxYWpkuXLrk4IgDIn0WLFmV7PWXKFDVr1kzffvutoqOjZbfbtXTpUg0ePFjt27eXJE2dOlXNmzdXYmKi+vTpY0bY8HRc0Q8AXoO6DACjUZsB4MmoywBOYECdyZBBawAwUlhYmHbu3KkWLVrkmPf5558rLCzMhKgAoOBuFXRKly4tSUpKSlJKSopatmyZ1cbPz0/R0dHav39/nidHycnJio2NzXM9O3bscGLU8Ehc0Q8AAAAnoDYDwEqoywAF5OQ6k2GD1ufPn9d7772nr776SufOnVNgYKCaN2+u/v37Z3V8ACiI7t27a9q0abLb7eratauCg4OVkpKiDRs2aNmyZfrLX/5idogA4DC73a4pU6aocePGioiIkCSlpKRIksqVK5etbVBQkE6ePOnyGAEAgOehLgPASNRmAFgFdRnAfRgyaH3q1Ck9+uijOnnypKpXr67Q0FCdPn1ac+fO1fr167Vy5UpVqFDB4eXt3btXixYt0qFDh5SSkqI5c+aoXbt2t33Pnj17FB8fr++++07ly5fXk08+qUcffbSwHw2AG3jyySd1/PhxLVu2TMuXL8+abrfb1bt3bw0cOLBAy12+fLkWLVqklJQU1axZUy+99JLuueeePNunp6drzpw52rBhg1JSUlSxYkUNHjxYPXv2LND6AXinSZMm6ejRo1qxYkWOeTabLdtru91+22WFhIRw1S4AAKAuA8BwRtRmqMsAMAN1GcB9GDJoPX36dKWlpWn16tWKjIzMmn7w4EE988wzmjFjhuLj4x1eXmpqqmrVqqXu3bsrLi7uju2PHz+up556Sr169dKbb76pf/7zn5o4caLKli2rBx54oECfCYD7sNlsmjRpkh5//HF9/fXXOn/+vMqUKaN7771XVatWLdAyN2/erClTpuiVV15Ro0aN9MEHH2jQoEHatGmTQkNDc33PsGHDdPbsWb322mu6++679csvv+jGjRuF+WgAvMyrr76qTz/9VMuWLVPFihWzpgcHB0uSzpw5o/Lly2dNP3v2rIKCglweJwAA8CzUZQAYzdm1GeoyAMxAXQZwL4YMWu/atUvDhw/PdmIkSZGRkXruuec0c+bMfC2vdevWat26tcPtP/jgA4WEhGjs2LGSpOrVq+ubb77R4sWLOTkCLKRatWqqVq2aU5a1ZMkS9ejRQ7169ZIkjR07Vl988YVWrlyZ6y2tPv/8c+3du1fbt29XmTJlJInnNQFwmN1u16uvvqpt27bp/fffV+XKlbPNDwsLU3BwsHbv3q06depIyvwVwd69ezVy5EgzQgYAAB6EugwAV3FWbYa6DABXoi4DuCdDBq0vXbqkSpUq5TovLCws66H2Rjlw4IBatGiRbVpMTIzWrl2r69evq2jRojnek5ycrNjY2DyXyS0dAHNduHBB48aNU/fu3dWmTZtc23z22Wdat26dJk2apMDAQIeXnZ6erm+//VZPPfVUtuktWrTQ/v37c33Pp59+qnr16mnhwoX6+OOPFRAQoLZt22rYsGHy9/fP9T3kGQC3TJw4UYmJiZo7d66KFy+e9aykkiVLyt/fXzabTf369VNCQoLCw8NVpUoVJSQkyN/fX507dzY5egAA4O48sS4jcc4EuDujajPUZQC4GnUZwD35GLHQsLAw7dy5M9d5n3/+ueFXvZ05cybHLRrKlSunGzdu6Ny5c4auG4AxPvzwQ/3nP/9RTExMnm1iYmJ09OjRbM9ScsS5c+eUkZGhcuXKZZseFBSUdcDyR8ePH9e+ffv03Xffac6cOXrppZf0ySefaOLEiflaNwDvtHLlSl26dEmPPfaYWrZsmfVv8+bNWW0GDRqk/v37a+LEierRo4dOnTqlxYsXq0SJEiZGDgAAPAF1GQBGMKo2Q10GgKtRlwHckyG/tO7evbumTZsmu92url27Kjg4WCkpKdqwYYOWLVuW6y1dnM1ms2V7bbfbc51+S0hICFfTAW5s8+bN6tWrl4oUyTttFSlSRL169dLmzZs1dOjQfK8jt7yRV864Ne+tt95SyZIlJUmjR4/Wc889p1deeSXXq3rJMwBuOXLkyB3b2Gw2xcXFOfTcSAAAgN/zxLqMxDkT4O6Mrs1QlwHgKtRlAPdkyKD1k08+qePHj2vZsmXZrqqz2+3q3bu3Bg4caMRqs+R2Fd4vv/yiIkWKZD3jBIBn+eGHH1S/fv07tqtbt67mzp2br2UHBgbK19dXZ86cyTb97NmzOX4dcEtwcLAqVKiQdWIkZT6nzW636+eff1Z4eHi+YgAAAAAAZ6EuA8AIRtVmqMsAAADJoEFrm82mSZMm6fHHH9fXX3+t8+fPq0yZMrr33ntVtWpVI1aZTYMGDfTZZ59lm/bFF1+oXr16eT43CYB7y8jIuO2VvLcUKVJEN27cyNey/fz8VLduXe3evVv3339/1vQvv/wyz2cdNWrUSFu2bNGVK1dUvHhxSZknbz4+PqpYsWK+1g8AAAAAzkRdBoARjKrNUJcBAACSQYPWt1SrVk3VqlUr9HKuXLmin376Ket1UlKSDh8+rNKlSys0NFTTpk3TqVOn9MYbb0iS+vTpo+XLl2vKlCnq3bu39u/fr7Vr12ratGmFjgWAOYKDg/X9998rOjr6tu2+++67PK/CvZ0BAwboxRdfVL169dSwYUOtWrVKycnJ6tOnjyTlyDOdO3fW3LlzNWbMGD333HM6d+6c3nzzTfXo0SPXW1ABAAAAgKtRlwHgTEbWZqjLAAAAH2ct6MKFC4qLi8txJe3vffbZZ4qLi9O5c+fytexDhw6pa9eu6tq1qyRpypQp6tq1q9555x1JUkpKipKTk7PaV65cWfPnz9eePXv08MMPa+7cuRo7dqweeOCB/H8wAG6hSZMmWrFiha5fv55nm+vXr2vlypVq2rRpvpffsWNHjRkzRnPnztXDDz+sf/zjH5o/f74qVaokKWeeKV68uBYvXqxLly6pR48eGjlypNq0aaNx48bl/8MBAAAAQCFRlwFgNCNrM9RlAACA035p/eGHH+o///mPYmJi8mwTExOj+Ph4LV++XEOHDnV42U2bNtWRI0fynB8fH59jWpMmTfTRRx85vA4A7q1///7q2bOnhg4dqkmTJqlChQrZ5p86dUovv/yyfvjhB7311lsFWkffvn3Vt2/fXOfllmeqV6+uJUuWFGhdAAAAAOBM1GUAGM3o2gx1GQAAvJvTBq03b96sXr163fa5JkWKFFGvXr20efPmfJ0cAUDt2rU1fvx4TZw4UbGxsapXr17W1bYnTpzQoUOHZLfbNWHCBNWqVcvkaAEAAADAtajLADAatRkAAGAkpw1a//DDD6pfv/4d29WtW1dz58511moBeJHevXurZs2aSkhI0Ndff60DBw5IkooVK6aYmBg9/fTTatCggakxAgAAAIAZqMsAcAVqMwAAwChOG7TOyMi47dW8WSssUkQ3btxw1moBFNTNDClll3Q1WSoWIgXHSD6+Zkd1Rw0bNtS7776rmzdvZj2HLTAwUD4+PiZHhmw8dPsCAACAB+BYM1fUZTwM2zE8mGVrM/RLAABM5bRB6+DgYH3//feKjo6+bbvvvvtOQUFBzlotgII4vk7aN0xKTfptWkCY1HimVLm7eXHlg4+Pj8qVK2d2GMiNBbYvAEAhUfAz3YoVK7Ry5UqdOHFCklSzZk09++yzat26tSTJbrdr9uzZWrVqlS5evKioqCiNHz9eNWvWNDNs4M441swTdRkPwnYMi7BUbYZ+CcBKOCeHh3La5W9NmjTRihUrdP369TzbXL9+XStXrlTTpk2dtVoA+XV8nbSrZ/aDcElKPZE5/fg6c+KCNbB9AQCOr5M2hEs72khf/jnzvxvC2Qe4WMWKFTVy5EitXbtWa9eu1b333qshQ4bou+++kyQtWLBAS5Ys0fjx47VmzRoFBQVpwIABunz5ssmRA7fBseZtUZfxEGzHgPuhXwKwEs7J4cGcNmjdv39//fDDDxo6dKhOnTqVY/6pU6c0ZMgQ/fDDD3r88cedtVoA+XEzI/OqUdlzmfnrtH3DM9sB+cX2BQCg4Oc22rZtq9atW6tq1aqqWrWqRowYoYCAAB04cEB2u11Lly7V4MGD1b59e0VERGjq1KlKS0tTYmKi2aEDueNY846oy3gAtmPA/dAvAVgJ5+TwcE67PXjt2rU1fvx4TZw4UbGxsapXr54qVaokSTpx4oQOHToku92uCRMmqFatWs5aLYD8SNmVc4eVjV1KPZ7ZrsJ9rooKVsH2BQDe7Y4FP1tmwa/Sw9yWzMUyMjK0ZcsWpaamqmHDhkpKSlJKSopatmyZ1cbPz0/R0dHav3+/+vTpk+tykpOTFRsbm+d6duzY4fTYgSwca94RdRkPwHYMuB/6JQCr4JwcFuC0QWtJ6t27t2rWrKmEhAR9/fXXOnDggCSpWLFiiomJ0dNPP60GDRo4c5UA8uNqsnPbAb/H9gUA3o2Cn9s5cuSI+vTpo2vXrikgIEBz5sxRjRo19M9//lOScjyDMigoSCdPnjQjVODOONZ0CHUZN8d2DLgf+iUAq+CcHBbg1EFrSWrYsKHeffdd3bx5U+fOnZMkBQYGysfHaXciB1BQxUKc2w74PbYvAPBuFPzcTtWqVbV+/XpdvHhRW7du1ahRo7Rs2bKs+TabLVt7uz23K/J/ExISwq+pYR6ONR1GXcaNsR0D7od+CcAqOCeHBRh2xuLj46Ny5cqpXLlynBgB7iI4RgoIk2TLo4FNCqic2Q7IL7YvAPBuFPzcjp+fn6pUqaL69evrL3/5i2rXrq2lS5cqODhYknTmzJls7c+ePaugoCAzQgXujGPNfKMu44bYjgH3Q78EYBWck8MCOGsBvImPr9R45q8v/ngw/uvrxm/zTAsUDNsXAHg3Cn5uz263Kz09XWFhYQoODtbu3buz5qWnp2vv3r1q2LChiRECt8GxJqyA7RhwP/RLAFbBOTksgEFrwNtU7i7FrJECKmWfHhCWOb1yd3PigjWwfQGA96Lg51amT5+uf/zjH0pKStKRI0c0Y8YM7dmzR126dJHNZlO/fv2UkJCgbdu26ejRoxozZoz8/f3VuXNns0MH8saxJqyA7RhwP/RLAFbAOTkswOnPtAbgASp3lyo9LKXsynyGRbGQzCus2GHBGdi+AMB73Sr47RsmpSb9Nj0gLPPkmIKfy5w5c0YvvviiTp8+rZIlS6pWrVpauHChWrRoIUkaNGiQrl27pokTJ+rChQuKiorS4sWLVaJECZMjB+6AY01YAdsx4H7olwCsgHNyeDgGrQFv5eMrVbjP7ChgVWxfAOC9KPi5hddff/228202m+Li4hQXF+eiiAAn4lgTVsB2DLgf+iUAK+CcHB6MQWsAAAAAzkXBDwAAAAAAc3BODg/FM60BAAAAAAAAAAAAAKZh0BoAAAAAAAAAAAAAYBoGrQEAAEywd+9eDR48WC1btlStWrW0ffv2bPNHjx6tWrVqZfvXu3dvk6IFAAAAAACwDuoygPvhmdYAAAAmSE1NVa1atdS9e3fFxcXl2iYmJkZTpkzJel20aFFXhQcAAAAAAGBZ1GUA98OgNQAAgAlat26t1q1b37aNn5+fgoODXRQRAAAAAACAd6AuA7gfBq0BAADc1J49e9SsWTOVKlVK0dHRGjFihMqVK5dn++TkZMXGxuY5f8eOHUaECQAAAAAAYDnUZQDXYtAaAADADbVq1UoPPvigQkNDlZSUpJkzZ6p///5at26d/Pz8zA4PAAAAAADAsqjLAK7HoDUAAIAb6tixY9b/R0REqF69emrbtq127typ9u3b5/qekJAQrtoFAAAAAAAoJOoygOv5mB0AAAAA7qx8+fIKDQ3VsWPHzA4FAAAAAADAq1CXAYzHoDUAAIAHOHfunJKTk1W+fHmzQwEAAAAAAPAq1GUA43F7cAAAABNcuXJFP/30U9brpKQkHT58WKVLl1bp0qU1e/ZstW/fXsHBwTpx4oRmzJihwMBAtWvXzsSoAQAAAAAAPB91GcD9MGgNAABggkOHDqlfv35Zr6dMmSJJ6tatmyZMmKCjR49q/fr1unTpkoKDg9W0aVPNmDFDJUqUMCtkAAAAAAAAS6AuA7gfBq0BAABM0LRpUx05ciTP+YsWLXJhNAAAAAAAAN6DugzgfnimNQAAAAAAAAAAAADANAxaAwAAAAAAAAAAAABMw+3BvdHNDClll3Q1WSoWIgXHSD6+ZkcFAPlHPgO8F/0fABxHzoQVsB0DAFyB/Q2MwrYF3BGD1t7m+Dpp3zApNem3aQFhUuOZUuXu5sUFAPlFPgO8F/0fABxHzoQVsB0DAFyB/Q2MwrYFOITbg3uT4+ukXT2zJ0ZJSj2ROf34OnPiAoD8Ip8B3ov+DwCOI2fCCtiOAQCuwP4GRmHbAhzGoLW3uJmReSWP7LnM/HXavuGZ7QDAnZHPAO9F/wcAx5EzYQVsxwAAV2B/A6OwbQH5wqC1t0jZlfNKnmzsUurxzHYA4M7IZ4D3ov8DgOPImbACtmMAgCuwv4FR2LaAfGHQ2ltcTXZuOwAwC/kM8F70fwBwHDkTVsB2DABwBfY3MArbFpAvDFq7gdjYWMXGxhq7kmIhzm3nIVzy3QIewFJ9wY3ymaW+V6AQXNYX3Kj/uwp5BshEXygAB3Ph82PfNDgQoBAssO/3hvzlDZ9R8p7PCc/iLdul4Z/TDfY33vK39DpusG0Vlrdsm97wOT3hMzJo7S2CY6SAMEm2PBrYpIDKme0AwJ2RzwDvRf8HAMc5kDNPX75L35wq48KggHxi3w8AcAX2NzAK2xaQLwxaewsfX6nxzF9f/DFB/vq68duZ7QDAnZHPAO9F/wcAxzmQM+d8VVM37XkV0AA3wL4fAOAK7G9gFLYtIF8YtPYmlbtLMWukgErZpweEZU6v3N2cuAAgv8hngPei/wOA4+6QM7/4sbw5cQH5wb4fAOAK7G9gFLYtwGFFzA4ALla5u1TpYSlll3Q1OfNZCcExXMkDwPOQzwDvRf8HAMfdNmfOMTs6wDHs+wEArsD+BkZh2wIcwqC1N/LxlSrcZ3YUAFB45DPAe9H/AcBx5ExYAdsxAMAV2N/AKGxbwB1xe3AAAAAAAAAAAAAAgGlsdrvdbnYQZqtfv74yMjIUEhJiyvqTk5MlybT1WxnfbeElJyfL19dX33zzjdmheDTyjDXxvToHecY5zMwz9AXj8N06B3mm8DiWsSa+V+cgxziH2XnGKN7Qz7zhM0rmfk7yTOGRYzybN3xOcoznI894Nm/4nJ6QZ7g9uKS77rpL6enppq3fyp3AbHy3hVekSBH5+fmZHYbHI89YE9+rc5BnnMPMPENfMA7frXOQZwqPYxlr4nt1DnKMc5idZ4ziDf3MGz6jZO7nJM8UHjnGs3nD5yTHeD7yjGfzhs/pCXmGX1oDAAAAAAAAAAAAAEzDM60BAAAAAAAAAAAAAKZh0BoAAAAAAAAAAAAAYBoGrQEAAAAAAAAAAAAApmHQGgAAAAAAAAAAAABgGgatAQAAAAAAAAAAAACmYdDaIHv37tXgwYPVsmVL1apVS9u3b8823263a9asWWrZsqUiIyP12GOP6bvvvsvWJj09Xa+++qqaNm2qBg0aaPDgwfr5559d+THc0p2+29GjR6tWrVrZ/vXu3TtbG75bWAF5xhjkGCATOcY45BkgE3nGOOQZwFje0McSEhLUo0cPNWzYUM2aNdOzzz6r//3vf9naWCFPO/I5rfD3hOchz2Ty9DxDjoG7Isdk8vQcI1kvzzBobZDU1FTVqlVL48ePz3X+ggULtGTJEo0fP15r1qxRUFCQBgwYoMuXL2e1ee2117Rt2zbNmDFDK1asUGpqqp5++mllZGS46mO4pTt9t5IUExOjL774Iuvf/Pnzs83nu4UVkGeMQY4BMpFjjEOeATKRZ4xDngGM5Q19bM+ePerbt69Wr16tJUuWKCMjQwMHDlRqampWGyvkaUc+p+T5f094HvJMJk/PM+QYuCtyTCZPzzGSBfOMHYaLiIiwb9u2Lev1zZs37S1atLAnJCRkTbt27Zq9cePG9pUrV9rtdrv94sWL9rp169o3bdqU1ebnn3+2165d2/7555+7Lng398fv1m6320eNGmV/5pln8nwP3y2siDxjDHIMkIkcYxzyDJCJPGMc8gxgLG/pY2fPnrVHRETY9+zZY7fbrZun//g57XZr/j3hWcgz1skz5Bi4I3KMdXKM3e75eYZfWpsgKSlJKSkpatmyZdY0Pz8/RUdHa//+/ZKkQ4cO6fr162rRokVWmwoVKqhmzZpZbZC3PXv2qFmzZnrggQc0btw4nT17Nmse3y28AXnGWOQYeDtyjPHIM/B25BnjkWcAY1mtj126dEmSVLp0aUnWzdN//Jy3WO3vCWuw2nbpDXmGHANPYrXt0htyjOT5eaaIS9cGSVJKSookqVy5ctmmBwUF6eTJk5KkM2fOqGjRojk2rKCgIJ05c8Y1gXqoVq1a6cEHH1RoaKiSkpI0c+ZM9e/fX+vWrZOfnx/fLbwCecY45BiAHGM08gxAnjEaeQYwltX6mN1u15QpU9S4cWNFRERIsmaezu1zStb7e8IarLZdekOeIcfAk1htu/SGHCNZI88waG0im82W7bXdbr/jexxp4+06duyY9f8RERGqV6+e2rZtq507d6p9+/Z5vo/vFlZEnnE+cgzwG3KMMcgzwG/IM8YgzwDGslofmzRpko4ePaoVK1bkmGelPJ3X57Ta3xPWYLXt0hvyDDkGnsRq26U35BjJGnmG24ObIDg4WJJyXKFw9uxZBQUFScq8guH69eu6cOFCnm3gmPLlyys0NFTHjh2TxHcL70CecR1yDLwROca1yDPwRuQZ1yLPAMby5D726quv6tNPP9Vf//pXVaxYMWu61fJ0Xp8zN57894R1efJ26Q15hhwDT+fJ26U35BjJOnmGQWsThIWFKTg4WLt3786alp6err1796phw4aSpHr16qlo0aLZ2pw+fVrfffddVhs45ty5c0pOTlb58uUl8d3CO5BnXIccA29EjnEt8gy8EXnGtcgzgLE8sY/Z7XZNmjRJW7du1V//+ldVrlw523yr5Ok7fc7ceOLfE9bnidulN+QZcgyswhO3S2/IMZL18gy3BzfIlStX9NNPP2W9TkpK0uHDh1W6dGmFhoaqX79+SkhIUHh4uKpUqaKEhAT5+/urc+fOkqSSJUuqR48emjp1qgIDA1W6dGlNnTpVERERat68uVkfyy3c7rstXbq0Zs+erfbt2ys4OFgnTpzQjBkzFBgYqHbt2kniu4V1kGeMQY4BMpFjjEOeATKRZ4xDngGM5Q19bOLEiUpMTNTcuXNVvHjxrOc+lixZUv7+/rLZbJbI03f6nFeuXLHE3xOehzxjjTxDjoG7IsdYI8dI1sszNru73nzdw3399dfq169fjundunVTfHy87Ha7Zs+erVWrVunChQuKiorS+PHjsz0c/dq1a3rjjTeUmJiotLQ0NWvWTK+88opCQkJc+VHczu2+2wkTJmjIkCH697//rUuXLik4OFhNmzbVsGHDsn1vfLewAvKMMcgxQCZyjHHIM0Am8oxxyDOAsbyhj9WqVSvX6VOmTFH37t0lyRJ5+k6fMy0tzRJ/T3ge8ow18gw5Bu6KHGONHCNZL88waA0AAAAAAAAAAAAAMA3PtAYAAAAAAAAAAAAAmIZBawAAAAAAAAAAAACAaRi0BgAAAAAAAAAAAACYhkFrAAAAAAAAAAAAAIBpGLQGAAAAAAAAAAAAAJiGQWsAAAAAAAAAAAAAgGkYtAYAAAAAAAAAAAAAmIZB6wJat26datWqpaSkpHy/9+uvv1atWrW0ZcsWh9aR27+pU6dmtWvbtq2efvrpfMdxy+zZs9WxY0fdvHkza1qtWrU0adKkAi/TVT788EPFxMQoNTXVofZt27bN+g4L+vlGjx6thg0bOtQ2IyNDzZo103vvvVegdf3RrFmzVKtWrQK99/Dhw9m2oTttfzAXOcY9kGMcR47xPOQZ90CecRx5xrOQY9wDOcZx5BjrsHr+kaRz585p2rRp6tixo6KiotSoUSP17t1by5cv1/Xr1wu8vrxcv35d7dq1c7iv/vH7+eWXX/K9zqSkJNWqVUuLFi1yqP3SpUvVtGlT3bhxI9/ryk2tWrU0a9asAr332WefzfrsnTt3dko8cC/kGfKMM5BnkBdyDDnGGTw1xxRx6dos5L777tOqVatUvnx5w9c1ZcoUVatWLds0Z6331KlTWrRokaZMmSIfH8+7hqFbt25auHChFi5cqOeee86h97Ru3VrPPvusgoKCDI5O2rt3r3755Rfdf//9Tller169FBMTU6D3hoeHa9WqVfr22289osDm7cgx7oEc4zhyjOchz7gH8ozjyDOehRzjHsgxjiPHWIfV889///tfDRw4UKmpqRowYIAaNmyoa9eu6bPPPtNrr72mLVu2aP78+SpWrJhT4pCkokWLasiQIZoyZYoefvhhBQYGOvS+2bNnKzg4WKVKlXJaLHnZunWr2rZtqyJFnFPqXLVqlSpWrFig977wwgt66qmnNHHiREMK7zAfeYY84wzkGeSFHEOOcQZPzTEMWhdQ2bJlVbZsWZesq2bNmqpfv74hy166dKlKliyp9u3bG7L8/Lp69Wq+klGRIkX0yCOPaO7cuRo0aJBD7y1btqwaNGhQiCgd98knn6hevXqqVKmSU5ZXsWLFAieaYsWKqUGDBrp27ZpTYoGxyDHGIMfcHjnGu5BnjEGeuT3yjPcgxxiDHHN75BhI1s4/GRkZeu6553T58mV9+OGHqlq1ata81q1bq0mTJhoxYoSmTJly24svMjIylJGRIT8/P4fj6dSpk+Lj47Vq1SoNHjzYoff86U9/UlhYmMPrKKgzZ85o3759evLJJ522zMLkwVt/lxIlSujcuXNOigjuhDxDnnEG8gzyQo4hxziDp+YYz7tU3E3kdouGxx57TJ07d9bBgwf15z//WVFRUYqNjdX8+fNz3P7gjy5fvqyBAweqefPmOnjwoNHhS5LS09O1Zs0ade7c+Y6/Grh1W4nExETNmDFDLVu2VKNGjfT444/rf//7X472a9as0UMPPaT69eurSZMmGjJkiP773/9ma3PrtnFHjhzRE088oYYNG+rxxx+XJF28eFEvvfSSmjRpooYNG+qpp57S8ePHc72lQZcuXXT58mVt2rSpwN9FXrfcuPW5v/7663wv0263a9u2bXrggQeypt26ncZnn32mrl27KjIyUh06dNBnn32WFUeHDh3UoEED9ezZU9988022ZeZ2u7tby/z888/VrVs3RUZG6sEHH9SaNWvyHTPcBzmGHHMn5BgUFnmGPHMn5BkUBjmGHHMn5BgYxcr5Z9u2bfr+++/11FNPZSvy3tKxY0e1bNlSa9euVUpKiqTfbk25YMECzZ07V23btlX9+vX11VdfSZK2b9+uLl26qF69eoqNjdVf//rXXPuSn5+fOnTooNWrV8tutxf4s7Vt21ajR4/OMf2xxx7TY489VqBlbtu2TQEBAWrevLmk37aBv//97xo3bpyaNm2qRo0a6cUXX1RqaqpSUlI0bNgw3XPPPWrZsqWmTp2a41dEf8ynt5b51Vdf6ZVXXlHTpk3VtGlTDR06VKdOnSpQ3PBc5BnyDHkGRiLHkGO8OccwaO1kKSkpeuGFF/TQQw9p3rx5atWqlaZNm6YNGzbk+Z6ff/5Zjz76qE6cOKFVq1YpMjIy2/ybN2/qxo0b2f45w8GDB3X+/Hk1bdrU4fdMnz5dJ06c0GuvvaZXX31VP/74o5555hllZGRktUlISNDYsWNVs2ZNzZ49Wy+99JKOHDmiRx55RMeOHcu2vOvXr+uZZ57Rvffeq7lz5youLk43b97U4MGDlZiYqCeffFKzZ89WZGRknleZBAcHq1q1avrb3/5WoO/BKP/85z+VkpKS41cZ//nPfzR9+nQNGjRIs2bNUsmSJRUXF6d33nlHH374oZ5//nm99dZbunTpkgYPHqy0tLQ7rus///mPpk6dqv79+2vu3LmqVauWxo4dq7179xr18WAScgw55hZyDIxCniHP3EKegRHIMeSYW8gxcDUr5J8vv/xSkhQbG5vne2NjY3Xjxg3t2bMn2/T3339fX331lUaNGqUFCxaoWrVq+vzzzxUXF6cyZcro7bff1gsvvKBNmzbpo48+ynXZTZo00YkTJ3T06NFCfkLn2rp1q+67774cv7YaN26cSpYsqenTp+uZZ55RYmKiXn75ZT399NOqXbu23nnnHXXt2lWLFy/WsmXLHFrXuHHjVLRoUU2bNk0jR47Unj179MILLxjxseCByDPkGfIMjESOIcd4Q47h9uBOdv78eS1YsCCr8zdv3lx79uzRxo0b1bVr1xztDx8+rKeeekp333235syZozJlyuRo07t37xzTvv3220Lf237//v2SpLp16zr8nho1auitt97Keu3j46Phw4frm2++UYMGDXTx4kXNnTtXrVu31rRp07LaNW3aVO3bt9esWbOyTb9+/bqGDBmiHj16ZE3729/+pn379mnChAl69NFHJUktWrSQn59ftvf+Xp06dfT3v//d4c/hCp988okiIiIUHh6ebfr58+e1evVqVahQQZJUoUIFPfzww1q9erW2bduW7ZZ9Q4YM0Zdffqm2bdvedl3nzp3TypUrFRoaKkmKjo7WV199pY0bNyo6Otq5HwymIseQY24hx8Ao5BnyzC3kGRiBHEOOuYUcA1ezQv45efKkJKly5cp5vvfWLSxvtb3lrrvu0qJFi1S0aNGsacOHD1eFChW0aNGirCJpTExMnn3qVjz//Oc/c/x6ySznzp3Tnj17NGPGjBzz2rRpo1GjRknKzJMHDhxQYmKixowZk3XniubNm+uLL77Qxo0bNWDAgDuuLyYmRuPGjct6feHCBb355ptKSUlRcHCwcz4UPBZ5hjxDnoGRyDHkGG/IMQxaO1lwcHCOq1Vq1aqlw4cP52j7xRdfaMyYMWrTpo3i4+PzvP/+1KlTVb169WzTnPEw9tOnT8tmszn80HlJOTr7rY598uRJNWjQQPv371daWpq6deuWrV1ISIjuvfferFs2/N7vbwcnKesKmg4dOmSb3qlTpzyLMOXKldPZs2d148YNpz2ovrC2bduWrbh0y5/+9KesAowkVatWTVJmoer3BZhbf/M/Jufc/OlPf8oqwEiZCTw8PNyh98KzkGPIMbeQY2AU8gx55hbyDIxAjiHH3EKOgatZPf/ccut2lzabLdv0tm3bZivypqam6tChQ/p//+//Zft8xYsXV9u2bbVu3bocyy5XrpwkudUtJHfs2KGiRYsqJiYmx7z77rsv2+vq1atr+/btat26dY7pX3zxhUPru12ed4dCL8xFniHPkGdgJHIMOcYbcox7nK1aSG5Xq/j5+enatWs5pm/fvl3Xrl3To48+etsHxlevXl3169d3ZpiSpGvXrqlIkSLy9fV1+D1//Hy34r51S7bz589LUq4bd/ny5bNu/3BLsWLFVKJEiWzTzp8/ryJFiuRYV1BQUJ5x3XXXXbLb7VmfyWwHDx7UyZMnc9zqTpJKly6d7fWt7/CP028l4Ny2nT/Kz3YHz0aOOS+JHEOOgZHIM+clkWfIMzAKOea8JHIMOQZmsEL+uXXxxfHjx3MUmG85ceKEpMyLYX7vj3nn4sWLstvtWcXb38ttmvRbf3SnvvPJJ5+oVatW2S5quSWvvJHb9PT0dIfWd6c8D+9GniHPkGdgJHIMOcYbcgzPtDbRmDFj1KpVKw0aNMjhqyCcKTAwUNevX1dqaqrTlnlrg09JSckx7/Tp0zmurPnj1TK3lnHjxo2sgs4tuS3zlvPnz8vPz0/FixfPf9DKLOJIytGxz507V6DlffLJJwoPD1dERESB3g84AzmGHAMYjTxDngGMRI4hxwBmcdf807x5c0mZv8jJy44dO1SkSBE1adIk2/Q/5pNSpUrJZrPp7NmzOZZx5syZXJd94cKFrPgKys/PL9eiakHyyaVLl/T3v/891wtgAHdHniHPAEYix5BjPBWD1ia66667NGvWLN1333165plntH37dpeuv2rVqpKkn376yWnLbNiwofz9/bVhw4Zs03/++Wd99dVXuvfee++4jFvJaPPmzdmm//H17yUlJalGjRoFiDhTpUqVJElHjhzJNv3TTz8t0PK2bt2a4zZ+gKuRY3JHjgGchzyTO/IM4BzkmNyRYwDjuWv+uf/++1WjRg3Nnz9fP/zwQ473bd68WV988YV69ux5x9s7BgQEqF69etq+fXu2wuuVK1f02Wef5fqe48ePS1Kev4xyRKVKlXLkkh9++CHXz3Mnn376qWw2W45bZwKegDxDngGMRI4hx3gq8+8L5uWKFi2q6dOna+zYsRo2bJimTp2qzp0753s5KSkp2rJlS47plSpVyvP2Dk2bNpUk/etf/1Lt2rXzvc7clCpVSs8++6ymT5+uF198UZ06ddL58+c1Z84c3XXXXRo6dOgdlxETE6NGjRpp6tSpunz5surVq6f9+/fr448/lpTzipqbN2/q4MGD6tmzZ4Hjrl+/vqpWrao33nhDGRkZKlWqlLZv3659+/bl2j4jIyPX77tYsWIqX768fvrpJ66OgVsgx+REjgGcizyTE3kGcB5yTE7kGMA13DH/+Pr66p133tETTzyhPn36aMCAAWrQoIHS09P12WefafXq1WrSpIlGjx7tUGzPPfecnn76aQ0cOFD9+/dXRkaGFi1apOLFi2f9Eun3/vWvf8nX11fR0dEOLT83Dz/8sF544QVNmDBBDzzwgE6cOKGFCxfm+Yuno0eP5vr91a9fX5988olatGiR4zEKgKcgz5BnACORY8gxnohBazfg4+Oj119/XcWLF9cLL7ygq1evqlevXvlaxrfffqthw4blmN6tWzfFx8fn+p6QkBDdc8892rFjhx555JECxZ6bp59+WmXLltX777+vzZs3y9/fX02aNNHzzz+v8PDwO77fx8dH7777ruLj47VgwQJdv35djRo10ptvvqnevXurVKlS2dp//fXXunTpkrp06VLgmH19ffXuu+/q1Vdf1SuvvCI/Pz916tRJ48eP11NPPZWj/bVr13L9vitVqqSHHnpIlSpVUr169QocD+BM5JjsyDGA85FnsiPPAM5FjsmOHAO4jjvmn+rVq2v9+vVavHixPv74Y82dO1e+vr6qUaOGXnrpJfXu3TvreYd30qpVK82aNUszZ87U8OHDFRwcrEcffVSnT5/OcUcIKfP5mK1atcqRZ/KjS5cuOn36tD744AOtW7dONWvW1IQJEzRnzpxc269fv17r16/PMX38+PH64osvNGHChALHArgD8kx25BnAucgx2ZFjPIAdXm3Lli32P/3pT/aff/7Z7FDuaMOGDfaIiAj7vn37sk0fOXKk/ZFHHnFoGW3atLG/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586dOVr+zTfftOz3DRo0sJrXp08fhYeHq127djZj3bdvn818s3PnTqscdfDgwbw1Csgl8gx5BgDyu4LuDsBo2rRpo9WrV6tUqVJO31ZMTIyqVKliNc1R2z1z5oyWLFmimJgYFSjwv2sbfvnlFw0YMEBXr15V//791aBBA924cUOffPKJpk2bpm3btmnhwoUqUqSIQ+KQpEKFCmno0KGKiYnRI488oqCgoBy9b+7cuQoJCdE999zjsFiysn37drVt21YFCzpml1q9erXKlCmTq/eOGTNGgwYNUnR0tFMuIoD3WrNmjaKjo1W5cmUNGDBA1apV061bt3To0CG9//77+u677zRv3jx3h5ljP/zwgzZs2KA1a9a4O5RcGTBggFauXKkPP/xQ3bp1y9F7unfvrh49eqhs2bKZ5hUtWlRr165VVFSU1fQTJ04oPj5exYoVy/SemTNn6tq1axo2bFjuGgHcgTzjWcgzMBpyjGchxyA/yS/555tvvtHgwYPl7++vAQMGKDw8XJcuXdJHH32kMWPG6JNPPtGsWbOsfifKq+LFi+sf//iHZs6cqVatWsnX1zdH71u9erXNOIoWLaqEhAR9/fXXatasmdW8devWqVixYrp8+bLV9IiICK1evVqffvqpFixYkPvGAHlAniHPAPnB+vXr9dxzz1le+/j4qESJEoqIiNCIESNUqVIlq+X79OmjlJQUbd68OdO6/vzzTzVr1kzDhg3T8OHDs9zm3r171bdvX8vrtWvXqk6dOpKko0eP6sUXX9RPP/2kypUr64UXXlCjRo2s3r948WJ98MEH2rhxowoXLmw17+mnn1ZcXJwkqXr16jbjhH3otHawEiVKqESJEi7ZVvXq1S07l6MtX75cAQEB6tChg2VaWlqannnmGV2+fFkffPCBKleubJnXunVrNW7cWM8++6xiYmI0ZcqULNedlpamtLS0HBcIkvTQQw9p+vTpWr16tQYPHpyj9/ztb39T+fLlc7yN3Dp37py++eYbPfnkkw5bZ/369XP93ozPpVixYkpJSXFQRPB2+/fv1+TJk9W8eXPNnz/fav9r0aKF+vfvr927d7sxQvstXLhQdevWdVoetNe1a9fsumAnICBADz/8sBYuXKioqCiZTKZs31OmTJks80OnTp30wQcf6Pjx41YF3rp161S6dGmFhYXpl19+sXpPeHi4JNmVj4GskGecjzyD/Iwc43zkGMC2/JJ/Ll68qOHDhysgIEBr1qxRcHCwZV779u0VHh6uWbNm6W9/+5sGDRqU5bpv3rwpk8lk10X9vXr10oIFC/Txxx+rS5cuOXpPVrmkbNmylotg/tqZdPnyZW3btk1dunTJ1IlWvHhx1a9fX7/++muOYwYciTxDngHym4wbMm/cuKFvv/1Wb731lvbu3auPPvpIxYsXd8o2J02apFq1aqlq1aqSpFu3bmn48OGqWrWq5s6dqy1btujpp5/Wjh07LDdCJiQkaN68eZo/f36mDmuJGwidgeHBHczW8OB9+vRR586ddeDAAT3xxBOqV6+e2rVrp4ULF2Y7lNrly5c1YMAANW/eXAcOHHB2+JKk1NRUrV27Vp07d7a6mmzHjh06duyYBg0aZNVhnaFTp05q2bKl1q1bp6SkJEn/G95u0aJFmj9/vmV4m6+//lpS+tAoXbp0Ue3atdWuXTu98847liFY/srX11cdO3bUmjVr8jRMTNu2bTV+/PhM0/v06aM+ffrkap07duyQv7+/mjdvLul/34GvvvpKEyZMUJMmTdSwYUONHTtWV69eVVJSkkaMGKH77rtPLVu21IwZMzIltDuHB89Y59dff60XX3xRTZo0UZMmTTRs2DCdOXMmV3Ejf4mNjZXJZNJLL71k80c9X19ftWvXzvL69u3bWrRokR588EHVrl1bzZo109ixY3X69Gmr99mT3y5evKjp06erXbt2lnUOHDgw04+POXHu3Dnt3LlTDz/8cLbL2rP/2Nvuffv2qVevXqpXr56ef/55SdLp06f1zDPPqEGDBrrvvvs0atQoHThwwOaQl4888oiOHz9uyYl50bx5c4WGhmrdunVW7dmwYYMeffRRh16lDNhCniHPkGfgTOQYcgw5Bu6SX/LPBx98oOTkZI0aNcqqIynDwIEDVaVKFS1ZssTyG0bG8P4bNmzQ9OnTFRkZqTp16uj333+XlH7n6AMPPKDatWurU6dO2rRpk8aPH59pqP/g4GA1b95c77//vt3tsaVbt27asWOHLl68aJm2detWSem/XQGehjyTjjwD5B/Vq1dX/fr11aRJEw0ZMkQDBw5UcnJyjofxz41q1aqpfv368vf3lyT9/vvvOn78uCZPnqwWLVooOjpaqamp+u677yzvmTx5sh544IFMoypkqFy5surXr29z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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# make scatterplots for all feature pairs\n", - "features = df.columns\n", - "plt.figure(figsize=(20, 20)) # adjust size as needed\n", - "plot_index = 1\n", - "for i in range(len(features)):\n", - " for j in range(i + 1, len(features)):\n", - " plt.subplot(len(features) - 1, len(features) - 1, plot_index)\n", - " plt.scatter(df[features[i]], df[features[j]], color='orange', edgecolor='orange')\n", - " plt.xlabel(features[i], fontsize=12)\n", - " plt.ylabel(features[j], fontsize=12)\n", - " plt.tight_layout()\n", - " plt.tick_params(axis='both', which='major', labelsize=10)\n", - " plot_index += 1\n", - "plt.suptitle(\"Scatterplots for Feature Pairs\", y=1.02, fontsize=14)\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 32, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "C:\\Users\\eschw\\AppData\\Local\\Temp\\ipykernel_121920\\4185179031.py:8: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.\n", - " plt.tight_layout()\n" - ] - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "correlation_matrix = df.corr()\n", - "plt.figure(figsize=(6, 5))\n", - "plt.matshow(correlation_matrix, cmap='coolwarm', fignum=1)\n", - "plt.colorbar()\n", - "plt.xticks(range(len(features)), features, rotation=45, fontsize=10)\n", - "plt.yticks(range(len(features)), features, fontsize=10)\n", - "plt.title(\"Pearson Correlation Matrix\", fontsize=14, pad=20)\n", - "plt.tight_layout()\n", - "plt.show()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python [conda env:base] *", - "language": "python", - "name": "conda-base-py" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.13" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/unsorted/UW-MIT FOM MOBO-Copy4.ipynb b/unsorted/UW-MIT FOM MOBO-Copy4.ipynb deleted file mode 100644 index bb0ab0b..0000000 --- a/unsorted/UW-MIT FOM MOBO-Copy4.ipynb +++ /dev/null @@ -1,1560 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 72, - "id": "cda845d3-bb40-428a-b2a5-e4cce3c5a659", - "metadata": {}, - "outputs": [ - { - "ename": "ImportError", - "evalue": "cannot import name 'qLogNoisyExpectedHypervolumeImprovement' from 'botorch.acquisition.multi_objective.monte_carlo' (C:\\Users\\eschw\\anaconda3\\envs\\mobo-env\\lib\\site-packages\\botorch\\acquisition\\multi_objective\\monte_carlo.py)", - "output_type": "error", - "traceback": [ - "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[1;31mImportError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[1;32mIn[72], line 21\u001b[0m\n\u001b[0;32m 18\u001b[0m \u001b[38;5;28;01mimport\u001b[39;00m \u001b[38;5;21;01mmatplotlib\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mpyplot\u001b[39;00m \u001b[38;5;28;01mas\u001b[39;00m \u001b[38;5;21;01mplt\u001b[39;00m\n\u001b[0;32m 19\u001b[0m \u001b[38;5;28;01mfrom\u001b[39;00m \u001b[38;5;21;01msklearn\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mmetrics\u001b[39;00m \u001b[38;5;28;01mimport\u001b[39;00m r2_score, mean_absolute_error, mean_squared_error\n\u001b[1;32m---> 21\u001b[0m \u001b[38;5;28;01mfrom\u001b[39;00m \u001b[38;5;21;01mbotorch\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01macquisition\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mmulti_objective\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mmonte_carlo\u001b[39;00m \u001b[38;5;28;01mimport\u001b[39;00m qExpectedHypervolumeImprovement, qNoisyExpectedHypervolumeImprovement, qLogNoisyExpectedHypervolumeImprovement\n\u001b[0;32m 22\u001b[0m \u001b[38;5;28;01mfrom\u001b[39;00m \u001b[38;5;21;01mbotorch\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mutils\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mmulti_objective\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mpareto\u001b[39;00m \u001b[38;5;28;01mimport\u001b[39;00m is_non_dominated\n\u001b[0;32m 23\u001b[0m \u001b[38;5;28;01mfrom\u001b[39;00m \u001b[38;5;21;01mbotorch\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mutils\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mmulti_objective\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mhypervolume\u001b[39;00m \u001b[38;5;28;01mimport\u001b[39;00m Hypervolume\n", - "\u001b[1;31mImportError\u001b[0m: cannot import name 'qLogNoisyExpectedHypervolumeImprovement' from 'botorch.acquisition.multi_objective.monte_carlo' (C:\\Users\\eschw\\anaconda3\\envs\\mobo-env\\lib\\site-packages\\botorch\\acquisition\\multi_objective\\monte_carlo.py)" - ] - } - ], - "source": [ - "import pandas as pd\n", - "import numpy as np\n", - "import torch\n", - "from sklearn.preprocessing import StandardScaler\n", - "\n", - "from botorch.models import SingleTaskGP, ModelListGP\n", - "from gpytorch.kernels import MaternKernel, ScaleKernel, RBFKernel\n", - "from botorch.fit import fit_gpytorch_mll\n", - "from gpytorch.mlls import ExactMarginalLogLikelihood, LeaveOneOutPseudoLikelihood\n", - "from gpytorch.mlls.sum_marginal_log_likelihood import SumMarginalLogLikelihood\n", - "from gpytorch.priors import GammaPrior, LogNormalPrior\n", - "import gpytorch\n", - "\n", - "import matplotlib.pyplot as plt\n", - "from mpl_toolkits.mplot3d import Axes3D\n", - "\n", - "from scipy.spatial import Delaunay\n", - "import matplotlib.pyplot as plt\n", - "from sklearn.metrics import r2_score, mean_absolute_error, mean_squared_error\n", - "\n", - "from botorch.acquisition.multi_objective.monte_carlo import qExpectedHypervolumeImprovement, qNoisyExpectedHypervolumeImprovement, qLogNoisyExpectedHypervolumeImprovement\n", - "from botorch.utils.multi_objective.pareto import is_non_dominated\n", - "from botorch.utils.multi_objective.hypervolume import Hypervolume\n", - "from botorch.utils.multi_objective.box_decompositions import NondominatedPartitioning\n", - "from botorch.sampling.normal import SobolQMCNormalSampler\n", - "from botorch.optim.optimize import optimize_acqf, optimize_acqf_list\n", - "\n", - "from botorch.utils.transforms import unnormalize\n", - "import seaborn as sns\n", - "from scipy.spatial.distance import pdist, squareform" - ] - }, - { - "cell_type": "code", - "execution_count": 73, - "id": "35959029-3e5a-4c9e-a582-dadaeb1503e4", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "2.4.0\n", - "True\n", - "12.4\n", - "NVIDIA GeForce GTX 1080 with Max-Q Design\n" - ] - } - ], - "source": [ - "import torch\n", - "print(torch.__version__)\n", - "print(torch.cuda.is_available())\n", - "print(torch.version.cuda)\n", - "print(torch.cuda.get_device_name(0))\n" - ] - }, - { - "cell_type": "code", - "execution_count": 74, - "id": "3e1e062d-8959-4682-bf9b-8f05a47f83ba", - "metadata": {}, - "outputs": [], - "source": [ - "def x_normalizer(X, var_array):\n", - " \n", - " def max_min_scaler(x, x_max, x_min):\n", - " return (x-x_min)/(x_max-x_min)\n", - " x_norm = []\n", - " for x in (X):\n", - " x_norm.append([max_min_scaler(x[i], \n", - " max(var_array[i]), \n", - " min(var_array[i])) for i in range(len(x))])\n", - " \n", - " return x_norm\n", - "\n", - "def x_denormalizer(x_norm, var_array):\n", - " \n", - " def max_min_rescaler(x, x_max, x_min):\n", - " return x*(x_max-x_min)+x_min\n", - " x_original = []\n", - " for x in (x_norm):\n", - " x_original.append([max_min_rescaler(x[i], \n", - " max(var_array[i]), \n", - " min(var_array[i])) for i in range(len(x))])\n", - " \n", - " return x_original\n", - "\n", - "def get_closest_value(given_value, array_list):\n", - " absolute_difference_function = lambda list_value : abs(list_value - given_value)\n", - " closest_value = min(array_list, key=absolute_difference_function)\n", - " return closest_value\n", - " \n", - "def get_closest_array(suggested_x, var_list):\n", - " modified_array = []\n", - " for x in suggested_x:\n", - " modified_array.append([get_closest_value(x[i], var_list[i]) for i in range(len(x))])\n", - " return np.array(modified_array)" - ] - }, - { - "cell_type": "code", - "execution_count": 75, - "id": "2bfcba47-fe51-47be-9be4-798dc3aa4d4f", - "metadata": {}, - "outputs": [], - "source": [ - "def make_linspace(start, stop, step):\n", - " num_points = int(round((stop - start) / step)) + 1\n", - " return np.round(np.linspace(start, stop, num_points), 4) # round for cleaner floats\n", - "\n", - "speed_inorg_var = make_linspace(0.25, 1.00, 0.01) # m/min\n", - "speed_org_var = make_linspace(0.25, 1.00, 0.01) # m/min\n", - "inkFL_inorg_var = make_linspace(80, 240, 1) # uL/min\n", - "inkFL_org_var = make_linspace(100, 280, 1) # uL/min\n", - "conc_inorg_var = make_linspace(0.8, 1.4, 0.05) # M\n", - "conc_org_var = make_linspace(0.4, 1.2, 0.05) # M\n", - "humidity_var = make_linspace(2, 37, 1) # g/m^3\n", - "temp_var = make_linspace(20, 50, 1) # °C\n", - "\n", - "# Tracking unique values\n", - "speed_inorg_num = len(speed_inorg_var)\n", - "speed_org_num = len(speed_org_var)\n", - "inkFL_inorg_num = len(inkFL_inorg_var)\n", - "inkFL_org_num = len(inkFL_org_var)\n", - "conc_inorg_num = len(conc_inorg_var)\n", - "conc_org_num = len(conc_org_var)\n", - "humidity_num = len(humidity_var)\n", - "temp_num = len(temp_var)\n", - "\n", - "# Pack into var_array for downstream normalization\n", - "var_array = [speed_inorg_var, speed_org_var, \n", - " inkFL_inorg_var, inkFL_org_var,\n", - " conc_inorg_var, conc_org_var,\n", - " humidity_var, temp_var]\n", - "\n", - "x_labels = ['Speed (Inorg) [m/min]', 'Speed (Org) [m/min]', \n", - " 'inkFL (Inorg) [uL/min]', 'inkFL (Org) [uL/min]',\n", - " 'Conc. (Inorg) [M]', 'Conc. (Org) [M]',\n", - " 'AH [g/m^3]', 'Temp [C]']" - ] - }, - { - "cell_type": "code", - "execution_count": 76, - "id": "3c85426d-2ead-4f86-b604-bb9bc2c6c47f", - "metadata": {}, - "outputs": [], - "source": [ - "# parameter_space = ParameterSpace([ContinuousParameter('x1', 0, 1),\n", - "# ContinuousParameter('x2', 0, 1),\n", - "# ContinuousParameter('x3', 0, 1),\n", - "# ContinuousParameter('x4', 0, 1),\n", - "# ContinuousParameter('x5', 0, 1),\n", - "# ContinuousParameter('x6', 0, 1),\n", - "# ContinuousParameter('x7', 0, 1),\n", - "# ContinuousParameter('x8', 0, 1),\n", - "# ])\n", - "\n", - "## why is the parameter space different in the round 1 of the Joule code?" - ] - }, - { - "cell_type": "code", - "execution_count": 77, - "id": "a948d7fa-4a19-4ead-b20d-988188fc711e", - "metadata": {}, - "outputs": [], - "source": [ - "def check_clausius_clapeyron(ah_vals, temp_vals):\n", - " \"\"\"\n", - " Return boolean mask for valid points satisfying Clausius-Clapeyron constraints.\n", - " Ensures absolute humidity does not exceed saturation limit at given temperature.\n", - " \n", - " Parameters:\n", - " ah_vals: array-like, absolute humidity [g/m³]\n", - " temp_vals: array-like, temperature [°C]\n", - " \n", - " Returns:\n", - " valid_mask: boolean array, True if AH is within saturation limit\n", - " \"\"\"\n", - " valid_mask = []\n", - " for AH, T in zip(ah_vals, temp_vals):\n", - " # Saturation vapor pressure (kPa)\n", - " es = 0.6108 * np.exp((17.27 * T) / (T + 237.3))\n", - " # Max absolute humidity (g/m³)\n", - " AH_max = 216.7 * es / (T + 273.15)\n", - " valid_mask.append(AH <= AH_max)\n", - " return np.array(valid_mask)" - ] - }, - { - "cell_type": "code", - "execution_count": 154, - "id": "ce471e12-4e94-47ad-9588-f2040cf3e219", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "tensor([[0.9057, 0.0000, 0.0947],\n", - " [0.0000, 0.0000, 1.0000],\n", - " [0.0000, 0.0000, 1.0000],\n", - " [0.0000, 0.0000, 1.0000],\n", - " [0.0000, 0.0000, 1.0000],\n", - " [0.6506, 0.2377, 0.7895],\n", - " [0.0000, 0.0000, 1.0000],\n", - " [1.0000, 1.0000, 0.0000]], device='cuda:0', dtype=torch.float64)\n", - "tensor([[0.0667, 0.0667, 0.0156, 0.1111, 1.0000, 0.0000, 0.0000, 0.1000],\n", - " [0.4400, 0.0667, 0.6875, 0.1889, 0.0833, 0.4375, 0.0286, 0.1567],\n", - " [0.9333, 0.4400, 0.5625, 0.8111, 0.1667, 0.5625, 0.4857, 0.8000],\n", - " [0.5600, 0.9333, 0.4375, 0.4389, 0.4167, 0.9375, 0.1143, 0.3933],\n", - " [0.6933, 0.1867, 0.3125, 0.9389, 0.5833, 0.3125, 0.1143, 0.2833],\n", - " [0.1867, 0.5600, 0.9375, 0.5611, 0.6667, 0.1875, 0.3714, 0.7200],\n", - " [0.0667, 0.8133, 0.0625, 0.6889, 0.8333, 0.6875, 0.4857, 0.7200],\n", - " [0.8133, 0.3067, 0.8125, 0.0611, 0.9167, 0.8125, 0.4000, 0.5433]],\n", - " device='cuda:0', dtype=torch.float64)\n" - ] - }, - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 154, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "#Load your real LHS experimental results (replace with real path)\n", - "dataset_final = pd.read_csv(r'C:\\Users\\eschw\\Downloads\\R0 Summary for MOBO R1 conditions.csv')\n", - "#dataset_np = dataset_final.to_numpy()\n", - "#print(dataset_np)\n", - "\n", - "#Extract input features (first 8 columns assumed to be process parameters) ===\n", - "X = dataset_final.iloc[:, 0:8].values # shape: (8, 8)\n", - "\n", - "#Extract targets\n", - "y = dataset_final[['PCE', 'Stability', 'Repeatability']].values # shape: (8, 3)\n", - "#print(y)\n", - "\n", - "#Standardize input and output\n", - "X_scaled = x_normalizer(X, var_array)\n", - "# Y_scaled = StandardScaler().fit_transform(y) ## is this the right way to normalize this? do i need to normalize this?\n", - "Y_max = np.max(y, axis=0)\n", - "Y_min = np.min(y, axis=0)\n", - "Y_scaled = (y - Y_min) / (Y_max - Y_min)\n", - "Y_scaled[:, 2] = 1 - Y_scaled[:, 2]\n", - "#print(Y_scaled)\n", - "\n", - "#Convert to torch tensors\n", - "device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n", - "train_X = torch.tensor(X_scaled, dtype=torch.float64, device=device)\n", - "#print(train_X)\n", - "train_Y = torch.tensor(Y_scaled, dtype=torch.float64, device=device)\n", - "print(train_Y)\n", - "print(train_X)\n", - "\n", - "#Set seeds for reproducibility\n", - "np.random.seed(20)\n", - "torch.manual_seed(20)" - ] - }, - { - "cell_type": "code", - "execution_count": 129, - "id": "ae38a521-2b43-48f3-81c8-c82733926285", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
Speed (Inorg) [m/min]Speed (Org) [m/min]Pump rate (Inorg) [uL/min]Pump rate (Org) [uL/min]Conc. (Inorg) [M]Conc. (Org) [M]AH (Org) [g/m^3]Temp (Org) [C]PCEStabilityRepeatability
00.300.3082.51201.400.40223.014.880.000.86
10.580.30190.01340.850.75324.70.000.000.00
20.950.58170.02460.900.851944.00.000.000.00
30.670.95150.01791.051.15631.80.000.000.00
40.770.39130.02691.150.65628.50.000.000.00
50.390.67230.02011.200.551541.610.691.350.20
60.300.8690.02241.300.951941.60.000.000.00
70.860.48210.01111.351.051636.316.435.680.95
\n", - "
" - ], - "text/plain": [ - " Speed (Inorg) [m/min] Speed (Org) [m/min] Pump rate (Inorg) [uL/min] \\\n", - "0 0.30 0.30 82.5 \n", - "1 0.58 0.30 190.0 \n", - "2 0.95 0.58 170.0 \n", - "3 0.67 0.95 150.0 \n", - "4 0.77 0.39 130.0 \n", - "5 0.39 0.67 230.0 \n", - "6 0.30 0.86 90.0 \n", - "7 0.86 0.48 210.0 \n", - "\n", - " Pump rate (Org) [uL/min] Conc. (Inorg) [M] Conc. (Org) [M] \\\n", - "0 120 1.40 0.40 \n", - "1 134 0.85 0.75 \n", - "2 246 0.90 0.85 \n", - "3 179 1.05 1.15 \n", - "4 269 1.15 0.65 \n", - "5 201 1.20 0.55 \n", - "6 224 1.30 0.95 \n", - "7 111 1.35 1.05 \n", - "\n", - " AH (Org) [g/m^3] Temp (Org) [C] PCE Stability Repeatability \n", - "0 2 23.0 14.88 0.00 0.86 \n", - "1 3 24.7 0.00 0.00 0.00 \n", - "2 19 44.0 0.00 0.00 0.00 \n", - "3 6 31.8 0.00 0.00 0.00 \n", - "4 6 28.5 0.00 0.00 0.00 \n", - "5 15 41.6 10.69 1.35 0.20 \n", - "6 19 41.6 0.00 0.00 0.00 \n", - "7 16 36.3 16.43 5.68 0.95 " - ] - }, - "execution_count": 129, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "dataset_final" - ] - }, - { - "cell_type": "code", - "execution_count": 130, - "id": "921588f7-a878-4948-a9d1-49f1a4a8a2ee", - "metadata": {}, - "outputs": [], - "source": [ - "def fit_gp_models(X, Y, kernel_fn=None, noise_prior=None):\n", - " \"\"\"\n", - " Fits a list of GP models for each output dimension.\n", - " Returns a ModelListGP.\n", - " \n", - " Args:\n", - " X: torch tensor of shape (N, D)\n", - " Y: torch tensor of shape (N, M)\n", - " kernel_fn: function taking D and returning a GPyTorch kernel\n", - " noise_prior: artificial noise to kernel\n", - "\n", - " Returns:\n", - " model: ModelListGP with one GP per output\n", - " \"\"\"\n", - " models = []\n", - " for j in range(Y.shape[1]):\n", - " covar_module = ScaleKernel(kernel_fn(X.shape[1])) if kernel_fn else RBFKernel(ard_num_dims = X.shape[1])\n", - " likelihood = gpytorch.likelihoods.GaussianLikelihood(noise_prior=noise_prior) if noise_prior else gpytorch.likelihoods.GaussianLikelihood()\n", - " gp = SingleTaskGP(X, Y[:, j:j+1], covar_module=covar_module, likelihood=likelihood)\n", - " mll = ExactMarginalLogLikelihood(gp.likelihood, gp)\n", - " #mll = LeaveOneOutPseudoLikelihood(gp.likelihood, gp)\n", - " fit_gpytorch_mll(mll)\n", - " models.append(gp)\n", - " return ModelListGP(*models)\n", - "\n", - "# for i, gp in enumerate(gp_models):\n", - "# ls = gp.covar_module.base_kernel.lengthscale.detach().cpu().numpy().flatten()\n", - "# print(f\"Objective {i+1} lengthscales: {np.round(ls, 3)}\")\n" - ] - }, - { - "cell_type": "code", - "execution_count": 131, - "id": "d4b8f005-c5d5-438c-bb94-59ab3f8cb0fc", - "metadata": {}, - "outputs": [], - "source": [ - "matern_options = {\n", - " \"Matern_0.5\": lambda d: MaternKernel(nu=0.5, ard_num_dims=d),\n", - " \"Matern_1.5\": lambda d: MaternKernel(nu=1.5, ard_num_dims=d),\n", - " \"Matern_2.5\": lambda d: MaternKernel(nu=2.5, ard_num_dims=d),\n", - "}\n", - "\n", - "noise_options = {\n", - " \"None\": None,\n", - " \"Gamma(1.1, 0.05)\": GammaPrior(1.1, 0.05).to(device),\n", - " \"LogNormal(-4, 0.1)\": LogNormalPrior(-4.0, 0.5).to(device),\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 132, - "id": "e10eaa40-f13b-4441-9deb-777b06bf6c5d", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " Kernel NoisePrior Objective R2 RMSE\n", - "Matern_0.5 None PCE -1.532 0.672\n", - "Matern_0.5 None Stability -0.246 0.367\n", - "Matern_0.5 None Repeatability -1.025 0.575\n", - "Matern_0.5 Gamma(1.1, 0.05) PCE -1.441 0.660\n", - "Matern_0.5 Gamma(1.1, 0.05) Stability -0.063 0.339\n", - "Matern_0.5 Gamma(1.1, 0.05) Repeatability -0.974 0.567\n", - "Matern_0.5 LogNormal(-4, 0.1) PCE -1.674 0.691\n", - "Matern_0.5 LogNormal(-4, 0.1) Stability -0.238 0.366\n", - "Matern_0.5 LogNormal(-4, 0.1) Repeatability -1.009 0.572\n", - "Matern_1.5 None PCE -1.473 0.664\n", - "Matern_1.5 None Stability -0.247 0.367\n", - "Matern_1.5 None Repeatability -1.375 0.622\n", - "Matern_1.5 Gamma(1.1, 0.05) PCE -1.472 0.664\n", - "Matern_1.5 Gamma(1.1, 0.05) Stability -0.347 0.382\n", - "Matern_1.5 Gamma(1.1, 0.05) Repeatability -1.379 0.623\n", - "Matern_1.5 LogNormal(-4, 0.1) PCE -1.600 0.681\n", - "Matern_1.5 LogNormal(-4, 0.1) Stability -1.807 0.551\n", - "Matern_1.5 LogNormal(-4, 0.1) Repeatability -0.876 0.553\n", - "Matern_2.5 None PCE -1.444 0.660\n", - "Matern_2.5 None Stability -0.671 0.425\n", - "Matern_2.5 None Repeatability -1.417 0.628\n", - "Matern_2.5 Gamma(1.1, 0.05) PCE -1.441 0.660\n", - "Matern_2.5 Gamma(1.1, 0.05) Stability -0.476 0.399\n", - "Matern_2.5 Gamma(1.1, 0.05) Repeatability -1.419 0.628\n", - "Matern_2.5 LogNormal(-4, 0.1) PCE -1.476 0.665\n", - "Matern_2.5 LogNormal(-4, 0.1) Stability -1.841 0.554\n", - "Matern_2.5 LogNormal(-4, 0.1) Repeatability -0.910 0.558\n" - ] - } - ], - "source": [ - "# Grid search with LOO-CV\n", - "results = []\n", - "best_rmse = [np.inf] * train_Y.shape[1]\n", - "best_models = [None] * train_Y.shape[1]\n", - "\n", - "for kernel_name, kernel_fn in matern_options.items():\n", - " for noise_name, noise_prior in noise_options.items():\n", - " preds_all = [[] for _ in range(train_Y.shape[1])]\n", - " actuals_all = [[] for _ in range(train_Y.shape[1])]\n", - "\n", - " for i in range(train_X.shape[0]):\n", - " X_cv = torch.cat([train_X[:i], train_X[i+1:]])\n", - " Y_cv = torch.cat([train_Y[:i], train_Y[i+1:]])\n", - " model_cv = fit_gp_models(X_cv, Y_cv, kernel_fn, noise_prior)\n", - "\n", - " for j, gp in enumerate(model_cv.models):\n", - " pred = gp.posterior(train_X[i:i+1]).mean.item()\n", - " preds_all[j].append(pred)\n", - " actuals_all[j].append(train_Y[i, j].item())\n", - "\n", - " for j in range(train_Y.shape[1]):\n", - " r2 = r2_score(actuals_all[j], preds_all[j])\n", - " rmse = mean_squared_error(actuals_all[j], preds_all[j], squared=False)\n", - " \n", - " # Save best model for this objective\n", - " if rmse < best_rmse[j]:\n", - " best_rmse[j] = rmse\n", - " model_j = fit_gp_models(train_X, train_Y, kernel_fn, noise_prior).models[j]\n", - " best_models[j] = model_j\n", - "\n", - "\n", - " results.append({\n", - " \"Kernel\": kernel_name,\n", - " \"NoisePrior\": noise_name,\n", - " \"Objective\": [\"PCE\", \"Stability\", \"Repeatability\"][j],\n", - " \"R2\": round(r2, 3),\n", - " \"RMSE\": round(rmse, 3)\n", - " })\n", - " \n", - "model = ModelListGP(*best_models)\n", - "\n", - "# Show results in a DataFrame\n", - "results_df = pd.DataFrame(results)\n", - "print(results_df.to_string(index=False))" - ] - }, - { - "cell_type": "code", - "execution_count": 133, - "id": "fd4993b4-1d74-4fd5-894c-1b9f34bc814c", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[[ 8.81750162e-01 1.68933651e-02 8.67137507e-02]\n", - " [ 9.09196457e-03 1.56690456e-02 9.96708348e-01]\n", - " [ 5.12375491e-04 1.05653783e-02 9.99984523e-01]\n", - " [ 1.95982376e-02 1.46428673e-02 9.85204061e-01]\n", - " [-4.48693139e-03 1.68445666e-02 1.00061802e+00]\n", - " [ 6.28982832e-01 2.64144661e-01 8.09152369e-01]\n", - " [ 2.78691662e-02 1.26756171e-02 9.81969048e-01]\n", - " [ 9.92981047e-01 8.86240355e-01 2.38606894e-02]]\n" - ] - }, - { - "data": { - "image/png": "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7KFTp07ky5ePSpUq0adPH+bPn09YWNhDXbtnz56UKVMmTpmDgwP9+vWja9eunD59mh07diR4bmRkJH5+fnHa7qZNm1pj/eSTT+KMOstIjz32GKVKleKff/5h//798fbH9EJTTzMRkZwnKCiIP/74g/bt2xMZGYmvry9NmjQBzN7IY8aMwd7ensWLF8eZgsze3p4RI0bw7LPPcv78eRYuXBjv2pGRkbz22mt07drVWpY/f35++uknnJ2d+euvv+KM8vrnn3+YN28e3t7eLF26NE6bnC9fPn744Qe8vLxYvHgxZ8+ete5zd3enV69eeHp6xvl8Dw8PvvzyS7y8vFiwYEGC06s9SKdOnfDx8YlTZrFYaNeuHW+//TZBQUHWHvoJ1f/LL7+kevXq1rKqVatap077+uuvCQ4OTnFMaaFcuXI0adKE69evJ7iOjL4bSHpREl1Ekm3ixIk0btyYxo0bU7VqVby8vPjjjz9wdHRkxowZ5M2b19qQurq6pvrz6tSpE2f41r0/NWvWTPX1RUREMtp7773Hn3/+yVNPPUWuXLkwDINjx47h5+dH165dqVChQpJTryTl6NGjjB49mo4dO9K8eXNrmx0zp/rff/+d4HkNGzakVq1a8cr79u2Ls7MzZ86cSXDYekawWCz06tULiD9se//+/fzzzz8UKVKEJ5980hbhiYhIBrt3DnB3d3datmzJ0aNHee655+IkhLdv387ly5epVatWos+Obdu2BbC2k/fr379/vLJChQrRqVMnwJxrPcbSpUsB6NKlC3nz5o13Xu7cuXn88ccxDMM6/em9/vzzT9555x2efvppmjZtam3Db926xd27d63rj6XUuXPn+PDDD+nSpQuPPvqo9brz588HEv9uULx4cdq1axev/JlnnqFkyZLcuXOHrVu3PlRMaaFv375A/O8G165dY/Xq1ZrmTdKFg60DEJGs48SJE9bGO1euXBQpUoSmTZvy7rvvWnuGx3xhuHPnTqo/b+HChWnSo11ERCQzadGiBS1atCAkJITdu3ezc+dOVq1axYYNGzh37hxPPfUUe/fupVKlSsm+5qRJk3j//feTnL88ICAgwfLKlSsnWO7q6oqXlxcnTpzg+PHjKYonLfXp04dx48bx888/8/HHH+PgYD7CxDw4d+/eHXt7e5vEJiIiGat8+fIUKlQIwzC4fPkyp0+fxtHR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RERyrpkzZwL6biDpT0l0ERERERHJ9L744gsuXbpEs2bNqFKliq3DERERERtbvHgxu3fvpkyZMprmTdKdg60DEBERERERScjly5fp2rUrN27c4ODBg9jZ2fHBBx/YOiwRERGxoebNmxMcHMy+ffsAmDBhQrqu4SICSqKLiIiIiEgmFRoaysaNG3F0dOSRRx5h7Nix+Pr62josERERsaGNGzdib29PmTJlePfdd7WgqGQILSwqIiIiIiIiIiIiIpIIjXUQEREREREREREREUmEpnNJpujoaC5evEjevHmxWCy2DkdERHIwwzAIDg6mWLFimvvvPmqvRUQks1B7nTi11yIiklkkt71WEj2ZLl68iJeXl63DEBERsfr3338pUaKErcPIVNRei4hIZqP2Oj611yIiktk8qL1WEj2Z8ubNC5g31M3NLU2uGRERwdq1a2nVqhWOjo5pcs3MSPXMXlTP7EX1zJqCgoLw8vKytk0SKy3b6+z27ya1dD9i6V7E0r2IS/cjlu6F2uukqL3OXHQPU0f3L/V0D1NH9y91ktteK4meTDFDzNzc3NI0iZ47d27c3Nyy9T9y1TN7UT2zF9Uza9Pw5/jSsr3Orv9uHpbuRyzdi1i6F3HpfsTSvYil9jo+tdeZi+5h6uj+pZ7uYero/qWNB7XXmphNRERERERERERERCQRSqKLiIiIiIiIiIiIiCRCSXQRERERERERERERkUQoiS4iIiIiIiIiIiIikggl0UVEREREREREREREEqEkuoiIiIiIiIiIiIhIIpREFxERERERERERERFJhJLoIiIiIiIiIiIiIiKJUBJdRERERERERERERCQRSqKLiIiIiIiIiIiIiCRCSXQRERERERGRHGrTpk20adOGYsWKYbFYWLZs2QPP2bhxI7Vr18bZ2ZkyZcrwzTffpH+gIiIiNuRg6wDut2nTJj7++GP27NnDpUuXWLp0Ke3bt7fut1gsCZ43efJkBg8enOA+Pz8/+vTpE688JCQEZ2fnNIk7JcLCwvjnn384ffo0O3fu5ObNmzg4ZLpfRZqJjIzk77//zjb1dHR0pFChQtSsWRN3d3dbhyMiIukkPDycU6dOsWTJEiIjI20djs1ltfbc0dGRggULUqNGDfLly2frcEREMq07d+5QvXp1+vTpw7PPPvvA4/39/Xnqqad46aWX+PHHH9m6dSuvv/46BQsWTNb5aS0iIoLTp0+zdOlSIiMjMQwjw2PI6mzZxjs6OpI/f35q1KhB/vz5M/SzRURSItM9AT2oAb906VKc7dWrV9OvX78HNtZubm4cO3YsTpktEugbNmxg0aJFRERE4OnpCcC///6LnV32HRQQHR0NZI96GoZBREQE69evZ968eTRs2JAePXpk+XqJiEhc27Zt46effuLSpUtUqFCBPHny5Pj/67Naex4eHs61a9eYN28edevWpXfv3lki+S8iktFat25N69atk338N998Q8mSJZk6dSoAlStXZvfu3XzyyScZnkTftWsXc+bM4eLFi2qvU8GWbXxMe71gwQJq1qxJv379cHR0zNAYRESSI9M9STyoAS9SpEic7eXLl9OiRQvKlCmT5HUtFku8czPa1q1b+eWXX2jatClPPPEE+fLl499//8XLywt7e3ubxpaeoqKisl0979y5w44dO1i0aBGGYdC7d29bhyQiImlk165dzJ07l3r16lGtWjVq1qyZbdqv1MiK7XlISAi7du1i/vz5REVF8corr9g6JBGRLG/79u20atUqTtkTTzzBzJkziYiIyLAE6P79+5k5cya1atWiV69e1KpVK8u0T5mNrdv40NBQ9uzZwy+//MI333zDgAEDEp2FQETEVjJdEj0lrly5wsqVK5kzZ84Dj719+zbe3t5ERUVRo0YNxo8fT82aNRM9PiwsjLCwMOt2UFAQYA4Vi4iISHGshmHw66+/UqtWLZ577jksFov1bW/Mn9lVdqyns7MzzZs3B2DevHm0bt2avHnzAjzUv4+sJKZ+qmf2oHqmr5CQEEJDQxPd7+zsjIuLS4qvm91/X7a2atUqfHx86NmzJ+fPn7d1OJIKLi4uNG3aFAcHB+bMmcOVK1coXLiwrcMSkcxq9myoXh1q1bJ1JJna5cuX4/1fWrhwYSIjI7l+/TpFixaNd05aP18DrFixgvLly9OrVy8uXbqUrZ43M5qtn9kdHR1p0KABjo6OzJgxg9OnT1OyZEmbxPIwcsozVXrSPUydnHL/AgMDuXXrVqL73d3dH2oax+TetyydRJ8zZw558+alY8eOSR5XqVIl/Pz88PHxISgoiM8//xxfX1/+/vtvypcvn+A5kyZNYuzYsfHK165dS+7cuVMc69WrVzl27BitWrWK90B+4cKFFF8vK8qO9SxRogTBwcF8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bN4M991nalgiIiIObdEiGD7cWIsEoHFj2LQJfHzMjUtERESy5danzuPj44mIiKBIkSK4uLgAd3jq/Pp16NPHuJkO8Oabxvpk9erlR+giIpIJSqLns1svrJGRkdy4cYM9e/Yk3ZHO1+lc9uyBZ55JLrdvD198ASVL5s/ri4iIOKO4OPjss+QEes+esHw5FC5sblwiIiKSbbc+dR4WFsbu3btp3rw53t7eQAZPnQcHG9O3HDlilN3c4OOPlUAXESlglETPZ7deWP/44w/++ecf6tevT5UqVYB8ns6lRQsYNAiWLDFGw82ebVywRUREJO+4uhoLhPn5GQn0adOMtUhERETEbt0+XUuhQoXw9vamZEaD1E6dgs6djTXKwJhadf16aNs2j6MVEZGsUhLdmVkssHChcdHu2dPsaERERJxHmTLwww/GQqIiIiLifHbvhkcfhdBQo1yxorFeitZGEREpkJREz2e3TucSHh5OfHw8hw4d4tdffwXyeDqXgACIioKHHkquK1RICXQREZG8dPw4vPACrFoFpUsn1yuBLiIi4pxWrYL+/ZPXJqtf35gPvXx5U8MSEZH0KYmez26dzuXnn38mODiYWrVqUfvm3eY8m87lgw9g2DDw9IQDB6BWrbx5HREREUn2zTfQqxdcuwY9esCOHeDubnZUIiIiYpbYWGPh0MQE+kMPGWuTFStmblwiIpIhTcDp6OLiYORIeP554+dr12DePLOjEhERcXyLFxsLhV27ZpSjo5N/FhEREefk5maMOi9XDgYPhk2blEAXEbEDGomez26dziX05txnx48f5/z580AuT+cSGgq9e4O/f3Ld//4HM2bkzvFFREQktfh4ePFFeOed5LoePWDFCihc2Ly4REREpGCoXBmOHAFfX2OtMhERKfCURM9nFSpUoGjRogD89NNP/Pvvv1SoUIH//Oc/AHh7e+fOC/32G3TrZvwNxt3u99+HZ57JneOLiIhIahER8PTTsGFDct3YscYNbKseABQREXE6Fy/Cq6/Cu++mvJmu+c9FROyKkuj57O+//04aiX7p0iUAgoKCuHHjBmCMRC9ZsmTOXsTf35h/9epVo1ymDKxbBy1a5Oy4IiIikr7gYOMG9uHDRtnFBRYsMKZUExEREadjPX0annwSzp0zkulr1xqfD0RExO4oiZ7Pbl1YdM+ePVy5coVKlSrRsmVL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WTM+++wzevfuTaFChShXrhzFixfnvffey4+QHZb+Neex20fw2my2DEf1ptU+rfqCaPjw4fz000989913GbarUaMGNWrUSCr7+fnx119/MWvWLFq1apXXYWZL586dk36uW7cufn5+3H333XzyySeMGTMmzX3s+b0EWLJkCZ07d6Z8+fLptrHH9zI9Wf23mt19CoLY2Fj69OlDQkICCxcuzLBt06ZNUyzK2bx5c+6//37ee+895s2bl9ehZkvv3r2Tfq5Tpw6NGjWicuXKbNmyhcceeyzd/ez1/ZS8o98JyUhmP/c4sho1anDs2DGuXr3K2rVr6d+/P4GBgU6XSP/rr78YNWoU27dvN3XNkIIiO5+bRe7Emb5X55Xsfq5ZuXIlkydPZuPGjZQtWzavwivwMtt/8fHxPPXUU0yZMoV77703v8KzC1n5HUxISMBisfDZZ5/h7e0NwOzZs3n88cdZsGABnp6eeR5vQZOV/jt58iQjR47k1VdfpVOnTly4cIFx48YxZMgQlixZkh/hOiQl0fNI6dKlcXFxSXVXKCQkJNXdo0TlypVLs72rqyulSpXKs1hzw4gRI9i0aRO7d+/mrrvuyvL+TZs2ZcWKFXkQWd4oUqQIdevWTXc0rj2/lwB//vknO3bsYN26dVne197ey3LlygHGiBVfX9+k+oz+rSbul5V/3wVFbGwsvXr14ty5c+zatSvDUehpsVqtPPDAAwV6JPrtfH19qVy5coYx2+v7KXkjO9dwcS45/dzjKAoVKsQ999wDQKNGjTh06BBz587l/fffNzmy/HXkyBFCQkJo2LBhUl18fDy7d+9m/vz5REdH4+LiYmKE5rrT52aRjDjb9+q8kJPPNatXr2bQoEF8+eWXTjtVVVb779q1axw+fJij/9/evUfHdO7/A39PMpncmktJEJcIjaDukqo0YiJxcFAhQS84cW9UV2mp0vpKcnq0iqMtB6cLibYILVGttO5JiIi6JC2KkBsJgoMmROT2+f3hN7OMmYkkItf3a61ZS5797Gd/nr0Tn72f2fvZSUl45513ADwcEBYRKJVK7NmzB76+vtUSe21Rmd9BJycntGjRQjuADgAdO3aEiCArKwvt2rV7pjHXJpXZf5999hm8vLzwwQcfAAC6du0Ka2treHt741//+pfO2AeVH6dzeUZUKhXc3d2xd+9enfK9e/filVde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fHy8wW0WFxfL119/LV5eXmJrayvm5ubi7OwsgwYNktWrV8vdu3e1dSuaw4uKimTOnDni7OwsSqVS79jExMSIl5eX2NjYiL29vQwePFhOnDih3VcxMTE6dTX5OC4uTry9vcXKykrs7e3l9ddfl0uXLun1rSK531BdEZHo6Gh56aWXxNLSUntOY4inp6cAkH379hlcTiQiohAx8DwLERERERFRPbZ48WJ8+OGH+Oabb/joNhER1TiFQgG1Wo3Y2NiaDqVBKSgoQIsWLWBvb4+LFy8afbEtEedEJyIiIiKiBqWgoAArV65Eo0aNMGrUqJoOh4iIiGpIeHg4bt26hbfeeosD6FQmzolOREREREQNQnx8POLi4rB7925cunQJixYtgqWlZU2HRURERNVs0aJFuHHjBr7++ms0adIEwcHBNR0S1XIcRCciIiIiogZh3759CAsLg4ODA9577z3MmjWrpkMiIiKiGjBv3jyoVCp069YNy5cvh62tbU2HRLUc50QnIiIiIiIiIiIiIjKCc6ITERERERERERERERnBQXQiIiIiIiIiIiIiIiM4iE5EREREREREREREZAQH0YmIiIiIiIiIiIiIjOAgOhERERERERERERGRERxEJyIiIiIiIiIiIiIygoPoRERERERERERERERGcBCdiIiIiIiIiIiIiMgIDqITERERERERERERERnx/wBEGVOTnsBIPAAAAABJRU5ErkJggg==", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "--- Model 1 (PCE) ---\n", - "Kernel: MaternKernel\n", - "Matern ν: 2.5\n", - "Noise prior: GammaPrior\n", - "\n", - "--- Model 2 (Stability) ---\n", - "Kernel: MaternKernel\n", - "Matern ν: 0.5\n", - "Noise prior: GammaPrior\n", - "\n", - "--- Model 3 (Repeatability) ---\n", - "Kernel: MaternKernel\n", - "Matern ν: 1.5\n", - "Noise prior: LogNormalPrior\n", - "\n" - ] - } - ], - "source": [ - "# Evaluate posterior predictions on training data\n", - "\n", - "posterior = model.posterior(train_X)\n", - "pred_std = torch.sqrt(posterior.variance).detach().cpu().numpy()\n", - "pred_mean = posterior.mean.detach().cpu().numpy()\n", - "print(pred_mean)\n", - "#pred_mean = torch.cat([p.mean for p in posterior.distributions], dim=-1)\n", - "true_Y = train_Y.detach().cpu().numpy() # already scaled\n", - "\n", - "# Reverse scaling\n", - "pred_mean_unnorm = pred_mean * (Y_max - Y_min) + Y_min\n", - "pred_std_unnorm = pred_std * (Y_max - Y_min)\n", - "true_mean_unnorm = true_Y * (Y_max - Y_min) + Y_min\n", - "\n", - "# # Flip repeatability back\n", - "# pred_mean_unnorm[:, 2] *= -1\n", - "# true_mean_unnorm[:, 2] *= -1\n", - "\n", - "\n", - "labels = ['PCE', 'Stability', 'Repeatability']\n", - "num_obj = train_Y.shape[1]\n", - "\n", - "fig, axes = plt.subplots(1, num_obj, figsize=(5 * num_obj, 5))\n", - "\n", - "for i in range(num_obj):\n", - " ax = axes[i]\n", - "\n", - " # Compute metrics\n", - " r2 = r2_score(true_mean_unnorm[:, i], pred_mean_unnorm[:, i])\n", - " rmse = mean_squared_error(true_mean_unnorm[:, i], pred_mean_unnorm[:, i], squared=False)\n", - "\n", - " # Parity scatter with uncertainty bars\n", - " ax.errorbar(\n", - " true_mean_unnorm[:, i], pred_mean_unnorm[:, i],\n", - " yerr=pred_std_unnorm[:, i],\n", - " fmt='o', color='blue', ecolor='gray', elinewidth=1, capsize=3,\n", - " alpha=0.7\n", - " )\n", - "\n", - " # Parity line\n", - " min_val = min(true_mean_unnorm[:, i].min(), pred_mean_unnorm[:, i].min())\n", - " max_val = max(true_mean_unnorm[:, i].max(), pred_mean_unnorm[:, i].max())\n", - " ax.plot([min_val, max_val], [min_val, max_val], 'r--', linewidth=2)\n", - "\n", - " # Labels and metrics\n", - " ax.set_xlabel('True ' + labels[i], fontsize=14)\n", - " ax.set_ylabel('Predicted ' + labels[i], fontsize=14)\n", - " ax.set_title(f'{labels[i]}', fontsize=16)\n", - " ax.text(0.05, 0.95, f'R² = {r2:.2f}\\nRMSE = {rmse:.2f}', transform=ax.transAxes,\n", - " fontsize=12, verticalalignment='top', bbox=dict(boxstyle=\"round\", facecolor='white', alpha=0.6))\n", - "\n", - " ax.grid(True)\n", - "\n", - "plt.tight_layout()\n", - "plt.show()\n", - "\n", - "for i, gp in enumerate(model.models):\n", - " print(f\"--- Model {i+1} ({['PCE', 'Stability', 'Repeatability'][i]}) ---\")\n", - " # Kernel type\n", - " kernel = gp.covar_module.base_kernel\n", - " print(\"Kernel:\", type(kernel).__name__)\n", - "\n", - " # Check Matern ν if it's a Matern kernel\n", - " if hasattr(kernel, \"nu\"):\n", - " print(\"Matern ν:\", kernel.nu)\n", - "\n", - " # Noise prior info\n", - " noise_prior = gp.likelihood.noise_covar.raw_noise_constraint.initial_value\n", - " prior_type = type(gp.likelihood.noise_covar.noise_prior).__name__ if gp.likelihood.noise_covar.noise_prior else \"None\"\n", - " print(\"Noise prior:\", prior_type)\n", - " \n", - " # print(\"Lengthscales:\", gp.covar_module.base_kernel.lengthscale.detach().cpu().numpy().flatten())\n", - " # print(\"Outputscale:\", gp.covar_module.outputscale.item())\n", - " # print(\"Noise:\", gp.likelihood.noise.item())\n", - " print()\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "42e0c6fe-1314-4ce0-ba54-0386fec624e1", - "metadata": {}, - "outputs": [], - "source": [ - "# --- Fit generic GP model with default settings ---\n", - "model_generic = fit_gp_models(train_X, train_Y)\n", - "\n", - "# --- Posterior prediction ---\n", - "posterior_generic = model_generic.posterior(train_X)\n", - "pred_std_gen = torch.sqrt(posterior_generic.variance).detach().cpu().numpy()\n", - "pred_mean_gen = posterior_generic.mean.detach().cpu().numpy()\n", - "true_Y = train_Y.detach().cpu().numpy()\n", - "\n", - "# --- Reverse scaling ---\n", - "pred_mean_unnorm_gen = pred_mean_gen * (Y_max - Y_min) + Y_min\n", - "pred_std_unnorm_gen = pred_std_gen * (Y_max - Y_min)\n", - "true_mean_unnorm = true_Y * (Y_max - Y_min) + Y_min\n", - "\n", - "# --- Flip repeatability back ---\n", - "# pred_mean_unnorm_gen[:, 2] *= -1\n", - "# true_mean_unnorm[:, 2] *= -1\n", - "\n", - "labels = ['PCE', 'Stability', 'Repeatability']\n", - "num_obj = train_Y.shape[1]\n", - "\n", - "# --- Plot parity plots ---\n", - "fig, axes = plt.subplots(1, num_obj, figsize=(5 * num_obj, 5))\n", - "for i in range(num_obj):\n", - " ax = axes[i]\n", - " r2 = r2_score(true_mean_unnorm[:, i], pred_mean_unnorm_gen[:, i])\n", - " rmse = mean_squared_error(true_mean_unnorm[:, i], pred_mean_unnorm_gen[:, i], squared=False)\n", - "\n", - " ax.errorbar(\n", - " true_mean_unnorm[:, i], pred_mean_unnorm_gen[:, i],\n", - " yerr=pred_std_unnorm_gen[:, i],\n", - " fmt='o', color='blue', ecolor='gray', elinewidth=1, capsize=3, alpha=0.7\n", - " )\n", - "\n", - " min_val = min(true_mean_unnorm[:, i].min(), pred_mean_unnorm_gen[:, i].min())\n", - " max_val = max(true_mean_unnorm[:, i].max(), pred_mean_unnorm_gen[:, i].max())\n", - " ax.plot([min_val, max_val], [min_val, max_val], 'r--', linewidth=2)\n", - "\n", - " ax.set_xlabel('True ' + labels[i], fontsize=14)\n", - " ax.set_ylabel('Predicted ' + labels[i], fontsize=14)\n", - " ax.set_title(f'{labels[i]}', fontsize=16)\n", - " ax.text(0.05, 0.95, f'R² = {r2:.2f}\\nRMSE = {rmse:.2f}',\n", - " transform=ax.transAxes, fontsize=12, verticalalignment='top',\n", - " bbox=dict(boxstyle=\"round\", facecolor='white', alpha=0.6))\n", - " ax.grid(True)\n", - "\n", - "plt.tight_layout()\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 135, - "id": "a4882379-5d50-42e1-a16d-311a714d6ad1", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "tensor([[0.9057, 0.0000, 0.0947],\n", - " [0.0000, 0.0000, 1.0000],\n", - " [0.0000, 0.0000, 1.0000],\n", - " [0.0000, 0.0000, 1.0000],\n", - " [0.0000, 0.0000, 1.0000],\n", - " [0.6506, 0.2377, 0.7895],\n", - " [0.0000, 0.0000, 1.0000],\n", - " [1.0000, 1.0000, 0.0000]], device='cuda:0', dtype=torch.float64)\n", - "Ref point: tensor([-0.1000, -0.1000, -0.1000], device='cuda:0')\n", - "Hypervolume: 0.32563138008117676\n", - "Pareto size: 4\n" - ] - } - ], - "source": [ - "pareto_mask = is_non_dominated(train_Y)\n", - "pareto_Y = train_Y[pareto_mask]\n", - "\n", - "ref_point = torch.tensor([-0.1, -0.1, -0.1], device=device) #\n", - " \n", - "hv = Hypervolume(ref_point=ref_point)\n", - "volume = hv.compute(pareto_Y)\n", - "print(train_Y)\n", - "print(\"Ref point:\", ref_point)\n", - "print(\"Hypervolume:\", volume)\n", - "print(\"Pareto size:\", pareto_Y.shape[0])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7588eaa2-cef0-431f-8bc7-59b0cece3541", - "metadata": {}, - "outputs": [], - "source": [ - "pareto_Y[:, 2] *= -1\n", - "pareto_Y_np = pareto_Y.detach().cpu().numpy()\n", - "\n", - "fig = plt.figure(figsize=(8, 6))\n", - "ax = fig.add_subplot(111, projection='3d')\n", - "\n", - "# Plot Pareto-optimal points\n", - "ax.scatter(pareto_Y_np[:, 0], pareto_Y_np[:, 1], pareto_Y_np[:, 2],\n", - " c='green', s=50, label='Pareto Front', depthshade=True)\n", - "\n", - "# Optionally, plot the reference point\n", - "ax.scatter([ref_point[0].item()], [ref_point[1].item()], [ref_point[2].item()],\n", - " c='red', s=60, marker='*', label='Reference Point')\n", - "\n", - "# Label axes\n", - "ax.set_xlabel('PCE (normalized)', fontsize=12)\n", - "ax.set_ylabel('Stability (normalized)', fontsize=12)\n", - "ax.set_zlabel('Repeatability (normalized, flipped)', fontsize=12)\n", - "ax.set_title('Pareto Front (3D)', fontsize=14)\n", - "\n", - "ax.legend()\n", - "ax.view_init(elev=25, azim=-45)\n", - "plt.tight_layout()\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 192, - "id": "c4f9b9e3-f525-4d08-90c4-ad369a4151b8", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "C:\\Users\\eschw\\anaconda3\\envs\\mobo-env\\lib\\site-packages\\botorch\\acquisition\\multi_objective\\monte_carlo.py:321: NumericsWarning: qNoisyExpectedHypervolumeImprovement has known numerical issues that lead to suboptimal optimization performance. It is strongly recommended to simply replace\n", - "\n", - "\t qNoisyExpectedHypervolumeImprovement \t --> \t qLogNoisyExpectedHypervolumeImprovement \n", - "\n", - "instead, which fixes the issues and has the same API. See https://arxiv.org/abs/2310.20708 for details.\n", - " legacy_ei_numerics_warning(legacy_name=type(self).__name__)\n" - ] - } - ], - "source": [ - "acq_func = qNoisyExpectedHypervolumeImprovement(\n", - " model=model,\n", - " ref_point=ref_point,\n", - " X_baseline=train_X,\n", - " sampler=SobolQMCNormalSampler(sample_shape=torch.Size([64])),\n", - " #eta=0.05,\n", - " prune_baseline=True,\n", - " alpha=0.0,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 112, - "id": "36c0de0a-b6e1-44df-a3c4-224cec2423e8", - "metadata": {}, - "outputs": [ - { - "ename": "NameError", - "evalue": "name 'model_generic' is not defined", - "output_type": "error", - "traceback": [ - "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[1;31mNameError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[1;32mIn[112], line 2\u001b[0m\n\u001b[0;32m 1\u001b[0m acq_func_gen \u001b[38;5;241m=\u001b[39m qNoisyExpectedHypervolumeImprovement(\n\u001b[1;32m----> 2\u001b[0m model\u001b[38;5;241m=\u001b[39m\u001b[43mmodel_generic\u001b[49m,\n\u001b[0;32m 3\u001b[0m ref_point\u001b[38;5;241m=\u001b[39mref_point,\n\u001b[0;32m 4\u001b[0m X_baseline\u001b[38;5;241m=\u001b[39mtrain_X,\n\u001b[0;32m 5\u001b[0m sampler\u001b[38;5;241m=\u001b[39mSobolQMCNormalSampler(sample_shape\u001b[38;5;241m=\u001b[39mtorch\u001b[38;5;241m.\u001b[39mSize([\u001b[38;5;241m128\u001b[39m])),\n\u001b[0;32m 6\u001b[0m \u001b[38;5;66;03m#eta=0.05,\u001b[39;00m\n\u001b[0;32m 7\u001b[0m prune_baseline\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mFalse\u001b[39;00m,\n\u001b[0;32m 8\u001b[0m alpha\u001b[38;5;241m=\u001b[39m\u001b[38;5;241m0.1\u001b[39m,\n\u001b[0;32m 9\u001b[0m )\n", - "\u001b[1;31mNameError\u001b[0m: name 'model_generic' is not defined" - ] - } - ], - "source": [ - "acq_func_gen = qNoisyExpectedHypervolumeImprovement(\n", - " model=model_generic,\n", - " ref_point=ref_point,\n", - " X_baseline=train_X,\n", - " sampler=SobolQMCNormalSampler(sample_shape=torch.Size([128])),\n", - " #eta=0.05,\n", - " prune_baseline=False,\n", - " alpha=0.0,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 193, - "id": "65b89431-0024-47ad-88af-2ec7bcab4996", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Final candidate batch:\n", - " [[ 0.85 0.64 210. 231. 1.35 0.85 12. 26. ]\n", - " [ 0.97 0.57 216. 182. 1.4 0.8 28. 37. ]\n", - " [ 0.89 0.29 220. 275. 1.4 1.1 19. 49. ]\n", - " [ 0.83 0.33 240. 222. 1.4 1.15 30. 43. ]]\n" - ] - } - ], - "source": [ - "NUM_RESTARTS = 20\n", - "RAW_SAMPLES = 512\n", - "BATCH_SIZE = 4\n", - "MAX_ATTEMPTS = 1\n", - "\n", - "bounds = torch.tensor([[0.0] * 8, [1.0] * 8], device=device)\n", - "\n", - "valid_candidates = None\n", - "valid_candidates_gen=None\n", - "attempts = 0\n", - "\n", - "# while attempts < MAX_ATTEMPTS:\n", - "# attempts += 1\n", - "\n", - " # Optimize acquisition function in normalized space\n", - "candidates, _ = optimize_acqf(\n", - " acq_function=acq_func,\n", - " bounds=bounds,\n", - " q=BATCH_SIZE,\n", - " num_restarts=NUM_RESTARTS,\n", - " raw_samples=RAW_SAMPLES,\n", - " options={\"batch_limit\": 4, \"maxiter\": 200},\n", - " sequential=True,\n", - ")\n", - "\n", - " # Optimize acquisition function in normalized space for generic regressor\n", - " # candidates_gen, _ = optimize_acqf(\n", - " # acq_function=acq_func_gen,\n", - " # bounds=bounds,\n", - " # q=BATCH_SIZE,\n", - " # num_restarts=NUM_RESTARTS,\n", - " # raw_samples=RAW_SAMPLES,\n", - " # options={\"batch_limit\": 5, \"maxiter\": 200},\n", - " # )\n", - " \n", - " # Convert to original space and snap to valid grid\n", - "X_cont = x_denormalizer(unnormalize(candidates, bounds).cpu().numpy(), var_array)\n", - "X_snapped = get_closest_array(X_cont, var_array)\n", - "\n", - " # Convert generic to original space and snap to valid grid\n", - " # X_cont_gen = x_denormalizer(unnormalize(candidates_gen, bounds).cpu().numpy(), var_array)\n", - " # X_snapped_gen = get_closest_array(X_cont_gen, var_array)\n", - "\n", - " # Check Clausius-Clapeyron constraint\n", - " # RH_vals = X_snapped[:, 6] # RH [%]\n", - " # Temp_vals = X_snapped[:, 7] # Temp [C]\n", - "\n", - " # RH_vals_gen = X_snapped_gen[:, 6] # RH [%]\n", - " # Temp_vals_gen = X_snapped_gen[:, 7] # Temp [C]\n", - " \n", - " # cc_mask = check_clausius_clapeyron(RH_vals, Temp_vals)\n", - " # cc_mask_gen = check_clausius_clapeyron(RH_vals_gen, Temp_vals_gen)\n", - "\n", - " # if cc_mask.all(): #i know this isn't great logic, but we never break cc relation; just benchmarking right now\n", - "valid_candidates = X_snapped\n", - " # # valid_candidates_gen = X_snapped_gen\n", - " # print(f\"Found valid candidates after {attempts} attempts.\")\n", - " # break\n", - "\n", - "# if valid_candidates is not None: #and valid_candidates_gen is not None:\n", - "print(\"Final candidate batch:\\n\", valid_candidates)\n", - " # print(\"Final generic candidate batch:\\n\", valid_candidates_gen)\n", - "# else:\n", - "# print(\"No valid batch found after max attempts.\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ce701516-4db9-4e9b-9df5-a1f19328d4d6", - "metadata": {}, - "outputs": [], - "source": [ - "print(\"Final candidate batch:\\n\", valid_candidates)\n", - "#print(\"Final generic candidate batch:\\n\", valid_candidates_gen)" - ] - }, - { - "cell_type": "code", - "execution_count": 194, - "id": "70a83751-9300-459e-abee-b7b4d4e7fbb4", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Random candidate acquisition values: [0.00288875]\n", - "Acquisition values of optimized suggested candidates: [0.01465591]\n" - ] - } - ], - "source": [ - "# After optimizing\n", - "acq_val = acq_func(torch.tensor(x_normalizer(valid_candidates, var_array), dtype=torch.float64, device=device))\n", - "#acq_val_gen = acq_func_gen(torch.tensor(x_normalizer(valid_candidates_gen, var_array), dtype=torch.float64, device=device))\n", - "\n", - "# Random candidates\n", - "rand_X = torch.rand(BATCH_SIZE, train_X.shape[-1], device=device)\n", - "rand_acq_val = acq_func(rand_X)\n", - "\n", - "print(\"Random candidate acquisition values:\", rand_acq_val.detach().cpu().numpy())\n", - "print(\"Acquisition values of optimized suggested candidates:\", acq_val.detach().cpu().numpy())\n", - "#print(\"Acquisition values of generic suggested candidates:\", acq_val_gen.detach().cpu().numpy())" - ] - }, - { - "cell_type": "code", - "execution_count": 195, - "id": "e5d89875-dc9a-492e-b0bc-68faf9f09295", - "metadata": {}, - "outputs": [], - "source": [ - "X_new_scaled = torch.tensor(x_normalizer(valid_candidates, var_array), dtype=torch.float64, device=device)\n", - "#X_new_scaled_gen = torch.tensor(x_normalizer(valid_candidates_gen, var_array), dtype=torch.float64, device=device)\n", - "\n", - "posterior_new = model.posterior(X_new_scaled)\n", - "mean_new = posterior_new.mean.detach().cpu().numpy()\n", - "std_new = posterior_new.variance.sqrt().detach().cpu().numpy()\n", - "mean_new_unnorm = mean_new * (Y_max - Y_min) + Y_min\n", - "std_new_unnorm = std_new * (Y_max - Y_min)\n", - "# mean_new_unnorm[:, 2] *= -1\n", - "#std_new_unnorm[:, 2] *= -1\n", - "\n", - "# posterior_new_gen = model_generic.posterior(X_new_scaled_gen)\n", - "# mean_new_gen = posterior_new_gen.mean.detach().cpu().numpy()\n", - "# std_new_gen = posterior_new_gen.variance.sqrt().detach().cpu().numpy()\n", - "# mean_new_unnorm_gen = mean_new_gen * (Y_max - Y_min) + Y_min\n", - "# std_new_unnorm_gen = std_new_gen * (Y_max - Y_min)\n", - "# mean_new_unnorm_gen[:, 2] *= -1\n", - "\n", - "columns = ['PCE', 'Stability', 'Repeatability']\n", - "results_df = pd.DataFrame(mean_new_unnorm, columns=[f'{col}_mean' for col in columns])\n", - "for i, col in enumerate(columns):\n", - " results_df[f'{col}_std'] = std_new_unnorm[:, i]\n", - "\n", - "# results_df_gen = pd.DataFrame(mean_new_unnorm_gen, columns=[f'{col}_mean' for col in columns])\n", - "# for i, col in enumerate(columns):\n", - "# results_df_gen[f'{col}_std'] = std_new_unnorm_gen[:, i]\n", - "\n", - "# Add parameter values too\n", - "param_df = pd.DataFrame(valid_candidates, columns=x_labels)\n", - "full_results = pd.concat([param_df, results_df], axis=1)\n", - "#print(full_results.values.tolist())\n", - "\n", - "# param_df_gen = pd.DataFrame(valid_candidates_gen, columns=x_labels)\n", - "# full_results_gen = pd.concat([param_df_gen, results_df_gen], axis=1)" - ] - }, - { - "cell_type": "code", - "execution_count": 196, - "id": "f2b89a41-e17d-4e06-aade-0ac2e34970e7", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
Speed (Inorg) [m/min]Speed (Org) [m/min]inkFL (Inorg) [uL/min]inkFL (Org) [uL/min]Conc. (Inorg) [M]Conc. (Org) [M]AH [g/m^3]Temp [C]PCE_meanStability_meanRepeatability_meanPCE_stdStability_stdRepeatability_std
00.850.64210.0231.01.350.8512.026.09.9680405.0304190.9568913.4550280.6215200.098370
10.970.57216.0182.01.400.8028.037.013.9541563.9406800.5822913.3298071.2907050.187314
20.890.29220.0275.01.401.1019.049.08.8746083.7705210.9570705.1770921.3447090.241858
30.830.33240.0222.01.401.1530.043.012.6467852.7696170.8904114.1611921.5790610.149726
\n", - "
" - ], - "text/plain": [ - " Speed (Inorg) [m/min] Speed (Org) [m/min] inkFL (Inorg) [uL/min] \\\n", - "0 0.85 0.64 210.0 \n", - "1 0.97 0.57 216.0 \n", - "2 0.89 0.29 220.0 \n", - "3 0.83 0.33 240.0 \n", - "\n", - " inkFL (Org) [uL/min] Conc. (Inorg) [M] Conc. (Org) [M] AH [g/m^3] \\\n", - "0 231.0 1.35 0.85 12.0 \n", - "1 182.0 1.40 0.80 28.0 \n", - "2 275.0 1.40 1.10 19.0 \n", - "3 222.0 1.40 1.15 30.0 \n", - "\n", - " Temp [C] PCE_mean Stability_mean Repeatability_mean PCE_std \\\n", - "0 26.0 9.968040 5.030419 0.956891 3.455028 \n", - "1 37.0 13.954156 3.940680 0.582291 3.329807 \n", - "2 49.0 8.874608 3.770521 0.957070 5.177092 \n", - "3 43.0 12.646785 2.769617 0.890411 4.161192 \n", - "\n", - " Stability_std Repeatability_std \n", - "0 0.621520 0.098370 \n", - "1 1.290705 0.187314 \n", - "2 1.344709 0.241858 \n", - "3 1.579061 0.149726 " - ] - }, - "execution_count": 196, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "full_results" - ] - }, - { - "cell_type": "code", - "execution_count": 65, - "id": "b74ce42a-432a-41b1-a910-78216e03269d", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[[ 0.54852876 0.88516943 0.04352899]\n", - " [-0.02403182 0.12423929 -0.04972143]\n", - " [-0.11500688 0.12856106 0.06174395]\n", - " [ 0.76973734 0.4876084 -0.06272536]\n", - " [ 0.81066491 0.86717505 -0.42012853]]\n" - ] - } - ], - "source": [ - "# full_results_gen\n", - "print(mean_new)" - ] - }, - { - "cell_type": "code", - "execution_count": 145, - "id": "9b052b77-01da-4c18-acc5-98ed20864a23", - "metadata": {}, - "outputs": [], - "source": [ - "def compute_diversity_score(X: np.ndarray) -> float:\n", - " \"\"\"\n", - " Computes the average pairwise Euclidean distance between rows in X.\n", - " A higher value indicates greater diversity among candidate points.\n", - " \n", - " Args:\n", - " X (np.ndarray): 2D array of shape (n_points, n_features)\n", - "\n", - " Returns:\n", - " float: average pairwise distance\n", - " \"\"\"\n", - " if len(X) < 2:\n", - " return 0.0 # Not enough points to compute diversity\n", - " distances = pdist(X, metric='euclidean') # All pairwise distances\n", - " return distances.mean()\n" - ] - }, - { - "cell_type": "code", - "execution_count": 197, - "id": "868334b5-5df3-4fb4-bfb1-74c52efccc07", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Diversity score of batch: 0.7861\n" - ] - } - ], - "source": [ - "diversity = compute_diversity_score(X_new_scaled.cpu().numpy())\n", - "#diversity_gen = compute_diversity_score(X_new_scaled_gen.cpu().numpy())\n", - "print(f\"Diversity score of batch: {diversity:.4f}\")\n", - "#print(f\"Diversity score of generic batch: {diversity_gen:.4f}\")" - ] - }, - { - "cell_type": "code", - "execution_count": 198, - "id": "8d7b4c18-631a-49ea-90e5-86c598b3c6b6", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.7712543606758118\n", - "0.5372302532196045\n", - "0.5414943099021912\n", - "0.5301148891448975\n" - ] - }, - { - "data": { - "image/png": "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0GbNmtndrnnuT4cOHS76+ADgKZdffrlt6avdu3drxIgRqlOnjq644gqNHz9eq1evlsViKdOxx4wZo+bNm9uV+fv7a+LEibrlllv0yy+/6Msvv3TaNj8/X4mJiXbX8D59+thiffHFF+3uPnKngQMHqmnTpvrmm2+UmprqsL9o9iEzDIGqLScnR59//rmGDRum/Px89ezZU71795Z0djZyXFycqlWrpvfff99uKapq1arp8ccf10033aTff/9da9ascTh2fn6+7r77bt1yyy22snr16mnlypUKCgrSV199ZXe3zzfffKN3331XkZGRWrt2rd21uU6dOvrXv/6liIgIvf/++/r1119t+2rXrq2xY8eqbt26dv2HhobqtddeU0REhP797387XWbrQkaMGKF27drZlZlMJg0dOlTTp09XTk6ObYa+s/f/2muvqX379raytm3b2pbQWrRokU6cOFHqmMrDZZddpt69eys7O9vp80T4jAAA4OKRRAdKaO7cuerVq5d69eqltm3bKiIiQp9//rkCAgL05ptvqlatWrYvzjVq1Ljo/qKiouxu1zz3p2PHjhd9fADwpMcee0wbN27Uddddp8DAQBmGof379ysxMVG33HKLLr/88mKXXinOvn37NGvWLA0fPlz9+vWzXbuL1lT/+uuvnbbr3r27OnXq5FA+YcIEBQUF6dChQ06XK3AHk8mksWPHSnK8XT81NVXffPONGjZsqH/84x+eCA+AB527Bnjt2rU1aNAg7du3T6NGjbJLCO/YsUOZmZnq1KmTy++SQ4YMkSTb9fJ806ZNcyhr0KCBRowYIensWutF1q5dK0kaOXKkatWq5dAuODhY11xzjQzDsC2HeK6NGzfqgQce0PXXX68+ffrYruV///23cnNzbc8jKq309HQ9++yzGjlypAYMGGA77urVqyW5/oxo3Lixhg4d6lB+ww03qEmTJjp16pS2bdtWppjKw4QJEyQ5fkb8+eef+uSTT1juCwCAi+Tv6QAAX3HgwAHbl/XAwEA1bNhQffr00YMPPmibGV70H4RTp05ddH9r1qwplxntAOCt+vfvr/79++v06dPatWuX/u///k8bNmxQSkqK0tPTdd1112nPnj264oorSnzMhIQEPfHEE8WuX/7XX385LW/durXT8ho1aigiIkIHDhzQTz/9VKp4ytP48eM1e/ZsvfPOO3rhhRfk73/2a1xRwuSOO+5QtWrVPBIbAM9p2bKlGjRoIMMwlJmZqV9++UUBAQHq0qWL6tSpY6v37bffSjr7wMtevXo5PVbRcyqK7q48V0BAgC677DKn7Yqunz/99JNDf2vXrnW5znrRDPRz+ztz5oxGjRqldevWOW1TxNW1vDjLli3TlClTlJeXV+rjtmrVSn5+jnPQTCaTWrVqpfT0dP30008e+2PmzTffrPvuu08ff/yxsrOzVb9+fUnSO++8I6vVqhEjRjjM7gcAACXHTHSghJYuXSrDMGQYhiwWi3799Vf961//sltapXHjxpLO/ucEAFAy1atXV+/evTVjxgxt3LhRW7ZsUY0aNXT69Gm99NJLJT7Oli1b9Nhjj8lkMikhIUHff/+9Tp48qcLCQhmGoccff1ySZLVanbYveiidM0UPL/XUrfqSFBkZqQEDBigrK0uffPKJpLPLCxQ9CI/b9IGq6bHHHtPWrVu1bds2HTx4UFu3blWtWrU0Y8YMrVixwlbv77//lnR2ZvK2bduc/nz//feSpNOnTzv0U69ePadJZMn5NbKov59//tllf7///rtDf88++6zWrVunhg0bavny5Tp06JDy8vJs38N79uwpyfW13JWDBw/qzjvvVF5enh588EHt3btXOTk5ts+IN998s9jjevtnRI0aNTRy5EhZrVatWrXKVs5SLgAAlA+S6EA56tGjhyTp2LFj+uabbzwcDQD4pl69emnq1KmSpK+++qrE7VauXClJeuihh/Too4/qyiuvVI0aNWQymSRJv/32W7Ht//zzT5f7srKyJMnpkgTudP7t+p988omysrIUFRWlNm3aeDI0AF6iZ8+etoTw/fffr5ycHElSzZo1JUm33367LSHt6sfZclpHjx51eZePs2tkUX9vvvnmBfuLi4uztSu6licmJmr06NGKjIyU2Wy27b/QtdyVf//737Jarbrlllv04osvqkOHDqpVq1al/oz49ttvtXfvXpb7AgCgHJBEB8pRo0aNbLfHLly40MPRAIDvKnoA3ZkzZ2xlRYkOV4ruAir6g+b5XK1zW+THH390Wp6bm6v09HRJZx+KWhEu9N6KDB8+XKGhofroo4/0119/KTExURIzDAHYGzZsmLp166a//vpLL7/8siTpyiuvlCR99913ZTqm1WrVwYMHne4run6ee40sa3/FXcuPHj3qdKkZqeI/I/bv3+/0jwhFz/SQPP8Z0aNHD11xxRXavXu3vvvuO9tnBMt9AQBw8UiiA+XsiSeekHR21s2GDRuKrfvHH3/YZtsAQFWRnZ0twzCKrVO0fm7Lli1tZdWrV5fkfJmBc/cfOXLEYV9SUtIFEyTbt29XamqqQ/nbb7+tvLw8RUZGqlWrVsUeo6wu9N6KBAUF6dZbb9WZM2c0f/58/ec//+FhcQCcevTRRyVJr776qk6ePKnevXurfv36+vrrr8v84GZnk0T+/PNPrVmzRpIUHR1tK7/xxhslSStWrNDRo0dL3Edx1/KXXnpJBQUFxbYry2fEvn377B7C6szvv//utM7HH3+sX3/9VTVq1LAtNVPeSvoZIZ19foYkLVmyxPb/DP7QCgDAxSOJDpSza6+9VrGxsSosLNSNN96o+Ph4ZWdn29X5888/9dJLL6ldu3bauXOnhyIFAM9YsWKFOnTooDfffNMhsXL8+HE99dRTtnV8i5IB0v9mp2/evNnpcYvuBHr22WeVlpZmK9+5c6cmTJigoKCgYuPy9/fXuHHjbA+6k6StW7fqqaeekiTNmDGjxLMBS6vovW3fvl35+fnF1i26Xf/pp5/WmTNnNGTIEB4WB8DBkCFD1Lp1ax07dkyLFi1SUFCQZs+eLensQyjXrl3r8AfN7777To888oi2bdvmcDx/f38tXLjQljCXzj6E84477lBeXp6ioqLUv39/276oqCiNHDlSR48e1aBBg7R371674xUUFCglJUW33367LBaLrbzoWv7ggw/q5MmTks7O9l6+fLlefPFFl9fyouvoli1bnP6h9ty7Rc/9g+lPP/2km2++WYGBgU6Pe+77v/fee20PTJWkH374Qffcc48kacqUKRW2nEvRe9u5c6dyc3OLrTtmzBj5+/tr/vz5OnLkCMt9AQBQXgwAxYqMjDQkGUuXLi1Vu9mzZxsBAQGGJMPPz8+4/PLLja5duxqXXXaZ4efnZ0gygoODjZUrV9rapKWlGZIMSUZUVJTRs2dPlz9btmwp53cKAO4xb94827VOktGsWTOja9euRsuWLY3AwEBb+YwZM+zabdmyxbbv8ssvN/r06WP07dvX+OSTTwzDMIy///7baN68uSHJCAwMNNq1a2e0atXKkGRceeWVRmxsrCHJmDVrlt1xZ82aZUgypk2bZkRERBj+/v5Ghw4dbG0lGYMHDzYKCgrs2m3atMmQZPTt29fhPRa1O1/fvn0NScamTZvsyv/++2+jTp06hiQjPDzc6Nmzp9G3b18jISHB6RheddVVtj7+85//XGDEAVRGJfmOumTJEkOS0bBhQ+P06dOGYRjGo48+art+1K1b1+jSpYvRqVMno27durbyouuqYfzv+2lkZKRx9913215HRUUZ1atXNyQZ9erVM3744QeH/k+cOGEMGjTIdtwmTZoYV199tdGuXTtbW0m22AzDMHbt2mWYzWZDkhESEmJ07tzZaNSokSHJGD16tMvr6M8//2z7DImMjDR69+5t9O3b1zY+VqvV6NatmyHJqFatmtG6dWujbdu2hslkMsLDw405c+YYkoyxY8faHXfp0qWGJOOWW24xOnbsaJhMJqNt27ZGu3btDJPJZEgyunTpYpw8edKu3bnj5up3l5aWZlc+duxYp7/TgoICo2XLlrax7t69u9G3b1/j/vvvd/ylG4YxePBg29jOnz/faR0AAFA6zEQHKsiTTz6p/fv36+GHH1aHDh2UnZ2tPXv26NixY+rZs6eeeeYZ/fzzz7rtttuctt+1a5e2bdvm8qc0t8UCgDeZOnWqNm7cqIceekg9evRQQUGBUlNTdfjwYUVGRmrMmDH64osv9MILL9i16927t9555x117dpVhw8f1pYtW7R582ZlZmZKkkJCQrR161aNGTNGISEh2r9/v86cOaPY2Fjt2LHjgjME69evr6+++kpjxozRkSNHlJaWplatWum5557TBx98ID+/ivvaFBISoqSkJMXExMhisWjHjh3avHmz9u3b57R+0a35PCwOQHHuuOMONWrUSJmZmXr77bclSQkJCdq2bZtuu+021ahRQ19//bUOHTqkSy+9VBMmTNDHH3+sgQMHOj3eggUL9Morr6hWrVr67rvvVKNGDd1+++3avXu3Wrdu7VC/Zs2a+vTTT7Vy5Upde+21ys3N1Z49e5Sdna2rrrpKjzzyiL766iu72eWdO3fWli1bNGjQIBUWFmrfvn1q0KCBXn31VdsDM51p0aKFPvroI/Xt21fHjh3T1q1btXnzZtta6P7+/vrss8907733KiwsTD///LOOHz+uiRMnavfu3WrcuHGxY2k2m7V582bbw1r379+vJk2a6NFHH9WmTZtUo0aNC/06yszPz08ff/yxRowYoWrV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gAGsX3kezh3XfOncKbprBgAAAPBGPLAXAFAZ+FwS/ejRo/rtt9+K/UDt3r27kpOT9cADD9jKkpKS1KNHD5dtzGaz0+VhAgICfCLB6CtxutO548H4uMbYOPKlc8ebYwMAAAB4YC8AoDLweBL95MmT+vnnn23baWlpSk1NVd26dVW3bl3FxcXppptuUnh4uA4dOqTHHntM9evX14033mhrM2bMGDVu3FgJCQmSpPvvv199+vTRc889p6FDh2r9+vX6/PPPtXXrVre/PwAAAAAAAACA7/J4En3Xrl3q37+/bbtoSZWxY8dq0aJF+vbbb7V8+XIdP35c4eHh6t+/v1avXm13u1d6err8/Pxs2z169NC7776rJ554Qk8++aRatGih1atX6+qrr3bfGwMAAAAAAAAA+DyPJ9H79esnwzBc7v/ss88ueIyUlBSHshEjRmjEiBEXExoAAAAAAAAAoIrzu3AVAAAAAAAAeJuFCxeqWbNmCgoKUufOnfXFF18UW3/lypVq3769goODFR4ervHjx+vo0aNuihYAfBdJdAAAAAAAAB+zevVqTZ8+XY8//rj27t2r3r17KyYmRunp6U7rb926VWPGjNHEiRP1/fffa82aNdq5c6cmTZrk5sgBwPeQRAcAAACgLVu2aPDgwWrUqJFMJpPWrVt3wTabN29W586dFRQUpObNm+v111+v+EABAJKkl19+WRMnTtSkSZPUunVrzZs3TxEREVq0aJHT+l9++aWaNm2q++67T82aNVOvXr00efJk7dq1y82RA4DvIYkOAAAAQKdOnVL79u01f/78EtVPS0vTddddp969e2vv3r167LHHdN999+n999+v4EgBAGfOnNHu3bsVHR1tVx4dHa3t27c7bdOjRw/9/vvv2rBhgwzD0JEjR/Tee+/p+uuvd9mPxWJRTk6O3Q8AVEUef7AoAAAAAM+LiYlRTExMieu//vrratKkiebNmydJat26tXbt2qUXX3xRN910UwVFCQCQpOzsbBUUFCgsLMyuPCwsTJmZmU7b9OjRQytXrtSoUaOUl5en/Px8DRkyRK+99prLfhISEhQfH1+usQOALyKJDgAAAKDUduzY4TAD8tprr9WSJUtktVoVEBDg0MZischisdi2i2Y0Wq1WWa3Wig24jM6Ny5vj9JSi8WBcHHHuuOZrY+PN8ZlMJrttwzAcyor8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mjRx2mbkyJE6cuSIlixZossuu0xZWVnKz88vt5gAAAAAb1CqJLok3XDDDfr222/18ssvKykpSXv37pUkNWnSRDfffLMeeOABEugAUMnNnz/faflHH31kt52enq758+frlVdecUdYAFAlHThwQIsXL9aPP/6o06dP2+0zmUz673//W6LjvPzyy5o4caImTZokSZo3b54+++wzLVq0SAkJCQ71P/30U23evFm//PKL6tatK0n8PwAAAACVUqmT6KdPn9aOHTvUqFEjPfTQQxoyZIguueSSiogNAOClCgsLPR0CAEDSd999p27duqlx48b6+eefddVVVyk7O1uHDx9WRESEWrRoUaLjnDlzRrt379ajjz5qVx4dHa3t27c7bfPhhx8qKipKzz//vP71r3+pRo0aGjJkiJ5++mmXS8lYLBZZLBbbdk5OjiTJarXKarWWKFZJ8pd7P4eK+nN3v6UZE084N77S/g49hXOneO6M05fGxhfObQBAxSpVEv2PP/5Qnz59lJaWJsMwZDKZNGPGDH3yySfq1q1bRcUIAAAAwInHHntM1157rVavXq3AwEAtWbJEnTp10scff6wJEyZozpw5JTpOdna2CgoKFBYWZlceFhamzMxMp21++eUXbd26VUFBQVq7dq2ys7M1depU/fXXXy7XRU9ISFB8fLxDeVJSkoKDg0sUqyRdV6fEVctVdJ0st/a3YcMGt/ZXWnl5ebbXGzduVFBQkAejKRnOneJ5Ynx8YWxyc3MrIBIAgC8pVRL9iSee0OHDh/XEE0+oW7duOnDggJ555hndfffdtmVdAAAAALjHnj17tHDhQvn5+Un6351C119/vWbMmKGZM2dq8+bNJT6eyWSy2y6aOONMYWGhTCaTVq5cqdq1a0s6uyTMiBEjtGDBAqez0WfOnKnY2Fjbdk5OjiIiIhQdHa2QkJASx3nPyj0lrlse/FWo6DpZSjrWQPnyc1u/82/v5La+yuLUqVO21wMGDFBoaKjngikhzp3iuXN8fGlsiu6aAQBUXaVKoicnJ+uxxx7Tk08+KUmKiYlRixYtNGTIEB05csRh5goAoGpYsWKF5s2bpx9//NFuVlqRgoICD0QFAJXfsWPHVLduXfn5+SkgIEDHjh2z7YuKitLs2bNLdJz69eurWrVqDrPOs7KyXH7HDw8PV+PGjW0JdElq3bq1DMPQ77//rpYtWzq0MZvNMpvNDuUBAQEKCAgoUayS3JpwO79fd/ZdmjHxhHPjK+3v0FM4d4rnifHxhbHxhXMbAFCxSvVJlZmZqT59+tiV9evXT4Zh6MiRI+UaGAC4Q+7xbGUf2mf3czT9J9v+7PSfHPZnH9qn3OPZHozau3z44YcaP368OnbsqNOnT2v8+PG69dZbVaNGDbVs2VJPPfWUp0MEgEqrcePGys4++5l02WWXacuWLbZ933zzjWrWrFmi4wQGBqpz585KTk62K09OTlaPHj2ctunZs6f++OMPnTx50lb2008/yc/PT5deemlp3woAAADgtUo1E72goMDhtsyide/y8/PLLyoAcJN9KR8odf1bLvd/NHey0/IOQyep07C7Kiosn/Lss88qNjZWc+fO1ZIlSzR16lR16tRJmZmZ6t27tyIiIjwdIgAPmZi40y39WC2nba9jV6fKZK7hln4lacm4Lm7ry5levXpp+/btGjZsmG6//XbNmjVLGRkZCgwMVGJiou64444SHys2NlajR49WVFSUunfvrjfeeEPp6emaMmWKpLNLsRw+fFjLly+XJN122216+umnNX78eMXHxys7O1sPPfSQJkyY4PLBogAAAIAvKlUSXZL2798vf///NSu6RX/fvn0OdTt18u41/ADgin7D1aRDH4dyfxWqZ8hRbcup5/T20uDQ+u4Izyfs379f8fHxtjVzi/6o2rBhQz3xxBN64YUXNGHCBE+GCACV1uOPP64//vhDkvTII48oMzNTK1eulMlk0siRI/Xiiy+W+FijRo3S0aNHNXv2bGVkZKht27basGGDIiMjJUkZGRlKT0+31a9Zs6aSk5N17733KioqSvXq1dPIkSNL/DBTAAAAwFeUOok+btw4p+WjR4+2vS56ABFr4ALwdsGh9Z0mxP1VqBZ1MrX/WEOPrZ3pKwoKChQYGCg/Pz/VqFHDbj3dJk2a6JdffvFgdABQubVo0UItWrSQJFWrVk2vvvqqXn311TIfb+rUqZo6darTfYmJiQ5lV1xxhcMSMPA87gIBAAAoX6VKoi9durSi4gAA+KhmzZrZZkG2b99eq1at0pAhQyRJ7733nsLDwz0ZHgBUGadPn9Zff/2lsLAwuztHAQAAAFycUn27Hjt2bEXFAQDwUQMHDtTnn3+uW2+9Vffff79GjRqlnTt3KjAwUPv379ezzz7r6RABoFLbtGmTHnvsMe3ceXb28VdffaVOnTpp2rRpGjhwoIYPH+7hCAEAAADfxhoFAICL8swzz+if//ynJOnmm2/We++9p/bt2+vKK6/UkiVL9NBDD3k4QgCovDZu3Kjo6Gjl5eVpxowZKiwstO2rX7++0yVYAAAAAJQO93kCAC6K2WyW2Wy2bQ8fPpxZjwDgJk899ZSuu+46rV+/Xvn5+Xr++edt+9q3b89yjAAAAEA5IIkOALgozZs319q1a9W+fXuHfd99952GDBnCw0XhIPd4tnKPZ9uV5VstttfZ6T/JFFDdoZ2rhwEDVdXevXu1Zs0aSZLJZLLbd8kllygrK8sTYQEAAACVCkl0AMBFOXTokCwWi9N9eXl5+vXXX90cEXzBvpQPlLr+LZf7P5o72Wl5h6GT1GnYXRUVFuBz/P39ZbVane7LyspSrVq13BwRAAAAUPmQRAcAXLTzZz8W+eWXX0jgwKkr+g1Xkw59HMr9VaieIUe1Laee8p08uoVZ6IC9Ll266F//+peGDh3qsO+9995T9+7dPRAVAAAAULmQRAcAlNqyZcu0bNky2/bdd9+tkJAQuzqnT5/W119/rb59+7o7PPgAV8uy+KtQLepkav+xhk6T6ADsPfroo7r22mt14403asyYMTKZTPq///s/vf3223rvvfe0adMmT4cIAAAA+DyS6ACAUsvNzdWff/4p6ews9OPHjzss6WI2mzVq1CjFx8d7IkQAqBKuueYaLVu2TNOnT9f69eslSdOmTVNoaKgSExPVq1cvD0cIAAAA+D6S6ACAUrv77rt19913S5KaNWum999/3+mDRQEAFe+OO+7QTTfdpG3btikrK0v169dXz549VaNGDU+HBgAAAFQKJNEBABclLS3N9vqvv/7S888/r++++06NGzfW/fffryuvvNKD0QFA1VC9enVdc801ng4DAAAAqJRIogMASm3GjBn697//rfT0dFvZqVOnFBUVpV9//VWGYUiS3n33XX311Vdq1aqVp0IFgEovJydHCxYs0KZNm3T06FHVq1dP/fv31913363Q0FBPhwcAAAD4PJ7YBQAote3bt+uWW26xK5s/f74OHTqk6dOn6/jx49q+fbtq1qypZ5991kNRAkDll5aWpquuukqPP/64Dhw4oMDAQB04cECPP/642rdvr19++cXTIQIAAAA+jyQ6AKDU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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "labels = ['PCE', 'Stability', 'Repeatability']\n", - "num_candidates = mean_new_unnorm.shape[0]\n", - "x = np.arange(num_candidates)\n", - "\n", - "fig, axes = plt.subplots(1, 4, figsize=(15, 5))\n", - "hv_values = []\n", - "# Define reference point and compute Pareto front\n", - "for i in range(num_candidates):\n", - " pareto_mask_cand = is_non_dominated(torch.tensor(mean_new[i:i+1], device=device, dtype=torch.float64))\n", - " pareto_Y_cand = torch.tensor(mean_new[i:i+1], device=device, dtype=torch.float64)[pareto_mask_cand]\n", - " \n", - " # Compute hypervolume\n", - " hv = Hypervolume(ref_point=ref_point)\n", - " hv_value = hv.compute(pareto_Y_cand)\n", - " print(hv_value)\n", - "\n", - "for i in range(3):\n", - " axes[i].bar(x, mean_new_unnorm[:, i], yerr=std_new_unnorm[:, i], capsize=5, alpha=0.7)\n", - " axes[i].set_title(labels[i], fontsize=16)\n", - " axes[i].set_xlabel('Candidate Index', fontsize=12)\n", - " axes[i].set_ylabel(labels[i], fontsize=12)\n", - " axes[i].grid(True)\n", - "\n", - "plt.tight_layout()\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d8000fd5-5864-427d-abc7-6008ca5c67bf", - "metadata": {}, - "outputs": [], - "source": [ - "labels = ['PCE', 'Stability', 'Repeatability']\n", - "num_candidates_gen = mean_new_unnorm_gen.shape[0]\n", - "x = np.arange(num_candidates_gen)\n", - "\n", - "fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n", - "\n", - "for i in range(3):\n", - " axes[i].bar(x, mean_new_unnorm_gen[:, i], yerr=std_new_unnorm_gen[:, i], capsize=5, alpha=0.7)\n", - " axes[i].set_title(labels[i], fontsize=16)\n", - " axes[i].set_xlabel('Candidate Index', fontsize=12)\n", - " axes[i].set_ylabel(labels[i], fontsize=12)\n", - " axes[i].grid(True)\n", - "\n", - "plt.tight_layout()\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3725dc75-85f2-4e53-97bc-db4cdaf3951b", - "metadata": {}, - "outputs": [], - "source": [ - "# Combine old and new predicted means (in normalized space)\n", - "pred_old = torch.tensor(pred_mean, dtype=torch.float64, device=device)\n", - "pred_new = torch.tensor(mean_new, dtype=torch.float64, device=device)\n", - "\n", - "combined = torch.cat([pred_old, pred_new], dim=0)\n", - "\n", - "# Define reference point and compute Pareto front\n", - "pareto_mask_combined = is_non_dominated(combined)\n", - "pareto_Y_combined = combined[pareto_mask_combined]\n", - "\n", - "# Compute hypervolume\n", - "hv = Hypervolume(ref_point=ref_point)\n", - "hv_value = hv.compute(pareto_Y_combined)\n", - "\n", - "print(f\"Updated Pareto front has {pareto_Y_combined.shape[0]} points\")\n", - "print(f\"Hypervolume after batch: {hv_value:.4f}\")\n", - "print(f\"Volume Improvement of {hv_value - volume}\")\n", - "print(\"Max predicted PCE:\", combined[:, 0].max().item())\n", - "print(\"Max predicted Stability:\", combined[:, 1].max().item())\n", - "print(\"Max predicted Repeatability (flipped):\", combined[:, 2].max().item())\n", - "\n", - "\n", - "\n", - "# Plot 3D Pareto front\n", - "pareto_Y_combined[:, 2] *= -1\n", - "pareto_Y_np = pareto_Y_combined.detach().cpu().numpy()\n", - "\n", - "fig = plt.figure(figsize=(8, 6))\n", - "ax = fig.add_subplot(111, projection='3d')\n", - "\n", - "# Plot Pareto-optimal points\n", - "ax.scatter(pareto_Y_np[:, 0], pareto_Y_np[:, 1], pareto_Y_np[:, 2],\n", - " c='green', s=50, label='Pareto Front', depthshade=True)\n", - "\n", - "# Plot the reference point\n", - "ax.scatter([ref_point[0].item()], [ref_point[1].item()], [ref_point[2].item()],\n", - " c='red', s=60, marker='*', label='Reference Point')\n", - "\n", - "# Labels\n", - "ax.set_xlabel('PCE (normalized)', fontsize=12)\n", - "ax.set_ylabel('Stability (normalized)', fontsize=12)\n", - "ax.set_zlabel('Repeatability (normalized, flipped)', fontsize=12)\n", - "ax.set_title('Updated Pareto Front (3D)', fontsize=14)\n", - "\n", - "ax.legend()\n", - "ax.view_init(elev=25, azim=-45)\n", - "plt.tight_layout()\n", - "plt.show()\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8a69a0ee-be14-427d-8d29-26e06111376b", - "metadata": {}, - "outputs": [], - "source": [ - "# Combine old and new predicted means (in normalized space)\n", - "pred_old = torch.tensor(pred_mean, dtype=torch.float64, device=device)\n", - "pred_new_gen = torch.tensor(mean_new_gen, dtype=torch.float64, device=device)\n", - "\n", - "combined_gen = torch.cat([pred_old, pred_new_gen], dim=0)\n", - "\n", - "# Define reference point and compute Pareto front\n", - "pareto_mask_combined_gen = is_non_dominated(combined_gen)\n", - "pareto_Y_combined_gen = combined_gen[pareto_mask_combined_gen]\n", - "\n", - "# Compute hypervolume\n", - "hv = Hypervolume(ref_point=ref_point)\n", - "hv_value_gen = hv.compute(pareto_Y_combined_gen)\n", - "\n", - "print(f\"Updated Pareto front has {pareto_Y_combined_gen.shape[0]} points\")\n", - "print(f\"Hypervolume after batch: {hv_value_gen:.4f}\")\n", - "print(f\"Volume Improvement of {hv_value_gen - volume}\")\n", - "print(\"Max predicted PCE:\", combined_gen[:, 0].max().item())\n", - "print(\"Max predicted Stability:\", combined_gen[:, 1].max().item())\n", - "print(\"Max predicted Repeatability (flipped):\", combined_gen[:, 2].max().item())\n", - "\n", - "\n", - "\n", - "# Plot 3D Pareto front\n", - "pareto_Y_combined_gen[:, 2] *= -1\n", - "pareto_Y_np_gen = pareto_Y_combined_gen.detach().cpu().numpy()\n", - "\n", - "fig = plt.figure(figsize=(8, 6))\n", - "ax = fig.add_subplot(111, projection='3d')\n", - "\n", - "# Plot Pareto-optimal points\n", - "ax.scatter(pareto_Y_np_gen[:, 0], pareto_Y_np_gen[:, 1], pareto_Y_np_gen[:, 2],\n", - " c='green', s=50, label='Pareto Front Generic', depthshade=True)\n", - "\n", - "# Plot the reference point\n", - "ax.scatter([ref_point[0].item()], [ref_point[1].item()], [ref_point[2].item()],\n", - " c='red', s=60, marker='*', label='Reference Point')\n", - "\n", - "# Labels\n", - "ax.set_xlabel('PCE (normalized)', fontsize=12)\n", - "ax.set_ylabel('Stability (normalized)', fontsize=12)\n", - "ax.set_zlabel('Repeatability (normalized, flipped)', fontsize=12)\n", - "ax.set_title('Updated Pareto Front (3D)', fontsize=14)\n", - "\n", - "ax.legend()\n", - "ax.view_init(elev=25, azim=-45)\n", - "plt.tight_layout()\n", - "plt.show()\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9a329791-1e58-4b4d-a6eb-fc8304517bdd", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python [conda env:mobo-env] *", - "language": "python", - "name": "conda-env-mobo-env-py" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.16" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -}