Metadata-Version: 2.1
Name: genLhs
Version: 0.1.1
Summary: A package for generating Latin Hypercube Samples.
License: MIT
Author: SpaceDragon Labs
Author-email: jcluff@spacedragonlabs.com
Requires-Python: >=3.8,<4.0
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Dist: numpy (>=1.20.1,<2.0.0)
Requires-Dist: scipy (>=1.6.2,<2.0.0)
Description-Content-Type: text/markdown

# genLhs

<!-- Clean Badges -->
<p align="left">
  <img src="https://img.shields.io/badge/license-MIT-blue.svg" />
  <img src="https://img.shields.io/badge/python-3.8-green.svg" />
  <img src="https://img.shields.io/badge/numpy-1.22.4-orange.svg" />
  <img src="https://img.shields.io/badge/scipy-1.7.3-orange.svg" />
  <img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" />
</p>

A lightweight, fast, NumPy‑first toolkit for generating **Latin Hypercube Samples (LHS)** across **continuous**, **discrete/categorical**, and **mixed** parameter spaces—backed by SciPy distributions for PPF/PMF transforms and designed for reproducibility and clarity.

---

[[_TOC_]]

---

## Features

- **Continuous, discrete, and mixed LHS** with a consistent, structured‑array interface.
- **Reproducible randomness**: accept a scalar `seed` or a parent `np.random.Generator`.
- **Discrete/categorical sampling** via **PMF → CDF → inverse‑categorical** mapping (indices are always in range).
- **Tail policy** for infinite‑support discrete laws: `tail='renormalize'` (default) or `tail='lump'`.
- **Structured array outputs** with named fields—easy to save or convert to DataFrames.
- **Unit tests** provided in `tests/` directory, at the repo root.

---

## Installation

`genLhs` depends on **NumPy** and **SciPy**.

```bash
# Create/activate an environment, then:
pip install numpy scipy

# Install this repository in editable mode (run from the directory that contains the genLhs/ folder)
pip install -e .
```

---

## Repository Layout

- `src/genLhs`
  - `__init__.py`: public types & helpers: split_rngs, lcm_reduce, combine_samples, parse_seed
  - `ContLhs.py`: continuous LHS builder
  - `CatLhs.py`: categorical/discrete LHS builder
  - `distribution.py`: continuous (PPF) & discrete (PMF→CDF) distributions (instance-owned configs)
  - `example.py`: a simple example showing a mixed continuout and discrete sample generation.
  - `Lhs.py`: mixed (continuous + categorical) orchestrator
- `test/`
  - `test_sample_generators.py`: tests the functionality of classes and functions in `core.py`.
  - `test_distributions.py`: validates the implementation of probability distributions.
- `CHANGELOG.md`: description of changes between versions
- `LICENSE`: the MIT license file.
- `README.md`: detailed description of `genLhs` package
- `setup.py`: is a script used in Python projects for defining package metadata and dependencies required for installing the package using pip. It includes information such as the package name, version, author details, and dependencies.
- `.gitignore`: specifies intentionally untracked files and directories to ignore when committing changes.

---

## Quick Start

### Continuous LHS (`ContLhs`)

```python
import numpy as np
from genLhs import ContLhs
from genLhs import distribution as dst

dists = [
    dst.Uniform('uniform', low=0.0, high=1.0),
    dst.Normal('normal', loc=0.0, scale=1.0),
    dst.Triangular('triangular', left=-1.0, mode=0.0, right=2.0),
]

lhs = ContLhs(dists, n_samples=8)
sample = lhs(seed=42)      # or lhs(rng=np.random.default_rng(42))
print(sample[:3])          # structured array, shape (n_samples,)
```

### Categorical LHS (`CatLhs`)

```python
import numpy as np
from genLhs import CatLhs
from genLhs import distribution as dst

dists = [
    dst.Default_Cat("color", np.array(["red","green","blue"])),
    dst.Poisson("label", np.array(["p1","p2","p3","p4"]), lam=1.2, tail="renormalize"),
]

lhs = CatLhs(dists, n_samples=9)   # CatLhs: no rounding when n_samples provided
sample = lhs(seed=42)
print(sample[:3])
```

### Mixed LHS (`Lhs`)

```python
import numpy as np
from genLhs import Lhs
from genLhs import distribution as dst

cont = [dst.Uniform('uniform', 0.0, 1.0), dst.Normal('normal', 0.0, 1.0)]

cat = [
    dst.Default_Cat("color", np.array(["red","green","blue"])),
    dst.Poisson("pois", np.array(["p1","p2","p3","p4"]), lam=1.1)
]

lhs = Lhs(cont + cat, sample_factor=10)  # rounded to categorical LCM if needed
sample = lhs(seed=42)
print(sample[:3])
```

### Full Example: User Options and Replicates

```python
from genLhs import get_parser, parse_seed, dst, Lhs

if "__main__" == __name__:
    cont = [dst.Uniform('uniform_param', 0.0, 1.0),
            dst.Normal('normal_param', 0.0, 1.0)]
    cat = [dst.Default_Cat("color", np.array(["red", "green", "blue"])),
           dst.Poisson("pois", np.array(["p1", "p2", "p3", "p4"]), lam=1.1)]
    distributions = cont + cat

    parser = get_parser(add_help=True)
    opts = parser.parse_args()
    seeds = parse_seed(opts.seed)
    lhs = Lhs(distributions, **opts.__dict__)

    replicates = []
    for seed in seeds:
        sample = lhs(seed)
        replicates.append(sample)

    scenario_matrix = np.concatenate(replicates, axis=0)

    print(f"\nSCENARIO MATRIX RESULTS\
            \n=======================\
            \nNUMBER OF SAMPLES     : {scenario_matrix.size}\
            \nNUMBER OF REPLICATES  : {len(seeds)}\
            \nNUMBER OF PARAMETERS  : {len(scenario_matrix.dtype.names)}\
            \nPARAMETERS            : {scenario_matrix.dtype.names}\
            \n\n{scenario_matrix[:3]}\n...")
```

---

## LHS Builders: Behavioral Details


- **Structured array output**: Every sampler returns a 1‑D **structured array** `(n_samples,)` with named fields matching names user provided distribution names.
- **Reproducibility**: Mixed runs split randomness into independent substreams for continuous vs. categorical parts, keeping results stable under refactors when using a seed.
- **Discrete mapping**: Discrete families map uniforms to **category indices** via **PMF→CDF**; tail policy controls the representation of extreme mass with finite categories.
- **No shared class state**: All distribution instances own their configs; no cross‑instance mutation.

### `ContLhs`

- **Inputs**: list of continuous `Distribution` instances.
- **Construction**: `ContLhs(dists, n_samples=None, sample_factor=None, criterion='random')`
  - If `n_samples` is provided, it’s used as‑is.
  - Else `n_samples = n_vars * sample_factor`.
- **Call**: `__call__(seed=None, rng=None) → structured array (n_samples,)`
  - Builds a stratified `U ∈ [0,1)` LHS with per‑column permutations, then applies each distribution’s **PPF**.
  - Optional clipping if an instance provides `dist.bounds = (lo, hi)`.

### `CatLhs`

- **Inputs**: list of discrete/categorical `Distribution` instances.
- **Construction**: `CatLhs(dists, n_samples=None, sample_factor=None, criterion='random')`
  - If `n_samples` is provided, it’s used as‑is (no rounding).
  - Else `n_samples = n_cats * sample_factor`, **then rounded up** to the LCM of category counts for equal representation.
- **Call**: `__call__(seed=None, rng=None) → structured array (n_samples,)`
  - Builds stratified `U ∈ [0,1)` and maps each column to category indices via **PMF→CDF**.
  - Maps indices to labels with `np.take`, producing only in‑range category values.

### `Lhs` (mixed)

- **Inputs**: a single list with any mix of continuous and discrete `Distribution` instances.
- **Construction**: `Lhs(dists, n_samples=None, sample_factor=None, criterion='random')`
  - If **any** categorical columns exist, `n_samples` is **rounded up to the LCM** of their sizes (even when `n_samples` is provided).
  - Otherwise, behaves like `ContLhs` for sizing.
- **Call**: `__call__(seed=None, rng=None) → structured array (n_samples,)`
  - Splits random streams for continuous vs. categorical parts using `split_rngs` (seed‑based stable substreams or RNG‑derived substreams).
  - Merges outputs with `combine_samples`.

---

## Distributions

All distributions are **instance‑owned**: each instance carries its own `distType`, optional `name`, and a `kwargs` dict containing per‑instance configuration (`numpy` + `ppf` or `pmf`). This avoids shared mutable state and cross‑instance contamination.

### Continuous Families (PPF)

Map uniforms `u ∈ (0,1)` to target marginals via SciPy **PPF** (inverse CDF):

- `Uniform(low, high)`
- `Normal(loc, scale)`
- `Lognormal(mean, sigma)`
- `Exponential(scale)`
- `Gamma(shape, scale)`
- `Beta(a, b)`
- `ChiSquare(df)` / `NoncentralChiSquare(df, nonc)`
- `FisherF(dfnum, dfden)` / `NoncentralF(dfnum, dfden, nonc)`
- `StudentT(df)`
- `Cauchy(loc, scale)`
- `Gumbel(loc, scale)` / `Laplace(loc, scale)` / `Logistic(loc, scale)`
- `Rayleigh(scale)` / `Wald(mean, scale)`
- `WeibullMin(a, scale)` / `Pareto(a, xm)` / `Power(a, left, right)` / `Triangular(left, mode, right)`
- `VonMises(mu, kappa)`

> Optional clipping: attach `dist.bounds = (lo, hi)` to the instance; `ContLhs` will clip outputs.

### Discrete/Categorical Families (PMF→CDF)

Discrete classes return **index arrays** (`0 ≤ idx < size`) by converting stratified uniforms through **PMF→CDF→inverse‑categorical** mapping:

- `Default_Cat(name, cats)`
- `Poisson(name, cats, lam, tail=...)`
- `Binomial(name, cats, n=None, p=0.5)`
  - If `n is None`, it infers `n = size - 1`.
  - Mismatch between `n+1` and `size` emits a warning and truncates/pads accordingly.
- `NegativeBinomial(name, cats, n, p, tail=...)`
- `Geometric(name, cats, p, tail=...)` (support {1,2,…})
- `Hypergeometric(name, cats, ngood, nbad, nsample)` (finite support)
- `LogSeries(name, cats, p, tail=...)` (support {1,2,…})
- `Zipf(name, cats, a, tail=...)` (support {1,2,…})

#### Tail Policy for Infinite‑Support Discrete Laws

Applicable to `{Poisson, NegativeBinomial, Geometric, LogSeries, Zipf}`:

- `tail='renormalize'` (default): truncate to your `size` categories and renormalize.
- `tail='lump'`: fold all remaining probability mass beyond the last visible category into the last category:
  - Poisson / NegativeBinomial (support 0..∞): last bin = `P(K ≥ size-1)`
  - Geometric / LogSeries / Zipf (support 1..∞): last bin = `P(K ≥ size)`

---

## Seeding & RNG Control

All samplers accept **either** a scalar `seed` **or** a parent `rng: np.random.Generator`:

- With a **seed**, the mixed orchestrator (`Lhs`) uses **stable substream splitting** so continuous vs. categorical streams remain reproducible even if you add/remove sibling samplers.
- With a **parent `Generator`**, child seeds are sampled from that parent; reproducibility then follows the parent’s state.

### `split_rngs(seed, rng, k)`

- If `rng` is provided: sample `k` full‑range `uint64` seeds from it and build child `Generator`s.
- Else if `seed` is provided: use `SeedSequence(seed).spawn(k)` to build child `Generator`s.
- Else: create a non‑deterministic parent and sample child seeds from it.

---

## Helpers

### `combine_samples(*arrays)`

Merges any number of **structured arrays** field‑wise into a single structured array with shape `(n,)`.

### `lcm_reduce(sizes)`

Computes the **Least Common Multiple** across integer sizes. Used to round up `n_samples` for categorical balance (e.g., in `Lhs` when categoricals are present).

### `parse_seed(list_of_strings)`

Parses seeds from tokens like:

- `['0']` → `[0]`
- `['2-4']` → `[2,3,4]` (inclusive upper bound)
- `['1','2','5-7']` → `[1,2,5,6,7]`

### `get_parser(add_help: bool = False)`

returns the parser object. this is simply to allow genLhs to run either as a
main module or as a submodule.

- main: `get_parser(add_help=True)`
- submodule: `get_parser(add_help=False)` | get_parser()`

---

## Running the Tests

Comprehensive unittest modules live in **`tests/`** at the repository root (next to the package code).

All tests can be run with:
```bash
# from the repository root
python -m unittest -bv
```

`-bv` are optional inputs that suppress regular stdout from the scripts being
tested (`-b`); and to all tests run, as well as their results (`-v`), instead of
the just the default summary.

---

## FAQ

**Why are categorical `n_samples` sometimes “rounded up”?**
`CatLhs` itself does not round when you pass `n_samples`, but the **mixed** orchestrator `Lhs` will round `n_samples` **up to the LCM of categorical sizes** to maintain equal representation when mixing continuous and categorical columns.

**What format is the result?**
A NumPy **structured array** with one named field per column. Convert to a DataFrame via `pd.DataFrame(sample)` if desired.

**How do I set bounds for continuous outputs?**
Attach `dist.bounds = (lo, hi)` to a continuous distribution instance; `ContLhs` clips after the PPF transform.

**How do I pick a tail policy?**
Pass `tail='renormalize'` (default) or `tail='lump'` to infinite‑support discrete families (e.g., `Poisson(..., tail='lump')`).

**Can I pass my own `Generator` instead of a seed?**
Yes: `__call__(rng=np.random.default_rng(2025))`. Mixed `Lhs` will derive independent child streams from the parent RNG.

---

## Choosing `n_samples` and `n_replicates` (seeds)

This section offers practical, theory‑informed guidance for picking a **single‑replicate sample size** (`n_samples`) and the **number of replicates** (`n_replicates`, i.e., how many `--seed` values you run) for Latin Hypercube Sampling.

### 1) How to choose `n_samples` (single replicate)

**Core ideas from the LHS and computer‑experiment literature**

- **Variance reduction vs. plain Monte Carlo:** LHS reduces estimator variance relative to simple random sampling, especially when the response is additive or near‑additive in its inputs. Asymptotically, the LHS estimator is normal with **smaller variance** than simple random sampling (SRS). This motivates using LHS even when budgets are tight.
- **Space‑filling designs for emulation:** For surrogate modeling (e.g., Gaussian process emulators), a common **rule‑of‑thumb** for an **initial** design is **`n_samples ≈ 10 × d`**, where `d` is the number of inputs. This balances coverage and cost and has empirical/theoretical support; it’s a starting point, not a guarantee. If your surface is rough/complex or high‑dimensional, you’ll typically need more than `10d`.
- **Revisiting the `10d` rule:** Later work shows that the “right” `n` depends on **anticipated roughness** and the **accuracy criterion** (e.g., IMSPE). Use `10d` as an initial plan and be prepared to **scale up** for complex responses. Sequential designs and maximum‑projection/sliced LHS can improve efficiency if you expand in stages.
- **Foundational texts:** For broader context and good practice around LHS and space‑filling designs in computer experiments, see Santner, Williams & Notz.

**Practical guidance**

- Start with: **`n_samples = max(10*d, L)`**, where
  - `d` = number of inputs,
  - `L` = **LCM of category sizes** if you have discrete/categorical variables, so each category is represented equally (for mixed designs, `Lhs` will round up to the LCM automatically).
  This respects both **coverage** and **categorical balance** in one shot. (The package already handles LCM rounding for mixed designs.)
- If your model surface seems **rough** (non‑additive, interactions, discontinuities), plan to **increase `n_samples`** beyond `10d`. Use diagnostic plots or emulator fit diagnostics to justify increments.

### 2) How to choose `n_replicates` (how many `--seed`s)

Replicates answer different questions than `n_samples`: they measure **stability of conclusions** under the randomization that remains in LHS (column permutations/jitter) and support **uncertainty analysis** workflows (quantiles, bands, reproducibility checks).

- **Replicating LHS** (i.e., running multiple seeds with the same `n_samples`) is a standard **uncertainty propagation** and **sensitivity** practice. Replicates let you quantify spread across runs and test the robustness of rankings/metrics. Helton & Davis explicitly discuss replicated LHS samples to test **reproducibility** and to support **uncertainty/sensitivity** summaries.
- **How many replicates?**
  - For **screening** or quick feasibility: **`n_replicates = 3–5`** is common to get a sense of stability.
  - For **formal UQ** or publication‑grade summaries: **`5–10`** (or more) depending on how tight you need your across‑replicate confidence intervals to be. (This is analogous to using multiple randomized space‑filling designs to gauge design‑to‑design variability.)
- **Budget trade‑off:** With a fixed total budget `N_total`, you can either
  - run **fewer replicates** with a **larger `n_samples`** each (better within‑replicate coverage), or
  - run **more replicates** with **smaller `n_samples`** (better across‑replicate stability).
  In practice, ensure each replicate’s `n_samples` still meets **`≥ max(10d, L)`** for meaningful coverage.

### 3) Recommended workflow (putting it together)

1. **Pick an initial `n_samples`:**
   `n0 = max(10*d, LCM_of_category_sizes)`.
2. **Choose `n_replicates`:**
   start with **3** for screening; increase to **5–10** as needed for stability in summaries (means, quantiles, PRCC/SRC, etc.).
3. **Assess stability:**
   Compare replicate summaries (e.g., mean/quantiles of key outputs). If across‑replicate variability is large, **increase `n_samples`** or **increase `n_replicates`**, guided by your accuracy needs and compute budget.

---

## Environment Compatibility

There are newer versions of scipy that include latin hypercube generation, but
this package-release is intended for stacks common in industrial HPC stacks that
often rely on older versions of software.

### Intended Versions
The design originally targeted:
- **Python 3.8.8**
- **NumPy 1.20.1**
- **SciPy 1.6.2**

These versions are fully workable on **x86_64** platforms (Linux, Windows, macOS‑Intel) where SciPy 1.6.2 wheels exist.

### Actually Tested Versions (Today)

On modern Apple Silicon (macOS arm64), the earliest SciPy providing **native arm64 wheels** for Python 3.8+ is **SciPy 1.7.3**; earlier versions (e.g., 1.6.2) do **not** publish arm64 wheels. Therefore, the **tested** environment is:

- **Python 3.8.13**
- **NumPy 1.22.4**
- **SciPy 1.7.3** (first macOS arm64 wheel for Py3.8+)

SciPy’s version matrix shows that **SciPy 1.7.x** supports **Python 3.8** and **NumPy < 1.23**, which includes **NumPy 1.22.4**.

### Why They Differ

- **Platform support moved forward:** Apple Silicon wheels for SciPy were added later; **1.6.2** predates that. Hence it can’t be installed natively on macOS arm64 without building from source (Fortran + BLAS), which is non‑trivial on modern macOS.
- **Functionality is unchanged for genLhs:** The APIs `genLhs` relies on (NumPy arrays, SciPy `ppf`/`pmf`/`cdf`) are stable across **SciPy 1.6.x → 1.7.x**, so **behavior is equivalent**. The difference is **binary wheel availability**, not algorithmic changes.
- **If you target x86_64 (Linux/Windows/macOS‑Intel):** using **Python 3.8.8 + NumPy 1.20.1 + SciPy 1.6.2** remains valid, provided wheels are available on your platform.

---

## Troubleshooting

- **`ModuleNotFoundError: genLhs`**
  Run from the **repository root**, or install the package in editable mode:
  ```bash
  python -m pip install -e .
  ```

- **SciPy installation fails on Apple Silicon with older versions**
  Use **SciPy 1.7.3+** which provides macOS arm64 wheels for Python 3.8+; earlier versions (e.g., 1.6.2) lack arm64 wheels.

- **Numerical asserts are too strict**
  If you validate floating values to machine precision, consider slightly looser tolerances (e.g., `rtol=1e-10`, `atol=1e-12`) to accommodate minor numerical differences across platforms or BLAS variants.

---

### References

- **McKay, Beckman, Conover** — *Technometrics* (1979)
- **Stein** — *Technometrics* (1987)
- **Helton & Davis** — Sandia Reports (2002–2003)
- **Loeppky, Sacks, Welch** — *Technometrics* (2009)
- **Santner, Williams, Notz** — *Design and Analysis of Computer Experiments* (2003/2018)

- **SciPy 1.7.3 Release Notes** — first to provide **macOS arm64 wheels** for Python 3.8, 3.9, 3.10 (macOS 12+).
- **SciPy Toolchain/Version Support** — compatibility matrix for SciPy ↔ Python ↔ NumPy (shows 1.7.x supports Python 3.8 and NumPy `<1.23`).
- **SciPy Install Guide** — general installation help across platforms.

---

## License

MIT
Copyright (c) 2026 SpaceDragon Labs




