diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 5665723c..3f6f5671 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -50,7 +50,7 @@ $ rust-code-analysis-cli -p src/algorithm/neighbour/fastpair.rs --ls 22 --le 213 1. After a PR is opened maintainers are notified 2. Probably changes will be required to comply with the workflow, these commands are run automatically and all tests shall pass: - * **Formatting**: run `rustfmt src/*.rs` to apply automatic formatting + * **Formatting**: run `cargo fmt --all -- --check` to apply automatic formatting * **Linting**: `clippy` is used with command `cargo clippy --all-features -- -Drust-2018-idioms -Dwarnings` * **Coverage** (optional): `tarpaulin` is used with command `cargo tarpaulin --out Lcov --all-features -- --test-threads 1` * **Testing**: multiple test pipelines are run for different targets diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..49780e13 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: [Mec-iS, tuned-org-uk] +custom: ['https://tuned.org.uk'] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0762e08f..875a3c34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,6 @@ jobs: { os: "ubuntu", target: "wasm32-unknown-unknown" }, { os: "macos", target: "aarch64-apple-darwin" }, ] - env: - TZ: "/usr/share/zoneinfo/your/location" steps: - uses: actions/checkout@v4 - name: Cache .cargo and target @@ -55,9 +53,11 @@ jobs: strategy: matrix: platform: [{ os: "ubuntu" }] - features: ["--features serde", "--features datasets", ""] - env: - TZ: "/usr/share/zoneinfo/your/location" + features: + - "--features serde" + - "--features datasets" + - "--features ndarray-bindings" + - "" steps: - uses: actions/checkout@v4 - name: Cache .cargo and target @@ -72,3 +72,27 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Stable Build run: cargo build --no-default-features ${{ matrix.features }} + - name: Tests + if: matrix.features == '--features ndarray-bindings' + run: cargo test --no-default-features ${{ matrix.features }} + + msrv: + # Verify the declared rust-version (MSRV) in Cargo.toml still builds. + name: MSRV (1.85) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Cache .cargo and target + uses: actions/cache@v4 + with: + path: | + ~/.cargo + ./target + key: ${{ runner.os }}-msrv-${{ hashFiles('Cargo.toml') }} + restore-keys: ${{ runner.os }}-msrv + - name: Install Rust 1.85 + uses: dtolnay/rust-toolchain@1.85.0 + - name: Build with all features at MSRV + run: cargo +1.85.0 build --all-features + - name: Build without features at MSRV + run: cargo +1.85.0 build diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 5934a4c5..c3a590bd 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -24,10 +24,11 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@nightly - name: Install cargo-tarpaulin - run: cargo install cargo-tarpaulin + run: cargo install --locked cargo-tarpaulin - name: Run cargo-tarpaulin - run: cargo tarpaulin --out Lcov --all-features -- --test-threads 1 + run: cargo tarpaulin --all-features --run-types Tests --run-types Doctests --out Lcov --fail-under 44 -- --test-threads 1 - name: Upload to codecov.io + if: always() uses: codecov/codecov-action@v4 with: fail_ci_if_error: false diff --git a/.github/workflows/release-bench.yml b/.github/workflows/release-bench.yml new file mode 100644 index 00000000..b10dc3d9 --- /dev/null +++ b/.github/workflows/release-bench.yml @@ -0,0 +1,71 @@ +name: Release benchmarks + +# When a release is published, clone smartcore-benches into a sibling +# directory and run its criterion + iai-callgrind harness against the +# released smartcore code. A `[patch.crates-io]` override repoints the +# benches' `smartcore = "0.6"` dependency to the local checkout, so no +# crates.io round-trip is needed. The benches repo is checked out OUTSIDE +# the smartcore tree to avoid Cargo's workspace auto-detection picking up +# smartcore's [workspace] table and treating benches as a member. +# See https://github.com/smartcorelib/smartcore/issues/407 + +on: + release: + types: [published] + +jobs: + bench: + runs-on: ubuntu-latest + steps: + - name: Checkout smartcore (release commit) + uses: actions/checkout@v4 + + - name: Checkout smartcore-benches (sibling directory, outside smartcore tree) + run: git clone --depth=1 https://github.com/smartcorelib/smartcore-benches.git ../smartcore-benches + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install Valgrind (for iai-callgrind) + run: sudo apt-get update && sudo apt-get install -y valgrind + + - name: Cache .cargo and target + uses: actions/cache@v4 + with: + path: | + ~/.cargo + ../smartcore-benches/target + key: release-bench-${{ hashFiles('Cargo.toml', '../smartcore-benches/Cargo.toml') }} + restore-keys: release-bench- + + - name: Patch benches to use local smartcore + run: | + mkdir -p ../smartcore-benches/.cargo + cat > ../smartcore-benches/.cargo/config.toml <<'CFG' + [patch.crates-io] + smartcore = { path = "../smartcore" } + CFG + + - name: Build benches (no-run) + working-directory: ../smartcore-benches + run: cargo bench --no-run + + - name: Run criterion benchmarks + working-directory: ../smartcore-benches + run: | + mkdir -p bench-output + cargo bench -- --output-format bencher | tee bench-output/criterion.json + + - name: Run iai-callgrind benchmarks + working-directory: ../smartcore-benches + run: | + for bench in iai_matmul iai_ab iai_svd iai_cover_tree iai_iterator_mut; do + cargo bench --bench "$bench" -- --save-baseline=release 2>&1 | tee "bench-output/${bench}.json" || true + done + + - name: Upload benchmark results + uses: actions/upload-artifact@v4 + with: + name: release-benchmark-results + path: ../smartcore-benches/bench-output/ + retention-days: 90 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0983a159..5ba4dd19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Generated by Cargo # will have compiled files and executables /target/ +.opencode +opencode.json # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html @@ -28,4 +30,10 @@ out.svg FlameGraph/ out.stacks *.json -*.txt \ No newline at end of file +*.txt + +# coverage artifacts (cargo-tarpaulin / LLVM instrumentation) +*.profraw +lcov.info +cobertura.xml +tarpaulin-report.* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..77433f33 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,129 @@ +# AGENTS.md + +Agent-focused guidance for working on the `smartcore` Rust machine-learning library. + +## Project basics + +- **Language / edition**: Rust 2024 (MSRV 1.85 — verified by the `msrv` CI job). +- **Repository**: https://github.com/smartcorelib/smartcore +- **Default branch**: `development`. All changes should target `development` first. +- **License**: Apache-2.0. +- **Authors**: "smartcore Developers". + +Always use ASD-STE100 Simplified Technical English + +## Build and test + +Common commands used in this codebase: + +```bash +# Build default (no features) +cargo build + +# Build with optional ndarray support +cargo build --features ndarray-bindings + +# Build everything +cargo build --all-features + +# Run tests +cargo test +cargo test --features ndarray-bindings +cargo test --all-features + +# Formatting (enforced in CI) +cargo fmt --all -- --check + +# Linting (enforced in CI) +cargo clippy --all-features -- -Drust-2018-idioms -Dwarnings + +# Generate and review docs +cargo doc --no-deps --open +``` + +## Cargo features + +Key features defined in `Cargo.toml`: + +- `ndarray-bindings` — optional `ndarray` integration. +- `serde` — serialization support (also pulls in `typetag`). +- `datasets` — built-in sample datasets; implies `std_rand` and `serde`. +- `std_rand` — enables standard RNG facilities in `rand`. +- `js` — for `wasm32-unknown-unknown` in-browser usage. + +When touching feature-gated code, run at least `cargo build --all-features` and `cargo test --all-features`. + +## Code conventions + +- Follow the existing **sklearn-inspired API** where possible for a frictionless user experience. +- Keep the library code **pure Rust**. Unsafe code is strongly discouraged; limited low-level exceptions are allowed only with clear justification. +- **Do not use macros in library code**. Prefer explicit, readable implementations. +- Target small/average datasets with a limited memory footprint rather than big-data optimizations. +- Every public module should: + - Start with a `//!` doc comment that includes references to scientific literature relating the code to research. + - Provide Rust **doctests** that demonstrate usage. + - Provide comprehensive unit tests in a `mod tests {}` submodule at the end of the file. +- IO-related code should prefer abstractions that make non-IO testing straightforward (see `readers/iotesting`). +- Dataset serialization helpers should be gated so they do not trigger unintended file writes on wasm targets. + +## Pull request workflow + +- Open an issue describing the change before starting significant work. +- Search open and closed issues/PRs for related discussion. +- Open PRs against the `development` branch. +- Use the PR template (`.github/PULL_REQUEST_TEMPLATE.md`) and erase sections that do not apply. +- Update `CHANGELOG.md` for breaking changes, new environment variables, exposed ports, useful file locations, and container parameters. +- Ensure CI checks pass: + - `cargo fmt --all -- --check` + - `cargo clippy --all-features -- -Drust-2018-idioms -Dwarnings` + - Full test suite on relevant targets +- A PR requires sign-off from at least one other developer before merging. + +## Code structure + +High-level layout: + +- `src/numbers/` — foundational numeric traits built on `num-traits`. +- `src/linalg/basic/` — core linear-algebra traits: + - `arrays` — `Array`, `Array1`, `Array2`, view traits (`ArrayView*`, `MutArrayView*`). + - `matrix` — `DenseMatrix`, the main instantiable matrix type. + - `vector` — convenience implementations for `std::Vec`. +- `src/linalg/traits/` — theoretical linear-algebra capability traits (`QRDecomposable`, `SVDDecomposable`, `CholeskyDecomposable`, etc.). +- `src/metrics/` — classification, regression, clustering metrics and distance measures. +- `src/linear/`, `src/tree/`, `src/ensemble/`, `src/svm/`, `src/neighbors/`, `src/naive_bayes/`, `src/clustering/`, `src/decomposition/`, `src/preprocessing/` — algorithm modules. +- `src/model_selection/` — cross-validation, search parameters. +- `src/readers/` — CSV and dataset readers. + +Most algorithm code is generic over the `numbers` and `linalg` traits rather than concrete types. + +## Edition 2024 invariants + +The crate was ported from edition 2021 to edition 2024 (#401). The port relies on the following invariants — when touching this code, keep them intact so the edition-2024 lint group (`rust-2024-compatibility`) stays clean: + +- **No return-position `impl Trait` (RPIT)**. The codebase returns zero `-> impl Trait` items today. Introducing one would opt into the 2024 lifetime over-capture rules; gate any new RPIT with an explicit lifetime bound and re-run `cargo clippy --all-features -- -Drust-2024-compatibility` before landing. +- **`dyn Trait` bounds carry explicit lifetimes**. All `Box` returns are written as `Box` (see `linalg/basic/arrays.rs` and `vector.rs` iterators/views). Do not drop the `+ 'a`; edition 2024 changes the default elision and a bare `dyn Trait` may not mean what you intend. +- **Tail-expression drop order matters**. Edition 2024 reorders temporaries in tail position. When a tail expression owns a temporary that a borrow extends through (e.g. `(self.sub(other)).iterator(0).all(...)`), bind the owned intermediate to a local first: + ```rust + // 2024-safe form + let diff = self.sub(other); + diff.iterator(0).all(|v| v.abs() <= error) + ``` + A regression here triggers `tail_expr_drop_order` warnings under `rust-2024-compatibility`. +- **Lint suppressions use `#[expect(...)]` where the lint actually fires, `#[allow(...)]` otherwise**. The crate migrated `#[allow]` → `#[expect]` where clippy confirmed the lint still fires under `--all-features`; sites where the lint does *not* fire (e.g. some `clippy::ptr_arg`/`upper_case_acronyms`/`dead_code` suppressions) remain `#[allow]` to avoid `unfulfilled_lint_expectations` errors. `#[expect]` errors if its lint later stops firing — prefer it for new suppressions where you've verified the lint fires; fall back to `#[allow]` when a lint genuinely doesn't fire and you still want to document intent. Re-run `cargo clippy --all-features -- -Drust-2018-idioms -Drust-2024-compatibility -Dwarnings` before landing. +- **No `unsafe` in library code**. The four remaining `unsafe {}` blocks in `linalg/basic/matrix.rs` (`iterator_mut` raw-pointer traversal for `DenseMatrixMutView`) are tracked by #368 and slated for a safe `split_at_mut` rewrite in a dedicated PR. Until that lands, do not add new `unsafe`; any new `unsafe` requires clear justification and must not trigger `unsafe_op_in_unsafe_fn` (default-warn in 2024) or other unsafe-attr lints. +- **Preserve the bespoke numerical-system logic and performance.** The numeric/linalg traits and their concrete impls (`numbers/`, `linalg/basic/arrays.rs`, `linalg/basic/matrix.rs`, the `HighOrderOperations`/`matmul`/`iterator` paths) are hand-tuned for correctness and speed. When refactoring (e.g. for edition-2024 tail-expr drop order, for #368 `unsafe` removal, or for any other non-behavioral change), **keep the numerical logic intact and do not regress performance**: change only the structural form (bind intermediates, swap the unsafe mechanism), never the math, indexing scheme, traversal order, or allocation strategy. Verify a refactor is behavior-preserving by re-running `cargo test --all-features` (known-answer tests must still pass) before landing. +- **Coverage cfg is not referenced in source**. We never `cfg(coverage)` or `cfg(tarpaulin)` in `src/`, so the 2024 `unexpected_cfgs` lint stays silent. If you add such a cfg, declare it in the `Cargo.toml` `[lints.rust]` `check-cfg` table to avoid the warning. + +### Commands for edition-2024 health + +```bash +# The rust-2018-idioms gate is still enforced; add the 2024 group locally: +cargo clippy --all-features -- -Drust-2018-idioms -Drust-2024-compatibility -Dwarnings + +# MSRV check (mirrors the msrv CI job): +cargo +1.85.0 build --all-features +``` + +## Conduct + +This project follows the [Contributor Covenant Code of Conduct](.github/CODE_OF_CONDUCT.md). Interactions should be respectful and harassment-free. diff --git a/CHANGELOG.md b/CHANGELOG.md index d68fd2e9..40e13c8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,57 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.6.4] +### Added +- Stage 2 test-coverage push (#393): `proptest` dev-dependency + property-based invariant tests and linalg edge cases. + - `linalg/basic/arrays.rs`: proptest invariants — transpose involution `(A^T)^T == A`, matmul with identity `A*I == A`, matmul associativity `(AB)C ≈ A(BC)` (approximate comparison for FP), `(AB)^T == B^T A^T`, reshape preserves element count. Edge cases — 1x1 matmul, row×col matmul, shape-mismatch panic, reshape-incompatible panic, 1xN transpose. + - `algorithm/sort/quick_sort.rs`: proptest — `quick_argsort` produces a valid permutation (all indices present exactly once, values non-decreasing in permutation order). + - `metrics/distance/euclidian.rs`: proptest — `d(a,a) == 0`, symmetry `d(a,b) == d(b,a)`, triangle inequality `d(a,c) ≤ d(a,b) + d(b,c)`. + +### Changed +- Added `proptest = "1.5"` to `[dev-dependencies]`. + +## [0.6.3] +### Changed +- Replaced the remaining 4 `unsafe {}` raw-pointer blocks in `linalg/basic/matrix.rs::iterator_mut` / `DenseMatrixMutView::iter_mut` with a safe `split_first_mut`-based helper `ordered_iter_mut` (#368). The traversal order and offset formula are identical to the previous raw-pointer implementation; only the borrow-proving mechanism changed — eliminating `unsafe` from library code entirely. +- **Performance note**: the cross-axis path (axis ≠ natural storage order) now extracts the needed refs via `split_first_mut` in sorted-offset order and reorders, introducing a small allocation. The fast path (axis matches storage order) shortcuts to `values.iter_mut().take(n)` with zero overhead. Benchmarks to quantify the cross-axis delta are tracked in #407. + +## [0.6.2] +### Changed +- Ported the crate from Rust edition 2021 to edition 2024 (#401, #402). `cargo fix --edition` made no auto-edits; the only behavioral-adjacent change is `linalg/basic/arrays.rs::approximate_eq`, rewritten to the 2024-safe tail-expr drop-order form (bind the owned intermediate before the borrowing iterator) — numerical logic unchanged. +- Declared `rust-version = "1.85"` (MSRV) in `Cargo.toml` and added an `msrv` CI job that builds with `dtolnay/rust-toolchain@1.85.0` to verify the claim (#404). +- Migrated lint suppressions: `#[allow(...)]` → `#[expect(...)]` at sites where the lint still fires under `--all-features`; `#[allow]` retained where the lint genuinely does not fire (avoids `unfulfilled_lint_expectations`) (#403). +- Added `[lints.rust] unexpected_cfgs` `check-cfg` table in `Cargo.toml` for `cfg(coverage, coverage_nightly)` and `cfg(tarpaulin)` (edition-2024 `unexpected_cfgs` lint). +- `AGENTS.md`: documented the edition-2024 invariants (no RPIT, explicit `dyn Trait + 'a`, tail-expr drop-order, lint-suppression policy, `unsafe` stance) and the "preserve bespoke numerical-system logic and performance" constraint for non-behavioral refactors. + +### Fixed +- `svm/svc.rs`: removed two redundant `let svc = ...; svc` tail expressions surfaced by the edition-2024 `clippy::let_and_return` lint. +- `preprocessing/categorical.rs`: kept the nested-`if` form (annotated `#[allow(clippy::collapsible_if)]`) because collapsing to a let-chain requires let-chains, unstable until Rust 1.88 — incompatible with the declared MSRV 1.85. + +## [0.6.1] +### Added +- Stage 1 test-coverage push (#392): tests for previously-untested modules. + - `linalg/traits/high_order.rs`: implemented the `/* TODO */` test module — all 4 `ab` transpose-flag branches, non-square inputs, and a matmul/transpose equivalence check. + - `linear/lasso_optimizer.rs`: direct tests for `InteriorPointOptimizer` (`new` shape of `ata`, known-answer l1-regularized least squares with `lambda → 0`). + - `error/mod.rs`: tests for all 6 `Failed` constructors, all 8 `FailedError` variants, both `Display` impls, both `PartialEq` impls, and the `Error` trait impl. + - `rand_custom.rs`: seeded-RNG determinism and `None`-seed usability tests. + +### Changed +- Revived 6 previously-commented-out serde round-trip tests (migrated to `postcard`, the post-#390 serialization backend) for `LinearRegression`, `RidgeRegression`, `Lasso`, `ElasticNet`, `PCA`, `SVD`. +- Fixed a latent type mismatch in the revived `SVD` serde test: the original commented-out code deserialized into `SVD>` but `SVD::fit` on the `f64` iris literals produces `SVD` — corrected to `SVD>`. +- Renamed two copy-paste-misnamed tests: `dataset::diabetes::boston_dataset` → `diabetes_dataset`; `algorithm::sort::quick_sort::with_capacity` → `quick_argsort`. + +## [0.6.0] +### Changed +- CI coverage workflow now includes doctests and enforces a strict 44% line-coverage gate via cargo-tarpaulin (#399). + +## [0.5.3] +### Changed +- Classification metrics refactored: `Precision`, `Recall`, and `F1` now derive per-class scores from a single shared `ConfusionCounts` helper (`src/metrics/confusion.rs`) instead of each re-implementing the per-class tp/predicted/support bookkeeping. `Precision` and `Recall` expose a crate-private `per_class_scores_from_counts` used by `F1`'s multiclass path. +- `Precision` and `Recall` now early-return `0.0` on empty input and drop the unreachable `classes == 0` / `support.is_empty()` branches. +- Multiclass macro `F1` (landed in #382, cleaned up in #383) is unchanged behaviourally; it now consumes `Precision`/`Recall::per_class_scores_from_counts` instead of its own `HashMap` bookkeeping. + +## [0.4.8] - 2025-11-29 - WARNING: Breaking changes! - `LassoParameters` and `LassoSearchParameters` have a new field `fit_intercept`. When it is set to false, the `beta_0` term in the formula will be forced to zero, and `intercept` field in `Lasso` will be set to `None`. diff --git a/CITATION.cff b/CITATION.cff index 09c5ed9f..f746c466 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,10 +2,10 @@ cff-version: 1.2.0 message: "If this software contributes to published work, please cite smartcore." type: software title: "smartcore: Machine Learning in Rust" -abstract: "smartcore is a comprehensive machine learning and numerical computing library for Rust, offering supervised and unsupervised algorithms, model evaluation tools, and linear algebra abstractions, with optional ndarray integration." [web:5][web:3] -repository-code: "https://github.com/smartcorelib/smartcore" [web:5] -url: "https://github.com/smartcorelib" [web:3] -license: "MIT" [web:13] +abstract: "smartcore is a comprehensive machine learning and numerical computing library for Rust, offering supervised and unsupervised algorithms, model evaluation tools, and linear algebra abstractions, with optional ndarray integration." +repository-code: "https://github.com/smartcorelib/smartcore" +url: "https://github.com/smartcorelib" +license: "MIT" keywords: - Rust - machine learning @@ -18,24 +18,24 @@ keywords: - Random Forest - XGBoost [web:5] authors: - - name: "smartcore Developers" [web:7] - - name: "Lorenzo (contributor)" [web:16] - - name: "Community contributors" [web:7] + - name: "smartcore Developers" + - name: "Lorenzo (contributor)" + - name: "Community contributors" version: "0.4.2" [attached_file:1] -date-released: "2025-09-14" [attached_file:1] +date-released: "2025-09-14" preferred-citation: type: software title: "smartcore: Machine Learning in Rust" authors: - - name: "smartcore Developers" [web:7] - url: "https://github.com/smartcorelib" [web:3] - repository-code: "https://github.com/smartcorelib/smartcore" [web:5] - license: "MIT" [web:13] + - name: "smartcore Developers" + url: "https://github.com/smartcorelib" + repository-code: "https://github.com/smartcorelib/smartcore" + license: "MIT" references: - type: manual title: "smartcore Documentation" - url: "https://docs.rs/smartcore" [web:5] + url: "https://docs.rs/smartcore" - type: webpage title: "smartcore Homepage" - url: "https://github.com/smartcorelib" [web:3] -notes: "For development features, see the docs.rs page and the repository README; SmartCore includes algorithms such as SVM, Random Forest, K-Means, PCA, DBSCAN, and XGBoost." [web:5] + url: "https://github.com/smartcorelib" +notes: "For development features, see the docs.rs page and the repository README; SmartCore includes algorithms such as SVM, Random Forest, K-Means, PCA, DBSCAN, and XGBoost." diff --git a/Cargo.toml b/Cargo.toml index bd569cf4..546cdf19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,11 @@ [package] name = "smartcore" description = "Machine Learning in Rust." -homepage = "https://smartcorelib.org" -version = "0.4.7" +homepage = "https://smartcorelib.github.io/" +version = "0.6.6" authors = ["smartcore Developers"] -edition = "2021" +edition = "2024" +rust-version = "1.85" license = "Apache-2.0" documentation = "https://docs.rs/smartcore" repository = "https://github.com/smartcorelib/smartcore" @@ -16,17 +17,17 @@ exclude = [ ".gitignore", "smartcore.iml", "smartcore.svg", - "tests/" + "tests/", + "AGENTS.md" ] [dependencies] approx = "0.5.1" cfg-if = "1.0.0" -ndarray = { version = "0.15", optional = true } +ndarray = { version = "0.17", optional = true } num-traits = "0.2.12" num = "0.4" -rand = { version = "0.8.5", default-features = false, features = ["small_rng"] } -rand_distr = { version = "0.4", optional = true } +rand = { version = "0.10.1", default-features = false, features = ["alloc"] } serde = { version = "1", features = ["derive"], optional = true } ordered-float = "5.1.0" @@ -34,24 +35,43 @@ ordered-float = "5.1.0" typetag = { version = "0.2", optional = true } [features] +# No features enabled by default; keeps the build WASM-compatible +# and avoids pulling in serde/rand unless explicitly requested. default = [] + +# Enable serde Serialize/Deserialize for models and related types. +# Also enables typetag on non-wasm targets for trait object serialization. serde = ["dep:serde", "dep:typetag"] + +# Optional bindings for the ndarray crate (DenseMatrix <-> ndarray). ndarray-bindings = ["dep:ndarray"] -datasets = ["dep:rand_distr", "std_rand", "serde"] -std_rand = ["rand/std_rng", "rand/std"] -# used by wasm32-unknown-unknown for in-browser usage -js = ["getrandom/js"] + +# Sample datasets (iris, boston, digits, ...) and dataset generators. +# Enables std_rand and serde because datasets ship serialized bundles. +datasets = ["std_rand", "serde"] + +# Use the standard library RNG (StdRng / thread_rng) instead of SmallRng. +# Required for non-deterministic seeding without an explicit seed. +std_rand = ["rand/std_rng", "rand/std", "rand/thread_rng"] + +# Enable getrandom's wasm_js backend for wasm32-unknown-unknown (in-browser). +js = ["getrandom/wasm_js"] [target.'cfg(target_arch = "wasm32")'.dependencies] -getrandom = { version = "0.2.8", optional = true } +getrandom = { version = "0.4", optional = true } [target.'cfg(all(target_arch = "wasm32", not(target_os = "wasi")))'.dev-dependencies] wasm-bindgen-test = "0.3" [dev-dependencies] -itertools = "0.13.0" +itertools = "0.15.0" serde_json = "1.0" -bincode = "1.3.1" +postcard = { version = "1.1", features = ["use-std"] } + +# proptest pulls in wait-timeout (Unix syscalls), which doesn't compile on +# wasm32. Gate it to non-wasm targets only. +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +proptest = "1.5" [workspace] @@ -60,7 +80,14 @@ debug = 1 opt-level = 3 [profile.release] -strip = true +strip = true lto = true codegen-units = 1 overflow-checks = true + +[lints.rust] +# Avoid unexpected_cfgs warnings (edition 2024) for the cfgs code/tests set. +unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(coverage, coverage_nightly)', + 'cfg(tarpaulin)', +] } diff --git a/README.md b/README.md index f370abdb..04a00e62 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@

- + smartcore

- User guide | API | Notebooks + User guide | API | Notebooks

@@ -18,7 +18,7 @@ ----- [![CI](https://github.com/smartcorelib/smartcore/actions/workflows/ci.yml/badge.svg)](https://github.com/smartcorelib/smartcore/actions/workflows/ci.yml) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.17219259.svg)](https://doi.org/10.5281/zenodo.17219259) -To start getting familiar with the new smartcore v0.4 API, there is now available a [**Jupyter Notebook environment repository**](https://github.com/smartcorelib/smartcore-jupyter). Please see instructions there, contributions welcome see [CONTRIBUTING](.github/CONTRIBUTING.md). +To start getting familiar with the smartcore API, there is now available a [**Jupyter Notebook environment repository**](https://github.com/smartcorelib/smartcore-jupyter). Please see instructions there, contributions welcome see [CONTRIBUTING](.github/CONTRIBUTING.md). smartcore is a fast, ergonomic machine learning library for Rust, covering classical supervised and unsupervised methods with a modular linear algebra abstraction and optional ndarray support. It aims to provide production-friendly APIs, strong typing, and good defaults while remaining flexible for research and experimentation. @@ -37,7 +37,7 @@ Add to Cargo.toml: ```toml [dependencies] -smartcore = "^0.4.3" +smartcore = "^0.6" ``` For the latest development branch: @@ -70,7 +70,7 @@ let x = DenseMatrix::from_2d_array(&[ &[5., 6.], &[7., 8.], &[9., 10.], -]).unwrap; +]).unwrap(); // Class labels let y = vec![2, 2, 2, 3, 3]; @@ -113,26 +113,41 @@ smartcore adopts a WASM/WASI-first posture in defaults to ease browser and embed ## Notebooks -A curated set of Jupyter notebooks is available via the companion repository to explore smartcore interactively. To run locally, use EVCXR to enable Rust notebooks. This is the recommended path to quickly experiment with the v0.4 API. +A curated set of Jupyter notebooks is available via the [companion repository to explore smartcore interactively](https://github.com/smartcorelib/smartcore-jupyter). To run locally, use EVCXR to enable Rust notebooks. This is the recommended path to quickly experiment with the smartcore API. ## Roadmap and recent changes - Trait-system refactor, fewer structs and more object-safe traits, large codebase reorganization. -- Move to Rust 2021 edition and cleanup of duplicate code paths. +- Move to Rust 2024 edition (MSRV 1.85) and cleanup of duplicate code paths. - Seeds and deterministic controls across algorithms using RNG plumbing. - Search parameter API for hyperparameter exploration in K-Means and SVM families. - Tree and forest components refactored for reuse; Extra Trees added. - SVM multiclass support; SVR kernel enum and related improvements. - XGBoost-style regression introduced; single-linkage clustering implemented. +- Classification metrics hardened: multiclass macro F1 now averages per-class + F-measures (matching sklearn), and Precision/Recall/F1 share a single + per-class confusion-counts helper so the per-class bookkeeping lives in one + place. See CHANGELOG.md for precise details, deprecations, and breaking changes. Some features like nalgebra-bindings have been dropped in favor of ndarray-only paths. Default features are tuned for WASM/WASI builds; enable serde/datasets as needed. +## Live trend charts + +[benchmark-action/github-action-benchmark](https://github.com/benchmark-action/github-action-benchmark) renders an interactive chart page per tool on the `gh-pages` branch, published via GitHub Pages: + +| Tool | Chart URL | What it plots | Direction | Alert | +|---|---|---|---|---| +| criterion (wall-clock) | | `cargo bench` wall-clock time per bench (ns/iter) over time | lower = better | 200% — advisory, posts a comment, does not fail CI | +| iai-callgrind (instruction count) | | instructions retired (`Ir`) per bench — deterministic, machine-independent | lower = better | 120% — fails the `iai` job + status check | + +Open the URLs above in a browser. Each page shows a searchable line chart (`data.js` is the raw history) with one series per benchmark name (e.g. `matmul/1024`, `iai_matmul::matmul::bench_matmul_256`). Hover for the value, range, and commit that produced each point. The iai page is the one to watch for regressions: instruction counts are deterministic on + ## Contributing Contributions are welcome: - Open an issue describing the change and link it in the PR. -- Keep PRs in sync with the development branch and ensure tests pass on stable Rust. +- Keep PRs in sync with the development branch and ensure tests pass on stable Rust (MSRV 1.85, edition 2024). - Provide or update tests; run clippy and apply formatting. Coverage and linting are part of the workflow. - Use the provided PR and issue templates to describe behavior changes, new features, and expectations. diff --git a/src/algorithm/neighbour/cosinepair.rs b/src/algorithm/neighbour/cosinepair.rs index 889be689..5ab8f3a0 100644 --- a/src/algorithm/neighbour/cosinepair.rs +++ b/src/algorithm/neighbour/cosinepair.rs @@ -46,7 +46,7 @@ pub struct CosinePairParameters { pub approximate: bool, } -#[allow(clippy::derivable_impls)] +#[expect(clippy::derivable_impls)] impl Default for CosinePairParameters { fn default() -> Self { Self { diff --git a/src/algorithm/neighbour/cover_tree.rs b/src/algorithm/neighbour/cover_tree.rs index 9989ae24..9a200a12 100644 --- a/src/algorithm/neighbour/cover_tree.rs +++ b/src/algorithm/neighbour/cover_tree.rs @@ -290,7 +290,10 @@ impl> CoverTree { self.new_leaf(p) } else { let max_dist = self.max(point_set); - let next_scale = (max_scale - 1).min(self.get_scale(max_dist)); + let next_scale = max_scale // bugfix i64::MIN - 1 causes overflow + .checked_sub(1) // returns None if overflow + .map(|s| s.min(self.get_scale(max_dist))) + .unwrap_or(i64::MIN); // safely getting required value if next_scale == i64::MIN { let mut children: Vec = Vec::new(); let mut leaf = self.new_leaf(p); diff --git a/src/algorithm/neighbour/fastpair.rs b/src/algorithm/neighbour/fastpair.rs index f494a7da..b86578fe 100644 --- a/src/algorithm/neighbour/fastpair.rs +++ b/src/algorithm/neighbour/fastpair.rs @@ -29,8 +29,8 @@ use num::Bounded; use crate::error::{Failed, FailedError}; use crate::linalg::basic::arrays::{Array1, Array2}; -use crate::metrics::distance::euclidian::Euclidian; use crate::metrics::distance::PairwiseDistance; +use crate::metrics::distance::euclidian::Euclidian; use crate::numbers::floatnum::FloatNumber; use crate::numbers::realnum::RealNumber; @@ -146,7 +146,7 @@ impl<'a, T: RealNumber + FloatNumber, M: Array2> FastPair<'a, T, M> { // compute sparse matrix (connectivity matrix) let mut sparse_matrix = M::zeros(len, len); - for (_, p) in distances.iter() { + for p in distances.values() { sparse_matrix.set((p.node, p.neighbour.unwrap()), p.distance.unwrap()); } @@ -192,7 +192,7 @@ impl<'a, T: RealNumber + FloatNumber, M: Array2> FastPair<'a, T, M> { // Compute distances from input to all other points in data-structure. // input is the row index of the sample matrix // - #[allow(dead_code)] + #[expect(dead_code)] fn distances_from(&self, index_row: usize) -> Vec> { let mut distances = Vec::>::with_capacity(self.samples.shape().0); for other in self.neighbours.iter() { diff --git a/src/algorithm/sort/quick_sort.rs b/src/algorithm/sort/quick_sort.rs index 56efec94..bab15c2a 100644 --- a/src/algorithm/sort/quick_sort.rs +++ b/src/algorithm/sort/quick_sort.rs @@ -4,7 +4,7 @@ pub trait QuickArgSort { #[allow(dead_code)] fn quick_argsort_mut(&mut self) -> Vec; - #[allow(dead_code)] + #[expect(dead_code)] fn quick_argsort(&self) -> Vec; } @@ -120,7 +120,7 @@ mod tests { wasm_bindgen_test::wasm_bindgen_test )] #[test] - fn with_capacity() { + fn quick_argsort() { let arr1 = vec![0.3, 0.1, 0.2, 0.4, 0.9, 0.5, 0.7, 0.6, 0.8]; assert_eq!(vec![1, 2, 0, 3, 5, 7, 6, 8, 4], arr1.quick_argsort()); @@ -129,8 +129,43 @@ mod tests { 1.0, 1.3, 1.4, ]; assert_eq!( - vec![9, 7, 1, 8, 0, 2, 4, 3, 6, 5, 17, 18, 15, 13, 19, 10, 14, 11, 12, 16], + vec![ + 9, 7, 1, 8, 0, 2, 4, 3, 6, 5, 17, 18, 15, 13, 19, 10, 14, 11, 12, 16 + ], arr2.quick_argsort() ); } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn quick_argsort_is_valid_permutation() { + use proptest::prelude::*; + + proptest!( + |(arr in proptest::collection::vec(-100.0f64..100.0, 1..=20))| + { + let n = arr.len(); + let perm = arr.quick_argsort(); + prop_assert_eq!(perm.len(), n, "permutation length mismatch"); + + // perm must be a permutation of 0..n + let mut seen = vec![false; n]; + for &idx in &perm { + prop_assert!(idx < n, "index {idx} out of range"); + prop_assert!(!std::mem::replace(&mut seen[idx], true), + "index {idx} appears twice"); + } + + // the values visited in permutation order must be non-decreasing + for i in 1..n { + prop_assert!(arr[perm[i - 1]] <= arr[perm[i]], + "not sorted at position {i}"); + } + } + ); + } } diff --git a/src/cluster/kmeans.rs b/src/cluster/kmeans.rs index 2fade68f..02fd96de 100644 --- a/src/cluster/kmeans.rs +++ b/src/cluster/kmeans.rs @@ -55,7 +55,7 @@ use std::fmt::Debug; use std::marker::PhantomData; -use rand::Rng; +use rand::RngExt; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -356,7 +356,7 @@ impl, Y: Array1> KMeans let (n, _) = data.shape(); let mut y = vec![0; n]; let mut centroid: Vec = data - .get_row(rng.gen_range(0..n)) + .get_row(rng.random_range(0..n)) .iterator(0) .cloned() .collect(); @@ -382,7 +382,7 @@ impl, Y: Array1> KMeans for i in d.iter() { sum += *i; } - let cutoff = rng.gen::() * sum; + let cutoff = rng.random::() * sum; let mut cost = 0f64; let mut index = 0; while index < n { @@ -426,11 +426,13 @@ mod tests { fn invalid_k() { let x = DenseMatrix::from_2d_array(&[&[1, 2, 3], &[4, 5, 6]]).unwrap(); - assert!(KMeans::, Vec>::fit( - &x, - KMeansParameters::default().with_k(0) - ) - .is_err()); + assert!( + KMeans::, Vec>::fit( + &x, + KMeansParameters::default().with_k(0) + ) + .is_err() + ); assert_eq!( "Fit failed: invalid number of clusters: 1", KMeans::, Vec>::fit( diff --git a/src/cluster/tests_edge_cases.rs b/src/cluster/tests_edge_cases.rs new file mode 100644 index 00000000..463e5f16 --- /dev/null +++ b/src/cluster/tests_edge_cases.rs @@ -0,0 +1,112 @@ +//! Stage 3: edge-case & known-answer parity tests for src/cluster. +//! +//! Covers: KMeans, DBSCAN. +//! Tracking issue: #394 / #391. + +#[cfg(test)] +mod cluster_edge_cases { + use crate::linalg::basic::matrix::DenseMatrix; + use crate::cluster::kmeans::{KMeans, KMeansParameters}; + use crate::cluster::dbscan::{DBSCAN, DBSCANParameters}; + + // ── KMeans ──────────────────────────────────────────────────────────────── + + /// k=1: every point assigned to cluster 0. + #[test] + fn kmeans_k1_all_same_cluster() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 1.0], + &[2.0, 2.0], + &[3.0, 3.0], + ]).unwrap(); + let model = KMeans::fit(&x, KMeansParameters::default().with_k(1).with_seed(0)).unwrap(); + let labels = model.predict(&x).unwrap(); + assert!(labels.iter().all(|&l| l == 0), "k=1 must assign all to cluster 0"); + } + + /// k=N (one cluster per point): each point gets a unique cluster. + #[test] + fn kmeans_k_equals_n_unique_clusters() { + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64, 0.0], + &[10.0, 0.0], + &[0.0, 10.0], + ]).unwrap(); + let model = KMeans::fit(&x, KMeansParameters::default().with_k(3).with_seed(0)).unwrap(); + let labels = model.predict(&x).unwrap(); + let unique: std::collections::HashSet<_> = labels.iter().cloned().collect(); + assert_eq!(unique.len(), 3, "k=3 on 3 distant points should yield 3 distinct clusters"); + } + + /// Seed determinism: same seed → same cluster assignments. + #[test] + fn kmeans_seed_determinism() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 0.0], &[1.1, 0.0], &[0.9, 0.0], + &[9.0, 0.0], &[9.1, 0.0], &[8.9, 0.0], + ]).unwrap(); + let l1 = KMeans::fit(&x, KMeansParameters::default().with_k(2).with_seed(7)).unwrap().predict(&x).unwrap(); + let l2 = KMeans::fit(&x, KMeansParameters::default().with_k(2).with_seed(7)).unwrap().predict(&x).unwrap(); + assert_eq!(l1, l2, "same seed should give deterministic cluster assignments"); + } + + /// Well-separated clusters: intra-cluster label consistency. + #[test] + fn kmeans_well_separated_clusters() { + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64, 0.0], &[0.1, 0.0], &[0.0, 0.1], + &[9.9, 9.9], &[10.0, 9.9], &[9.9, 10.0], + ]).unwrap(); + let model = KMeans::fit(&x, KMeansParameters::default().with_k(2).with_seed(0)).unwrap(); + let labels = model.predict(&x).unwrap(); + // First 3 must share a label, last 3 must share a different label. + assert_eq!(labels[0], labels[1]); + assert_eq!(labels[1], labels[2]); + assert_eq!(labels[3], labels[4]); + assert_eq!(labels[4], labels[5]); + assert_ne!(labels[0], labels[3]); + } + + // ── DBSCAN ──────────────────────────────────────────────────────────────── + + /// All-noise: epsilon very small → every point is noise (-1). + #[test] + fn dbscan_all_noise() { + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64, 0.0], + &[10.0, 0.0], + &[0.0, 10.0], + &[10.0, 10.0], + ]).unwrap(); + let model = DBSCAN::fit(&x, DBSCANParameters::default().with_eps(0.001).with_min_samples(2)).unwrap(); + let labels = model.predict(&x).unwrap(); + // All points are noise (label == usize::MAX or a sentinel); no valid cluster. + // We just verify fit+predict don't panic and return the right count. + assert_eq!(labels.len(), 4); + } + + /// Dense cluster: two tight groups each exceeding min_samples. + #[test] + fn dbscan_two_dense_clusters() { + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64, 0.0], &[0.1, 0.0], &[0.0, 0.1], + &[9.9, 9.9], &[10.0, 9.9], &[9.9, 10.0], + ]).unwrap(); + let model = DBSCAN::fit(&x, DBSCANParameters::default().with_eps(0.5).with_min_samples(2)).unwrap(); + let labels = model.predict(&x).unwrap(); + assert_eq!(labels.len(), 6); + // Both groups must form valid (non-noise) clusters. + let unique: std::collections::HashSet<_> = labels.iter().cloned().collect(); + assert_eq!(unique.len(), 2, "expected exactly 2 clusters, got {unique:?}"); + } + + /// min_samples edge: min_samples=1 makes every point its own cluster. + #[test] + fn dbscan_min_samples_one() { + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64], &[1.0], &[2.0], + ]).unwrap(); + let result = DBSCAN::fit(&x, DBSCANParameters::default().with_eps(0.1).with_min_samples(1)); + assert!(result.is_ok()); + } +} diff --git a/src/dataset/boston.rs b/src/dataset/boston.rs index cc7a55bd..498ffeba 100644 --- a/src/dataset/boston.rs +++ b/src/dataset/boston.rs @@ -24,8 +24,8 @@ //! | LSTAT, % lower status of the population | Numerical | No | //! | MEDV, Median value of owner-occupied homes in $1000's | Numerical | Yes | //! -use crate::dataset::deserialize_data; use crate::dataset::Dataset; +use crate::dataset::deserialize_data; /// Get dataset pub fn load_dataset() -> Dataset { diff --git a/src/dataset/breast_cancer.rs b/src/dataset/breast_cancer.rs index c60da872..eec14b37 100644 --- a/src/dataset/breast_cancer.rs +++ b/src/dataset/breast_cancer.rs @@ -26,8 +26,8 @@ //! //! The mean, standard error, and "worst" or largest (mean of the three worst/largest values) of these features were computed for each image, resulting in 30 features. //! For instance, field 0 is Mean Radius, field 10 is Radius SE, field 20 is Worst Radius. -use crate::dataset::deserialize_data; use crate::dataset::Dataset; +use crate::dataset::deserialize_data; /// Get dataset pub fn load_dataset() -> Dataset { diff --git a/src/dataset/diabetes.rs b/src/dataset/diabetes.rs index a95b5116..f863264c 100644 --- a/src/dataset/diabetes.rs +++ b/src/dataset/diabetes.rs @@ -19,8 +19,8 @@ //! //! ## References: //! * ["Least Angle Regression", Efron B., Hastie T., Johnstone I., Tibshirani R., 2004, Annals of Statistics (with discussion), 407-499](http://statweb.stanford.edu/~tibs/ftp/lars.pdf) -use crate::dataset::deserialize_data; use crate::dataset::Dataset; +use crate::dataset::deserialize_data; /// Get dataset pub fn load_dataset() -> Dataset { @@ -72,7 +72,7 @@ mod tests { wasm_bindgen_test::wasm_bindgen_test )] #[test] - fn boston_dataset() { + fn diabetes_dataset() { let dataset = load_dataset(); assert_eq!( dataset.data.len(), diff --git a/src/dataset/digits.rs b/src/dataset/digits.rs index c32648cd..c39c7e7c 100644 --- a/src/dataset/digits.rs +++ b/src/dataset/digits.rs @@ -9,8 +9,8 @@ //! //! All input attributes are integers in the range 0..16. //! -use crate::dataset::deserialize_data; use crate::dataset::Dataset; +use crate::dataset::deserialize_data; /// Get dataset pub fn load_dataset() -> Dataset { diff --git a/src/dataset/generator.rs b/src/dataset/generator.rs index f8e59443..bf4c7741 100644 --- a/src/dataset/generator.rs +++ b/src/dataset/generator.rs @@ -1,38 +1,47 @@ //! # Dataset Generators //! -use rand::distributions::Uniform; -use rand::prelude::*; -use rand_distr::Normal; +use rand::distr::Distribution; +use rand::distr::Uniform; use crate::dataset::Dataset; +/// Sample from N(mean, std) via Box-Muller transform using only rand 0.10 +#[inline] +fn sample_normal(mean: f32, std: f32, rng: &mut impl rand::Rng) -> f32 { + let unit = Uniform::new(f32::EPSILON, 1.0f32).unwrap(); + let u1 = unit.sample(rng); + let u2 = unit.sample(rng); + let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f32::consts::PI * u2).cos(); + mean + std * z +} + /// Generate `num_centers` clusters of normally distributed points pub fn make_blobs( num_samples: usize, num_features: usize, num_centers: usize, ) -> Dataset { - let center_box = Uniform::from(-10.0..10.0); - let cluster_std = 1.0; - let mut centers: Vec>> = Vec::with_capacity(num_centers); + let center_box = Uniform::new(-10.0f32, 10.0f32).expect("Invalid uniform range"); + let cluster_std = 1.0f32; + let mut rng = rand::rng(); - let mut rng = rand::thread_rng(); - for _ in 0..num_centers { - centers.push( + // Pre-compute cluster centers (one mean per feature per cluster) + let centers: Vec> = (0..num_centers) + .map(|_| { (0..num_features) - .map(|_| Normal::new(center_box.sample(&mut rng), cluster_std).unwrap()) - .collect(), - ); - } + .map(|_| center_box.sample(&mut rng)) + .collect() + }) + .collect(); let mut y: Vec = Vec::with_capacity(num_samples); - let mut x: Vec = Vec::with_capacity(num_samples); + let mut x: Vec = Vec::with_capacity(num_samples * num_features); for i in 0..num_samples { let label = i % num_centers; y.push(label as f32); for j in 0..num_features { - x.push(centers[label][j].sample(&mut rng)); + x.push(sample_normal(centers[label][j], cluster_std, &mut rng)); } } @@ -59,21 +68,20 @@ pub fn make_circles(num_samples: usize, factor: f32, noise: f32) -> Dataset = Vec::with_capacity(num_samples * 2); let mut y: Vec = Vec::with_capacity(num_samples); for v in linspace_out { - x.push(v.cos() + noise.sample(&mut rng)); - x.push(v.sin() + noise.sample(&mut rng)); + x.push(v.cos() + sample_normal(0.0, noise, &mut rng)); + x.push(v.sin() + sample_normal(0.0, noise, &mut rng)); y.push(0.0); } for v in linspace_in { - x.push(v.cos() * factor + noise.sample(&mut rng)); - x.push(v.sin() * factor + noise.sample(&mut rng)); + x.push(v.cos() * factor + sample_normal(0.0, noise, &mut rng)); + x.push(v.sin() * factor + sample_normal(0.0, noise, &mut rng)); y.push(1.0); } @@ -96,21 +104,20 @@ pub fn make_moons(num_samples: usize, noise: f32) -> Dataset { let linspace_out = linspace(0.0, std::f32::consts::PI, num_samples_out); let linspace_in = linspace(0.0, std::f32::consts::PI, num_samples_in); - let noise = Normal::new(0.0, noise).unwrap(); - let mut rng = rand::thread_rng(); + let mut rng = rand::rng(); let mut x: Vec = Vec::with_capacity(num_samples * 2); let mut y: Vec = Vec::with_capacity(num_samples); for v in linspace_out { - x.push(v.cos() + noise.sample(&mut rng)); - x.push(v.sin() + noise.sample(&mut rng)); + x.push(v.cos() + sample_normal(0.0, noise, &mut rng)); + x.push(v.sin() + sample_normal(0.0, noise, &mut rng)); y.push(0.0); } for v in linspace_in { - x.push(1.0 - v.cos() + noise.sample(&mut rng)); - x.push(1.0 - v.sin() + noise.sample(&mut rng) - 0.5); + x.push(1.0 - v.cos() + sample_normal(0.0, noise, &mut rng)); + x.push(1.0 - v.sin() + sample_normal(0.0, noise, &mut rng) - 0.5); y.push(1.0); } diff --git a/src/dataset/iris.rs b/src/dataset/iris.rs index 75c58acc..0b4b1dcf 100644 --- a/src/dataset/iris.rs +++ b/src/dataset/iris.rs @@ -15,8 +15,8 @@ //! | Petal width | Numerical | No | //! | Class | Nominal | Yes | //! -use crate::dataset::deserialize_data; use crate::dataset::Dataset; +use crate::dataset::deserialize_data; /// Get dataset pub fn load_dataset() -> Dataset { diff --git a/src/dataset/mod.rs b/src/dataset/mod.rs index 91628942..066f4eef 100644 --- a/src/dataset/mod.rs +++ b/src/dataset/mod.rs @@ -55,15 +55,17 @@ impl Dataset { // Running this in wasm throws: operation not supported on this platform. #[cfg(not(target_arch = "wasm32"))] -#[allow(dead_code)] +#[expect(dead_code)] pub(crate) fn serialize_data( dataset: &Dataset, filename: &str, ) -> Result<(), io::Error> { match File::create(filename) { Ok(mut file) => { - file.write_all(&dataset.num_features.to_le_bytes())?; - file.write_all(&dataset.num_samples.to_le_bytes())?; + // Write header as fixed-width u64 (little-endian) so the .xy files + // can be read correctly on any target width, including wasm32. + file.write_all(&(dataset.num_features as u64).to_le_bytes())?; + file.write_all(&(dataset.num_samples as u64).to_le_bytes())?; let x: Vec = dataset .data .iter() @@ -84,34 +86,123 @@ pub(crate) fn serialize_data( Ok(()) } +/// Deserialise a `.xy` dataset blob embedded via `include_bytes!`. +/// +/// # Wire format +/// ```text +/// [u64 LE: num_features][u64 LE: num_samples] +/// [f32 LE × (num_features * num_samples)] <- X matrix, row-major +/// [f32 LE × num_samples] <- y vector +/// ``` +/// +/// The header uses a **fixed 8-byte (u64) width** regardless of the host +/// pointer size. Previous versions used `usize`, which is 4 bytes on +/// `wasm32` but 8 bytes on x86-64 — meaning the `.xy` files (generated +/// on x86-64) could not be parsed under WASM and every dataset test +/// returned `data.len() == 0`. pub(crate) fn deserialize_data( bytes: &[u8], ) -> Result<(Vec, Vec, usize, usize), io::Error> { - // read the same file back into a Vec of bytes - const USIZE_SIZE: usize = std::mem::size_of::(); + // Header: two u64 fields, each 8 bytes, platform-independent. + const FIELD_SIZE: usize = std::mem::size_of::(); // always 8 + const HEADER_LEN: usize = 2 * FIELD_SIZE; // always 16 + + // Reject obviously-truncated buffers before reading any fields. + if bytes.len() < HEADER_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "deserialize_data: buffer too small for header (need {HEADER_LEN} bytes, got {})", + bytes.len() + ), + )); + } + let (num_samples, num_features) = { - let mut buffer = [0u8; USIZE_SIZE]; - buffer.copy_from_slice(&bytes[0..USIZE_SIZE]); - let num_features = usize::from_le_bytes(buffer); - buffer.copy_from_slice(&bytes[8..8 + USIZE_SIZE]); - let num_samples = usize::from_le_bytes(buffer); + let mut buf8 = [0u8; FIELD_SIZE]; + buf8.copy_from_slice(&bytes[0..FIELD_SIZE]); + let num_features = u64::from_le_bytes(buf8) as usize; + buf8.copy_from_slice(&bytes[FIELD_SIZE..HEADER_LEN]); + let num_samples = u64::from_le_bytes(buf8) as usize; (num_samples, num_features) }; - let mut x = Vec::with_capacity(num_samples * num_features); + // Guard against integer overflow in num_samples * num_features. + let num_x_values = num_samples.checked_mul(num_features).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "deserialize_data: num_samples * num_features overflows usize", + ) + })?; + + // Validate the total byte length before any allocation. + // Layout: HEADER_LEN + num_x_values * 4 + num_samples * 4 + let x_bytes = num_x_values.checked_mul(4).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "deserialize_data: x byte range overflows usize", + ) + })?; + let y_bytes = num_samples.checked_mul(4).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "deserialize_data: y byte range overflows usize", + ) + })?; + let expected_len = HEADER_LEN + .checked_add(x_bytes) + .and_then(|n| n.checked_add(y_bytes)) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "deserialize_data: total expected length overflows usize", + ) + })?; + if bytes.len() < expected_len { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "deserialize_data: buffer too short (expected {expected_len} bytes, got {})", + bytes.len() + ), + )); + } + + let mut x = Vec::with_capacity(num_x_values); let mut y = Vec::with_capacity(num_samples); - let mut buffer = [0u8; 4]; - let mut c = 16; - for _ in 0..(num_samples * num_features) { - buffer.copy_from_slice(&bytes[c..(c + 4)]); - x.push(f32::from_bits(u32::from_le_bytes(buffer))); + let mut buf4 = [0u8; 4]; + let mut c = HEADER_LEN; + + for _ in 0..num_x_values { + buf4.copy_from_slice(&bytes[c..(c + 4)]); + let v = f32::from_bits(u32::from_le_bytes(buf4)); + if !v.is_finite() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "deserialize_data: non-finite value in feature data (bits: {:#010x})", + u32::from_le_bytes(buf4) + ), + )); + } + x.push(v); c += 4; } - for _ in 0..(num_samples) { - buffer.copy_from_slice(&bytes[c..(c + 4)]); - y.push(f32::from_bits(u32::from_le_bytes(buffer))); + for _ in 0..num_samples { + buf4.copy_from_slice(&bytes[c..(c + 4)]); + let v = f32::from_bits(u32::from_le_bytes(buf4)); + if !v.is_finite() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "deserialize_data: non-finite value in target data (bits: {:#010x})", + u32::from_le_bytes(buf4) + ), + )); + } + y.push(v); c += 4; } @@ -144,4 +235,71 @@ mod tests { assert_eq!(m[0].len(), 5); assert_eq!(*m[1][3], 9); } + + // deserialize_data unit tests — run on native AND wasm32. + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn deserialize_data_too_short() { + let result = deserialize_data(&[0u8; 4]); + assert!(result.is_err()); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn deserialize_data_truncated_body() { + // Valid header (u64 LE): 1 feature, 1 sample — but no payload bytes. + // Header is 16 bytes; expected total = 16 + 4 (x) + 4 (y) = 24. + let mut buf = vec![0u8; 16]; + buf[0..8].copy_from_slice(&1u64.to_le_bytes()); // num_features = 1 + buf[8..16].copy_from_slice(&1u64.to_le_bytes()); // num_samples = 1 + let result = deserialize_data(&buf); + assert!(result.is_err()); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn deserialize_data_nan_rejected() { + // Construct a valid 1×1 dataset where the feature value is NaN. + let nan_bits: u32 = f32::NAN.to_bits(); + let mut buf = vec![0u8; 16 + 4 + 4]; + buf[0..8].copy_from_slice(&1u64.to_le_bytes()); // num_features = 1 + buf[8..16].copy_from_slice(&1u64.to_le_bytes()); // num_samples = 1 + buf[16..20].copy_from_slice(&nan_bits.to_le_bytes()); // x[0] = NaN + buf[20..24].copy_from_slice(&1.0f32.to_le_bytes()); // y[0] = 1.0 + let result = deserialize_data(&buf); + assert!(result.is_err()); + } + + /// Smoke-test that a correctly-formed 1×1 round-trip parses on every + /// target width, including wasm32. + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn deserialize_data_roundtrip_1x1() { + let x_val = 3.14f32; + let y_val = 1.0f32; + let mut buf = vec![0u8; 16 + 4 + 4]; + buf[0..8].copy_from_slice(&1u64.to_le_bytes()); // num_features = 1 + buf[8..16].copy_from_slice(&1u64.to_le_bytes()); // num_samples = 1 + buf[16..20].copy_from_slice(&x_val.to_bits().to_le_bytes()); + buf[20..24].copy_from_slice(&y_val.to_bits().to_le_bytes()); + let (x, y, ns, nf) = deserialize_data(&buf).expect("roundtrip must succeed"); + assert_eq!(ns, 1); + assert_eq!(nf, 1); + assert_eq!(x.len(), 1); + assert_eq!(y.len(), 1); + assert!((x[0] - x_val).abs() < 1e-6); + assert!((y[0] - y_val).abs() < 1e-6); + } } diff --git a/src/decomposition/pca.rs b/src/decomposition/pca.rs index 11853648..35a53895 100644 --- a/src/decomposition/pca.rs +++ b/src/decomposition/pca.rs @@ -711,40 +711,42 @@ mod tests { )); } - // Disable this test for now - // TODO: implement deserialization for new DenseMatrix - // #[cfg_attr(all(target_arch = "wasm32", not(target_os = "wasi")), wasm_bindgen_test::wasm_bindgen_test)] - // #[test] - // #[cfg(feature = "serde")] - // fn pca_serde() { - // let iris = DenseMatrix::from_2d_array(&[ - // &[5.1, 3.5, 1.4, 0.2], - // &[4.9, 3.0, 1.4, 0.2], - // &[4.7, 3.2, 1.3, 0.2], - // &[4.6, 3.1, 1.5, 0.2], - // &[5.0, 3.6, 1.4, 0.2], - // &[5.4, 3.9, 1.7, 0.4], - // &[4.6, 3.4, 1.4, 0.3], - // &[5.0, 3.4, 1.5, 0.2], - // &[4.4, 2.9, 1.4, 0.2], - // &[4.9, 3.1, 1.5, 0.1], - // &[7.0, 3.2, 4.7, 1.4], - // &[6.4, 3.2, 4.5, 1.5], - // &[6.9, 3.1, 4.9, 1.5], - // &[5.5, 2.3, 4.0, 1.3], - // &[6.5, 2.8, 4.6, 1.5], - // &[5.7, 2.8, 4.5, 1.3], - // &[6.3, 3.3, 4.7, 1.6], - // &[4.9, 2.4, 3.3, 1.0], - // &[6.6, 2.9, 4.6, 1.3], - // &[5.2, 2.7, 3.9, 1.4], - // ]).unwrap(); - - // let pca = PCA::fit(&iris, Default::default()).unwrap(); - - // let deserialized_pca: PCA> = - // serde_json::from_str(&serde_json::to_string(&pca).unwrap()).unwrap(); - - // assert_eq!(pca, deserialized_pca); - // } + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + #[cfg(feature = "serde")] + fn pca_serde() { + let iris = DenseMatrix::from_2d_array(&[ + &[5.1, 3.5, 1.4, 0.2], + &[4.9, 3.0, 1.4, 0.2], + &[4.7, 3.2, 1.3, 0.2], + &[4.6, 3.1, 1.5, 0.2], + &[5.0, 3.6, 1.4, 0.2], + &[5.4, 3.9, 1.7, 0.4], + &[4.6, 3.4, 1.4, 0.3], + &[5.0, 3.4, 1.5, 0.2], + &[4.4, 2.9, 1.4, 0.2], + &[4.9, 3.1, 1.5, 0.1], + &[7.0, 3.2, 4.7, 1.4], + &[6.4, 3.2, 4.5, 1.5], + &[6.9, 3.1, 4.9, 1.5], + &[5.5, 2.3, 4.0, 1.3], + &[6.5, 2.8, 4.6, 1.5], + &[5.7, 2.8, 4.5, 1.3], + &[6.3, 3.3, 4.7, 1.6], + &[4.9, 2.4, 3.3, 1.0], + &[6.6, 2.9, 4.6, 1.3], + &[5.2, 2.7, 3.9, 1.4], + ]) + .unwrap(); + + let pca = PCA::fit(&iris, Default::default()).unwrap(); + + let deserialized_pca: PCA> = + postcard::from_bytes(&postcard::to_allocvec(&pca).unwrap()).unwrap(); + + assert_eq!(pca, deserialized_pca); + } } diff --git a/src/decomposition/svd.rs b/src/decomposition/svd.rs index 259bfbc0..9b88d546 100644 --- a/src/decomposition/svd.rs +++ b/src/decomposition/svd.rs @@ -316,40 +316,42 @@ mod tests { )); } - // Disable this test for now - // TODO: implement deserialization for new DenseMatrix - // #[cfg_attr(all(target_arch = "wasm32", not(target_os = "wasi")), wasm_bindgen_test::wasm_bindgen_test)] - // #[test] - // #[cfg(feature = "serde")] - // fn serde() { - // let iris = DenseMatrix::from_2d_array(&[ - // &[5.1, 3.5, 1.4, 0.2], - // &[4.9, 3.0, 1.4, 0.2], - // &[4.7, 3.2, 1.3, 0.2], - // &[4.6, 3.1, 1.5, 0.2], - // &[5.0, 3.6, 1.4, 0.2], - // &[5.4, 3.9, 1.7, 0.4], - // &[4.6, 3.4, 1.4, 0.3], - // &[5.0, 3.4, 1.5, 0.2], - // &[4.4, 2.9, 1.4, 0.2], - // &[4.9, 3.1, 1.5, 0.1], - // &[7.0, 3.2, 4.7, 1.4], - // &[6.4, 3.2, 4.5, 1.5], - // &[6.9, 3.1, 4.9, 1.5], - // &[5.5, 2.3, 4.0, 1.3], - // &[6.5, 2.8, 4.6, 1.5], - // &[5.7, 2.8, 4.5, 1.3], - // &[6.3, 3.3, 4.7, 1.6], - // &[4.9, 2.4, 3.3, 1.0], - // &[6.6, 2.9, 4.6, 1.3], - // &[5.2, 2.7, 3.9, 1.4], - // ]).unwrap(); - - // let svd = SVD::fit(&iris, Default::default()).unwrap(); - - // let deserialized_svd: SVD> = - // serde_json::from_str(&serde_json::to_string(&svd).unwrap()).unwrap(); - - // assert_eq!(svd, deserialized_svd); - // } + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + #[cfg(feature = "serde")] + fn serde() { + let iris = DenseMatrix::from_2d_array(&[ + &[5.1, 3.5, 1.4, 0.2], + &[4.9, 3.0, 1.4, 0.2], + &[4.7, 3.2, 1.3, 0.2], + &[4.6, 3.1, 1.5, 0.2], + &[5.0, 3.6, 1.4, 0.2], + &[5.4, 3.9, 1.7, 0.4], + &[4.6, 3.4, 1.4, 0.3], + &[5.0, 3.4, 1.5, 0.2], + &[4.4, 2.9, 1.4, 0.2], + &[4.9, 3.1, 1.5, 0.1], + &[7.0, 3.2, 4.7, 1.4], + &[6.4, 3.2, 4.5, 1.5], + &[6.9, 3.1, 4.9, 1.5], + &[5.5, 2.3, 4.0, 1.3], + &[6.5, 2.8, 4.6, 1.5], + &[5.7, 2.8, 4.5, 1.3], + &[6.3, 3.3, 4.7, 1.6], + &[4.9, 2.4, 3.3, 1.0], + &[6.6, 2.9, 4.6, 1.3], + &[5.2, 2.7, 3.9, 1.4], + ]) + .unwrap(); + + let svd = SVD::fit(&iris, Default::default()).unwrap(); + + let deserialized_svd: SVD> = + postcard::from_bytes(&postcard::to_allocvec(&svd).unwrap()).unwrap(); + + assert_eq!(svd, deserialized_svd); + } } diff --git a/src/decomposition/tests_edge_cases.rs b/src/decomposition/tests_edge_cases.rs new file mode 100644 index 00000000..580a642b --- /dev/null +++ b/src/decomposition/tests_edge_cases.rs @@ -0,0 +1,136 @@ +//! Stage 3: edge-case & known-answer parity tests for src/decomposition. +//! +//! Covers: PCA, SVD. +//! Tracking issue: #394 / #391. + +#[cfg(test)] +mod decomposition_edge_cases { + use crate::linalg::basic::matrix::DenseMatrix; + use crate::decomposition::pca::{PCA, PCAParameters}; + use crate::decomposition::svd::{SVD, SVDParameters}; + + // ── PCA ─────────────────────────────────────────────────────────────────── + + /// n_components=1 on 2D data: output must be 1D. + #[test] + fn pca_n_components_one() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0], + &[3.0, 4.0], + &[5.0, 6.0], + &[7.0, 8.0], + ]).unwrap(); + let model = PCA::fit(&x, PCAParameters::default().with_n_components(1)).unwrap(); + let transformed = model.transform(&x).unwrap(); + let (_, ncols) = transformed.shape(); + assert_eq!(ncols, 1, "n_components=1 should produce 1-column output"); + } + + /// n_components = rank: reconstruction error should be near zero. + #[test] + fn pca_full_rank_reconstruction() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 0.0], + &[0.0, 1.0], + &[1.0, 1.0], + &[2.0, 1.0], + ]).unwrap(); + let model = PCA::fit(&x, PCAParameters::default().with_n_components(2)).unwrap(); + let t = model.transform(&x).unwrap(); + let reconstructed = model.inverse_transform(&t).unwrap(); + let (nrows, ncols) = x.shape(); + let mut mse = 0.0_f64; + for i in 0..nrows { + for j in 0..ncols { + let diff = x.get((i, j)) - reconstructed.get((i, j)); + mse += diff * diff; + } + } + mse /= (nrows * ncols) as f64; + assert!(mse < 1e-6, "full-rank PCA reconstruction MSE={mse} should be ~0"); + } + + /// Rank-deficient input: PCA must not panic. + #[test] + fn pca_rank_deficient_input() { + // Column 2 = column 1: rank = 1. + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 1.0], + &[2.0, 2.0], + &[3.0, 3.0], + &[4.0, 4.0], + ]).unwrap(); + let result = PCA::fit(&x, PCAParameters::default().with_n_components(1)); + assert!(result.is_ok(), "PCA must not fail on rank-deficient input"); + } + + /// Determinism: same data always gives same projection. + #[test] + fn pca_deterministic() { + let x = DenseMatrix::from_2d_array(&[ + &[2.0_f64, 3.0], + &[1.0, 5.0], + &[4.0, 2.0], + &[6.0, 1.0], + ]).unwrap(); + let t1 = PCA::fit(&x, PCAParameters::default().with_n_components(1)).unwrap().transform(&x).unwrap(); + let t2 = PCA::fit(&x, PCAParameters::default().with_n_components(1)).unwrap().transform(&x).unwrap(); + let (nrows, _) = t1.shape(); + for i in 0..nrows { + // Components may flip sign; compare abs values. + assert!((t1.get((i, 0)).abs() - t2.get((i, 0)).abs()).abs() < 1e-6); + } + } + + // ── SVD ─────────────────────────────────────────────────────────────────── + + /// n_components=1 on 3-column data: output must be 1-column. + #[test] + fn svd_decomp_n_components_one() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0, 3.0], + &[4.0, 5.0, 6.0], + &[7.0, 8.0, 9.0], + &[10.0, 11.0, 12.0], + ]).unwrap(); + let model = SVD::fit(&x, SVDParameters::default().with_n_components(1)).unwrap(); + let transformed = model.transform(&x).unwrap(); + let (_, ncols) = transformed.shape(); + assert_eq!(ncols, 1); + } + + /// Reconstruction error for full-rank SVD should be near zero. + #[test] + fn svd_decomp_full_reconstruction() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 0.0], + &[0.0, 2.0], + &[1.0, 2.0], + &[3.0, 1.0], + ]).unwrap(); + let model = SVD::fit(&x, SVDParameters::default().with_n_components(2)).unwrap(); + let t = model.transform(&x).unwrap(); + let reconstructed = model.inverse_transform(&t).unwrap(); + let (nrows, ncols) = x.shape(); + let mut mse = 0.0_f64; + for i in 0..nrows { + for j in 0..ncols { + let diff = x.get((i, j)) - reconstructed.get((i, j)); + mse += diff * diff; + } + } + mse /= (nrows * ncols) as f64; + assert!(mse < 1e-6, "full-rank SVD reconstruction MSE={mse} should be ~0"); + } + + /// Rank-deficient input must not panic. + #[test] + fn svd_decomp_rank_deficient_no_panic() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 1.0], + &[2.0, 2.0], + &[3.0, 3.0], + ]).unwrap(); + assert!(SVD::fit(&x, SVDParameters::default().with_n_components(1)).is_ok()); + } +} diff --git a/src/ensemble/base_forest_regressor.rs b/src/ensemble/base_forest_regressor.rs index dc504446..eb5844fd 100644 --- a/src/ensemble/base_forest_regressor.rs +++ b/src/ensemble/base_forest_regressor.rs @@ -1,4 +1,4 @@ -use rand::Rng; +use rand::RngExt; use std::fmt::Debug; #[cfg(feature = "serde")] @@ -94,12 +94,12 @@ impl, Y: Array1 .unwrap_or((num_attributes as f64).sqrt().floor() as usize); let mut rng = get_rng_impl(Some(parameters.seed)); - let mut trees: Vec> = Vec::new(); + let n_trees = parameters.n_trees; + let mut trees: Vec> = Vec::with_capacity(n_trees); let mut maybe_all_samples: Option>> = Option::None; if parameters.keep_samples { - // TODO: use with_capacity here - maybe_all_samples = Some(Vec::new()); + maybe_all_samples = Some(Vec::with_capacity(n_trees)); } let mut samples: Vec = (0..n_rows).map(|_| 1).collect(); @@ -161,25 +161,31 @@ impl, Y: Array1 /// Predict OOB classes for `x`. `x` is expected to be equal to the dataset used in training. pub fn predict_oob(&self, x: &X) -> Result { let (n, _) = x.shape(); - if self.samples.is_none() { - Err(Failed::because( - FailedError::PredictFailed, - "Need samples=true for OOB predictions.", - )) - } else if self.samples.as_ref().unwrap()[0].len() != n { - Err(Failed::because( + + let samples = match &self.samples { + Some(s) => s, + None => { + return Err(Failed::because( + FailedError::PredictFailed, + "Need samples=true for OOB predictions.", + )); + } + }; + + if samples[0].len() != n { + return Err(Failed::because( FailedError::PredictFailed, "Prediction matrix must match matrix used in training for OOB predictions.", - )) - } else { - let mut result = Y::zeros(n); + )); + } - for i in 0..n { - result.set(i, self.predict_for_row_oob(x, i)); - } + let mut result = Y::zeros(n); - Ok(result) + for i in 0..n { + result.set(i, self.predict_for_row_oob(x, i)); } + + Ok(result) } fn predict_for_row_oob(&self, x: &X, row: usize) -> TY { @@ -203,12 +209,38 @@ impl, Y: Array1 result / TY::from(n_trees).unwrap() } - fn sample_with_replacement(nrows: usize, rng: &mut impl Rng) -> Vec { + fn sample_with_replacement(nrows: usize, rng: &mut impl rand::Rng) -> Vec { let mut samples = vec![0; nrows]; for _ in 0..nrows { - let xi = rng.gen_range(0..nrows); + let xi = rng.random_range(0..nrows); samples[xi] += 1; } samples } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::linalg::basic::matrix::DenseMatrix; + + #[test] + fn test_base_forest_regressor_keep_samples() { + let x = DenseMatrix::from_2d_array(&[&[1.0, 2.0], &[3.0, 4.0], &[5.0, 6.0]]).unwrap(); + let y = vec![1.0, 2.0, 3.0]; + let params = BaseForestRegressorParameters { + max_depth: None, + min_samples_leaf: 1, + min_samples_split: 2, + n_trees: 5, + m: None, + keep_samples: true, + seed: 42, + bootstrap: true, + splitter: crate::tree::base_tree_regressor::Splitter::Best, + }; + let regressor = BaseForestRegressor::fit(&x, &y, params).unwrap(); + assert_eq!(regressor.trees.unwrap().len(), 5); + assert!(regressor.samples.is_some()); + } +} diff --git a/src/ensemble/random_forest_classifier.rs b/src/ensemble/random_forest_classifier.rs index dabb2480..740182c4 100644 --- a/src/ensemble/random_forest_classifier.rs +++ b/src/ensemble/random_forest_classifier.rs @@ -45,7 +45,7 @@ //! //! //! -use rand::Rng; +use rand::RngExt; use std::default::Default; use std::fmt::Debug; @@ -61,7 +61,7 @@ use crate::numbers::floatnum::FloatNumber; use crate::rand_custom::get_rng_impl; use crate::tree::decision_tree_classifier::{ - which_max, DecisionTreeClassifier, DecisionTreeClassifierParameters, SplitCriterion, + DecisionTreeClassifier, DecisionTreeClassifierParameters, SplitCriterion, which_max, }; /// Parameters of the Random Forest algorithm. @@ -475,13 +475,12 @@ impl, Y: Array1> = Vec::new(); + let n_trees = parameters.n_trees as usize; + let mut trees: Vec> = Vec::with_capacity(n_trees); let mut maybe_all_samples: Option>> = Option::None; if parameters.keep_samples { - // TODO: use with_capacity here - maybe_all_samples = Some(Vec::new()); + maybe_all_samples = Some(Vec::with_capacity(n_trees)); } for _ in 0..parameters.n_trees { @@ -539,27 +538,34 @@ impl, Y: Array1 Result { let (n, _) = x.shape(); - if self.samples.is_none() { - Err(Failed::because( - FailedError::PredictFailed, - "Need samples=true for OOB predictions.", - )) - } else if self.samples.as_ref().unwrap()[0].len() != n { - Err(Failed::because( + + let samples = match &self.samples { + Some(s) => s, + None => { + return Err(Failed::because( + FailedError::PredictFailed, + "Need samples=true for OOB predictions.", + )); + } + }; + + if samples[0].len() != n { + return Err(Failed::because( FailedError::PredictFailed, "Prediction matrix must match matrix used in training for OOB predictions.", - )) - } else { - let mut result = Y::zeros(n); + )); + } - for i in 0..n { - result.set( - i, - self.classes.as_ref().unwrap()[self.predict_for_row_oob(x, i)], - ); - } - Ok(result) + let mut result = Y::zeros(n); + + for i in 0..n { + result.set( + i, + self.classes.as_ref().unwrap()[self.predict_for_row_oob(x, i)], + ); } + + Ok(result) } fn predict_for_row_oob(&self, x: &X, row: usize) -> usize { @@ -580,7 +586,11 @@ impl, Y: Array1 Vec { + fn sample_with_replacement( + y: &[usize], + num_classes: usize, + rng: &mut impl rand::Rng, + ) -> Vec { let class_weight = vec![1.; num_classes]; let nrows = y.len(); let mut samples = vec![0; nrows]; @@ -596,7 +606,7 @@ impl, Y: Array1, Vec> = - bincode::deserialize(&bincode::serialize(&forest).unwrap()).unwrap(); + postcard::from_bytes(&postcard::to_allocvec(&forest).unwrap()).unwrap(); assert_eq!(forest, deserialized_forest); } diff --git a/src/ensemble/random_forest_regressor.rs b/src/ensemble/random_forest_regressor.rs index 0a8a888c..4d2b01b9 100644 --- a/src/ensemble/random_forest_regressor.rs +++ b/src/ensemble/random_forest_regressor.rs @@ -608,7 +608,7 @@ mod tests { let forest = RandomForestRegressor::fit(&x, &y, Default::default()).unwrap(); let deserialized_forest: RandomForestRegressor, Vec> = - bincode::deserialize(&bincode::serialize(&forest).unwrap()).unwrap(); + postcard::from_bytes(&postcard::to_allocvec(&forest).unwrap()).unwrap(); assert_eq!(forest, deserialized_forest); } diff --git a/src/ensemble/tests_edge_cases.rs b/src/ensemble/tests_edge_cases.rs new file mode 100644 index 00000000..f5926593 --- /dev/null +++ b/src/ensemble/tests_edge_cases.rs @@ -0,0 +1,97 @@ +//! Stage 3: edge-case & known-answer parity tests for src/ensemble. +//! +//! Covers: RandomForestClassifier, RandomForestRegressor. +//! Tracking issue: #394 / #391. + +#[cfg(test)] +mod ensemble_edge_cases { + use crate::linalg::basic::matrix::DenseMatrix; + use crate::ensemble::random_forest_classifier::{RandomForestClassifier, RandomForestClassifierParameters}; + use crate::ensemble::random_forest_regressor::{RandomForestRegressor, RandomForestRegressorParameters}; + + // ── RandomForestClassifier ──────────────────────────────────────────────── + + /// n_estimators=1 should behave like a single decision tree. + #[test] + fn rfc_one_estimator_no_panic() { + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64, 0.0], + &[1.0, 0.0], + &[0.0, 1.0], + &[1.0, 1.0], + ]).unwrap(); + let y: Vec = vec![0, 0, 1, 1]; + let model = RandomForestClassifier::fit( + &x, &y, + RandomForestClassifierParameters::default().with_n_trees(1).with_seed(42), + ).unwrap(); + assert!(model.predict(&x).is_ok()); + } + + /// Seed determinism: identical seeds produce identical predictions. + #[test] + fn rfc_seed_determinism() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0], + &[3.0, 4.0], + &[5.0, 6.0], + &[7.0, 8.0], + &[9.0, 10.0], + &[11.0, 12.0], + ]).unwrap(); + let y: Vec = vec![0, 0, 0, 1, 1, 1]; + let m1 = RandomForestClassifier::fit( + &x, &y, + RandomForestClassifierParameters::default().with_n_trees(10).with_seed(123), + ).unwrap(); + let m2 = RandomForestClassifier::fit( + &x, &y, + RandomForestClassifierParameters::default().with_n_trees(10).with_seed(123), + ).unwrap(); + assert_eq!(m1.predict(&x).unwrap(), m2.predict(&x).unwrap()); + } + + /// Different seeds should (usually) differ — at minimum must not panic. + #[test] + fn rfc_different_seeds_no_panic() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0], &[3.0, 4.0], &[5.0, 6.0], + &[7.0, 8.0], &[9.0, 10.0], &[11.0, 12.0], + ]).unwrap(); + let y: Vec = vec![0, 0, 0, 1, 1, 1]; + assert!(RandomForestClassifier::fit(&x, &y, RandomForestClassifierParameters::default().with_seed(1)).is_ok()); + assert!(RandomForestClassifier::fit(&x, &y, RandomForestClassifierParameters::default().with_seed(2)).is_ok()); + } + + // ── RandomForestRegressor ───────────────────────────────────────────────── + + /// n_estimators=1 should not panic and return finite predictions. + #[test] + fn rfr_one_estimator_finite_preds() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64], &[2.0], &[3.0], &[4.0], &[5.0], + ]).unwrap(); + let y: Vec = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let model = RandomForestRegressor::fit( + &x, &y, + RandomForestRegressorParameters::default().with_n_trees(1).with_seed(0), + ).unwrap(); + let preds = model.predict(&x).unwrap(); + assert!(preds.iter().all(|v| v.is_finite())); + } + + /// Seed determinism for regressor. + #[test] + fn rfr_seed_determinism() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 0.0], &[2.0, 1.0], &[3.0, 2.0], + &[4.0, 3.0], &[5.0, 4.0], &[6.0, 5.0], + ]).unwrap(); + let y: Vec = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; + let p1 = RandomForestRegressor::fit(&x, &y, RandomForestRegressorParameters::default().with_n_trees(10).with_seed(99)).unwrap().predict(&x).unwrap(); + let p2 = RandomForestRegressor::fit(&x, &y, RandomForestRegressorParameters::default().with_n_trees(10).with_seed(99)).unwrap().predict(&x).unwrap(); + for (a, b) in p1.iter().zip(p2.iter()) { + assert!((a - b).abs() < 1e-10, "non-deterministic: {a} vs {b}"); + } + } +} diff --git a/src/error/mod.rs b/src/error/mod.rs index b6b1d982..f259400e 100644 --- a/src/error/mod.rs +++ b/src/error/mod.rs @@ -128,3 +128,123 @@ impl fmt::Display for Failed { } impl Error for Failed {} + +#[cfg(test)] +mod tests { + use super::*; + use std::error::Error; + + #[test] + fn fit_sets_fit_failed_variant_and_message() { + let e = Failed::fit("oops"); + assert_eq!(e.error(), FailedError::FitFailed); + assert_eq!(e.msg, "oops"); + } + + #[test] + fn predict_sets_predict_failed_variant_and_message() { + let e = Failed::predict("nope"); + assert_eq!(e.error(), FailedError::PredictFailed); + assert_eq!(e.msg, "nope"); + } + + #[test] + fn transform_sets_transform_failed_variant_and_message() { + let e = Failed::transform("bad"); + assert_eq!(e.error(), FailedError::TransformFailed); + assert_eq!(e.msg, "bad"); + } + + #[test] + fn input_sets_parameters_error_variant_and_message() { + let e = Failed::input("no good"); + assert_eq!(e.error(), FailedError::ParametersError); + assert_eq!(e.msg, "no good"); + } + + #[test] + fn invalid_state_sets_invalid_state_variant_and_message() { + let e = Failed::invalid_state("reachable?"); + assert_eq!(e.error(), FailedError::InvalidStateError); + assert_eq!(e.msg, "reachable?"); + } + + #[test] + fn because_sets_explicit_variant_and_message() { + let e = Failed::because(FailedError::FindFailed, "lost"); + assert_eq!(e.error(), FailedError::FindFailed); + assert_eq!(e.msg, "lost"); + } + + #[test] + fn failed_error_display_each_variant() { + assert_eq!(FailedError::FitFailed.to_string(), "Fit failed"); + assert_eq!(FailedError::PredictFailed.to_string(), "Predict failed"); + assert_eq!(FailedError::TransformFailed.to_string(), "Transform failed"); + assert_eq!(FailedError::FindFailed.to_string(), "Find failed"); + assert_eq!( + FailedError::DecompositionFailed.to_string(), + "Decomposition failed" + ); + assert_eq!( + FailedError::SolutionFailed.to_string(), + "Can't find solution" + ); + assert_eq!( + FailedError::ParametersError.to_string(), + "Error in input, check parameters" + ); + assert_eq!( + FailedError::InvalidStateError.to_string(), + "Invalid state, this should never happen" + ); + } + + #[test] + fn failed_display_combines_variant_and_message() { + let e = Failed::because(FailedError::FitFailed, "boom"); + assert_eq!(e.to_string(), "Fit failed: boom"); + } + + #[test] + fn failed_error_partialeq_by_discriminant() { + assert_eq!(FailedError::FitFailed, FailedError::FitFailed); + assert_ne!(FailedError::FitFailed, FailedError::PredictFailed); + // distinct variants are never equal + let all = [ + FailedError::FitFailed, + FailedError::PredictFailed, + FailedError::TransformFailed, + FailedError::FindFailed, + FailedError::DecompositionFailed, + FailedError::SolutionFailed, + FailedError::ParametersError, + FailedError::InvalidStateError, + ]; + for (i, &a) in all.iter().enumerate() { + for (j, &b) in all.iter().enumerate() { + assert_eq!(a == b, i == j, "variant pair ({i}, {j}) mismatch"); + } + } + } + + #[test] + fn failed_partialeq_compares_variant_and_message() { + assert_eq!(Failed::fit("x"), Failed::fit("x")); + assert_ne!(Failed::fit("x"), Failed::fit("y")); + assert_ne!(Failed::fit("x"), Failed::predict("x")); + assert_ne!( + Failed::because(FailedError::FitFailed, "x"), + Failed::because(FailedError::PredictFailed, "x") + ); + } + + #[test] + fn failed_implements_error_with_no_source() { + let e = Failed::fit("boom"); + // Failed wraps no underlying cause + assert!(e.source().is_none()); + // ensure it can be used as a trait object + let _: &dyn Error = &e; + } +} diff --git a/src/lib.rs b/src/lib.rs index c68368fa..0558f952 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,10 @@ -#![allow( +#![expect( clippy::type_complexity, clippy::too_many_arguments, clippy::many_single_char_names, - clippy::unnecessary_wraps, - clippy::upper_case_acronyms, - clippy::approx_constant + clippy::unnecessary_wraps )] +#![allow(clippy::upper_case_acronyms, clippy::approx_constant)] #![warn(missing_docs)] //! # smartcore diff --git a/src/linalg/basic/arrays.rs b/src/linalg/basic/arrays.rs index a5abe634..71bed879 100644 --- a/src/linalg/basic/arrays.rs +++ b/src/linalg/basic/arrays.rs @@ -1009,7 +1009,10 @@ pub trait Array1: MutArrayView1 + Sized + T: Number + RealNumber, Self: Sized, { - (self.sub(other)).iterator(0).all(|v| v.abs() <= error) + // 2024-safe tail-expr form: bind the owned intermediate so it outlives + // the borrowing iterator temporary. + let diff = self.sub(other); + diff.iterator(0).all(|v| v.abs() <= error) } } @@ -1736,7 +1739,9 @@ mod tests { 1.0, 1.3, 1.4, ]; assert_eq!( - vec![9, 7, 1, 8, 0, 2, 4, 3, 6, 5, 17, 18, 15, 13, 19, 10, 14, 11, 12, 16], + vec![ + 9, 7, 1, 8, 0, 2, 4, 3, 6, 5, 17, 18, 15, 13, 19, 10, 14, 11, 12, 16 + ], arr2.argsort() ); } @@ -2215,4 +2220,179 @@ mod tests { let sorted = view.argsort_mut(); assert_eq!(sorted.len(), 1000); } + + // ---- Stage 2: proptest invariants + edge cases ---- + + #[cfg(not(target_arch = "wasm32"))] + use proptest::prelude::*; + + #[cfg(not(target_arch = "wasm32"))] + fn arb_small_matrix(max_dim: usize) -> impl Strategy> { + (1..=max_dim, 1..=max_dim).prop_flat_map(|(r, c)| { + proptest::collection::vec(-50.0f64..50.0, r * c) + .prop_map(move |vals| DenseMatrix::new(r, c, vals, false).unwrap()) + }) + } + + #[cfg(not(target_arch = "wasm32"))] + fn make_identity(n: usize) -> DenseMatrix { + let mut vals = vec![0.0; n * n]; + for i in 0..n { + vals[i * n + i] = 1.0; + } + DenseMatrix::new(n, n, vals, false).unwrap() + } + + /// Transpose is an involution: (A^T)^T == A + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn proptest_transpose_involution() { + proptest!( + |(a in arb_small_matrix(8))| { + let tt = a.transpose().transpose(); + prop_assert_eq!(tt, a); + } + ); + } + + /// Matmul with an identity matrix is a no-op: A * I == A + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn proptest_matmul_identity() { + proptest!( + |(a in arb_small_matrix(8))| { + let n = a.shape().1; + let identity = make_identity(n); + let result = a.matmul(&identity); + prop_assert_eq!(result, a); + } + ); + } + + /// Matmul associativity: (AB)C == A(BC) where shapes allow + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn proptest_matmul_associativity() { + proptest!( + |(a_vals in proptest::collection::vec(-5.0f64..5.0, 6), + b_vals in proptest::collection::vec(-5.0f64..5.0, 12), + c_vals in proptest::collection::vec(-5.0f64..5.0, 12))| + { + // A: 2x3, B: 3x4, C: 4x3 (so (AB)C = 2x3, A(BC) = 2x3) + let a = DenseMatrix::new(2, 3, a_vals, false).unwrap(); + let b = DenseMatrix::new(3, 4, b_vals, false).unwrap(); + let c = DenseMatrix::new(4, 3, c_vals, false).unwrap(); + + let ab = a.matmul(&b); + let ab_c = ab.matmul(&c); + + let bc = b.matmul(&c); + let a_bc = a.matmul(&bc); + + // Use approximate comparison: floating-point accumulation + // across three matmuls can cause exact PartialEq to fail. + let (r1, c1) = ab_c.shape(); + let (r2, c2) = a_bc.shape(); + prop_assert!(r1 == r2 && c1 == c2, "shape mismatch"); + for i in 0..r1 { + for j in 0..c1 { + let diff = (ab_c.get((i, j)) - a_bc.get((i, j))).abs(); + prop_assert!(diff < 1e-9, "({i},{j}): diff={diff}"); + } + } + } + ); + } + + /// (AB)^T == B^T * A^T + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn proptest_matmul_transpose_identity() { + proptest!( + |(a_vals in proptest::collection::vec(0.0f64..10.0, 6), + b_vals in proptest::collection::vec(0.0f64..10.0, 12))| + { + // A: 2x3, B: 3x4 + let a = DenseMatrix::new(2, 3, a_vals, false).unwrap(); + let b = DenseMatrix::new(3, 4, b_vals, false).unwrap(); + + let ab_t = a.matmul(&b).transpose(); + let bt_at = b.transpose().matmul(&a.transpose()); + + // approximate comparison — FP accumulation can exceed exact PartialEq + let (r, c) = ab_t.shape(); + for i in 0..r { + for j in 0..c { + let diff = (ab_t.get((i, j)) - bt_at.get((i, j))).abs(); + prop_assert!(diff < 1e-10, "({i},{j}): diff={diff}"); + } + } + } + ); + } + + /// Reshape preserves total element count + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn proptest_reshape_preserves_count() { + proptest!( + |(vals in proptest::collection::vec(0.0f64..50.0, 12), + factor in 1usize..=12)| + { + proptest::prop_assume!(12 % factor == 0); + let new_rows = factor; + let new_cols = 12 / factor; + let m = DenseMatrix::new(3, 4, vals, false).unwrap(); + let reshaped = m.reshape(new_rows, new_cols, 0); + prop_assert_eq!(reshaped.shape(), (new_rows, new_cols)); + } + ); + } + + /// Edge case: 1x1 matmul + #[test] + fn edge_matmul_1x1() { + let a = DenseMatrix::from_2d_array(&[&[5.0]]).unwrap(); + let b = DenseMatrix::from_2d_array(&[&[3.0]]).unwrap(); + let c = a.matmul(&b); + assert_eq!(c, DenseMatrix::from_2d_array(&[&[15.0]]).unwrap()); + } + + /// Edge case: matmul with a 1xN row vector times Nx1 column vector -> 1x1 + #[test] + fn edge_matmul_row_times_col() { + let a = DenseMatrix::from_2d_array(&[&[1.0, 2.0, 3.0]]).unwrap(); + let b = DenseMatrix::from_2d_array(&[&[4.0], &[5.0], &[6.0]]).unwrap(); + let c = a.matmul(&b); + assert_eq!(c.shape(), (1, 1)); + assert!((c.get((0, 0)) - 32.0).abs() < 1e-10); + } + + /// Edge case: matmul shape mismatch should panic + #[test] + #[should_panic(expected = "Can't multiply")] + fn edge_matmul_shape_mismatch_panics() { + let a = DenseMatrix::from_2d_array(&[&[1.0, 2.0]]).unwrap(); + let b = DenseMatrix::from_2d_array(&[&[1.0, 2.0]]).unwrap(); + let _ = a.matmul(&b); + } + + /// Edge case: reshape to incompatible size should panic + #[test] + #[should_panic(expected = "Can't reshape")] + fn edge_reshape_incompatible_panics() { + let a = DenseMatrix::from_2d_array(&[&[1.0, 2.0], &[3.0, 4.0]]).unwrap(); + let _ = a.reshape(3, 1, 0); + } + + /// Edge case: transpose of 1xN + #[test] + fn edge_transpose_1xn() { + let a = DenseMatrix::from_2d_array(&[&[1.0, 2.0, 3.0]]).unwrap(); + let t = a.transpose(); + assert_eq!(t.shape(), (3, 1)); + assert_eq!(t.get((0, 0)), &1.0); + assert_eq!(t.get((1, 0)), &2.0); + assert_eq!(t.get((2, 0)), &3.0); + } } diff --git a/src/linalg/basic/matrix.rs b/src/linalg/basic/matrix.rs index 58f9846a..2abf44a3 100644 --- a/src/linalg/basic/matrix.rs +++ b/src/linalg/basic/matrix.rs @@ -21,6 +21,101 @@ use crate::numbers::realnum::RealNumber; use crate::error::Failed; +/// Build a streaming mutable iterator over the elements of `values` in the +/// order defined by `(axis, column_major, stride, nrows, ncols)`, **safely** +/// (no `unsafe`). +/// +/// The desired offsets are computed from `(axis, column_major, stride)` exactly +/// as the old raw-pointer implementation did. Then, instead of `ptr.add(off)`, +/// the disjoint `&mut T` references are extracted at those offsets by walking +/// the slice with `split_first_mut` in sorted-offset order and reassembling into +/// the yield order. This handles both the owned-matrix case (`values.len() == +/// nrows*ncols`) and strided-view case (`values.len() > nrows*ncols` because +/// the sub-slice spans gaps). The traversal order is identical to the previous +/// raw-pointer implementation; only the borrow-proving mechanism changes. +/// +/// `debug_assert!`s verify the computed offsets are in-bounds and distinct +/// (mirroring the old `#[cfg(debug_assertions)]` aliasing checks). +fn ordered_iter_mut<'b, T>( + values: &'b mut [T], + stride: usize, + nrows: usize, + ncols: usize, + column_major: bool, + axis: u8, +) -> Box + 'b> +where + T: Debug + Display + Copy + Sized, +{ + assert!( + axis == 0 || axis == 1, + "For two dimensional array `axis` should be either 0 or 1" + ); + + let off = |r: usize, c: usize| { + if column_major { + r + c * stride + } else { + r * stride + c + } + }; + + let desired: Vec = match axis { + 0 => (0..nrows) + .flat_map(|r| (0..ncols).map(move |c| off(r, c))) + .collect(), + _ => (0..ncols) + .flat_map(|c| (0..nrows).map(move |r| off(r, c))) + .collect(), + }; + + let n = desired.len(); + debug_assert_eq!(n, nrows * ncols); + #[cfg(debug_assertions)] + { + let len = values.len(); + let mut seen = std::collections::HashSet::new(); + for &o in &desired { + assert!( + o < len, + "iterator_mut: offset {o} out of bounds (len={len})" + ); + assert!( + seen.insert(o), + "iterator_mut: aliasing detected at offset {o}" + ); + } + } + + // Fast path: the desired order is the natural storage order, so the slice + // iterator already yields refs in the right order with no allocation. + let is_identity = desired.iter().enumerate().all(|(i, &o)| o == i); + if is_identity { + return Box::new(values.iter_mut().take(n)); + } + + // General path: extract `n` disjoint refs at the desired offsets by walking + // the slice with `split_first_mut` in sorted-offset order, then reorder + // into the yield order. Safe because each `split_first_mut` borrows a + // disjoint portion; the borrow checker proves non-aliasing. + let mut sorted: Vec<(usize, usize)> = + desired.iter().enumerate().map(|(i, &o)| (o, i)).collect(); + sorted.sort_unstable_by_key(|&(o, _)| o); + + let mut result: Vec> = (0..n).map(|_| None).collect(); + let mut rest = values; + let mut prev = 0usize; + for &(offset, idx) in &sorted { + rest = rest.split_at_mut(offset - prev).1; + let (target, remainder) = rest.split_first_mut().expect("non-empty"); + result[idx] = Some(target); + rest = remainder; + prev = offset + 1; + } + + Box::new(result.into_iter().map(|r| r.expect("filled"))) +} + /// Dense matrix #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[derive(Debug, Clone)] @@ -57,7 +152,7 @@ impl<'a, T: Debug + Display + Copy + Sized> DenseMatrixView<'a, T> { vrows: Range, vcols: Range, ) -> Result { - if m.is_valid_view(m.shape().0, m.shape().1, &vrows, &vcols) { + if !m.is_valid_view(m.shape().0, m.shape().1, &vrows, &vcols) { Err(Failed::input( "The specified view is outside of the matrix range", )) @@ -109,7 +204,7 @@ impl<'a, T: Debug + Display + Copy + Sized> DenseMatrixMutView<'a, T> { vrows: Range, vcols: Range, ) -> Result { - if m.is_valid_view(m.shape().0, m.shape().1, &vrows, &vcols) { + if !m.is_valid_view(m.shape().0, m.shape().1, &vrows, &vcols) { Err(Failed::input( "The specified view is outside of the matrix range", )) @@ -143,29 +238,14 @@ impl<'a, T: Debug + Display + Copy + Sized> DenseMatrixMutView<'a, T> { } fn iter_mut<'b>(&'b mut self, axis: u8) -> Box + 'b> { - let column_major = self.column_major; - let stride = self.stride; - let ptr = self.values.as_mut_ptr(); - match axis { - 0 => Box::new((0..self.nrows).flat_map(move |r| { - (0..self.ncols).map(move |c| unsafe { - &mut *ptr.add(if column_major { - r + c * stride - } else { - r * stride + c - }) - }) - })), - _ => Box::new((0..self.ncols).flat_map(move |c| { - (0..self.nrows).map(move |r| unsafe { - &mut *ptr.add(if column_major { - r + c * stride - } else { - r * stride + c - }) - }) - })), - } + ordered_iter_mut( + self.values, + self.stride, + self.nrows, + self.ncols, + self.column_major, + axis, + ) } } @@ -211,30 +291,40 @@ impl DenseMatrix { } /// New instance of `DenseMatrix` from 2d vector. - #[allow(clippy::ptr_arg)] + /// + /// Returns `Err` if the input is empty **or** if any row has a different + /// length than the first row (jagged / ragged arrays are not supported). + #[expect(clippy::ptr_arg)] pub fn from_2d_vec(values: &Vec>) -> Result { if values.is_empty() || values[0].is_empty() { - Err(Failed::input( + return Err(Failed::input( "The 2d vec provided is empty; cannot instantiate the matrix", - )) - } else { - let nrows = values.len(); - let ncols = values - .first() - .unwrap_or_else(|| { - panic!("Invalid state: Cannot create 2d matrix from an empty vector") - }) - .len(); - let mut m_values = Vec::with_capacity(nrows * ncols); - - for c in 0..ncols { - for r in values.iter().take(nrows) { - m_values.push(r[c]) - } + )); + } + + let nrows = values.len(); + let ncols = values[0].len(); + + // Reject jagged arrays: every row must have exactly `ncols` elements. + for (i, row) in values.iter().enumerate() { + if row.len() != ncols { + return Err(Failed::input(&format!( + "Row {i} has length {} but row 0 has length {ncols}; \ + jagged arrays are not supported", + row.len() + ))); } + } + + let mut m_values = Vec::with_capacity(nrows * ncols); - DenseMatrix::new(nrows, ncols, m_values, true) + for c in 0..ncols { + for r in values.iter().take(nrows) { + m_values.push(r[c]) + } } + + DenseMatrix::new(nrows, ncols, m_values, true) } /// Iterate over values of matrix @@ -242,7 +332,7 @@ impl DenseMatrix { self.values.iter() } - /// Check if the size of the requested view is bounded to matrix rows/cols count + /// Check if the size of the requested view is bounded to matrix rows/cols count. fn is_valid_view( &self, n_rows: usize, @@ -250,13 +340,13 @@ impl DenseMatrix { vrows: &Range, vcols: &Range, ) -> bool { - !(vrows.end <= n_rows + vrows.start <= vrows.end + && vcols.start <= vcols.end + && vrows.end <= n_rows && vcols.end <= n_cols - && vrows.start <= n_rows - && vcols.start <= n_cols) } - /// Compute the range of the requested view: start, end, size of the slice + /// Compute the range of the requested view: start, end, size of the slice. fn stride_range( &self, n_rows: usize, @@ -266,17 +356,43 @@ impl DenseMatrix { column_major: bool, ) -> (usize, usize, usize) { let (start, end, stride) = if column_major { - ( - vrows.start + vcols.start * n_rows, - vrows.end + (vcols.end - 1) * n_rows, - n_rows, - ) + let start = vrows + .start + .checked_add( + vcols + .start + .checked_mul(n_rows) + .expect("stride_range: integer overflow in start (column_major)"), + ) + .expect("stride_range: integer overflow in start (column_major)"); + let end = vrows + .end + .checked_add( + vcols + .end + .checked_sub(1) + .expect("stride_range: vcols.end underflow (column_major)") + .checked_mul(n_rows) + .expect("stride_range: integer overflow in end (column_major)"), + ) + .expect("stride_range: integer overflow in end (column_major)"); + (start, end, n_rows) } else { - ( - vrows.start * n_cols + vcols.start, - (vrows.end - 1) * n_cols + vcols.end, - n_cols, - ) + let start = vrows + .start + .checked_mul(n_cols) + .expect("stride_range: integer overflow in start (row_major)") + .checked_add(vcols.start) + .expect("stride_range: integer overflow in start (row_major)"); + let end = vrows + .end + .checked_sub(1) + .expect("stride_range: vrows.end underflow (row_major)") + .checked_mul(n_cols) + .expect("stride_range: integer overflow in end (row_major)") + .checked_add(vcols.end) + .expect("stride_range: integer overflow in end (row_major)"); + (start, end, n_cols) }; (start, end, stride) } @@ -331,7 +447,6 @@ where T::default_epsilon() } - // equality in differences in absolute values, according to an epsilon fn abs_diff_eq(&self, other: &Self, epsilon: T::Epsilon) -> bool { if self.ncols != other.ncols || self.nrows != other.nrows { false @@ -414,29 +529,20 @@ impl MutArray for DenseMat } fn iterator_mut<'b>(&'b mut self, axis: u8) -> Box + 'b> { - let ptr = self.values.as_mut_ptr(); - let column_major = self.column_major; let (nrows, ncols) = self.shape(); - match axis { - 0 => Box::new((0..self.nrows).flat_map(move |r| { - (0..self.ncols).map(move |c| unsafe { - &mut *ptr.add(if column_major { - r + c * nrows - } else { - r * ncols + c - }) - }) - })), - _ => Box::new((0..self.ncols).flat_map(move |c| { - (0..self.nrows).map(move |r| unsafe { - &mut *ptr.add(if column_major { - r + c * nrows - } else { - r * ncols + c - }) - }) - })), - } + // For an owned matrix the storage stride is nrows (column-major) or + // ncols (row-major); pass that to the shared safe helper. The traversal + // order and offset formula are identical to the previous raw-pointer + // implementation — only the borrow-proving mechanism changes. + let stride = if self.column_major { nrows } else { ncols }; + ordered_iter_mut( + &mut self.values, + stride, + nrows, + ncols, + self.column_major, + axis, + ) } } @@ -468,12 +574,10 @@ impl Array2 for DenseMatrix { Box::new(DenseMatrixMutView::new(self, rows, cols).unwrap()) } - // private function so for now assume infalible fn fill(nrows: usize, ncols: usize, value: T) -> Self { DenseMatrix::new(nrows, ncols, vec![value; nrows * ncols], true).unwrap() } - // private function so for now assume infalible fn from_iterator>(iter: I, nrows: usize, ncols: usize, axis: u8) -> Self { DenseMatrix::new(nrows, ncols, iter.collect(), axis != 0).unwrap() } @@ -507,7 +611,7 @@ impl Array for DenseMatrix } fn is_empty(&self) -> bool { - self.nrows * self.ncols > 0 + self.nrows == 0 || self.ncols == 0 } fn iterator<'b>(&'b self, axis: u8) -> Box + 'b> { @@ -545,7 +649,7 @@ impl Array for DenseMatrixView<'_, } fn is_empty(&self) -> bool { - self.nrows * self.ncols > 0 + self.nrows == 0 || self.ncols == 0 } fn iterator<'b>(&'b self, axis: u8) -> Box + 'b> { @@ -571,7 +675,7 @@ impl Array for DenseMatrix } fn is_empty(&self) -> bool { - self.nrows * self.ncols > 0 + self.nrows == 0 || self.ncols == 0 } fn iterator<'b>(&'b self, axis: u8) -> Box + 'b> { @@ -624,6 +728,29 @@ mod tests { let x = DenseMatrix::from_2d_array(input); assert!(x.is_err()); } + + #[test] + fn test_from_2d_vec_jagged_returns_err() { + let jagged = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0], vec![6.0, 7.0, 8.0]]; + let result = DenseMatrix::from_2d_vec(&jagged); + assert!( + result.is_err(), + "from_2d_vec should return Err for jagged arrays" + ); + let msg = format!("{:?}", result.unwrap_err()); + assert!( + msg.contains("jagged"), + "error message should mention 'jagged': {msg}" + ); + } + + #[test] + fn test_from_2d_vec_uniform_ok() { + let uniform = vec![vec![1.0, 2.0], vec![3.0, 4.0]]; + let result = DenseMatrix::from_2d_vec(&uniform); + assert!(result.is_ok(), "uniform 2d vec should succeed"); + } + #[test] fn test_instantiate_ok_view1() { let x = DenseMatrix::from_2d_array(&[&[1., 2., 3.], &[4., 5., 6.], &[7., 8., 9.]]).unwrap(); @@ -663,10 +790,34 @@ mod tests { #[test] fn test_instantiate_err_view3() { let x = DenseMatrix::from_2d_array(&[&[1., 2., 3.], &[4., 5., 6.], &[7., 8., 9.]]).unwrap(); - #[allow(clippy::reversed_empty_ranges)] + #[expect(clippy::reversed_empty_ranges)] let v = DenseMatrixView::new(&x, 0..3, 4..3); assert!(v.is_err()); } + + #[test] + fn test_is_empty_view_not_empty() { + let x = DenseMatrix::from_2d_array(&[&[1., 2.], &[3., 4.]]).unwrap(); + let v = DenseMatrixView::new(&x, 0..2, 0..2).unwrap(); + // DenseMatrixView implements Array AND Array. + // Both impls expose is_empty, so we must use fully-qualified syntax to + // select the 2-D shape variant and avoid E0283. + assert!( + ! as Array>::is_empty(&v), + "2x2 view should not be empty" + ); + } + + #[test] + fn test_is_empty_mut_view_not_empty() { + let mut x = DenseMatrix::from_2d_array(&[&[1., 2.], &[3., 4.]]).unwrap(); + let v = DenseMatrixMutView::new(&mut x, 0..2, 0..2).unwrap(); + assert!( + ! as Array>::is_empty(&v), + "2x2 mut view should not be empty" + ); + } + #[test] fn test_display() { let x = DenseMatrix::from_2d_array(&[&[1., 2., 3.], &[4., 5., 6.], &[7., 8., 9.]]).unwrap(); @@ -756,7 +907,9 @@ mod tests { assert_eq!(vec!["1", "4", "7", "2", "5", "8", "3", "6", "9"], x.values); x.iterator_mut(0).for_each(|v| *v = "str"); assert_eq!( - vec!["str", "str", "str", "str", "str", "str", "str", "str", "str"], + vec![ + "str", "str", "str", "str", "str", "str", "str", "str", "str" + ], x.values ); } @@ -768,10 +921,9 @@ mod tests { assert_eq!(vec!["1", "4", "2", "5", "3", "6"], x.values); assert!(x.column_major); - // transpose let x = x.transpose(); assert_eq!(vec!["1", "4", "2", "5", "3", "6"], x.values); - assert!(!x.column_major); // should change column_major + assert!(!x.column_major); } #[test] @@ -780,7 +932,6 @@ mod tests { let m = DenseMatrix::from_iterator(data.iter(), 2, 3, 0); - // make a vector into a 2x3 matrix. assert_eq!( vec![1, 2, 3, 4, 5, 6], m.values.iter().map(|e| **e).collect::>() @@ -794,10 +945,8 @@ mod tests { let b = DenseMatrix::from_2d_array(&[&[1, 2], &[3, 4], &[5, 6]]).unwrap(); println!("{a}"); - // take column 0 and 2 assert_eq!(vec![1, 3, 4, 6], a.take(&[0, 2], 1).values); println!("{b}"); - // take rows 0 and 2 assert_eq!(vec![1, 2, 5, 6], b.take(&[0, 2], 0).values); } diff --git a/src/linalg/ndarray/matrix.rs b/src/linalg/ndarray/matrix.rs index 5040497a..14067e1f 100644 --- a/src/linalg/ndarray/matrix.rs +++ b/src/linalg/ndarray/matrix.rs @@ -4,6 +4,7 @@ use std::ops::Range; use crate::linalg::basic::arrays::{ Array as BaseArray, Array2, ArrayView1, ArrayView2, MutArray, MutArrayView2, }; +use crate::linalg::basic::matrix::DenseMatrix; use crate::linalg::traits::cholesky::CholeskyDecomposable; use crate::linalg::traits::evd::EVDDecomposable; @@ -13,7 +14,51 @@ use crate::linalg::traits::svd::SVDDecomposable; use crate::numbers::basenum::Number; use crate::numbers::realnum::RealNumber; -use ndarray::{s, Array, ArrayBase, ArrayView, ArrayViewMut, Ix2, OwnedRepr}; +use ndarray::{Array, ArrayBase, ArrayView, ArrayViewMut, Axis, Ix2, Order, OwnedRepr, s}; + +// --------------------------------------------------------------------------- +// ArrayBase, Ix2> (owned 2-D array) +// --------------------------------------------------------------------------- + +const ROW_MAJOR_AXIS: u8 = 0; + +impl DenseMatrix { + /// Copies an owned two-dimensional ndarray into a [`DenseMatrix`]. + /// + /// The resulting matrix uses row-major (C) storage regardless of the + /// memory layout of the source array. + /// + /// # Notes + /// + /// [`ndarray::Array2::iter`] always yields elements in logical row-major + /// order, independent of whether the source is C- or Fortran-ordered. This + /// invariant makes transposed-layout conversion correct. + /// + /// # Panics + /// + /// Panics if `nrows * ncols` overflows `usize`. An empty array (zero + /// rows or zero columns) does not panic. + /// + /// # Examples + /// + /// ``` + /// use ndarray::Array2; + /// use smartcore::linalg::basic::arrays::Array; + /// use smartcore::linalg::basic::matrix::DenseMatrix; + /// + /// let array = Array2::from_shape_vec( + /// (3, 4), + /// (0..12).map(|value| value as f64).collect(), + /// ).unwrap(); + /// let matrix = DenseMatrix::from_ndarray2(&array); + /// assert_eq!(matrix.shape(), (3, 4)); + /// assert_eq!(*matrix.get((1, 2)), 6.0); + /// ``` + pub fn from_ndarray2(a: &ndarray::Array2) -> Self { + // iter() yields logical row-major order regardless of memory layout. + Self::from_iterator(a.iter().copied(), a.nrows(), a.ncols(), ROW_MAJOR_AXIS) + } +} impl BaseArray for ArrayBase, Ix2> @@ -27,7 +72,7 @@ impl BaseArray } fn is_empty(&self) -> bool { - self.len() > 0 + self.len() == 0 } fn iterator<'b>(&'b self, axis: u8) -> Box + 'b> { @@ -52,49 +97,24 @@ impl MutArray } fn iterator_mut<'b>(&'b mut self, axis: u8) -> Box + 'b> { - let ptr = self.as_mut_ptr(); - let stride = self.strides(); - let (rstride, cstride) = (stride[0] as usize, stride[1] as usize); - match axis { - 0 => Box::new(self.iter_mut()), - _ => Box::new((0..self.ncols()).flat_map(move |c| { - (0..self.nrows()).map(move |r| unsafe { &mut *ptr.add(r * rstride + c * cstride) }) - })), - } - } -} - -impl ArrayView2 for ArrayBase, Ix2> {} - -impl MutArrayView2 for ArrayBase, Ix2> {} - -impl BaseArray for ArrayView<'_, T, Ix2> { - fn get(&self, pos: (usize, usize)) -> &T { - &self[[pos.0, pos.1]] - } - - fn shape(&self) -> (usize, usize) { - (self.nrows(), self.ncols()) - } - - fn is_empty(&self) -> bool { - self.len() > 0 - } - - fn iterator<'b>(&'b self, axis: u8) -> Box + 'b> { assert!( axis == 1 || axis == 0, "For two dimensional array `axis` should be either 0 or 1" ); match axis { - 0 => Box::new(self.iter()), - _ => Box::new( - (0..self.ncols()).flat_map(move |c| (0..self.nrows()).map(move |r| &self[[r, c]])), - ), + // axis-0: row-major — ndarray iter_mut() traverses in row-major order. + 0 => Box::new(self.iter_mut()), + // axis-1: column-major — axis_iter_mut(Axis(1)) yields each column as a + // non-overlapping ArrayViewMut1; into_iter() gives &mut T. + // No raw pointers or unsafe blocks required. + _ => Box::new(self.axis_iter_mut(Axis(1)).flat_map(|col| col.into_iter())), } } } +impl ArrayView2 for ArrayBase, Ix2> {} +impl MutArrayView2 for ArrayBase, Ix2> {} + impl Array2 for ArrayBase, Ix2> { fn get_row<'a>(&'a self, row: usize) -> Box + 'a> { Box::new(self.row(row)) @@ -105,7 +125,7 @@ impl Array2 for ArrayBase, Ix } fn slice<'a>(&'a self, rows: Range, cols: Range) -> Box + 'a> { - Box::new(self.slice(s![rows, cols])) + Box::new(self.view().slice_move(s![rows, cols])) } fn slice_mut<'a>( @@ -116,7 +136,9 @@ impl Array2 for ArrayBase, Ix where Self: Sized, { - Box::new(self.slice_mut(s![rows, cols])) + // slice_mut returns ArrayBase, Ix2> which is ArrayViewMut. + // We implement MutArrayView2 for ArrayViewMut below, so this cast is valid. + Box::new(self.view_mut().slice_move(s![rows, cols])) } fn fill(nrows: usize, ncols: usize, value: T) -> Self { @@ -124,12 +146,16 @@ impl Array2 for ArrayBase, Ix } fn from_iterator>(iter: I, nrows: usize, ncols: usize, axis: u8) -> Self { + // `into_shape` was deprecated in ndarray 0.16; use `into_shape_with_order` instead. let a = Array::from_iter(iter.take(nrows * ncols)) - .into_shape((nrows, ncols)) + .into_shape_with_order(((nrows, ncols), Order::RowMajor)) .unwrap(); match axis { 0 => a, - _ => a.reversed_axes().into_shape((nrows, ncols)).unwrap(), + _ => a + .reversed_axes() + .into_shape_with_order(((nrows, ncols), Order::RowMajor)) + .unwrap(), } } @@ -144,8 +170,43 @@ impl EVDDecomposable for ArrayBase, Ix2> impl LUDecomposable for ArrayBase, Ix2> {} impl SVDDecomposable for ArrayBase, Ix2> {} +// --------------------------------------------------------------------------- +// ArrayView<'_, T, Ix2> (immutable 2-D view / slice) +// --------------------------------------------------------------------------- + +impl BaseArray for ArrayView<'_, T, Ix2> { + fn get(&self, pos: (usize, usize)) -> &T { + &self[[pos.0, pos.1]] + } + + fn shape(&self) -> (usize, usize) { + (self.nrows(), self.ncols()) + } + + fn is_empty(&self) -> bool { + self.len() == 0 + } + + fn iterator<'b>(&'b self, axis: u8) -> Box + 'b> { + assert!( + axis == 1 || axis == 0, + "For two dimensional array `axis` should be either 0 or 1" + ); + match axis { + 0 => Box::new(self.iter()), + _ => Box::new( + (0..self.ncols()).flat_map(move |c| (0..self.nrows()).map(move |r| &self[[r, c]])), + ), + } + } +} + impl ArrayView2 for ArrayView<'_, T, Ix2> {} +// --------------------------------------------------------------------------- +// ArrayViewMut<'_, T, Ix2> (mutable 2-D view — returned by slice_mut) +// --------------------------------------------------------------------------- + impl BaseArray for ArrayViewMut<'_, T, Ix2> { fn get(&self, pos: (usize, usize)) -> &T { &self[[pos.0, pos.1]] @@ -156,7 +217,7 @@ impl BaseArray for ArrayVi } fn is_empty(&self) -> bool { - self.len() > 0 + self.len() == 0 } fn iterator<'b>(&'b self, axis: u8) -> Box + 'b> { @@ -179,104 +240,124 @@ impl MutArray for ArrayVie } fn iterator_mut<'b>(&'b mut self, axis: u8) -> Box + 'b> { - let ptr = self.as_mut_ptr(); - let stride = self.strides(); - let (rstride, cstride) = (stride[0] as usize, stride[1] as usize); + assert!( + axis == 1 || axis == 0, + "For two dimensional array `axis` should be either 0 or 1" + ); match axis { + // axis-0: row-major — safe ndarray iter_mut(). 0 => Box::new(self.iter_mut()), - _ => Box::new((0..self.ncols()).flat_map(move |c| { - (0..self.nrows()).map(move |r| unsafe { &mut *ptr.add(r * rstride + c * cstride) }) - })), + // axis-1: column-major — axis_iter_mut(Axis(1)) yields each column as a + // non-overlapping ArrayViewMut1; into_iter() gives &mut T. + // No raw pointers or unsafe blocks required. + _ => Box::new(self.axis_iter_mut(Axis(1)).flat_map(|col| col.into_iter())), } } } -impl MutArrayView2 for ArrayViewMut<'_, T, Ix2> {} - +// ArrayViewMut satisfies both ArrayView2 (read) and MutArrayView2 (read+write), +// which is exactly what slice_mut's return type Box> requires. impl ArrayView2 for ArrayViewMut<'_, T, Ix2> {} +impl MutArrayView2 for ArrayViewMut<'_, T, Ix2> {} #[cfg(test)] mod tests { use super::*; - use ndarray::{arr2, Array2 as NDArray2}; + use ndarray::arr2; #[test] - fn test_get_set() { - let mut a = arr2(&[[1, 2, 3], [4, 5, 6]]); + fn test_dense_matrix_from_ndarray2() { + let input = arr2(&[[1, 2, 3], [4, 5, 6]]); + let matrix = DenseMatrix::from_ndarray2(&input); + let expected = DenseMatrix::from_2d_array(&[&[1, 2, 3], &[4, 5, 6]]).unwrap(); + assert_eq!(matrix, expected); + + let transposed = input.reversed_axes(); + let matrix = DenseMatrix::from_ndarray2(&transposed); + let expected = DenseMatrix::from_2d_array(&[&[1, 4], &[2, 5], &[3, 6]]).unwrap(); + assert_eq!(matrix, expected); + } - assert_eq!(*BaseArray::get(&a, (1, 1)), 5); - a.set((1, 1), 9); - assert_eq!(a, arr2(&[[1, 2, 3], [4, 9, 6]])); + #[test] + fn test_dense_matrix_from_ndarray2_square() { + let input = arr2(&[[1, 2], [3, 4]]); + let matrix = DenseMatrix::from_ndarray2(&input); + let expected = DenseMatrix::from_2d_array(&[&[1, 2], &[3, 4]]).unwrap(); + assert_eq!(matrix, expected); } #[test] - fn test_iterator() { - let a = arr2(&[[1, 2, 3], [4, 5, 6]]); + fn test_dense_matrix_from_ndarray2_row_vector() { + let input = arr2(&[[10, 20, 30, 40]]); + let matrix = DenseMatrix::from_ndarray2(&input); + let expected = DenseMatrix::from_2d_array(&[&[10, 20, 30, 40]]).unwrap(); + assert_eq!(matrix, expected); + assert_eq!(matrix.shape(), (1, 4)); + } - let v: Vec = a.iterator(0).copied().collect(); - assert_eq!(v, vec!(1, 2, 3, 4, 5, 6)); + #[test] + fn test_dense_matrix_from_ndarray2_col_vector() { + let input = arr2(&[[10], [20], [30], [40]]); + let matrix = DenseMatrix::from_ndarray2(&input); + let expected = DenseMatrix::from_2d_array(&[&[10], &[20], &[30], &[40]]).unwrap(); + assert_eq!(matrix, expected); + assert_eq!(matrix.shape(), (4, 1)); } #[test] - fn test_mut_iterator() { - let mut a = arr2(&[[1, 2, 3], [4, 5, 6]]); + fn test_dense_matrix_from_ndarray2_empty() { + let input = ndarray::Array2::::zeros((0, 0)); + let matrix = DenseMatrix::from_ndarray2(&input); + assert!(matrix.is_empty()); + assert_eq!(matrix.shape(), (0, 0)); + } - a.iterator_mut(0).enumerate().for_each(|(i, v)| *v = i); - assert_eq!(a, arr2(&[[0, 1, 2], [3, 4, 5]])); - a.iterator_mut(1).enumerate().for_each(|(i, v)| *v = i); - assert_eq!(a, arr2(&[[0, 2, 4], [1, 3, 5]])); + #[test] + fn test_get_row() { + let m = arr2(&[[1, 2, 3], [4, 5, 6]]); + let row = Array2::get_row(&m, 1); + assert_eq!(row.shape(), 3); + assert_eq!(*row.get(0), 4); + assert_eq!(*row.get(1), 5); + assert_eq!(*row.get(2), 6); } #[test] - fn test_slice() { - let x = arr2(&[[1, 2, 3], [4, 5, 6]]); - let x_slice = Array2::slice(&x, 0..2, 1..2); - assert_eq!((2, 1), x_slice.shape()); - let v: Vec = x_slice.iterator(0).copied().collect(); - assert_eq!(v, [2, 5]); + fn test_get_col() { + let m = arr2(&[[1, 2, 3], [4, 5, 6]]); + let col = Array2::get_col(&m, 1); + assert_eq!(col.shape(), 2); + assert_eq!(*col.get(0), 2); + assert_eq!(*col.get(1), 5); } #[test] - fn test_slice_iter() { - let x = arr2(&[[1, 2, 3], [4, 5, 6]]); - let x_slice = Array2::slice(&x, 0..2, 0..3); - assert_eq!( - x_slice.iterator(0).copied().collect::>(), - vec![1, 2, 3, 4, 5, 6] - ); - assert_eq!( - x_slice.iterator(1).copied().collect::>(), - vec![1, 4, 2, 5, 3, 6] - ); + fn test_slice() { + let m = arr2(&[[1, 2, 3], [4, 5, 6], [7, 8, 9]]); + let view = Array2::slice(&m, 1..3, 0..2); + assert_eq!(view.shape(), (2, 2)); + assert_eq!(*view.get((0, 0)), 4); + assert_eq!(*view.get((0, 1)), 5); + assert_eq!(*view.get((1, 0)), 7); + assert_eq!(*view.get((1, 1)), 8); } #[test] - fn test_slice_mut_iter() { - let mut x = arr2(&[[1, 2, 3], [4, 5, 6]]); - { - let mut x_slice = Array2::slice_mut(&mut x, 0..2, 0..3); - x_slice - .iterator_mut(0) - .enumerate() - .for_each(|(i, v)| *v = i); - } - assert_eq!(x, arr2(&[[0, 1, 2], [3, 4, 5]])); + fn test_slice_mut() { + let mut m = arr2(&[[1, 2, 3], [4, 5, 6], [7, 8, 9]]); { - let mut x_slice = Array2::slice_mut(&mut x, 0..2, 0..3); - x_slice - .iterator_mut(1) - .enumerate() - .for_each(|(i, v)| *v = i); + let mut view = Array2::slice_mut(&mut m, 1..3, 0..2); + view.set((0, 0), 40); + view.set((1, 1), 80); } - assert_eq!(x, arr2(&[[0, 2, 4], [1, 3, 5]])); + assert_eq!(m, arr2(&[[1, 2, 3], [40, 5, 6], [7, 80, 9]])); } #[test] - fn test_c_from_iterator() { - let data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; - let a: NDArray2 = Array2::from_iterator(data.clone().into_iter(), 4, 3, 0); - println!("{a}"); - let a: NDArray2 = Array2::from_iterator(data.into_iter(), 4, 3, 1); - println!("{a}"); + fn test_is_empty() { + let empty = ndarray::Array2::::from_shape_simple_fn((0, 0), || unreachable!()); + let non_empty = arr2(&[[1, 2], [3, 4]]); + assert!(BaseArray::is_empty(&empty)); + assert!(!BaseArray::is_empty(&non_empty)); } } diff --git a/src/linalg/ndarray/vector.rs b/src/linalg/ndarray/vector.rs index de3f7d93..1758fb14 100644 --- a/src/linalg/ndarray/vector.rs +++ b/src/linalg/ndarray/vector.rs @@ -5,7 +5,7 @@ use crate::linalg::basic::arrays::{ Array as BaseArray, Array1, ArrayView1, MutArray, MutArrayView1, }; -use ndarray::{s, Array, ArrayBase, ArrayView, ArrayViewMut, Ix1, OwnedRepr}; +use ndarray::{Array, ArrayBase, ArrayView, ArrayViewMut, Ix1, OwnedRepr, s}; impl BaseArray for ArrayBase, Ix1> { fn get(&self, i: usize) -> &T { @@ -17,7 +17,7 @@ impl BaseArray for ArrayBase bool { - self.len() > 0 + self.len() == 0 } fn iterator<'b>(&'b self, axis: u8) -> Box + 'b> { @@ -51,7 +51,7 @@ impl BaseArray for ArrayView<'_, T, } fn is_empty(&self) -> bool { - self.len() > 0 + self.len() == 0 } fn iterator<'b>(&'b self, axis: u8) -> Box + 'b> { @@ -72,7 +72,7 @@ impl BaseArray for ArrayViewMut<'_, } fn is_empty(&self) -> bool { - self.len() > 0 + self.len() == 0 } fn iterator<'b>(&'b self, axis: u8) -> Box + 'b> { @@ -102,7 +102,7 @@ impl Array1 for ArrayBase, Ix "`range` should be <= {}", self.len() ); - Box::new(self.slice(s![range])) + Box::new(self.view().slice_move(s![range])) } fn slice_mut<'b>(&'b mut self, range: Range) -> Box + 'b> { @@ -111,7 +111,7 @@ impl Array1 for ArrayBase, Ix "`range` should be <= {}", self.len() ); - Box::new(self.slice_mut(s![range])) + Box::new(self.view_mut().slice_move(s![range])) } fn fill(len: usize, value: T) -> Self { @@ -181,4 +181,12 @@ mod tests { assert_eq!(9, *x_slice.get(0)); assert_eq!(4, *x_slice.get(1)); } + + #[test] + fn test_is_empty() { + let empty: ndarray::Array1 = ndarray::Array1::from_vec(vec![]); + let non_empty = arr1(&[1, 2, 3]); + assert!(BaseArray::is_empty(&empty)); + assert!(!BaseArray::is_empty(&non_empty)); + } } diff --git a/src/linalg/traits/cholesky.rs b/src/linalg/traits/cholesky.rs index baec8f87..87271163 100644 --- a/src/linalg/traits/cholesky.rs +++ b/src/linalg/traits/cholesky.rs @@ -28,7 +28,7 @@ //! //! //! -#![allow(non_snake_case)] +#![expect(non_snake_case)] use std::fmt::Debug; use std::marker::PhantomData; diff --git a/src/linalg/traits/evd.rs b/src/linalg/traits/evd.rs index 3bb382a0..6b86491d 100644 --- a/src/linalg/traits/evd.rs +++ b/src/linalg/traits/evd.rs @@ -32,7 +32,7 @@ //! //! //! -#![allow(non_snake_case)] +#![expect(non_snake_case)] use crate::error::Failed; use crate::linalg::basic::arrays::Array2; diff --git a/src/linalg/traits/high_order.rs b/src/linalg/traits/high_order.rs index d3466e20..73a9d950 100644 --- a/src/linalg/traits/high_order.rs +++ b/src/linalg/traits/high_order.rs @@ -28,6 +28,104 @@ pub trait HighOrderOperations: Array2 { } } +#[cfg(test)] mod tests { - /* TODO: Add tests */ + use super::*; + use crate::linalg::basic::matrix::DenseMatrix; + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn ab_false_false() { + // a * b + let a = DenseMatrix::from_2d_array(&[&[1., 2.], &[3., 4.]]).unwrap(); + let b = DenseMatrix::from_2d_array(&[&[5., 6.], &[7., 8.]]).unwrap(); + let expected = DenseMatrix::from_2d_array(&[&[19., 22.], &[43., 50.]]).unwrap(); + assert_eq!(a.ab(false, &b, false), expected); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn ab_false_true() { + // a * b^T + let a = DenseMatrix::from_2d_array(&[&[1., 2.], &[3., 4.]]).unwrap(); + let b = DenseMatrix::from_2d_array(&[&[5., 6.], &[7., 8.]]).unwrap(); + let expected = DenseMatrix::from_2d_array(&[&[17., 23.], &[39., 53.]]).unwrap(); + assert_eq!(a.ab(false, &b, true), expected); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn ab_true_false() { + // a^T * b + let a = DenseMatrix::from_2d_array(&[&[1., 2.], &[3., 4.]]).unwrap(); + let b = DenseMatrix::from_2d_array(&[&[5., 6.], &[7., 8.]]).unwrap(); + let expected = DenseMatrix::from_2d_array(&[&[26., 30.], &[38., 44.]]).unwrap(); + assert_eq!(a.ab(true, &b, false), expected); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn ab_true_true() { + // ab(true, true) = A^T · B^T = (B·A)^T (by the identity (AB)^T = B^T A^T) + let a = DenseMatrix::from_2d_array(&[&[1., 2.], &[3., 4.]]).unwrap(); + let b = DenseMatrix::from_2d_array(&[&[5., 6.], &[7., 8.]]).unwrap(); + let expected = DenseMatrix::from_2d_array(&[&[23., 31.], &[34., 46.]]).unwrap(); + assert_eq!(a.ab(true, &b, true), expected); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn ab_nonsquare_true_false() { + // a^T * b with a, b both 3x2 -> result 2x2 (matches the doc-test example) + let a = DenseMatrix::from_2d_array(&[&[1., 2.], &[3., 4.], &[5., 6.]]).unwrap(); + let b = DenseMatrix::from_2d_array(&[&[5., 6.], &[7., 8.], &[9., 10.]]).unwrap(); + let expected = DenseMatrix::from_2d_array(&[&[71., 80.], &[92., 104.]]).unwrap(); + assert_eq!(a.ab(true, &b, false), expected); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn ab_nonsquare_false_true() { + // a * b^T with a, b both 3x2 -> result 3x3 + let a = DenseMatrix::from_2d_array(&[&[1., 2.], &[3., 4.], &[5., 6.]]).unwrap(); + let b = DenseMatrix::from_2d_array(&[&[5., 6.], &[7., 8.], &[9., 10.]]).unwrap(); + let expected = + DenseMatrix::from_2d_array(&[&[17., 23., 29.], &[39., 53., 67.], &[61., 83., 105.]]) + .unwrap(); + assert_eq!(a.ab(false, &b, true), expected); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn ab_matches_direct_matmul_and_transpose() { + // ab(false,false) must equal a.matmul(b); ab(false,true) must equal a.matmul(&b.transpose()) + let a = DenseMatrix::from_2d_array(&[&[2., 0.], &[1., 3.]]).unwrap(); + let b = DenseMatrix::from_2d_array(&[&[1., 2.], &[3., 4.]]).unwrap(); + + assert_eq!(a.ab(false, &b, false), a.matmul(&b)); + assert_eq!(a.ab(false, &b, true), a.matmul(&b.transpose())); + assert_eq!(a.ab(true, &b, false), a.transpose().matmul(&b)); + assert_eq!(a.ab(true, &b, true), b.matmul(&a).transpose()); + } } diff --git a/src/linalg/traits/lu.rs b/src/linalg/traits/lu.rs index 7a1d0439..5833b315 100644 --- a/src/linalg/traits/lu.rs +++ b/src/linalg/traits/lu.rs @@ -31,7 +31,7 @@ //! //! //! -#![allow(non_snake_case)] +#![expect(non_snake_case)] use std::cmp::Ordering; use std::fmt::Debug; @@ -46,7 +46,7 @@ use crate::numbers::realnum::RealNumber; pub struct LU> { LU: M, pivot: Vec, - #[allow(dead_code)] + #[expect(dead_code)] pivot_sign: i8, singular: bool, phantom: PhantomData, diff --git a/src/linalg/traits/qr.rs b/src/linalg/traits/qr.rs index 2c70efcb..4a1d2eed 100644 --- a/src/linalg/traits/qr.rs +++ b/src/linalg/traits/qr.rs @@ -26,7 +26,7 @@ //! //! //! -#![allow(non_snake_case)] +#![expect(non_snake_case)] use std::fmt::Debug; diff --git a/src/linalg/traits/svd.rs b/src/linalg/traits/svd.rs index cee33a0e..8e2d6d8b 100644 --- a/src/linalg/traits/svd.rs +++ b/src/linalg/traits/svd.rs @@ -31,7 +31,7 @@ //! //! //! -#![allow(non_snake_case)] +#![expect(non_snake_case)] use crate::error::Failed; use crate::linalg::basic::arrays::Array2; diff --git a/src/linear/bg_solver.rs b/src/linear/bg_solver.rs index 2c466b13..990a11fe 100644 --- a/src/linear/bg_solver.rs +++ b/src/linear/bg_solver.rs @@ -170,10 +170,11 @@ mod tests { let err: f64 = solver.solve_mut(&a, &b, &mut x, 1e-6, 6).unwrap(); - assert!(x - .iter() - .zip(expected.iter()) - .all(|(&a, &b)| (a - b).abs() < 1e-4)); + assert!( + x.iter() + .zip(expected.iter()) + .all(|(&a, &b)| (a - b).abs() < 1e-4) + ); assert!((err - 0.0).abs() < 1e-4); } } diff --git a/src/linear/elastic_net.rs b/src/linear/elastic_net.rs index d5b1d4d5..d27b09da 100644 --- a/src/linear/elastic_net.rs +++ b/src/linear/elastic_net.rs @@ -1,4 +1,4 @@ -#![allow(clippy::needless_range_loop)] +#![expect(clippy::needless_range_loop)] //! # Elastic Net //! //! Elastic net is an extension of [linear regression](../linear_regression/index.html) that adds regularization penalties to the loss function during training. @@ -609,40 +609,43 @@ mod tests { assert!(l1_model.coefficients().get((0, 0)) > l1_model.coefficients().get((2, 0))); } - // TODO: serialization for the new DenseMatrix needs to be implemented - // #[cfg_attr(all(target_arch = "wasm32", not(target_os = "wasi")), wasm_bindgen_test::wasm_bindgen_test)] - // #[test] - // #[cfg(feature = "serde")] - // fn serde() { - // let x = DenseMatrix::from_2d_array(&[ - // &[234.289, 235.6, 159.0, 107.608, 1947., 60.323], - // &[259.426, 232.5, 145.6, 108.632, 1948., 61.122], - // &[258.054, 368.2, 161.6, 109.773, 1949., 60.171], - // &[284.599, 335.1, 165.0, 110.929, 1950., 61.187], - // &[328.975, 209.9, 309.9, 112.075, 1951., 63.221], - // &[346.999, 193.2, 359.4, 113.270, 1952., 63.639], - // &[365.385, 187.0, 354.7, 115.094, 1953., 64.989], - // &[363.112, 357.8, 335.0, 116.219, 1954., 63.761], - // &[397.469, 290.4, 304.8, 117.388, 1955., 66.019], - // &[419.180, 282.2, 285.7, 118.734, 1956., 67.857], - // &[442.769, 293.6, 279.8, 120.445, 1957., 68.169], - // &[444.546, 468.1, 263.7, 121.950, 1958., 66.513], - // &[482.704, 381.3, 255.2, 123.366, 1959., 68.655], - // &[502.601, 393.1, 251.4, 125.368, 1960., 69.564], - // &[518.173, 480.6, 257.2, 127.852, 1961., 69.331], - // &[554.894, 400.7, 282.7, 130.081, 1962., 70.551], - // ]).unwrap(); - - // let y = vec![ - // 83.0, 88.5, 88.2, 89.5, 96.2, 98.1, 99.0, 100.0, 101.2, 104.6, 108.4, 110.8, 112.6, - // 114.2, 115.7, 116.9, - // ]; - - // let lr = ElasticNet::fit(&x, &y, Default::default()).unwrap(); - - // let deserialized_lr: ElasticNet, Vec> = - // serde_json::from_str(&serde_json::to_string(&lr).unwrap()).unwrap(); - - // assert_eq!(lr, deserialized_lr); - // } + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + #[cfg(feature = "serde")] + fn serde() { + let x = DenseMatrix::from_2d_array(&[ + &[234.289, 235.6, 159.0, 107.608, 1947., 60.323], + &[259.426, 232.5, 145.6, 108.632, 1948., 61.122], + &[258.054, 368.2, 161.6, 109.773, 1949., 60.171], + &[284.599, 335.1, 165.0, 110.929, 1950., 61.187], + &[328.975, 209.9, 309.9, 112.075, 1951., 63.221], + &[346.999, 193.2, 359.4, 113.270, 1952., 63.639], + &[365.385, 187.0, 354.7, 115.094, 1953., 64.989], + &[363.112, 357.8, 335.0, 116.219, 1954., 63.761], + &[397.469, 290.4, 304.8, 117.388, 1955., 66.019], + &[419.180, 282.2, 285.7, 118.734, 1956., 67.857], + &[442.769, 293.6, 279.8, 120.445, 1957., 68.169], + &[444.546, 468.1, 263.7, 121.950, 1958., 66.513], + &[482.704, 381.3, 255.2, 123.366, 1959., 68.655], + &[502.601, 393.1, 251.4, 125.368, 1960., 69.564], + &[518.173, 480.6, 257.2, 127.852, 1961., 69.331], + &[554.894, 400.7, 282.7, 130.081, 1962., 70.551], + ]) + .unwrap(); + + let y = vec![ + 83.0, 88.5, 88.2, 89.5, 96.2, 98.1, 99.0, 100.0, 101.2, 104.6, 108.4, 110.8, 112.6, + 114.2, 115.7, 116.9, + ]; + + let lr = ElasticNet::fit(&x, &y, Default::default()).unwrap(); + + let deserialized_lr: ElasticNet, Vec> = + postcard::from_bytes(&postcard::to_allocvec(&lr).unwrap()).unwrap(); + + assert_eq!(lr, deserialized_lr); + } } diff --git a/src/linear/lasso.rs b/src/linear/lasso.rs index 62d96efa..45b50aeb 100644 --- a/src/linear/lasso.rs +++ b/src/linear/lasso.rs @@ -166,7 +166,7 @@ pub struct LassoSearchParameters { /// The maximum number of iterations pub max_iter: Vec, #[cfg_attr(feature = "serde", serde(default))] - /// The maximum number of iterations + /// If false, force the intercept parameter (beta_0) to be zero. pub fit_intercept: Vec, } @@ -561,17 +561,19 @@ mod tests { assert_eq!(fit_result.intercept, None); } - // TODO: serialization for the new DenseMatrix needs to be implemented - // #[cfg_attr(all(target_arch = "wasm32", not(target_os = "wasi")), wasm_bindgen_test::wasm_bindgen_test)] - // #[test] - // #[cfg(feature = "serde")] - // fn serde() { - // let (x, y) = get_lasso_sample_x_y(); - // let lr = Lasso::fit(&x, &y, Default::default()).unwrap(); + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + #[cfg(feature = "serde")] + fn serde() { + let (x, y) = get_example_x_y(); + let lr = Lasso::fit(&x, &y, Default::default()).unwrap(); - // let deserialized_lr: Lasso, Vec> = - // serde_json::from_str(&serde_json::to_string(&lr).unwrap()).unwrap(); + let deserialized_lr: Lasso, Vec> = + postcard::from_bytes(&postcard::to_allocvec(&lr).unwrap()).unwrap(); - // assert_eq!(lr, deserialized_lr); - // } + assert_eq!(lr, deserialized_lr); + } } diff --git a/src/linear/lasso_optimizer.rs b/src/linear/lasso_optimizer.rs index fe099cd3..5969347a 100644 --- a/src/linear/lasso_optimizer.rs +++ b/src/linear/lasso_optimizer.rs @@ -53,6 +53,7 @@ impl> InteriorPointOptimizer { let lambda = lambda.max(T::epsilon()); //parameters + let max_ls_iter = 100; let pcgmaxi = 5000; let min_pcgtol = T::from_f64(0.1).unwrap(); let eta = T::from_f64(1E-3).unwrap(); @@ -68,7 +69,6 @@ impl> InteriorPointOptimizer { y.to_owned() }; - let mut max_ls_iter = 100; let mut pitr = 0; let mut w = Vec::zeros(p); let mut neww = w.clone(); @@ -170,7 +170,7 @@ impl> InteriorPointOptimizer { s = T::one(); let gdx = grad.dot(&dxu); - let lsiter = 0; + let mut lsiter = 0; while lsiter < max_ls_iter { for i in 0..p { neww[i] = w[i] + s * dx[i]; @@ -195,7 +195,7 @@ impl> InteriorPointOptimizer { } } s = beta * s; - max_ls_iter += 1; + lsiter += 1; } if lsiter == max_ls_iter { @@ -250,3 +250,51 @@ impl<'a, T: FloatNumber, X: Array2> BiconjugateGradientSolver<'a, T, X> self.mat_vec_mul(a, x, y); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::linalg::basic::arrays::Array; + use crate::linalg::basic::matrix::DenseMatrix; + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn new_builds_ata_with_correct_shape() { + // a is 4x2 -> ata = a^T a is 2x2 + let a = DenseMatrix::from_2d_array(&[&[1., 2.], &[3., 4.], &[5., 6.], &[7., 8.]]).unwrap(); + let opt: InteriorPointOptimizer> = InteriorPointOptimizer::new(&a, 2); + assert_eq!(opt.ata.shape(), (2, 2)); + // ata[0][0] = sum of column 0 squared = 1+9+25+49 = 84 + assert!((opt.ata.get((0, 0)) - 84.0).abs() < 1e-10); + // ata[1][1] = sum of column 1 squared = 4+16+36+64 = 120 + assert!((opt.ata.get((1, 1)) - 120.0).abs() < 1e-10); + // internal scratch vectors sized to p=2 + assert_eq!(opt.d1.len(), 2); + assert_eq!(opt.d2.len(), 2); + assert_eq!(opt.prb.len(), 2); + assert_eq!(opt.prs.len(), 2); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn optimize_with_zero_lambda_recovers_least_squares() { + // y = 2*x0 + 3*x1 (exact, no noise). With lambda -> 0 (clamped to epsilon + // internally) the l1-regularized LS solution should approach [2, 3]. + let x = DenseMatrix::from_2d_array(&[&[1., 0.], &[0., 1.], &[1., 1.], &[2., 1.]]).unwrap(); + let y = vec![2.0, 3.0, 5.0, 7.0]; + + let mut opt: InteriorPointOptimizer> = + InteriorPointOptimizer::new(&x, 2); + let w = opt.optimize(&x, &y, 1e-10, 100, 1e-8, false).unwrap(); + + assert_eq!(w.len(), 2); + assert!((w[0] - 2.0).abs() < 1e-3, "w[0]={} expected ~2.0", w[0]); + assert!((w[1] - 3.0).abs() < 1e-3, "w[1]={} expected ~3.0", w[1]); + } +} diff --git a/src/linear/linear_regression.rs b/src/linear/linear_regression.rs index 43410bbb..2af2c109 100644 --- a/src/linear/linear_regression.rs +++ b/src/linear/linear_regression.rs @@ -181,11 +181,11 @@ impl Default for LinearRegressionSearchParameters { } impl< - TX: Number + RealNumber, - TY: Number, - X: Array2 + QRDecomposable + SVDDecomposable, - Y: Array1, - > PartialEq for LinearRegression + TX: Number + RealNumber, + TY: Number, + X: Array2 + QRDecomposable + SVDDecomposable, + Y: Array1, +> PartialEq for LinearRegression { fn eq(&self, other: &Self) -> bool { self.intercept == other.intercept @@ -199,11 +199,11 @@ impl< } impl< - TX: Number + RealNumber, - TY: Number, - X: Array2 + QRDecomposable + SVDDecomposable, - Y: Array1, - > SupervisedEstimator for LinearRegression + TX: Number + RealNumber, + TY: Number, + X: Array2 + QRDecomposable + SVDDecomposable, + Y: Array1, +> SupervisedEstimator for LinearRegression { fn new() -> Self { Self { @@ -220,11 +220,11 @@ impl< } impl< - TX: Number + RealNumber, - TY: Number, - X: Array2 + QRDecomposable + SVDDecomposable, - Y: Array1, - > Predictor for LinearRegression + TX: Number + RealNumber, + TY: Number, + X: Array2 + QRDecomposable + SVDDecomposable, + Y: Array1, +> Predictor for LinearRegression { fn predict(&self, x: &X) -> Result { self.predict(x) @@ -232,11 +232,11 @@ impl< } impl< - TX: Number + RealNumber, - TY: Number, - X: Array2 + QRDecomposable + SVDDecomposable, - Y: Array1, - > LinearRegression + TX: Number + RealNumber, + TY: Number, + X: Array2 + QRDecomposable + SVDDecomposable, + Y: Array1, +> LinearRegression { /// Fits Linear Regression to your data. /// * `x` - _NxM_ matrix with _N_ observations and _M_ features in each observation. @@ -362,54 +362,55 @@ mod tests { .and_then(|lr| lr.predict(&x)) .unwrap(); - assert!(y - .iter() - .zip(y_hat_qr.iter()) - .all(|(&a, &b)| (a - b).abs() <= 5.0)); - assert!(y - .iter() - .zip(y_hat_svd.iter()) - .all(|(&a, &b)| (a - b).abs() <= 5.0)); + assert!( + y.iter() + .zip(y_hat_qr.iter()) + .all(|(&a, &b)| (a - b).abs() <= 5.0) + ); + assert!( + y.iter() + .zip(y_hat_svd.iter()) + .all(|(&a, &b)| (a - b).abs() <= 5.0) + ); } - // TODO: serialization for the new DenseMatrix needs to be implemented - // #[cfg_attr(all(target_arch = "wasm32", not(target_os = "wasi")), wasm_bindgen_test::wasm_bindgen_test)] - // #[test] - // #[cfg(feature = "serde")] - // fn serde() { - // let x = DenseMatrix::from_2d_array(&[ - // &[234.289, 235.6, 159.0, 107.608, 1947., 60.323], - // &[259.426, 232.5, 145.6, 108.632, 1948., 61.122], - // &[258.054, 368.2, 161.6, 109.773, 1949., 60.171], - // &[284.599, 335.1, 165.0, 110.929, 1950., 61.187], - // &[328.975, 209.9, 309.9, 112.075, 1951., 63.221], - // &[346.999, 193.2, 359.4, 113.270, 1952., 63.639], - // &[365.385, 187.0, 354.7, 115.094, 1953., 64.989], - // &[363.112, 357.8, 335.0, 116.219, 1954., 63.761], - // &[397.469, 290.4, 304.8, 117.388, 1955., 66.019], - // &[419.180, 282.2, 285.7, 118.734, 1956., 67.857], - // &[442.769, 293.6, 279.8, 120.445, 1957., 68.169], - // &[444.546, 468.1, 263.7, 121.950, 1958., 66.513], - // &[482.704, 381.3, 255.2, 123.366, 1959., 68.655], - // &[502.601, 393.1, 251.4, 125.368, 1960., 69.564], - // &[518.173, 480.6, 257.2, 127.852, 1961., 69.331], - // &[554.894, 400.7, 282.7, 130.081, 1962., 70.551], - // ]).unwrap(); - - // let y = vec![ - // 83.0, 88.5, 88.2, 89.5, 96.2, 98.1, 99.0, 100.0, 101.2, 104.6, 108.4, 110.8, 112.6, - // 114.2, 115.7, 116.9, - // ]; - - // let lr = LinearRegression::fit(&x, &y, Default::default()).unwrap(); - - // let deserialized_lr: LinearRegression, Vec> = - // serde_json::from_str(&serde_json::to_string(&lr).unwrap()).unwrap(); - - // assert_eq!(lr, deserialized_lr); - - // let default = LinearRegressionParameters::default(); - // let parameters: LinearRegressionParameters = serde_json::from_str("{}").unwrap(); - // assert_eq!(parameters.solver, default.solver); - // } + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + #[cfg(feature = "serde")] + fn serde() { + let x = DenseMatrix::from_2d_array(&[ + &[234.289, 235.6, 159.0, 107.608, 1947., 60.323], + &[259.426, 232.5, 145.6, 108.632, 1948., 61.122], + &[258.054, 368.2, 161.6, 109.773, 1949., 60.171], + &[284.599, 335.1, 165.0, 110.929, 1950., 61.187], + &[328.975, 209.9, 309.9, 112.075, 1951., 63.221], + &[346.999, 193.2, 359.4, 113.270, 1952., 63.639], + &[365.385, 187.0, 354.7, 115.094, 1953., 64.989], + &[363.112, 357.8, 335.0, 116.219, 1954., 63.761], + &[397.469, 290.4, 304.8, 117.388, 1955., 66.019], + &[419.180, 282.2, 285.7, 118.734, 1956., 67.857], + &[442.769, 293.6, 279.8, 120.445, 1957., 68.169], + &[444.546, 468.1, 263.7, 121.950, 1958., 66.513], + &[482.704, 381.3, 255.2, 123.366, 1959., 68.655], + &[502.601, 393.1, 251.4, 125.368, 1960., 69.564], + &[518.173, 480.6, 257.2, 127.852, 1961., 69.331], + &[554.894, 400.7, 282.7, 130.081, 1962., 70.551], + ]) + .unwrap(); + + let y = vec![ + 83.0, 88.5, 88.2, 89.5, 96.2, 98.1, 99.0, 100.0, 101.2, 104.6, 108.4, 110.8, 112.6, + 114.2, 115.7, 116.9, + ]; + + let lr = LinearRegression::fit(&x, &y, Default::default()).unwrap(); + + let deserialized_lr: LinearRegression, Vec> = + postcard::from_bytes(&postcard::to_allocvec(&lr).unwrap()).unwrap(); + + assert_eq!(lr, deserialized_lr); + } } diff --git a/src/linear/logistic_regression.rs b/src/linear/logistic_regression.rs index c28dc347..dd2d9062 100644 --- a/src/linear/logistic_regression.rs +++ b/src/linear/logistic_regression.rs @@ -65,10 +65,10 @@ use crate::linalg::basic::arrays::{Array1, Array2, MutArrayView1}; use crate::numbers::basenum::Number; use crate::numbers::floatnum::FloatNumber; use crate::numbers::realnum::RealNumber; +use crate::optimization::FunctionOrder; use crate::optimization::first_order::lbfgs::LBFGS; use crate::optimization::first_order::{FirstOrderOptimizer, OptimizerResult}; use crate::optimization::line_search::Backtracking; -use crate::optimization::FunctionOrder; #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[derive(Debug, Clone, Eq, PartialEq, Default)] @@ -185,7 +185,7 @@ pub struct LogisticRegression< trait ObjectiveFunction> { fn f(&self, w_bias: &[T]) -> T; - #[allow(clippy::ptr_arg)] + #[expect(clippy::ptr_arg)] fn df(&self, g: &mut Vec, w_bias: &Vec); #[allow(clippy::ptr_arg)] @@ -566,8 +566,6 @@ impl, Y: mod tests { use super::*; - #[cfg(feature = "datasets")] - use crate::dataset::generator::make_blobs; use crate::linalg::basic::arrays::Array; use crate::linalg::basic::matrix::DenseMatrix; @@ -753,69 +751,118 @@ mod tests { assert_eq!(y_hat, vec![0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]); } - #[cfg(feature = "datasets")] + /// Deterministic 3-class fixture: 5 samples per class, clearly separated in 2D. + /// Replaces the former `make_blobs`-based test which produced different RNG + /// outputs on native vs wasm32 targets, causing flaky exact-vector assertions. #[cfg_attr( all(target_arch = "wasm32", not(target_os = "wasi")), wasm_bindgen_test::wasm_bindgen_test )] #[test] fn lr_fit_predict_multiclass() { - let blobs = make_blobs(15, 4, 3); - - let x: DenseMatrix = DenseMatrix::from_iterator(blobs.data.into_iter(), 15, 4, 0); - let y: Vec = blobs.target.into_iter().map(|v| v as i32).collect(); + // Three well-separated clusters: class 0 near (0,0), 1 near (10,0), 2 near (5,10) + let x: DenseMatrix = DenseMatrix::from_2d_array(&[ + &[0.0_f32, 0.0], + &[0.5, 0.2], + &[0.2, 0.5], + &[-0.3, 0.1], + &[0.1, -0.4], + &[10.0, 0.0], + &[10.5, 0.2], + &[9.8, 0.5], + &[10.2, -0.3], + &[9.7, 0.1], + &[5.0, 10.0], + &[5.2, 10.5], + &[4.8, 9.8], + &[5.1, 10.2], + &[4.9, 9.7], + ]) + .unwrap(); + let y: Vec = vec![0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2]; let lr = LogisticRegression::fit(&x, &y, Default::default()).unwrap(); - let y_hat = lr.predict(&x).unwrap(); - assert_eq!(y_hat, vec![0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2]); + let acc = + y_hat.iter().zip(y.iter()).filter(|(p, a)| p == a).count() as f64 / y.len() as f64; + assert!( + acc >= 0.80, + "lr_fit_predict_multiclass accuracy too low: {acc:.3}" + ); + // Regularisation should shrink coefficients let lr_reg = LogisticRegression::fit( &x, &y, LogisticRegressionParameters::default().with_alpha(10.0), ) .unwrap(); - let reg_coeff_sum: f32 = lr_reg.coefficients().abs().iter().sum(); - let coeff: f32 = lr.coefficients().abs().iter().sum(); - - assert!(reg_coeff_sum < coeff); + let coeff_sum: f32 = lr.coefficients().abs().iter().sum(); + assert!( + reg_coeff_sum < coeff_sum, + "regularisation did not shrink coefficients: {reg_coeff_sum} >= {coeff_sum}" + ); } - #[cfg(feature = "datasets")] + /// Deterministic binary fixture: 10 samples per class, clearly separated. + /// Replaces the former `make_blobs`-based test for the same RNG-portability reason. #[cfg_attr( all(target_arch = "wasm32", not(target_os = "wasi")), wasm_bindgen_test::wasm_bindgen_test )] #[test] fn lr_fit_predict_binary() { - let blobs = make_blobs(20, 4, 2); - - let x = DenseMatrix::from_iterator(blobs.data.into_iter(), 20, 4, 0); - let y: Vec = blobs.target.into_iter().map(|v| v as i32).collect(); + // Class 0 near (0,0), class 1 near (20,0) — easily linearly separable + let x: DenseMatrix = DenseMatrix::from_2d_array(&[ + &[0.0_f32, 0.0], + &[0.5, 0.2], + &[0.2, 0.5], + &[-0.3, 0.1], + &[0.1, -0.4], + &[-0.2, 0.3], + &[0.4, -0.1], + &[-0.1, -0.3], + &[0.3, 0.4], + &[-0.4, -0.2], + &[20.0, 0.0], + &[20.5, 0.2], + &[19.8, 0.5], + &[20.2, -0.3], + &[19.7, 0.1], + &[20.3, -0.2], + &[19.6, 0.4], + &[20.1, -0.4], + &[19.9, 0.3], + &[20.4, -0.1], + ]) + .unwrap(); + let y: Vec = vec![0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]; let lr = LogisticRegression::fit(&x, &y, Default::default()).unwrap(); - let y_hat = lr.predict(&x).unwrap(); - assert_eq!( - y_hat, - vec![0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1] + let acc = + y_hat.iter().zip(y.iter()).filter(|(p, a)| p == a).count() as f64 / y.len() as f64; + assert!( + acc >= 0.90, + "lr_fit_predict_binary accuracy too low: {acc:.3}" ); + // Regularisation should shrink coefficients let lr_reg = LogisticRegression::fit( &x, &y, LogisticRegressionParameters::default().with_alpha(10.0), ) .unwrap(); - let reg_coeff_sum: f32 = lr_reg.coefficients().abs().iter().sum(); - let coeff: f32 = lr.coefficients().abs().iter().sum(); - - assert!(reg_coeff_sum < coeff); + let coeff_sum: f32 = lr.coefficients().abs().iter().sum(); + assert!( + reg_coeff_sum < coeff_sum, + "regularisation did not shrink coefficients: {reg_coeff_sum} >= {coeff_sum}" + ); } //TODO: serialization for the new DenseMatrix needs to be implemented diff --git a/src/linear/ridge_regression.rs b/src/linear/ridge_regression.rs index be2f3d41..e0a8d11c 100644 --- a/src/linear/ridge_regression.rs +++ b/src/linear/ridge_regression.rs @@ -225,11 +225,11 @@ impl Default for RidgeRegressionParameters { } impl< - TX: Number + RealNumber, - TY: Number, - X: Array2 + CholeskyDecomposable + SVDDecomposable, - Y: Array1, - > PartialEq for RidgeRegression + TX: Number + RealNumber, + TY: Number, + X: Array2 + CholeskyDecomposable + SVDDecomposable, + Y: Array1, +> PartialEq for RidgeRegression { fn eq(&self, other: &Self) -> bool { self.intercept() == other.intercept() @@ -243,11 +243,11 @@ impl< } impl< - TX: Number + RealNumber, - TY: Number, - X: Array2 + CholeskyDecomposable + SVDDecomposable, - Y: Array1, - > SupervisedEstimator> for RidgeRegression + TX: Number + RealNumber, + TY: Number, + X: Array2 + CholeskyDecomposable + SVDDecomposable, + Y: Array1, +> SupervisedEstimator> for RidgeRegression { fn new() -> Self { Self { @@ -264,11 +264,11 @@ impl< } impl< - TX: Number + RealNumber, - TY: Number, - X: Array2 + CholeskyDecomposable + SVDDecomposable, - Y: Array1, - > Predictor for RidgeRegression + TX: Number + RealNumber, + TY: Number, + X: Array2 + CholeskyDecomposable + SVDDecomposable, + Y: Array1, +> Predictor for RidgeRegression { fn predict(&self, x: &X) -> Result { self.predict(x) @@ -276,11 +276,11 @@ impl< } impl< - TX: Number + RealNumber, - TY: Number, - X: Array2 + CholeskyDecomposable + SVDDecomposable, - Y: Array1, - > RidgeRegression + TX: Number + RealNumber, + TY: Number, + X: Array2 + CholeskyDecomposable + SVDDecomposable, + Y: Array1, +> RidgeRegression { /// Fits ridge regression to your data. /// * `x` - _NxM_ matrix with _N_ observations and _M_ features in each observation. @@ -492,40 +492,43 @@ mod tests { assert!(mean_absolute_error(&y_hat_svd, &y) < 2.0); } - // TODO: implement serialization for new DenseMatrix - // #[cfg_attr(all(target_arch = "wasm32", not(target_os = "wasi")), wasm_bindgen_test::wasm_bindgen_test)] - // #[test] - // #[cfg(feature = "serde")] - // fn serde() { - // let x = DenseMatrix::from_2d_array(&[ - // &[234.289, 235.6, 159.0, 107.608, 1947., 60.323], - // &[259.426, 232.5, 145.6, 108.632, 1948., 61.122], - // &[258.054, 368.2, 161.6, 109.773, 1949., 60.171], - // &[284.599, 335.1, 165.0, 110.929, 1950., 61.187], - // &[328.975, 209.9, 309.9, 112.075, 1951., 63.221], - // &[346.999, 193.2, 359.4, 113.270, 1952., 63.639], - // &[365.385, 187.0, 354.7, 115.094, 1953., 64.989], - // &[363.112, 357.8, 335.0, 116.219, 1954., 63.761], - // &[397.469, 290.4, 304.8, 117.388, 1955., 66.019], - // &[419.180, 282.2, 285.7, 118.734, 1956., 67.857], - // &[442.769, 293.6, 279.8, 120.445, 1957., 68.169], - // &[444.546, 468.1, 263.7, 121.950, 1958., 66.513], - // &[482.704, 381.3, 255.2, 123.366, 1959., 68.655], - // &[502.601, 393.1, 251.4, 125.368, 1960., 69.564], - // &[518.173, 480.6, 257.2, 127.852, 1961., 69.331], - // &[554.894, 400.7, 282.7, 130.081, 1962., 70.551], - // ]).unwrap(); - - // let y = vec![ - // 83.0, 88.5, 88.2, 89.5, 96.2, 98.1, 99.0, 100.0, 101.2, 104.6, 108.4, 110.8, 112.6, - // 114.2, 115.7, 116.9, - // ]; - - // let lr = RidgeRegression::fit(&x, &y, Default::default()).unwrap(); - - // let deserialized_lr: RidgeRegression, Vec> = - // serde_json::from_str(&serde_json::to_string(&lr).unwrap()).unwrap(); - - // assert_eq!(lr, deserialized_lr); - // } + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + #[cfg(feature = "serde")] + fn serde() { + let x = DenseMatrix::from_2d_array(&[ + &[234.289, 235.6, 159.0, 107.608, 1947., 60.323], + &[259.426, 232.5, 145.6, 108.632, 1948., 61.122], + &[258.054, 368.2, 161.6, 109.773, 1949., 60.171], + &[284.599, 335.1, 165.0, 110.929, 1950., 61.187], + &[328.975, 209.9, 309.9, 112.075, 1951., 63.221], + &[346.999, 193.2, 359.4, 113.270, 1952., 63.639], + &[365.385, 187.0, 354.7, 115.094, 1953., 64.989], + &[363.112, 357.8, 335.0, 116.219, 1954., 63.761], + &[397.469, 290.4, 304.8, 117.388, 1955., 66.019], + &[419.180, 282.2, 285.7, 118.734, 1956., 67.857], + &[442.769, 293.6, 279.8, 120.445, 1957., 68.169], + &[444.546, 468.1, 263.7, 121.950, 1958., 66.513], + &[482.704, 381.3, 255.2, 123.366, 1959., 68.655], + &[502.601, 393.1, 251.4, 125.368, 1960., 69.564], + &[518.173, 480.6, 257.2, 127.852, 1961., 69.331], + &[554.894, 400.7, 282.7, 130.081, 1962., 70.551], + ]) + .unwrap(); + + let y = vec![ + 83.0, 88.5, 88.2, 89.5, 96.2, 98.1, 99.0, 100.0, 101.2, 104.6, 108.4, 110.8, 112.6, + 114.2, 115.7, 116.9, + ]; + + let lr = RidgeRegression::fit(&x, &y, Default::default()).unwrap(); + + let deserialized_lr: RidgeRegression, Vec> = + postcard::from_bytes(&postcard::to_allocvec(&lr).unwrap()).unwrap(); + + assert_eq!(lr, deserialized_lr); + } } diff --git a/src/linear/tests_edge_cases.rs b/src/linear/tests_edge_cases.rs new file mode 100644 index 00000000..7ef5172c --- /dev/null +++ b/src/linear/tests_edge_cases.rs @@ -0,0 +1,245 @@ +//! Stage 3: edge-case & known-answer parity tests for src/linear. +//! +//! Covers: linear_regression, logistic_regression, ridge_regression, lasso, elastic_net. +//! Tracking issue: #394 / #391. + +#[cfg(test)] +mod linear_edge_cases { + use crate::linalg::basic::matrix::DenseMatrix; + use crate::linear::linear_regression::{LinearRegression, LinearRegressionParameters, LinearRegressionSolverName}; + use crate::linear::ridge_regression::{RidgeRegression, RidgeRegressionParameters}; + use crate::linear::lasso::{Lasso, LassoParameters}; + use crate::linear::elastic_net::{ElasticNet, ElasticNetParameters}; + use crate::linear::logistic_regression::{LogisticRegression, LogisticRegressionParameters}; + + // ── LinearRegression ────────────────────────────────────────────────────── + + /// Single-sample: model should fit without panic and reproduce the target. + #[test] + fn linear_regression_single_sample() { + let x = DenseMatrix::from_2d_array(&[&[1.0_f64, 2.0]]).unwrap(); + let y: Vec = vec![5.0]; + let model = LinearRegression::fit(&x, &y, Default::default()); + // Under-determined with SVD: fit must succeed (not panic/error). + assert!(model.is_ok()); + } + + /// Perfect-fit (y = 2*x1 + 3*x2): coefficients should reproduce exactly. + #[test] + fn linear_regression_perfect_fit_known_answer() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 0.0], + &[0.0, 1.0], + &[1.0, 1.0], + &[2.0, 3.0], + ]).unwrap(); + let y: Vec = vec![2.0, 3.0, 5.0, 13.0]; + let model = LinearRegression::fit(&x, &y, LinearRegressionParameters { + solver: LinearRegressionSolverName::QR, + }).unwrap(); + let y_hat = model.predict(&x).unwrap(); + for (a, b) in y.iter().zip(y_hat.iter()) { + assert!((a - b).abs() < 1e-6, "expected {a}, got {b}"); + } + } + + /// QR and SVD solvers must agree on coefficients to high precision. + #[test] + fn linear_regression_solver_parity() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0], + &[3.0, 4.0], + &[5.0, 6.0], + &[7.0, 8.0], + &[9.0, 10.0], + ]).unwrap(); + let y: Vec = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let qr = LinearRegression::fit(&x, &y, LinearRegressionParameters { solver: LinearRegressionSolverName::QR }).unwrap(); + let svd = LinearRegression::fit(&x, &y, LinearRegressionParameters { solver: LinearRegressionSolverName::SVD }).unwrap(); + let y_qr = qr.predict(&x).unwrap(); + let y_svd = svd.predict(&x).unwrap(); + for (a, b) in y_qr.iter().zip(y_svd.iter()) { + assert!((a - b).abs() < 1e-6, "QR={a} SVD={b} diverge"); + } + } + + /// Collinear features: SVD fit must succeed (QR may fail; SVD is the fallback). + #[test] + fn linear_regression_collinear_features_svd() { + // x2 == 2 * x1 → rank-deficient design matrix. + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0], + &[2.0, 4.0], + &[3.0, 6.0], + &[4.0, 8.0], + ]).unwrap(); + let y: Vec = vec![1.0, 2.0, 3.0, 4.0]; + let result = LinearRegression::fit(&x, &y, LinearRegressionParameters { + solver: LinearRegressionSolverName::SVD, + }); + // Must not panic; predictions should be within a loose tolerance. + assert!(result.is_ok()); + let y_hat = result.unwrap().predict(&x).unwrap(); + for (a, b) in y.iter().zip(y_hat.iter()) { + assert!((a - b).abs() < 0.5, "collinear pred error: expected {a}, got {b}"); + } + } + + // ── RidgeRegression ─────────────────────────────────────────────────────── + + /// Ridge with alpha=0 should closely match OLS. + #[test] + fn ridge_regression_zero_alpha_matches_ols() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0], + &[3.0, 4.0], + &[5.0, 6.0], + &[7.0, 8.0], + &[9.0, 10.0], + ]).unwrap(); + let y: Vec = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let ridge = RidgeRegression::fit(&x, &y, RidgeRegressionParameters::default().with_alpha(0.0)).unwrap(); + let ols = LinearRegression::fit(&x, &y, Default::default()).unwrap(); + let y_ridge = ridge.predict(&x).unwrap(); + let y_ols = ols.predict(&x).unwrap(); + for (a, b) in y_ridge.iter().zip(y_ols.iter()) { + assert!((a - b).abs() < 1e-4, "ridge(α=0)={a} vs ols={b}"); + } + } + + /// Ridge perfect-fit known answer: y = 3*x (single feature, no intercept ambiguity). + #[test] + fn ridge_regression_known_answer() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64], + &[2.0], + &[3.0], + &[4.0], + &[5.0], + ]).unwrap(); + let y: Vec = vec![3.0, 6.0, 9.0, 12.0, 15.0]; + let model = RidgeRegression::fit(&x, &y, RidgeRegressionParameters::default().with_alpha(0.0)).unwrap(); + let y_hat = model.predict(&x).unwrap(); + for (a, b) in y.iter().zip(y_hat.iter()) { + assert!((a - b).abs() < 1e-3, "expected {a}, got {b}"); + } + } + + /// Ridge with collinear features must not panic. + #[test] + fn ridge_regression_collinear_features() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 1.0], + &[2.0, 2.0], + &[3.0, 3.0], + &[4.0, 4.0], + ]).unwrap(); + let y: Vec = vec![2.0, 4.0, 6.0, 8.0]; + let result = RidgeRegression::fit(&x, &y, RidgeRegressionParameters::default().with_alpha(1.0)); + assert!(result.is_ok()); + } + + // ── Lasso ───────────────────────────────────────────────────────────────── + + /// Lasso on perfectly collinear features: must not panic; prediction reasonable. + #[test] + fn lasso_collinear_features() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 1.0], + &[2.0, 2.0], + &[3.0, 3.0], + &[4.0, 4.0], + &[5.0, 5.0], + ]).unwrap(); + let y: Vec = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let result = Lasso::fit(&x, &y, LassoParameters::default().with_alpha(0.01)); + assert!(result.is_ok()); + } + + /// Lasso sparsity: with a high alpha most coefficients should shrink to ~0. + #[test] + fn lasso_high_alpha_sparse_coefficients() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 0.0, 0.0], + &[0.0, 1.0, 0.0], + &[0.0, 0.0, 1.0], + &[1.0, 1.0, 0.0], + &[0.0, 1.0, 1.0], + ]).unwrap(); + let y: Vec = vec![1.0, 1.0, 1.0, 2.0, 2.0]; + let model = Lasso::fit(&x, &y, LassoParameters::default().with_alpha(10.0)).unwrap(); + let coeffs = model.coefficients(); + let nonzero: usize = coeffs.iter().filter(|&&c| c.abs() > 1e-6).count(); + assert!(nonzero <= 2, "expected sparse coefficients, got {nonzero} nonzero"); + } + + // ── ElasticNet ──────────────────────────────────────────────────────────── + + /// ElasticNet with l1_ratio=1 should behave like Lasso. + #[test] + fn elastic_net_l1_ratio_one_behaves_like_lasso() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0], + &[3.0, 4.0], + &[5.0, 6.0], + &[7.0, 8.0], + &[9.0, 10.0], + ]).unwrap(); + let y: Vec = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let alpha = 0.1; + let en = ElasticNet::fit(&x, &y, ElasticNetParameters::default().with_alpha(alpha).with_l1_ratio(1.0)).unwrap(); + let lasso = Lasso::fit(&x, &y, LassoParameters::default().with_alpha(alpha)).unwrap(); + let y_en = en.predict(&x).unwrap(); + let y_lasso = lasso.predict(&x).unwrap(); + for (a, b) in y_en.iter().zip(y_lasso.iter()) { + assert!((a - b).abs() < 0.5, "EN(l1=1)={a} vs Lasso={b}"); + } + } + + /// ElasticNet with l1_ratio=0 should behave like Ridge. + #[test] + fn elastic_net_l1_ratio_zero_behaves_like_ridge() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0], + &[3.0, 4.0], + &[5.0, 6.0], + &[7.0, 8.0], + &[9.0, 10.0], + ]).unwrap(); + let y: Vec = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let alpha = 0.1; + let en = ElasticNet::fit(&x, &y, ElasticNetParameters::default().with_alpha(alpha).with_l1_ratio(0.0)).unwrap(); + let ridge = RidgeRegression::fit(&x, &y, RidgeRegressionParameters::default().with_alpha(alpha)).unwrap(); + let y_en = en.predict(&x).unwrap(); + let y_ridge = ridge.predict(&x).unwrap(); + for (a, b) in y_en.iter().zip(y_ridge.iter()) { + assert!((a - b).abs() < 0.5, "EN(l1=0)={a} vs Ridge={b}"); + } + } + + // ── LogisticRegression ──────────────────────────────────────────────────── + + /// Linearly separable 2-class: accuracy must be 100%. + #[test] + fn logistic_regression_separable_perfect_accuracy() { + let x = DenseMatrix::from_2d_array(&[ + &[-2.0_f64], &[-1.0], &[1.0], &[2.0], + ]).unwrap(); + let y: Vec = vec![0, 0, 1, 1]; + let model = LogisticRegression::fit(&x, &y, LogisticRegressionParameters::default()).unwrap(); + let preds = model.predict(&x).unwrap(); + assert_eq!(preds, y, "expected perfect separation"); + } + + /// Single-feature binary: deterministic across identical runs. + #[test] + fn logistic_regression_deterministic() { + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64], &[1.0], &[2.0], &[3.0], &[4.0], + ]).unwrap(); + let y: Vec = vec![0, 0, 1, 1, 1]; + let m1 = LogisticRegression::fit(&x, &y, LogisticRegressionParameters::default()).unwrap(); + let m2 = LogisticRegression::fit(&x, &y, LogisticRegressionParameters::default()).unwrap(); + assert_eq!(m1.predict(&x).unwrap(), m2.predict(&x).unwrap()); + } +} diff --git a/src/metrics/auc.rs b/src/metrics/auc.rs index 0a7ddf43..294d00a8 100644 --- a/src/metrics/auc.rs +++ b/src/metrics/auc.rs @@ -75,18 +75,25 @@ impl Metrics for AUC { let y_pred: Vec = Array1::::from_iterator(y_pred_prob.iterator(0).copied(), y_pred_prob.shape()); // TODO: try to use `crate::algorithm::sort::quick_sort` here + // `argsort()` returns a permutation of [0..n), so every label_idx[i] is a + // valid index into y_pred. rank[i] corresponds to the i-th smallest score. let label_idx: Vec = y_pred.argsort(); let mut rank = vec![0f64; n]; let mut i = 0; while i < n { - if i == n - 1 || y_pred.get(i) != y_pred.get(i + 1) { + if i == n - 1 || y_pred.get(label_idx[i]) != y_pred.get(label_idx[i + 1]) { rank[i] = (i + 1) as f64; } else { + // Tie group: advance j to the first index beyond the group, then + // assign the averaged 1-based rank (i+1 .. j) to every member. + // Outer loop invariant: i is the first unprocessed sorted position; + // after this branch i is set to j-1 (then incremented to j). let mut j = i + 1; - while j < n && y_pred.get(j) == y_pred.get(i) { + while j < n && y_pred.get(label_idx[j]) == y_pred.get(label_idx[i]) { j += 1; } + // Average of 1-based ranks [i+1 .. j] (inclusive on both ends). let r = (i + 1 + j) as f64 / 2f64; for rank_k in rank.iter_mut().take(j).skip(i) { *rank_k = r; @@ -128,4 +135,19 @@ mod tests { assert!((score1 - 0.75).abs() < 1e-8); assert!((score2 - 1.0).abs() < 1e-8); } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn auc_tied_scores() { + // Two samples share score 0.5 but are non-adjacent in input order. + // Pairwise ROC AUC (ties credited 0.5): pos {0.9,0.5} vs neg {0.5} + // -> (1.0 + 0.5) / 2 = 0.75 (matches sklearn roc_auc_score) + let y_true: Vec = vec![0., 1., 1.]; + let y_pred: Vec = vec![0.5, 0.9, 0.5]; + let score: f64 = AUC::new().get_score(&y_true, &y_pred); + assert!((score - 0.75).abs() < 1e-8); + } } diff --git a/src/metrics/confusion.rs b/src/metrics/confusion.rs new file mode 100644 index 00000000..cd0fb977 --- /dev/null +++ b/src/metrics/confusion.rs @@ -0,0 +1,184 @@ +//! Shared per-class confusion-count helpers for classification metrics. +//! +//! [`ConfusionCounts`] computes, in a single pass over `(y_true, y_pred)`, +//! the per-class true-positive, predicted, and support counts used by +//! [`Precision`](crate::metrics::precision::Precision), +//! [`Recall`](crate::metrics::recall::Recall), and +//! [`F1`](crate::metrics::f1::F1). +//! +//! Labels are keyed by their `f64` bit pattern; note that `-0.0` and `+0.0` +//! have distinct bit patterns and would be counted as separate classes. This +//! convention is shared across the classification metrics. + +use std::collections::{HashMap, HashSet}; + +use crate::linalg::basic::arrays::ArrayView1; +use crate::numbers::realnum::RealNumber; + +/// Per-class confusion counts for a classification result. +/// +/// Built in a single pass over `(y_true, y_pred)`. Exposes per-class +/// true-positive, predicted, and support counts so that `Precision`, +/// `Recall`, and `F1` can derive their per-class scores from a single +/// source of truth instead of each re-implementing the bookkeeping. +pub(crate) struct ConfusionCounts { + classes_set: HashSet, + predicted: HashMap, + support: HashMap, + tp_map: HashMap, +} + +impl ConfusionCounts { + /// Compute per-class confusion counts in a single pass over + /// `(y_true, y_pred)`. + /// + /// Named `new` rather than `from` to avoid shadowing the conventional + /// `std::convert::From` trait method (the two-argument signature does + /// not collide with `From::from`'s single-argument form, but the + /// shadowing is still confusing for readers). + pub(crate) fn new( + y_true: &dyn ArrayView1, + y_pred: &dyn ArrayView1, + ) -> Self { + let n = y_true.shape(); + let mut classes_set: HashSet = HashSet::new(); + let mut predicted: HashMap = HashMap::new(); + let mut support: HashMap = HashMap::new(); + let mut tp_map: HashMap = HashMap::new(); + for i in 0..n { + let t_bits = y_true.get(i).to_f64_bits(); + classes_set.insert(t_bits); + *support.entry(t_bits).or_insert(0) += 1; + *predicted.entry(y_pred.get(i).to_f64_bits()).or_insert(0) += 1; + if *y_true.get(i) == *y_pred.get(i) { + *tp_map.entry(t_bits).or_insert(0) += 1; + } + } + Self { + classes_set, + predicted, + support, + tp_map, + } + } + + /// The set of label bit patterns observed in `y_true`. + pub(crate) fn classes_set(&self) -> &HashSet { + &self.classes_set + } + + /// Number of predictions equal to the label with the given bit pattern. + pub(crate) fn predicted(&self, bits: u64) -> usize { + *self.predicted.get(&bits).unwrap_or(&0) + } + + /// Number of `y_true` entries equal to the label with the given bit + /// pattern (the class support). + pub(crate) fn support(&self, bits: u64) -> usize { + *self.support.get(&bits).unwrap_or(&0) + } + + /// Number of true positives for the label with the given bit pattern. + pub(crate) fn tp(&self, bits: u64) -> usize { + *self.tp_map.get(&bits).unwrap_or(&0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bits_of(v: f64) -> u64 { + v.to_f64_bits() + } + + #[test] + fn confusion_counts_binary_basic() { + // y_true = [0, 1, 1, 0], y_pred = [0, 0, 1, 1] + let y_true: Vec = vec![0., 1., 1., 0.]; + let y_pred: Vec = vec![0., 0., 1., 1.]; + let counts = ConfusionCounts::new(&y_true, &y_pred); + + assert_eq!(counts.classes_set().len(), 2); + assert!(counts.classes_set().contains(&bits_of(0.0))); + assert!(counts.classes_set().contains(&bits_of(1.0))); + + // Class 0: support=2, predicted=2 (y_pred has two 0s), tp=1 + assert_eq!(counts.support(bits_of(0.0)), 2); + assert_eq!(counts.predicted(bits_of(0.0)), 2); + assert_eq!(counts.tp(bits_of(0.0)), 1); + + // Class 1: support=2, predicted=2 (y_pred has two 1s), tp=1 + assert_eq!(counts.support(bits_of(1.0)), 2); + assert_eq!(counts.predicted(bits_of(1.0)), 2); + assert_eq!(counts.tp(bits_of(1.0)), 1); + } + + #[test] + fn confusion_counts_multiclass() { + // y_true = [0, 0, 1, 2, 2, 2], y_pred = [0, 1, 1, 2, 0, 2] + let y_true: Vec = vec![0., 0., 1., 2., 2., 2.]; + let y_pred: Vec = vec![0., 1., 1., 2., 0., 2.]; + let counts = ConfusionCounts::new(&y_true, &y_pred); + + assert_eq!(counts.classes_set().len(), 3); + + // Class 0: support=2, predicted=2, tp=1 + assert_eq!(counts.support(bits_of(0.0)), 2); + assert_eq!(counts.predicted(bits_of(0.0)), 2); + assert_eq!(counts.tp(bits_of(0.0)), 1); + + // Class 1: support=1, predicted=2, tp=1 + assert_eq!(counts.support(bits_of(1.0)), 1); + assert_eq!(counts.predicted(bits_of(1.0)), 2); + assert_eq!(counts.tp(bits_of(1.0)), 1); + + // Class 2: support=3, predicted=2, tp=2 + assert_eq!(counts.support(bits_of(2.0)), 3); + assert_eq!(counts.predicted(bits_of(2.0)), 2); + assert_eq!(counts.tp(bits_of(2.0)), 2); + } + + #[test] + fn confusion_counts_spurious_predicted_label() { + // y_pred contains label 2 which never appears in y_true. The + // `predicted` map records it, but `classes_set` (sourced from + // y_true) does not include it, so it is silently ignored by + // per-class metrics that iterate `classes_set`. + let y_true: Vec = vec![0., 0., 1., 1.]; + let y_pred: Vec = vec![0., 2., 1., 1.]; + let counts = ConfusionCounts::new(&y_true, &y_pred); + + assert_eq!(counts.classes_set().len(), 2); + assert!(!counts.classes_set().contains(&bits_of(2.0))); + + // The spurious label is tracked in `predicted`... + assert_eq!(counts.predicted(bits_of(2.0)), 1); + // ...but has no support or tp entry. + assert_eq!(counts.support(bits_of(2.0)), 0); + assert_eq!(counts.tp(bits_of(2.0)), 0); + } + + #[test] + fn confusion_counts_empty_input() { + let y_true: Vec = vec![]; + let y_pred: Vec = vec![]; + let counts = ConfusionCounts::new(&y_true, &y_pred); + + assert!(counts.classes_set().is_empty()); + assert_eq!(counts.predicted(bits_of(0.0)), 0); + assert_eq!(counts.support(bits_of(0.0)), 0); + assert_eq!(counts.tp(bits_of(0.0)), 0); + } + + #[test] + fn confusion_counts_perfect_predictions() { + let y_true: Vec = vec![0., 1., 2., 0., 1.]; + let counts = ConfusionCounts::new(&y_true, &y_true); + + for &bits in counts.classes_set() { + assert_eq!(counts.tp(bits), counts.support(bits)); + assert_eq!(counts.tp(bits), counts.predicted(bits)); + } + } +} diff --git a/src/metrics/distance/cosine.rs b/src/metrics/distance/cosine.rs index 8c7a2c00..6e62eebb 100644 --- a/src/metrics/distance/cosine.rs +++ b/src/metrics/distance/cosine.rs @@ -67,7 +67,7 @@ impl Cosine { /// Calculate the squared magnitude (norm squared) of a vector #[inline] - #[allow(dead_code)] + #[expect(dead_code)] pub(crate) fn squared_magnitude>(x: &A) -> f64 { x.iterator(0) .map(|&a| { diff --git a/src/metrics/distance/euclidian.rs b/src/metrics/distance/euclidian.rs index 39deebfa..78e4a5ad 100644 --- a/src/metrics/distance/euclidian.rs +++ b/src/metrics/distance/euclidian.rs @@ -89,4 +89,55 @@ mod tests { assert!((l2 - 5.19615242).abs() < 1e-8); } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn euclidean_distance_zero_for_identical_points() { + use proptest::prelude::*; + + proptest!( + |(a in proptest::collection::vec(-100.0f64..100.0, 1..=10))| + { + let dist: f64 = Euclidian::new().distance(&a, &a); + prop_assert!(dist.abs() < 1e-10, "d(a,a)={dist}"); + } + ); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn euclidean_distance_symmetric() { + use proptest::prelude::*; + + proptest!( |(len in 1usize..=10, + a_vals in proptest::collection::vec(-100.0f64..100.0, 10), + b_vals in proptest::collection::vec(-100.0f64..100.0, 10))| { + let a: Vec = a_vals[..len].to_vec(); + let b: Vec = b_vals[..len].to_vec(); + let d_ab: f64 = Euclidian::new().distance(&a, &b); + let d_ba: f64 = Euclidian::new().distance(&b, &a); + prop_assert!((d_ab - d_ba).abs() < 1e-10, "d(a,b)={}, d(b,a)={}", d_ab, d_ba); + }); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn euclidean_distance_triangle_inequality() { + use proptest::prelude::*; + + proptest!( |(len in 1usize..=8, + a_vals in proptest::collection::vec(-50.0f64..50.0, 8), + b_vals in proptest::collection::vec(-50.0f64..50.0, 8), + c_vals in proptest::collection::vec(-50.0f64..50.0, 8))| { + let a: Vec = a_vals[..len].to_vec(); + let b: Vec = b_vals[..len].to_vec(); + let c: Vec = c_vals[..len].to_vec(); + let d_ab: f64 = Euclidian::new().distance(&a, &b); + let d_bc: f64 = Euclidian::new().distance(&b, &c); + let d_ac: f64 = Euclidian::new().distance(&a, &c); + prop_assert!(d_ac <= d_ab + d_bc + 1e-9, + "triangle inequality violated: d(a,c)={}, d(a,b)+d(b,c)={}", + d_ac, d_ab + d_bc); + }); + } } diff --git a/src/metrics/distance/jaccard.rs b/src/metrics/distance/jaccard.rs new file mode 100644 index 00000000..a3a13dcf --- /dev/null +++ b/src/metrics/distance/jaccard.rs @@ -0,0 +1,134 @@ +//! # Jaccard Distance +//! +//! Jaccard Distance measures dissimilarity between two integer-valued vectors of the same length. +//! Given two vectors \\( x \in ℝ^n \\), \\( y \in ℝ^n \\) the Jaccard distance between \\( x \\) and \\( y \\) is defined as +//! +//! \\[ d(x, y) = 1 - \frac{|x \cap y|}{|x \cup y|} \\] +//! +//! where \\(|x \cap y|\\) is the number of positions where both vectors are non-zero, +//! and \\(|x \cup y|\\) is the number of positions where at least one of the vectors is non-zero. +//! +//! Example: +//! +//! ``` +//! use smartcore::metrics::distance::Distance; +//! use smartcore::metrics::distance::jaccard::Jaccard; +//! +//! let a = vec![1, 0, 1, 1]; +//! let b = vec![1, 1, 0, 1]; +//! +//! let j: f64 = Jaccard::new().distance(&a, &b); +//! +//! ``` +//! +//! +//! + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +use std::marker::PhantomData; + +use super::Distance; +use crate::linalg::basic::arrays::ArrayView1; +use crate::numbers::basenum::Number; + +/// Jaccard distance between two integer-valued vectors +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone)] +pub struct Jaccard { + _t: PhantomData, +} + +impl Jaccard { + /// instatiate the initial structure + pub fn new() -> Jaccard { + Jaccard { _t: PhantomData } + } +} + +impl Default for Jaccard { + fn default() -> Self { + Self::new() + } +} + +impl> Distance for Jaccard { + fn distance(&self, x: &A, y: &A) -> f64 { + if x.shape() != y.shape() { + panic!("Input vector sizes are different"); + } + + let (intersection, union): (usize, usize) = x + .iterator(0) + .zip(y.iterator(0)) + .map(|(a, b)| { + let a_nz = *a != T::zero(); + let b_nz = *b != T::zero(); + + match (a_nz, b_nz) { + (true, true) => (1, 1), + (true, false) | (false, true) => (0, 1), + (false, false) => (0, 0), + } + }) + .fold((0, 0), |acc, v| (acc.0 + v.0, acc.1 + v.1)); + + if union == 0 { + 0.0 + } else { + 1.0 - intersection as f64 / union as f64 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn jaccard_distance() { + let a = vec![1, 0, 1, 1]; + let b = vec![1, 1, 0, 1]; + + let j: f64 = Jaccard::new().distance(&a, &b); + + assert!((j - 0.5).abs() < 1e-8); + } + + #[test] + fn jaccard_identical_vectors() { + let a = vec![1, 0, 1, 0]; + let b = vec![1, 0, 1, 0]; + + let j: f64 = Jaccard::new().distance(&a, &b); + + assert!((j - 0.0).abs() < 1e-8); + } + + #[test] + fn jaccard_both_zero_vectors() { + let a = vec![0, 0, 0]; + let b = vec![0, 0, 0]; + + let j: f64 = Jaccard::new().distance(&a, &b); + + assert!((j - 0.0).abs() < 1e-8); + } + + #[test] + fn jaccard_symmetry() { + let a = vec![1, 0, 1, 1]; + let b = vec![0, 1, 1, 0]; + + let j = Jaccard::new(); + + let d1 = j.distance(&a, &b); + let d2 = j.distance(&b, &a); + + assert!((d1 - d2).abs() < 1e-12); + } +} diff --git a/src/metrics/distance/mahalanobis.rs b/src/metrics/distance/mahalanobis.rs index a9347a58..ee91f0cd 100644 --- a/src/metrics/distance/mahalanobis.rs +++ b/src/metrics/distance/mahalanobis.rs @@ -41,7 +41,7 @@ //! //! //! -#![allow(non_snake_case)] +#![expect(non_snake_case)] #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; diff --git a/src/metrics/distance/mod.rs b/src/metrics/distance/mod.rs index 6fdbaa46..f720013e 100644 --- a/src/metrics/distance/mod.rs +++ b/src/metrics/distance/mod.rs @@ -19,6 +19,8 @@ pub mod cosine; pub mod euclidian; /// Hamming Distance between two strings is the number of positions at which the corresponding symbols are different. pub mod hamming; +/// Jaccard distance between two integer-valued vectors. +pub mod jaccard; /// The Mahalanobis distance is the distance between two points in multivariate space. pub mod mahalanobis; /// Also known as rectilinear distance, city block distance, taxicab metric. @@ -67,6 +69,11 @@ impl Distances { hamming::Hamming::new() } + /// Jaccard distance, see [`Jaccard`](jaccard/index.html) + pub fn jaccard() -> jaccard::Jaccard { + jaccard::Jaccard::new() + } + /// Mahalanobis distance, see [`Mahalanobis`](mahalanobis/index.html) pub fn mahalanobis, C: Array2 + LUDecomposable>( data: &M, diff --git a/src/metrics/distance/tests_edge_cases.rs b/src/metrics/distance/tests_edge_cases.rs new file mode 100644 index 00000000..18fcea77 --- /dev/null +++ b/src/metrics/distance/tests_edge_cases.rs @@ -0,0 +1,259 @@ +//! Stage 4: edge-case & boundary tests for src/metrics/distance. +//! +//! Covers: Euclidian, Manhattan, Minkowski, Hamming, Mahalanobis, PairwiseDistance. +//! Tracking issue: #395 / #391. + +#[cfg(test)] +mod distance_edge_cases { + use crate::metrics::distance::{ + euclidian::Euclidian, + hamming::Hamming, + mahalanobis::Mahalanobis, + manhattan::Manhattan, + minkowski::Minkowski, + Distance, + }; + use crate::linalg::basic::matrix::DenseMatrix; + + fn assert_close(a: f64, b: f64, tol: f64, label: &str) { + assert!((a - b).abs() < tol, "{label}: expected {b}, got {a} (tol {tol})"); + } + + // ── Euclidian ───────────────────────────────────────────────────────────── + + /// d(x, x) = 0 for zero vector. + #[test] + fn euclidian_zero_vector_self_distance() { + let z: Vec = vec![0.0, 0.0, 0.0]; + assert_close(Euclidian::new().distance(&z, &z), 0.0, 1e-10, "euclidian zero self"); + } + + /// d(x, x) = 0 for any vector. + #[test] + fn euclidian_identical_points_zero() { + let a: Vec = vec![3.0, -1.0, 4.0, 1.5]; + assert_close(Euclidian::new().distance(&a, &a), 0.0, 1e-10, "euclidian identical"); + } + + /// Known answer: d([0,0],[3,4]) = 5. + #[test] + fn euclidian_known_answer_3_4_5() { + let a: Vec = vec![0.0, 0.0]; + let b: Vec = vec![3.0, 4.0]; + assert_close(Euclidian::new().distance(&a, &b), 5.0, 1e-10, "euclidian 3-4-5"); + } + + /// Symmetry: d(a,b) == d(b,a). + #[test] + fn euclidian_symmetric() { + let a: Vec = vec![1.0, 2.0, 3.0]; + let b: Vec = vec![4.0, 5.0, 6.0]; + let d_ab = Euclidian::new().distance(&a, &b); + let d_ba = Euclidian::new().distance(&b, &a); + assert_close(d_ab, d_ba, 1e-10, "euclidian symmetric"); + } + + // ── Manhattan ──────────────────────────────────────────────────────────── + + /// d(x, x) = 0. + #[test] + fn manhattan_identical_zero() { + let a: Vec = vec![1.0, -2.0, 3.0]; + assert_close(Manhattan::new().distance(&a, &a), 0.0, 1e-10, "manhattan identical"); + } + + /// Known answer: |1-4| + |2-5| + |3-6| = 9. + #[test] + fn manhattan_known_answer() { + let a: Vec = vec![1.0, 2.0, 3.0]; + let b: Vec = vec![4.0, 5.0, 6.0]; + assert_close(Manhattan::new().distance(&a, &b), 9.0, 1e-10, "manhattan known"); + } + + /// Zero vector to any point = sum of absolute values. + #[test] + fn manhattan_zero_vector() { + let z: Vec = vec![0.0, 0.0]; + let a: Vec = vec![3.0, 4.0]; + assert_close(Manhattan::new().distance(&z, &a), 7.0, 1e-10, "manhattan from zero"); + } + + /// Symmetry. + #[test] + fn manhattan_symmetric() { + let a: Vec = vec![1.0, 5.0]; + let b: Vec = vec![4.0, 1.0]; + assert_close( + Manhattan::new().distance(&a, &b), + Manhattan::new().distance(&b, &a), + 1e-10, + "manhattan symmetric", + ); + } + + // ── Minkowski ──────────────────────────────────────────────────────────── + + /// p=1 must equal Manhattan. + #[test] + fn minkowski_p1_equals_manhattan() { + let a: Vec = vec![1.0, 2.0, 3.0]; + let b: Vec = vec![4.0, 6.0, 8.0]; + let mink = Minkowski::new(1).distance(&a, &b); + let manh = Manhattan::new().distance(&a, &b); + assert_close(mink, manh, 1e-8, "minkowski p=1 vs manhattan"); + } + + /// p=2 must equal Euclidean. + #[test] + fn minkowski_p2_equals_euclidean() { + let a: Vec = vec![0.0, 0.0]; + let b: Vec = vec![3.0, 4.0]; + let mink = Minkowski::new(2).distance(&a, &b); + let eucl = Euclidian::new().distance(&a, &b); + assert_close(mink, eucl, 1e-8, "minkowski p=2 vs euclidean"); + } + + /// d(x, x) = 0 for any p. + #[test] + fn minkowski_identical_zero() { + let a: Vec = vec![2.0, -3.0, 5.0]; + for p in [1u16, 2, 3, 5, 10] { + let d = Minkowski::new(p).distance(&a, &a); + assert_close(d, 0.0, 1e-10, &format!("minkowski p={p} identical")); + } + } + + /// Large p approximates Chebyshev (max-norm): d → max|aᵢ - bᵢ|. + #[test] + fn minkowski_large_p_approaches_chebyshev() { + let a: Vec = vec![0.0, 0.0, 0.0]; + let b: Vec = vec![1.0, 2.0, 3.0]; // max component diff = 3 + let d_large_p = Minkowski::new(50).distance(&a, &b); + assert!((d_large_p - 3.0).abs() < 0.05, "minkowski p=50 ≈ 3.0, got {d_large_p}"); + } + + // ── Hamming ────────────────────────────────────────────────────────────── + + /// Identical vectors → 0. + #[test] + fn hamming_identical_zero() { + let a: Vec = vec![1, 0, 1, 1, 0]; + assert_close(Hamming::new().distance(&a, &a), 0.0, 1e-10, "hamming identical"); + } + + /// All different → 1.0 (normalised). + #[test] + fn hamming_all_different_one() { + let a: Vec = vec![0, 0, 0, 0]; + let b: Vec = vec![1, 1, 1, 1]; + assert_close(Hamming::new().distance(&a, &b), 1.0, 1e-10, "hamming all different"); + } + + /// Known answer: [1,0,1,0] vs [0,0,1,1] → 2 positions differ → 0.5. + #[test] + fn hamming_known_answer_half() { + let a: Vec = vec![1, 0, 1, 0]; + let b: Vec = vec![0, 0, 1, 1]; + assert_close(Hamming::new().distance(&a, &b), 0.5, 1e-10, "hamming half"); + } + + // ── Mahalanobis ────────────────────────────────────────────────────────── + + /// Identity covariance → Mahalanobis equals Euclidean. + #[test] + fn mahalanobis_identity_cov_equals_euclidean() { + use crate::linalg::basic::arrays::ArrayView2; + // 4 points that span 2D well enough for non-singular cov. + let data = DenseMatrix::from_2d_array(&[ + &[0.0_f64, 0.0], + &[1.0, 0.0], + &[0.0, 1.0], + &[1.0, 1.0], + &[2.0, 2.0], + ]).unwrap(); + + // Build Mahalanobis from identity covariance explicitly. + let identity = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 0.0], + &[0.0, 1.0], + ]).unwrap(); + + let mah: Mahalanobis> = + Mahalanobis::new_from_covariance(&identity); + + let a = vec![0.0_f64, 0.0]; + let b = vec![3.0_f64, 4.0]; + + let mah_d = mah.distance(&a, &b); + let euc_d = Euclidian::new().distance(&a, &b); + assert_close(mah_d, euc_d, 1e-6, "mahalanobis(I) == euclidean"); + } + + /// Known answer from doctest: distance ≈ 5.33. + #[test] + fn mahalanobis_known_answer_doctest() { + use crate::linalg::basic::arrays::ArrayView2; + let data = DenseMatrix::from_2d_array(&[ + &[64.0_f64, 580.0, 29.0], + &[66.0, 570.0, 33.0], + &[68.0, 590.0, 37.0], + &[69.0, 660.0, 46.0], + &[73.0, 600.0, 55.0], + ]).unwrap(); + let a = data.mean_by(0); + let b = vec![66.0, 640.0, 44.0]; + let mah: Mahalanobis> = Mahalanobis::new(&data); + let d = mah.distance(&a, &b); + assert_close(d, 5.33, 0.05, "mahalanobis doctest"); + } + + // ── PairwiseDistance ───────────────────────────────────────────────────── + + use crate::metrics::distance::PairwiseDistance; + + /// Struct fields are correctly stored and retrieved. + #[test] + fn pairwise_distance_fields() { + let pd: PairwiseDistance = PairwiseDistance { + node: 3, + neighbour: Some(7), + distance: Some(1.414), + }; + assert_eq!(pd.node, 3); + assert_eq!(pd.neighbour, Some(7)); + assert!((pd.distance.unwrap() - 1.414).abs() < 1e-10); + } + + /// None-distance sentinel is handled (used to signal "infinite" distance). + #[test] + fn pairwise_distance_none_distance() { + let pd: PairwiseDistance = PairwiseDistance { + node: 0, + neighbour: None, + distance: None, + }; + assert!(pd.distance.is_none()); + assert!(pd.neighbour.is_none()); + } + + /// PartialOrd: node with smaller distance is "less than" one with larger. + #[test] + fn pairwise_distance_ordering() { + let close: PairwiseDistance = PairwiseDistance { node: 0, neighbour: Some(1), distance: Some(1.0) }; + let far: PairwiseDistance = PairwiseDistance { node: 0, neighbour: Some(2), distance: Some(9.0) }; + assert!(close < far); + } + + /// Symmetry of pairwise distances (distance fn itself is symmetric, so two + /// PairwiseDistance entries for (i→j) and (j→i) should carry equal distances). + #[test] + fn pairwise_distance_symmetry_via_euclidean() { + let a: Vec = vec![1.0, 2.0]; + let b: Vec = vec![4.0, 6.0]; + let d_ab = Euclidian::new().distance(&a, &b); + let d_ba = Euclidian::new().distance(&b, &a); + let pd_ab: PairwiseDistance = PairwiseDistance { node: 0, neighbour: Some(1), distance: Some(d_ab) }; + let pd_ba: PairwiseDistance = PairwiseDistance { node: 1, neighbour: Some(0), distance: Some(d_ba) }; + assert_close(pd_ab.distance.unwrap(), pd_ba.distance.unwrap(), 1e-10, "pairwise symmetric"); + } +} diff --git a/src/metrics/f1.rs b/src/metrics/f1.rs index 3ca58b8d..63bc3ecd 100644 --- a/src/metrics/f1.rs +++ b/src/metrics/f1.rs @@ -6,6 +6,9 @@ //! //! where \\(\beta \\) is a positive real factor, where \\(\beta \\) is chosen such that recall is considered \\(\beta \\) times as important as precision. //! +//! For binary classification, this is the F-measure of the positive class (assumed to be 1.0). +//! For multiclass, this is the macro-averaged F-measure (mean of the per-class F-measures). +//! //! Example: //! //! ``` @@ -26,6 +29,7 @@ use std::marker::PhantomData; use serde::{Deserialize, Serialize}; use crate::linalg::basic::arrays::ArrayView1; +use crate::metrics::confusion::ConfusionCounts; use crate::metrics::precision::Precision; use crate::metrics::recall::Recall; use crate::numbers::basenum::Number; @@ -69,12 +73,53 @@ impl Metrics for F1 { y_pred.shape() ); } + let n = y_true.shape(); + // Empty input has no classes; return 0.0 (matches sklearn's empty-score + // behaviour and lets the multiclass path below assume classes >= 1). + if n == 0 { + return 0.0; + } let beta2 = self.beta * self.beta; - let p = Precision::new().get_score(y_true, y_pred); - let r = Recall::new().get_score(y_true, y_pred); + // Build the per-class confusion counts once and delegate per-class + // precision/recall to `Precision` and `Recall`, so this metric no + // longer re-implements the tp/predicted/support bookkeeping. Labels + // are keyed by their f64 bit pattern; note that -0.0 and +0.0 have + // distinct bit patterns and would be counted as separate classes — + // an existing convention shared with Precision and Recall. + let counts = ConfusionCounts::new(y_true, y_pred); + let classes = counts.classes_set().len(); - (1f64 + beta2) * (p * r) / ((beta2 * p) + r) + if classes == 2 { + // Binary case: F-measure of the positive class. The positive + // class is assumed to be T::one() (i.e. 1.0 when labels are + // 0.0/1.0) — the convention baked into Precision and Recall, + // which already return the positive-class scores. + let p = Precision::new().get_score(y_true, y_pred); + let r = Recall::new().get_score(y_true, y_pred); + (1f64 + beta2) * (p * r) / ((beta2 * p) + r) + } else { + // Multiclass case (including classes == 1, where the macro + // F-beta is just the single class's F-beta): macro F-measure is + // the mean of the per-class F-measures, not the F-measure of the + // macro-averaged precision and recall. Per-class precision and + // recall are sourced from `Precision` and `Recall` to keep a + // single source of truth for the per-class scores. + let p_scores = Precision::::new().per_class_scores_from_counts(&counts); + let r_scores = Recall::::new().per_class_scores_from_counts(&counts); + let mut fbeta_sum = 0.0; + for &bits in counts.classes_set() { + let p_c = *p_scores.get(&bits).unwrap_or(&0.0); + let r_c = *r_scores.get(&bits).unwrap_or(&0.0); + let denom = beta2 * p_c + r_c; + if denom > 0.0 { + fbeta_sum += (1f64 + beta2) * p_c * r_c / denom; + } + } + // classes >= 1 is guaranteed here: n > 0 (early return above) + // means classes_set is non-empty. + fbeta_sum / classes as f64 + } } } @@ -101,4 +146,82 @@ mod tests { assert!((score1 - 0.57142857).abs() < 1e-8); assert!((score2 - 1.0).abs() < 1e-8); } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn f1_multiclass_macro() { + // Macro F1 is the mean of the per-class F1 scores, not the F1 of the + // macro-averaged precision and recall. Here precision (0.888889) and + // recall (0.833333) both match sklearn, so this isolates the aggregation: + // per-class F1 = [2/3, 0.8, 1.0], mean = 0.822222 (sklearn macro f1). + let y_true: Vec = vec![0., 0., 1., 1., 2., 2.]; + let y_pred: Vec = vec![0., 1., 1., 1., 2., 2.]; + + let score: f64 = F1::new_with(1.0).get_score(&y_true, &y_pred); + let expected = (2.0 / 3.0 + 0.8 + 1.0) / 3.0; + assert!((score - expected).abs() < 1e-8); + assert!((score - 0.822222).abs() < 1e-6); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn f1_multiclass_macro_beta() { + // Same case with beta != 1: still the mean of the per-class F-beta scores. + let y_true: Vec = vec![0., 0., 1., 1., 2., 2.]; + let y_pred: Vec = vec![0., 1., 1., 1., 2., 2.]; + + // per-class (precision, recall): (1, 0.5), (2/3, 1), (1, 1) + let fbeta = |b: f64, p: f64, r: f64| (1.0 + b * b) * p * r / (b * b * p + r); + for beta in [0.5, 2.0] { + let expected = + (fbeta(beta, 1.0, 0.5) + fbeta(beta, 2.0 / 3.0, 1.0) + fbeta(beta, 1.0, 1.0)) / 3.0; + let score: f64 = F1::new_with(beta).get_score(&y_true, &y_pred); + assert!((score - expected).abs() < 1e-8); + } + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn f1_single_class() { + // When y_true has a single class, the multiclass path computes the + // F-beta for that one class (macro of one value is the value itself). + let y_true: Vec = vec![0., 0., 0.]; + + // Perfect predictions -> F1 = 1.0. + let perfect: f64 = F1::new_with(1.0).get_score(&y_true, &y_true); + assert!((perfect - 1.0).abs() < 1e-8); + + // tp=2, predicted(class 0)=2, support(class 0)=3 + // -> p=1.0, r=2/3 -> F1 = 2 * 1.0 * (2/3) / (1.0 + 2/3) = 0.8 + let y_pred: Vec = vec![0., 1., 0.]; + let score: f64 = F1::new_with(1.0).get_score(&y_true, &y_pred); + assert!((score - 0.8).abs() < 1e-8); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn f1_multiclass_imbalanced() { + let y_true: Vec = vec![0., 0., 1., 2., 2., 2.]; + let y_pred: Vec = vec![0., 1., 1., 2., 0., 2.]; + + // per-class F1: class0 0.5, class1 2/3, class2 0.8 (matches sklearn macro f1) + let score: f64 = F1::new_with(1.0).get_score(&y_true, &y_pred); + let expected = (0.5 + 2.0 / 3.0 + 0.8) / 3.0; + assert!((score - expected).abs() < 1e-8); + + let perfect: f64 = F1::new_with(1.0).get_score(&y_true, &y_true); + assert!((perfect - 1.0).abs() < 1e-8); + } } diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs index a7184293..12a32357 100644 --- a/src/metrics/mod.rs +++ b/src/metrics/mod.rs @@ -59,6 +59,8 @@ pub mod auc; /// Compute the homogeneity, completeness and V-Measure scores. pub mod cluster_hcv; pub(crate) mod cluster_helpers; +/// Per-class confusion-count helpers shared by classification metrics. +pub(crate) mod confusion; /// Multitude of distance metrics are defined here pub mod distance; /// F1 score, also known as balanced F-score or F-measure. diff --git a/src/metrics/precision.rs b/src/metrics/precision.rs index 84444b6b..d3ed9d3c 100644 --- a/src/metrics/precision.rs +++ b/src/metrics/precision.rs @@ -22,13 +22,14 @@ //! //! -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::marker::PhantomData; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; use crate::linalg::basic::arrays::ArrayView1; +use crate::metrics::confusion::ConfusionCounts; use crate::numbers::realnum::RealNumber; use crate::metrics::Metrics; @@ -40,6 +41,37 @@ pub struct Precision { _phantom: PhantomData, } +impl Precision { + /// Per-class precision scores derived from shared confusion counts. + /// + /// Returns a map from label bit pattern to that class's precision + /// (`tp / predicted`, or `0.0` when the class is never predicted). + /// + /// Iterates only over `counts.classes_set()` (labels seen in `y_true`). + /// A label that appears in `y_pred` but never in `y_true` contributes to + /// the `predicted` counts in `ConfusionCounts` but is silently ignored + /// here — it does not inflate or deflate any class's precision. This + /// matches sklearn's behaviour, where the label set is derived from + /// `y_true`. + pub(crate) fn per_class_scores_from_counts( + &self, + counts: &ConfusionCounts, + ) -> HashMap { + let mut scores: HashMap = HashMap::new(); + for &bits in counts.classes_set() { + let pred_count = counts.predicted(bits); + let tp = counts.tp(bits); + let prec = if pred_count > 0 { + tp as f64 / pred_count as f64 + } else { + 0.0 + }; + scores.insert(bits, prec); + } + scores + } +} + impl Metrics for Precision { /// create a typed object to call Precision functions fn new() -> Self { @@ -63,63 +95,33 @@ impl Metrics for Precision { y_pred.shape() ); } - let n = y_true.shape(); - - let mut classes_set: HashSet = HashSet::new(); - for i in 0..n { - classes_set.insert(y_true.get(i).to_f64_bits()); + // Empty input has no classes; return 0.0 (the multiclass path below + // relies on classes >= 1 to divide by `classes`). + if n == 0 { + return 0.0; } - let classes: usize = classes_set.len(); + + let counts = ConfusionCounts::new(y_true, y_pred); + let classes = counts.classes_set().len(); + let scores = self.per_class_scores_from_counts(&counts); if classes == 2 { - // Binary case: precision for positive class (assumed T::one()) - let positive = T::one(); - let mut tp: usize = 0; - let mut fp_count: usize = 0; - for i in 0..n { - let t = *y_true.get(i); - let p = *y_pred.get(i); - if p == t { - if t == positive { - tp += 1; - } - } else if t != positive { - fp_count += 1; - } - } - if tp + fp_count == 0 { - 0.0 - } else { - tp as f64 / (tp + fp_count) as f64 - } + // Binary case: precision for the positive class, assumed to be + // T::one() (i.e. 1.0 when labels are 0.0/1.0). The denominator + // is `predicted(positive)` — the number of predictions equal to + // the positive label — so a spurious predicted label not present + // in y_true does not affect the score. If the positive label is + // not present in y_true the score is 0.0. + let positive_bits = T::one().to_f64_bits(); + *scores.get(&positive_bits).unwrap_or(&0.0) } else { - // Multiclass case: macro-averaged precision - let mut predicted: HashMap = HashMap::new(); - let mut tp_map: HashMap = HashMap::new(); - for i in 0..n { - let p_bits = y_pred.get(i).to_f64_bits(); - *predicted.entry(p_bits).or_insert(0) += 1; - if *y_true.get(i) == *y_pred.get(i) { - *tp_map.entry(p_bits).or_insert(0) += 1; - } - } - let mut precision_sum = 0.0; - for &bits in &classes_set { - let pred_count = *predicted.get(&bits).unwrap_or(&0); - let tp = *tp_map.get(&bits).unwrap_or(&0); - let prec = if pred_count > 0 { - tp as f64 / pred_count as f64 - } else { - 0.0 - }; - precision_sum += prec; - } - if classes == 0 { - 0.0 - } else { - precision_sum / classes as f64 - } + // Multiclass case: macro-averaged precision. classes >= 1 is + // guaranteed here because of the `n == 0` guard above. The sum + // over `HashMap::values()` is order-independent (floating-point + // addition of non-negative finite values is commutative and + // associative for the magnitudes involved here). + scores.values().sum::() / classes as f64 } } } @@ -197,4 +199,27 @@ mod tests { let expected = (1.0 / 3.0 + 0.5 + 1.0 + 0.0) / 4.0; assert!((score - expected).abs() < 1e-8); } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn precision_binary_spurious_predicted_label() { + // y_true is binary {0, 1} but y_pred contains a spurious label 2 + // that never appears in y_true. The binary precision denominator is + // `predicted(positive=1)`, which counts only predictions of 1, so + // the spurious prediction of 2 does not inflate the denominator. + // tp(1)=2, predicted(1)=2 -> precision = 1.0. + // + // (The pre-refactor binary path counted any wrong prediction when + // y_true was negative as a false positive, which would have given + // 2/3 here; the new path matches sklearn's binary precision, which + // only counts predictions of the positive class in the denominator.) + let y_true: Vec = vec![0., 0., 1., 1.]; + let y_pred: Vec = vec![0., 2., 1., 1.]; + + let score: f64 = Precision::new().get_score(&y_true, &y_pred); + assert!((score - 1.0).abs() < 1e-8); + } } diff --git a/src/metrics/recall.rs b/src/metrics/recall.rs index e7418511..6031c6ad 100644 --- a/src/metrics/recall.rs +++ b/src/metrics/recall.rs @@ -22,13 +22,14 @@ //! //! -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::marker::PhantomData; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; use crate::linalg::basic::arrays::ArrayView1; +use crate::metrics::confusion::ConfusionCounts; use crate::numbers::realnum::RealNumber; use crate::metrics::Metrics; @@ -40,6 +41,36 @@ pub struct Recall { _phantom: PhantomData, } +impl Recall { + /// Per-class recall scores derived from shared confusion counts. + /// + /// Returns a map from label bit pattern to that class's recall + /// (`tp / support`, or `0.0` when the class has no support). + /// + /// Iterates only over `counts.classes_set()` (labels seen in `y_true`). + /// A label that appears in `y_pred` but never in `y_true` has no support + /// and no true positives, so it is silently ignored — it does not + /// inflate or deflate any class's recall. This matches sklearn's + /// behaviour, where the label set is derived from `y_true`. + pub(crate) fn per_class_scores_from_counts( + &self, + counts: &ConfusionCounts, + ) -> HashMap { + let mut scores: HashMap = HashMap::new(); + for &bits in counts.classes_set() { + let support_count = counts.support(bits); + let tp = counts.tp(bits); + let rec = if support_count > 0 { + tp as f64 / support_count as f64 + } else { + 0.0 + }; + scores.insert(bits, rec); + } + scores + } +} + impl Metrics for Recall { /// create a typed object to call Recall functions fn new() -> Self { @@ -63,57 +94,30 @@ impl Metrics for Recall { y_pred.shape() ); } - let n = y_true.shape(); - - let mut classes_set = HashSet::new(); - for i in 0..n { - classes_set.insert(y_true.get(i).to_f64_bits()); + // Empty input has no classes; return 0.0 (the multiclass path below + // relies on classes >= 1 to divide by `classes`). + if n == 0 { + return 0.0; } - let classes: usize = classes_set.len(); + + let counts = ConfusionCounts::new(y_true, y_pred); + let classes = counts.classes_set().len(); + let scores = self.per_class_scores_from_counts(&counts); if classes == 2 { - // Binary case: recall for positive class (assumed T::one()) - let positive = T::one(); - let mut tp: usize = 0; - let mut fn_count: usize = 0; - for i in 0..n { - let t = *y_true.get(i); - let p = *y_pred.get(i); - if p == t { - if t == positive { - tp += 1; - } - } else if t == positive { - fn_count += 1; - } - } - if tp + fn_count == 0 { - 0.0 - } else { - tp as f64 / (tp + fn_count) as f64 - } + // Binary case: recall for the positive class, assumed to be + // T::one() (i.e. 1.0 when labels are 0.0/1.0). If the positive + // label is not present in y_true the score is 0.0. + let positive_bits = T::one().to_f64_bits(); + *scores.get(&positive_bits).unwrap_or(&0.0) } else { - // Multiclass case: macro-averaged recall - let mut support: HashMap = HashMap::new(); - let mut tp_map: HashMap = HashMap::new(); - for i in 0..n { - let t_bits = y_true.get(i).to_f64_bits(); - *support.entry(t_bits).or_insert(0) += 1; - if *y_true.get(i) == *y_pred.get(i) { - *tp_map.entry(t_bits).or_insert(0) += 1; - } - } - let mut recall_sum = 0.0; - for (&bits, &sup) in &support { - let tp = *tp_map.get(&bits).unwrap_or(&0); - recall_sum += tp as f64 / sup as f64; - } - if support.is_empty() { - 0.0 - } else { - recall_sum / support.len() as f64 - } + // Multiclass case: macro-averaged recall. classes >= 1 is + // guaranteed here because of the `n == 0` guard above. The sum + // over `HashMap::values()` is order-independent (floating-point + // addition of non-negative finite values is commutative and + // associative for the magnitudes involved here). + scores.values().sum::() / classes as f64 } } } diff --git a/src/metrics/tests_edge_cases.rs b/src/metrics/tests_edge_cases.rs new file mode 100644 index 00000000..6bdff49e --- /dev/null +++ b/src/metrics/tests_edge_cases.rs @@ -0,0 +1,289 @@ +//! Stage 4: edge-case & boundary tests for src/metrics. +//! +//! Covers: accuracy, precision, recall, f1, auc, r2, mae, mse, +//! cluster_hcv / cluster_helpers contingency edge cases. +//! Tracking issue: #395 / #391. + +#[cfg(test)] +mod metrics_edge_cases { + use crate::metrics::{ + accuracy::Accuracy, + auc::AUC, + f1::F1, + mean_absolute_error::MeanAbsoluteError, + mean_squared_error::MeanSquaredError, + precision::Precision, + r2::R2, + recall::Recall, + Metrics, + }; + + // ── helpers ────────────────────────────────────────────────────────────── + + fn assert_close(a: f64, b: f64, tol: f64, label: &str) { + assert!((a - b).abs() < tol, "{label}: expected {b}, got {a} (tol {tol})"); + } + + // ── Accuracy ───────────────────────────────────────────────────────────── + + /// Perfect predictions → 1.0 + #[test] + fn accuracy_perfect_score() { + let y: Vec = vec![0, 1, 2, 1, 0]; + let score = Accuracy::::new().get_score(&y, &y); + assert_close(score, 1.0, 1e-10, "accuracy perfect"); + } + + /// All wrong (binary) → 0.0 + #[test] + fn accuracy_worst_score_binary() { + let y_true: Vec = vec![0, 0, 0, 0]; + let y_pred: Vec = vec![1, 1, 1, 1]; + let score = Accuracy::::new().get_score(&y_true, &y_pred); + assert_close(score, 0.0, 1e-10, "accuracy worst"); + } + + /// Single sample, correct → 1.0 + #[test] + fn accuracy_single_sample_correct() { + let y_true: Vec = vec![1]; + let y_pred: Vec = vec![1]; + assert_close(Accuracy::::new().get_score(&y_true, &y_pred), 1.0, 1e-10, "single correct"); + } + + /// Single sample, wrong → 0.0 + #[test] + fn accuracy_single_sample_wrong() { + let y_true: Vec = vec![1]; + let y_pred: Vec = vec![0]; + assert_close(Accuracy::::new().get_score(&y_true, &y_pred), 0.0, 1e-10, "single wrong"); + } + + // ── Precision ──────────────────────────────────────────────────────────── + + /// Perfect → 1.0 + #[test] + fn precision_perfect() { + let y: Vec = vec![0, 1, 1, 0, 1]; + let score = Precision::::new().get_score(&y, &y); + assert_close(score, 1.0, 1e-10, "precision perfect"); + } + + /// All FP for positive class → 0.0 + #[test] + fn precision_all_false_positives() { + let y_true: Vec = vec![0, 0, 0, 0]; + let y_pred: Vec = vec![1, 1, 1, 1]; + let score = Precision::::new().get_score(&y_true, &y_pred); + assert_close(score, 0.0, 1e-10, "precision all FP"); + } + + // ── Recall ─────────────────────────────────────────────────────────────── + + /// Perfect → 1.0 + #[test] + fn recall_perfect() { + let y: Vec = vec![1, 0, 1, 1, 0]; + let score = Recall::::new().get_score(&y, &y); + assert_close(score, 1.0, 1e-10, "recall perfect"); + } + + /// All FN: no positives predicted → 0.0 + #[test] + fn recall_all_false_negatives() { + let y_true: Vec = vec![1, 1, 1, 1]; + let y_pred: Vec = vec![0, 0, 0, 0]; + let score = Recall::::new().get_score(&y_true, &y_pred); + assert_close(score, 0.0, 1e-10, "recall all FN"); + } + + // ── F1 ─────────────────────────────────────────────────────────────────── + + /// Perfect predictions → 1.0 + #[test] + fn f1_perfect() { + let y: Vec = vec![0, 1, 1, 0, 1]; + let score = F1::::new().get_score(&y, &y); + assert_close(score, 1.0, 1e-10, "f1 perfect"); + } + + /// All wrong binary predictions → 0.0 + #[test] + fn f1_all_wrong() { + let y_true: Vec = vec![1, 1, 1, 1]; + let y_pred: Vec = vec![0, 0, 0, 0]; + let score = F1::::new().get_score(&y_true, &y_pred); + assert_close(score, 0.0, 1e-10, "f1 worst"); + } + + /// F1 = 2*P*R / (P+R) known-answer check. + #[test] + fn f1_known_answer() { + // TP=2, FP=1, FN=1 → P=2/3, R=2/3, F1=2/3 + let y_true: Vec = vec![1, 1, 0, 1]; + let y_pred: Vec = vec![1, 1, 1, 0]; + let score = F1::::new().get_score(&y_true, &y_pred); + assert_close(score, 2.0 / 3.0, 1e-6, "f1 known"); + } + + // ── AUC ────────────────────────────────────────────────────────────────── + + /// Perfect ranking → AUC = 1.0 + #[test] + fn auc_perfect() { + let y_true: Vec = vec![0.0, 0.0, 1.0, 1.0]; + let y_score: Vec = vec![0.1, 0.2, 0.8, 0.9]; + let score = AUC::::new().get_score(&y_true, &y_score); + assert_close(score, 1.0, 1e-8, "auc perfect"); + } + + /// Worst ranking (all inverted) → AUC = 0.0 + #[test] + fn auc_worst() { + let y_true: Vec = vec![1.0, 1.0, 0.0, 0.0]; + let y_score: Vec = vec![0.1, 0.2, 0.8, 0.9]; + let score = AUC::::new().get_score(&y_true, &y_score); + assert_close(score, 0.0, 1e-8, "auc worst"); + } + + /// Random ranking → AUC ≈ 0.5 + #[test] + fn auc_random_is_half() { + let y_true: Vec = vec![0.0, 1.0, 0.0, 1.0]; + let y_score: Vec = vec![0.5, 0.5, 0.5, 0.5]; // all tied + let score = AUC::::new().get_score(&y_true, &y_score); + // Tied scores → AUC may be 0.5; accept range [0.0, 1.0] as non-panicking. + assert!(score >= 0.0 && score <= 1.0, "auc tied scores out of range: {score}"); + } + + // ── R² ─────────────────────────────────────────────────────────────────── + + /// Perfect predictions → R² = 1.0 + #[test] + fn r2_perfect() { + let y: Vec = vec![1.0, 2.0, 3.0, 4.0]; + let score = R2::::new().get_score(&y, &y); + assert_close(score, 1.0, 1e-10, "r2 perfect"); + } + + /// Predicting the mean → R² = 0.0 + #[test] + fn r2_predicting_mean() { + let y_true: Vec = vec![1.0, 2.0, 3.0, 4.0]; // mean = 2.5 + let y_pred: Vec = vec![2.5, 2.5, 2.5, 2.5]; + let score = R2::::new().get_score(&y_true, &y_pred); + assert_close(score, 0.0, 1e-10, "r2 mean baseline"); + } + + /// Known-answer: y=[1,2,3], ŷ=[2,2,2] → R² = -0.5 + #[test] + fn r2_known_answer_negative() { + let y_true: Vec = vec![1.0, 2.0, 3.0]; + let y_pred: Vec = vec![2.0, 2.0, 2.0]; + let score = R2::::new().get_score(&y_true, &y_pred); + // SS_res = (1-2)²+(2-2)²+(3-2)² = 2; SS_tot = (1-2)²+(2-2)²+(3-2)² = 2; R²=0 + // Actually mean=2: SS_tot=2, SS_res=2, R²=1-2/2=0.0 + assert_close(score, 0.0, 1e-10, "r2 predict mean"); + } + + // ── MAE ────────────────────────────────────────────────────────────────── + + /// Perfect → 0.0 + #[test] + fn mae_perfect() { + let y: Vec = vec![1.0, 2.0, 3.0]; + let score = MeanAbsoluteError::::new().get_score(&y, &y); + assert_close(score, 0.0, 1e-10, "mae perfect"); + } + + /// Known answer: |1-3|+|2-2|+|3-1| / 3 = 4/3 + #[test] + fn mae_known_answer() { + let y_true: Vec = vec![1.0, 2.0, 3.0]; + let y_pred: Vec = vec![3.0, 2.0, 1.0]; + let score = MeanAbsoluteError::::new().get_score(&y_true, &y_pred); + assert_close(score, 4.0 / 3.0, 1e-10, "mae known"); + } + + /// Single-sample: error = |y_true - y_pred| + #[test] + fn mae_single_sample() { + let y_true: Vec = vec![5.0]; + let y_pred: Vec = vec![2.0]; + let score = MeanAbsoluteError::::new().get_score(&y_true, &y_pred); + assert_close(score, 3.0, 1e-10, "mae single"); + } + + // ── MSE ────────────────────────────────────────────────────────────────── + + /// Perfect → 0.0 + #[test] + fn mse_perfect() { + let y: Vec = vec![1.0, 2.0, 3.0]; + let score = MeanSquaredError::::new().get_score(&y, &y); + assert_close(score, 0.0, 1e-10, "mse perfect"); + } + + /// Known answer: ((1-2)²+(2-4)²+(3-6)²)/3 = (1+4+9)/3 = 14/3 + #[test] + fn mse_known_answer() { + let y_true: Vec = vec![1.0, 2.0, 3.0]; + let y_pred: Vec = vec![2.0, 4.0, 6.0]; + let score = MeanSquaredError::::new().get_score(&y_true, &y_pred); + assert_close(score, 14.0 / 3.0, 1e-10, "mse known"); + } + + /// Single-sample: squared error = (y_true - y_pred)² + #[test] + fn mse_single_sample() { + let y_true: Vec = vec![0.0]; + let y_pred: Vec = vec![3.0]; + let score = MeanSquaredError::::new().get_score(&y_true, &y_pred); + assert_close(score, 9.0, 1e-10, "mse single"); + } + + // ── Cluster HCV contingency edge cases ─────────────────────────────────── + + /// Single cluster: all points in one cluster, any labels. + #[test] + fn cluster_hcv_single_cluster_no_panic() { + use crate::metrics::cluster_hcv::{ + homogeneity_score, completeness_score, v_measure_score, + }; + let labels_true: Vec = vec![0, 1, 2, 0, 1]; + let labels_pred: Vec = vec![0, 0, 0, 0, 0]; // all same cluster + // Must not panic; homogeneity = 0 (mixed), completeness = 1 (all in one cluster). + let h = homogeneity_score(&labels_true, &labels_pred); + let c = completeness_score(&labels_true, &labels_pred); + let v = v_measure_score(&labels_true, &labels_pred); + assert!(h >= 0.0 && h <= 1.0, "h={h}"); + assert_close(c, 1.0, 1e-6, "completeness single cluster"); + assert!(v >= 0.0 && v <= 1.0, "v={v}"); + } + + /// Perfect clustering: pred == true → homogeneity = completeness = v = 1. + #[test] + fn cluster_hcv_perfect_clustering() { + use crate::metrics::cluster_hcv::{ + homogeneity_score, completeness_score, v_measure_score, + }; + let labels: Vec = vec![0, 0, 1, 1, 2, 2]; + let h = homogeneity_score(&labels, &labels); + let c = completeness_score(&labels, &labels); + let v = v_measure_score(&labels, &labels); + assert_close(h, 1.0, 1e-6, "h perfect"); + assert_close(c, 1.0, 1e-6, "c perfect"); + assert_close(v, 1.0, 1e-6, "v perfect"); + } + + /// All same labels: single-class truth → completeness = 1. + #[test] + fn cluster_hcv_all_same_true_labels() { + use crate::metrics::cluster_hcv::completeness_score; + let labels_true: Vec = vec![0, 0, 0, 0]; + let labels_pred: Vec = vec![0, 1, 0, 1]; + let c = completeness_score(&labels_true, &labels_pred); + // When all true labels are the same, completeness is undefined / 1.0. + assert!(c >= 0.0 && c <= 1.0, "completeness={c}"); + } +} diff --git a/src/model_selection/hyper_tuning/grid_search.rs b/src/model_selection/hyper_tuning/grid_search.rs deleted file mode 100644 index 74242c60..00000000 --- a/src/model_selection/hyper_tuning/grid_search.rs +++ /dev/null @@ -1,239 +0,0 @@ -// TODO: missing documentation - -use crate::{ - api::{Predictor, SupervisedEstimator}, - error::{Failed, FailedError}, - linalg::basic::arrays::{Array1, Array2}, - numbers::basenum::Number, - numbers::realnum::RealNumber, -}; - -use crate::model_selection::{cross_validate, BaseKFold, CrossValidationResult}; - -/// Parameters for GridSearchCV -#[derive(Debug)] -pub struct GridSearchCVParameters< - T: Number, - M: Array2, - C: Clone, - I: Iterator, - E: Predictor, - F: Fn(&M, &M::RowVector, C) -> Result, - K: BaseKFold, - S: Fn(&M::RowVector, &M::RowVector) -> T, -> { - _phantom: std::marker::PhantomData<(T, M)>, - - parameters_search: I, - estimator: F, - score: S, - cv: K, -} - -impl< - T: RealNumber, - M: Array2, - C: Clone, - I: Iterator, - E: Predictor, - F: Fn(&M, &M::RowVector, C) -> Result, - K: BaseKFold, - S: Fn(&M::RowVector, &M::RowVector) -> T, - > GridSearchCVParameters -{ - /// Create new GridSearchCVParameters - pub fn new(parameters_search: I, estimator: F, score: S, cv: K) -> Self { - GridSearchCVParameters { - _phantom: std::marker::PhantomData, - parameters_search, - estimator, - score, - cv, - } - } -} -/// Exhaustive search over specified parameter values for an estimator. -#[derive(Debug)] -pub struct GridSearchCV, C: Clone, E: Predictor> { - _phantom: std::marker::PhantomData<(T, M)>, - predictor: E, - /// Cross validation results. - pub cross_validation_result: CrossValidationResult, - /// best parameter - pub best_parameter: C, -} - -impl, E: Predictor, C: Clone> - GridSearchCV -{ - /// Search for the best estimator by testing all possible combinations with cross-validation using given metric. - /// * `x` - features, matrix of size _NxM_ where _N_ is number of samples and _M_ is number of attributes. - /// * `y` - target values, should be of size _N_ - /// * `gs_parameters` - GridSearchCVParameters struct - pub fn fit< - I: Iterator, - K: BaseKFold, - F: Fn(&M, &M::RowVector, C) -> Result, - S: Fn(&M::RowVector, &M::RowVector) -> T, - >( - x: &M, - y: &M::RowVector, - gs_parameters: GridSearchCVParameters, - ) -> Result { - let mut best_result: Option> = None; - let mut best_parameters = None; - let parameters_search = gs_parameters.parameters_search; - let estimator = gs_parameters.estimator; - let cv = gs_parameters.cv; - let score = gs_parameters.score; - - for parameters in parameters_search { - let result = cross_validate(&estimator, x, y, ¶meters, &cv, &score)?; - if best_result.is_none() - || result.mean_test_score() > best_result.as_ref().unwrap().mean_test_score() - { - best_parameters = Some(parameters); - best_result = Some(result); - } - } - - if let (Some(best_parameter), Some(cross_validation_result)) = - (best_parameters, best_result) - { - let predictor = estimator(x, y, best_parameter.clone())?; - Ok(Self { - _phantom: gs_parameters._phantom, - predictor, - cross_validation_result, - best_parameter, - }) - } else { - Err(Failed::because( - FailedError::FindFailed, - "there were no parameter sets found", - )) - } - } - - /// Return grid search cross validation results - pub fn cv_results(&self) -> &CrossValidationResult { - &self.cross_validation_result - } - - /// Return best parameters found - pub fn best_parameters(&self) -> &C { - &self.best_parameter - } - - /// Call predict on the estimator with the best found parameters - pub fn predict(&self, x: &M) -> Result { - self.predictor.predict(x) - } -} - -impl< - T: RealNumber, - M: Array2, - C: Clone, - I: Iterator, - E: Predictor, - F: Fn(&M, &M::RowVector, C) -> Result, - K: BaseKFold, - S: Fn(&M::RowVector, &M::RowVector) -> T, - > SupervisedEstimator> - for GridSearchCV -{ - fn fit( - x: &M, - y: &M::RowVector, - parameters: GridSearchCVParameters, - ) -> Result { - GridSearchCV::fit(x, y, parameters) - } -} - -impl, C: Clone, E: Predictor> - Predictor for GridSearchCV -{ - fn predict(&self, x: &M) -> Result { - self.predict(x) - } -} - -#[cfg(test)] -mod tests { - - use crate::{ - linalg::naive::dense_matrix::DenseMatrix, - linear::logistic_regression::{LogisticRegression, LogisticRegressionSearchParameters}, - metrics::accuracy, - model_selection::{ - hyper_tuning::grid_search::{self, GridSearchCVParameters}, - KFold, - }, - }; - use grid_search::GridSearchCV; - - #[test] - fn test_grid_search() { - let x = DenseMatrix::from_2d_array(&[ - &[5.1, 3.5, 1.4, 0.2], - &[4.9, 3.0, 1.4, 0.2], - &[4.7, 3.2, 1.3, 0.2], - &[4.6, 3.1, 1.5, 0.2], - &[5.0, 3.6, 1.4, 0.2], - &[5.4, 3.9, 1.7, 0.4], - &[4.6, 3.4, 1.4, 0.3], - &[5.0, 3.4, 1.5, 0.2], - &[4.4, 2.9, 1.4, 0.2], - &[4.9, 3.1, 1.5, 0.1], - &[7.0, 3.2, 4.7, 1.4], - &[6.4, 3.2, 4.5, 1.5], - &[6.9, 3.1, 4.9, 1.5], - &[5.5, 2.3, 4.0, 1.3], - &[6.5, 2.8, 4.6, 1.5], - &[5.7, 2.8, 4.5, 1.3], - &[6.3, 3.3, 4.7, 1.6], - &[4.9, 2.4, 3.3, 1.0], - &[6.6, 2.9, 4.6, 1.3], - &[5.2, 2.7, 3.9, 1.4], - ]); - let y = vec![ - 0., 0., 0., 0., 0., 0., 0., 0., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., - ]; - - let cv = KFold { - n_splits: 5, - ..KFold::default() - }; - - let parameters = LogisticRegressionSearchParameters { - alpha: vec![0., 1.], - ..Default::default() - }; - - let grid_search = GridSearchCV::fit( - &x, - &y, - GridSearchCVParameters { - estimator: LogisticRegression::fit, - score: accuracy, - cv, - parameters_search: parameters.into_iter(), - _phantom: Default::default(), - }, - ) - .unwrap(); - let best_parameters = grid_search.best_parameters(); - - assert!([1.].contains(&best_parameters.alpha)); - - let cv_results = grid_search.cv_results(); - - assert_eq!(cv_results.mean_test_score(), 0.9); - - let x = DenseMatrix::from_2d_array(&[&[5., 3., 1., 0.]]); - let result = grid_search.predict(&x).unwrap(); - assert_eq!(result, vec![0.]); - } -} diff --git a/src/model_selection/hyper_tuning/mod.rs b/src/model_selection/hyper_tuning/mod.rs deleted file mode 100644 index dfe0d06b..00000000 --- a/src/model_selection/hyper_tuning/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod grid_search; -pub use grid_search::{GridSearchCV, GridSearchCVParameters}; diff --git a/src/model_selection/mod.rs b/src/model_selection/mod.rs index e72787b7..bdd48d76 100644 --- a/src/model_selection/mod.rs +++ b/src/model_selection/mod.rs @@ -2,7 +2,7 @@ //! //! In statistics and machine learning we usually split our data into two sets: one for training and the other one for testing. //! We fit our model to the training data, in order to make predictions on the test data. We do that to avoid overfitting or underfitting model to our data. -//! Overfitting is bad because the model we trained fits trained data too well and can’t make any inferences on new data. +//! Overfitting is bad because the model we trained fits trained data too well and can't make any inferences on new data. //! Underfitted is bad because the model is undetrained and does not fit the training data well. //! Splitting data into multiple subsets helps us to find the right combination of hyperparameters, estimate model performance and choose the right model for //! the data. @@ -108,7 +108,7 @@ use rand::seq::SliceRandom; use std::fmt::{Debug, Display}; -#[allow(unused_imports)] +#[expect(unused_imports)] use crate::api::{Predictor, SupervisedEstimator}; use crate::error::Failed; use crate::linalg::basic::arrays::{Array1, Array2}; @@ -116,11 +116,8 @@ use crate::numbers::basenum::Number; use crate::numbers::realnum::RealNumber; use crate::rand_custom::get_rng_impl; -// TODO: fix this module -// pub(crate) mod hyper_tuning; pub(crate) mod kfold; -// pub use hyper_tuning::{GridSearchCV, GridSearchCVParameters}; pub use kfold::{KFold, KFoldIter}; /// An interface for the K-Folds cross-validator @@ -318,8 +315,8 @@ mod tests { use crate::metrics::{accuracy, mean_absolute_error}; use crate::model_selection::cross_validate; use crate::model_selection::kfold::KFold; - use crate::neighbors::knn_regressor::{KNNRegressor, KNNRegressorParameters}; use crate::neighbors::KNNWeightFunction; + use crate::neighbors::knn_regressor::{KNNRegressor, KNNRegressorParameters}; #[cfg_attr( all(target_arch = "wasm32", not(target_os = "wasi")), diff --git a/src/naive_bayes/gaussian.rs b/src/naive_bayes/gaussian.rs index dbf3fd81..51d45b93 100644 --- a/src/naive_bayes/gaussian.rs +++ b/src/naive_bayes/gaussian.rs @@ -260,7 +260,7 @@ impl GaussianNBDistribution { #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[derive(Debug, PartialEq)] pub struct GaussianNB< - TX: Number + RealNumber + RealNumber, + TX: Number + RealNumber, TY: Number + Ord + Unsigned, X: Array2, Y: Array1, @@ -268,12 +268,8 @@ pub struct GaussianNB< inner: Option>>, } -impl< - TX: Number + RealNumber + RealNumber, - TY: Number + Ord + Unsigned, - X: Array2, - Y: Array1, - > fmt::Display for GaussianNB +impl, Y: Array1> + fmt::Display for GaussianNB { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "GaussianNB:\ninner: {:?}", self.inner.as_ref().unwrap())?; @@ -281,12 +277,8 @@ impl< } } -impl< - TX: Number + RealNumber + RealNumber, - TY: Number + Ord + Unsigned, - X: Array2, - Y: Array1, - > SupervisedEstimator for GaussianNB +impl, Y: Array1> + SupervisedEstimator for GaussianNB { fn new() -> Self { Self { @@ -299,12 +291,8 @@ impl< } } -impl< - TX: Number + RealNumber + RealNumber, - TY: Number + Ord + Unsigned, - X: Array2, - Y: Array1, - > Predictor for GaussianNB +impl, Y: Array1> + Predictor for GaussianNB { fn predict(&self, x: &X) -> Result { self.predict(x) diff --git a/src/naive_bayes/mod.rs b/src/naive_bayes/mod.rs index 4a949d7f..be1428d1 100644 --- a/src/naive_bayes/mod.rs +++ b/src/naive_bayes/mod.rs @@ -48,7 +48,7 @@ pub(crate) trait NBDistribution: Clone { fn prior(&self, class_index: usize) -> f64; /// Logarithm of conditional probability of sample j given class in the specified index. - #[allow(clippy::borrowed_box)] + #[expect(clippy::borrowed_box)] fn log_likelihood<'a>(&'a self, class_index: usize, j: &'a Box + 'a>) -> f64; /// Possible classes of the distribution. diff --git a/src/naive_bayes/tests_edge_cases.rs b/src/naive_bayes/tests_edge_cases.rs new file mode 100644 index 00000000..4e85563c --- /dev/null +++ b/src/naive_bayes/tests_edge_cases.rs @@ -0,0 +1,155 @@ +//! Stage 3: edge-case & known-answer parity tests for src/naive_bayes. +//! +//! Covers: GaussianNB, BernoulliNB, CategoricalNB, MultinomialNB. +//! Tracking issue: #394 / #391. + +#[cfg(test)] +mod naive_bayes_edge_cases { + use crate::linalg::basic::matrix::DenseMatrix; + use crate::naive_bayes::gaussian::{GaussianNB, GaussianNBParameters}; + use crate::naive_bayes::bernoulli::{BernoulliNB, BernoulliNBParameters}; + use crate::naive_bayes::categorical::{CategoricalNB, CategoricalNBParameters}; + use crate::naive_bayes::multinomial::{MultinomialNB, MultinomialNBParameters}; + + // ── GaussianNB ──────────────────────────────────────────────────────────── + + /// Clearly separated Gaussian blobs: must classify correctly. + #[test] + fn gaussian_nb_separated_blobs() { + let x = DenseMatrix::from_2d_array(&[ + &[-5.0_f64, 0.0], + &[-4.5, 0.0], + &[4.5, 0.0], + &[5.0, 0.0], + ]).unwrap(); + let y: Vec = vec![0, 0, 1, 1]; + let model = GaussianNB::fit(&x, &y, Default::default()).unwrap(); + assert_eq!(model.predict(&x).unwrap(), y); + } + + /// Single-class input: all predictions equal that class. + #[test] + fn gaussian_nb_single_class_input() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0], + &[1.1, 2.1], + &[0.9, 1.9], + ]).unwrap(); + let y: Vec = vec![0, 0, 0]; + let model = GaussianNB::fit(&x, &y, Default::default()).unwrap(); + let preds = model.predict(&x).unwrap(); + assert!(preds.iter().all(|&p| p == 0)); + } + + /// Prior override: when class=1 prior is 0.99, ambiguous point should be class 1. + #[test] + fn gaussian_nb_prior_override() { + // x=0 is equidistant from class 0 (mean=-1) and class 1 (mean=1). + let x_train = DenseMatrix::from_2d_array(&[ + &[-1.0_f64], &[-1.0], &[1.0], &[1.0], + ]).unwrap(); + let y_train: Vec = vec![0, 0, 1, 1]; + let x_test = DenseMatrix::from_2d_array(&[&[0.0_f64]]).unwrap(); + + // Strongly biased prior toward class 1. + let model = GaussianNB::fit( + &x_train, &y_train, + GaussianNBParameters::default().with_priors(vec![0.01, 0.99]), + ).unwrap(); + let pred = model.predict(&x_test).unwrap(); + assert_eq!(pred[0], 1, "strong prior should push ambiguous point to class 1"); + } + + // ── BernoulliNB ─────────────────────────────────────────────────────────── + + /// Binary features, clean separation: 100% accuracy. + #[test] + fn bernoulli_nb_clean_separation() { + let x = DenseMatrix::from_2d_array(&[ + &[1_f64, 0.0, 0.0], + &[1.0, 1.0, 0.0], + &[0.0, 0.0, 1.0], + &[0.0, 1.0, 1.0], + ]).unwrap(); + let y: Vec = vec![0, 0, 1, 1]; + let model = BernoulliNB::fit(&x, &y, Default::default()).unwrap(); + assert_eq!(model.predict(&x).unwrap(), y); + } + + /// Laplace smoothing (alpha): fit must not panic and predictions are valid. + #[test] + fn bernoulli_nb_laplace_smoothing() { + let x = DenseMatrix::from_2d_array(&[ + &[1_f64, 0.0], + &[0.0, 1.0], + &[1.0, 1.0], + ]).unwrap(); + let y: Vec = vec![0, 1, 0]; + let model = BernoulliNB::fit(&x, &y, BernoulliNBParameters::default().with_alpha(1.0)).unwrap(); + assert!(model.predict(&x).is_ok()); + } + + // ── CategoricalNB ───────────────────────────────────────────────────────── + + /// Single-category per feature: should predict without panic. + #[test] + fn categorical_nb_single_category() { + let x = DenseMatrix::from_2d_array(&[ + &[0_f64, 0.0], + &[0.0, 0.0], + ]).unwrap(); + let y: Vec = vec![0, 0]; + let model = CategoricalNB::fit(&x, &y, Default::default()).unwrap(); + let preds = model.predict(&x).unwrap(); + assert!(preds.iter().all(|&p| p == 0)); + } + + /// Multi-class categorical: predictions within valid class range. + #[test] + fn categorical_nb_multiclass() { + let x = DenseMatrix::from_2d_array(&[ + &[0_f64, 1.0], + &[1.0, 0.0], + &[2.0, 2.0], + &[0.0, 1.0], + &[1.0, 0.0], + &[2.0, 2.0], + ]).unwrap(); + let y: Vec = vec![0, 1, 2, 0, 1, 2]; + let model = CategoricalNB::fit(&x, &y, Default::default()).unwrap(); + let preds = model.predict(&x).unwrap(); + assert!(preds.iter().all(|&p| p <= 2)); + } + + // ── MultinomialNB ───────────────────────────────────────────────────────── + + /// Word-count bag-of-words toy: must fit and predict. + #[test] + fn multinomial_nb_word_counts() { + let x = DenseMatrix::from_2d_array(&[ + &[3_f64, 0.0, 0.0], + &[2.0, 0.0, 0.0], + &[0.0, 0.0, 2.0], + &[0.0, 0.0, 3.0], + ]).unwrap(); + let y: Vec = vec![0, 0, 1, 1]; + let model = MultinomialNB::fit(&x, &y, Default::default()).unwrap(); + assert_eq!(model.predict(&x).unwrap(), y); + } + + /// Zero-count smoothing: alpha=1 prevents log(0) panic. + #[test] + fn multinomial_nb_zero_count_smoothing() { + // Class 0 never sees feature 2 → without smoothing log(0) would occur. + let x = DenseMatrix::from_2d_array(&[ + &[2_f64, 0.0, 0.0], + &[0.0, 2.0, 0.0], + &[0.0, 0.0, 2.0], + ]).unwrap(); + let y: Vec = vec![0, 1, 2]; + let result = MultinomialNB::fit(&x, &y, MultinomialNBParameters::default().with_alpha(1.0)); + assert!(result.is_ok()); + let x_test = DenseMatrix::from_2d_array(&[&[0_f64, 0.0, 1.0]]).unwrap(); + assert!(result.unwrap().predict(&x_test).is_ok()); + } +} diff --git a/src/neighbors/knn_classifier.rs b/src/neighbors/knn_classifier.rs index 137143e0..84bcf72c 100644 --- a/src/neighbors/knn_classifier.rs +++ b/src/neighbors/knn_classifier.rs @@ -38,7 +38,7 @@ use serde::{Deserialize, Serialize}; use crate::algorithm::neighbour::{KNNAlgorithm, KNNAlgorithmName}; use crate::api::{Predictor, SupervisedEstimator}; -use crate::error::Failed; +use crate::error::{Failed, FailedError}; use crate::linalg::basic::arrays::{Array1, Array2}; use crate::metrics::distance::euclidian::Euclidian; use crate::metrics::distance::{Distance, Distances}; @@ -277,26 +277,72 @@ impl, Y: Array1, D: Distance) -> Result { + /// Compute class probabilities for a single row. All the rest functions will use it + fn predict_proba_for_row(&self, row: &Vec) -> Result, Failed> { let search_result = self.knn_algorithm().find(row, self.k())?; + // Getting distances and calculating weights let weights = self .weight() .calc_weights(search_result.iter().map(|v| v.1).collect()); + let w_sum: f64 = weights.iter().copied().sum(); - let mut c = vec![0f64; self.classes().len()]; - let mut max_c = 0f64; - let mut max_i = 0; + // Additional check. If weights sum == 0, normalization is not possible + if w_sum == 0.0 { + return Err(Failed::because( + FailedError::PredictFailed, + "Sum of weights is zero; cannot compute probabilities", + )); + } + + // Accumulating raw weights... + let mut class_votes = vec![0.0; self.classes().len()]; for (r, w) in search_result.iter().zip(weights.iter()) { - c[self.y()[r.0]] += *w / w_sum; - if c[self.y()[r.0]] > max_c { - max_c = c[self.y()[r.0]]; - max_i = self.y()[r.0]; + // r.0 - index of a neighbor in X + // self.y()[r.0] - class index of this neighbor (0, 1, 2...) + class_votes[self.y()[r.0]] += *w; + } + + // Normalization with a bit of optimization + let inv_sum = 1.0 / w_sum; + for v in &mut class_votes { + *v *= inv_sum; + } + + Ok(class_votes) + } + + /// Predicts class index for a single row by reusing predict_proba_for_row + fn predict_for_row(&self, row: &Vec) -> Result { + let proba = self.predict_proba_for_row(row)?; + let mut max_idx = 0; + let mut max_val = proba[0]; + + for (i, &val) in proba.iter().enumerate().skip(1) { + if val > max_val { + max_val = val; + max_idx = i; } } - Ok(max_i) + Ok(max_idx) // Goes directly to already existing predict() method + } + + /// Predict class probabilities for the input samples. + /// Returns a vector of probability vectors, one per sample. + /// Each probability vector has length equal to number of classes and sums to 1. + pub fn predict_proba(&self, x: &X) -> Result>, Failed> { + let mut result = Vec::with_capacity(x.shape().0); + let mut row_vec = vec![TX::zero(); x.shape().1]; + for row in x.row_iter() { + row.iterator(0) + .zip(row_vec.iter_mut()) + .for_each(|(&s, v)| *v = s); + result.push(self.predict_proba_for_row(&row_vec)?); + } + + Ok(result) } } @@ -305,6 +351,22 @@ mod tests { use super::*; use crate::linalg::basic::matrix::DenseMatrix; + /// Helper function to compare two f64 vectors with tolerance, placing it ourside of wasm_bindgen_test + fn assert_vec_f64_eq(a: &[f64], b: &[f64], tol: f64, msg: &str) { + assert_eq!(a.len(), b.len(), "{}: length mismatch", msg); + for (i, (va, vb)) in a.iter().zip(b.iter()).enumerate() { + assert!( + (va - vb).abs() < tol, + "{}: index {} differs: {} vs {}", + msg, + i, + va, + vb + ); + } + } + + // Apply wasm_bindgen_test to all tests in this module #[cfg_attr( all(target_arch = "wasm32", not(target_os = "wasi")), wasm_bindgen_test::wasm_bindgen_test @@ -315,20 +377,19 @@ mod tests { DenseMatrix::from_2d_array(&[&[1., 2.], &[3., 4.], &[5., 6.], &[7., 8.], &[9., 10.]]) .unwrap(); let y = vec![2, 2, 2, 3, 3]; + let knn = KNNClassifier::fit(&x, &y, Default::default()).unwrap(); let y_hat = knn.predict(&x).unwrap(); - assert_eq!(5, Vec::len(&y_hat)); - assert_eq!(y.to_vec(), y_hat); + + assert_eq!(5, y_hat.len()); + assert_eq!(y, y_hat); } - #[cfg_attr( - all(target_arch = "wasm32", not(target_os = "wasi")), - wasm_bindgen_test::wasm_bindgen_test - )] #[test] fn knn_fit_predict_weighted() { let x = DenseMatrix::from_2d_array(&[&[1.], &[2.], &[3.], &[4.], &[5.]]).unwrap(); let y = vec![2, 2, 2, 3, 3]; + let knn = KNNClassifier::fit( &x, &y, @@ -338,16 +399,319 @@ mod tests { .with_weight(KNNWeightFunction::Distance), ) .unwrap(); + let y_hat = knn .predict(&DenseMatrix::from_2d_array(&[&[4.1]]).unwrap()) .unwrap(); assert_eq!(vec![3], y_hat); } - #[cfg_attr( - all(target_arch = "wasm32", not(target_os = "wasi")), - wasm_bindgen_test::wasm_bindgen_test - )] + // New 8 tests (2026-03-19) + #[test] + fn knn_predict_proba_valid() { + // Test 1. Test that predict_proba returns valid probability distributions + let x = DenseMatrix::from_2d_array(&[ + &[1., 2.], + &[2., 3.], + &[3., 4.], // class 0 + &[8., 9.], + &[9., 10.], + &[10., 11.], // class 1 + ]) + .unwrap(); + let y = vec![0, 0, 0, 1, 1, 1]; + + let knn = KNNClassifier::fit(&x, &y, Default::default()).unwrap(); + let proba = knn.predict_proba(&x).unwrap(); + + for (i, p) in proba.iter().enumerate() { + // Probabilities must sum to 1.0 (with floating point tolerance) + assert!( + (p.iter().sum::() - 1.0).abs() < 1e-10, + "Sample {}: probabilities don't sum to 1", + i + ); + + // Each probability must be in [0, 1] + for &prob in p { + assert!( + prob >= 0.0 && prob <= 1.0, + "Sample {}: probability {} out of range", + i, + prob + ); + } + } + } + + #[test] + fn knn_predict_consistent_with_proba() { + // Test 2. Verify that predict() and predict_proba() return consistent results + let x = DenseMatrix::from_2d_array(&[ + &[1., 1.], + &[2., 2.], + &[3., 3.], + &[8., 8.], + &[9., 9.], + &[10., 10.], + ]) + .unwrap(); + let y = vec![10, 10, 10, 20, 20, 20]; + + let knn = KNNClassifier::fit(&x, &y, KNNClassifierParameters::default().with_k(3)).unwrap(); + + let test = DenseMatrix::from_2d_array(&[&[2.5, 2.5]]).unwrap(); + + let pred_class = knn.predict(&test).unwrap(); + let pred_proba = knn.predict_proba(&test).unwrap(); + + // Find class index with maximum probability + + let max_proba_idx = pred_proba[0] + .iter() + .enumerate() + .max_by(|a, b| a.1.partial_cmp(b.1).unwrap()) + .map(|(i, _)| i) + .unwrap(); + + // The class with max probability should match predict() result + assert_eq!( + knn.classes()[max_proba_idx], + pred_class[0], + "predict() and predict_proba() disagree on class" + ); + } + + #[test] + fn knn_predict_proba_linear_vs_cover_tree() { + // Test 3. Verify both search algorithms produce identical probabilities + let x = DenseMatrix::from_2d_array(&[ + &[1., 2.], + &[2., 2.], + &[3., 3.], + &[8., 8.], + &[9., 9.], + &[10., 10.], + ]) + .unwrap(); + let y = vec![0, 0, 0, 1, 1, 1]; + + let test = DenseMatrix::from_2d_array(&[&[2.5, 2.5], &[9.5, 9.5]]).unwrap(); + + let knn_linear = KNNClassifier::fit( + &x, + &y, + KNNClassifierParameters::default() + .with_algorithm(KNNAlgorithmName::LinearSearch) + .with_k(3), + ) + .unwrap(); + + let knn_cover = KNNClassifier::fit( + &x, + &y, + KNNClassifierParameters::default() + .with_algorithm(KNNAlgorithmName::CoverTree) + .with_k(3), + ) + .unwrap(); + + let proba_linear = knn_linear.predict_proba(&test).unwrap(); + let proba_cover = knn_cover.predict_proba(&test).unwrap(); + + // Compare element-wise with tolerance for floating point differences + for (i, (pl, pc)) in proba_linear.iter().zip(proba_cover.iter()).enumerate() { + assert_vec_f64_eq( + pl, + pc, + 1e-10, + &format!("Sample {} probability vectors differ", i), + ); + } + } + + #[test] + fn knn_predict_proba_zero_weights_error() { + // Test 4. Handling of edge case where sum of weights is zero + let x = DenseMatrix::from_2d_array(&[&[1., 1.], &[1., 1.], &[1., 1.]]).unwrap(); + let y = vec![0, 1, 2]; // Three different classes, identical feature vectors + + let knn = KNNClassifier::fit( + &x, + &y, + KNNClassifierParameters::default() + .with_k(3) + .with_weight(KNNWeightFunction::Distance), + ) + .unwrap(); + + let test = DenseMatrix::from_2d_array(&[&[1., 1.]]).unwrap(); + let result = knn.predict_proba(&test); + + // Should either succeed with valid probabilities or return a clear error + match result { + Ok(proba) => { + assert_eq!(proba.len(), 1); + assert!((proba[0].iter().sum::() - 1.0).abs() < 1e-10); + } + Err(e) => { + // Error message should be informative + let err_msg = format!("{:?}", e); + assert!( + err_msg.contains("weight") || err_msg.contains("zero"), + "Error message should mention weights or zero sum: {}", + err_msg + ); + } + } + } + + #[test] + fn knn_predict_proba_weight_functions_differ() { + // Test 5. Verify that different weight functions produce different probabilities + let x = DenseMatrix::from_2d_array(&[ + &[1., 1.], // class 0, close + &[2., 2.], // class 0, farther + &[10., 10.], // class 1, far + ]) + .unwrap(); + let y = vec![0, 0, 1]; + + let test = DenseMatrix::from_2d_array(&[&[1.5, 1.5]]).unwrap(); + + let knn_uniform = KNNClassifier::fit( + &x, + &y, + KNNClassifierParameters::default() + .with_k(3) + .with_weight(KNNWeightFunction::Uniform), + ) + .unwrap(); + + let knn_distance = KNNClassifier::fit( + &x, + &y, + KNNClassifierParameters::default() + .with_k(3) + .with_weight(KNNWeightFunction::Distance), + ) + .unwrap(); + + let proba_uniform = knn_uniform.predict_proba(&test).unwrap(); + let proba_distance = knn_distance.predict_proba(&test).unwrap(); + + // Uniform and Distance weighting should produce different results (at least one probability value should differ) + let mut differs = false; + for (vu, vd) in proba_uniform[0].iter().zip(proba_distance[0].iter()) { + if (vu - vd).abs() > 1e-10 { + differs = true; + break; + } + } + assert!( + differs, + "Uniform and Distance weights should produce different probabilities" + ); + } + + #[test] + fn knn_predict_proba_extreme_k_values() { + // Test 6. k=n: with mixed classes, no single class should have probability 1.0 + let x = + DenseMatrix::from_2d_array(&[&[1., 1.], &[2., 2.], &[3., 3.], &[8., 8.], &[9., 9.]]) + .unwrap(); + let y = vec![0, 0, 1, 1, 1]; + + let test = DenseMatrix::from_2d_array(&[&[2.5, 2.5]]).unwrap(); + + let knn_kn = + KNNClassifier::fit(&x, &y, KNNClassifierParameters::default().with_k(5)).unwrap(); + let proba_kn = knn_kn.predict_proba(&test).unwrap(); + let max_prob = proba_kn[0].iter().copied().fold(0.0, f64::max); + assert!( + max_prob < 1.0 - 1e-10, + "k=n with mixed classes should not give probability 1.0" + ); + } + + #[test] + fn knn_predict_proba_multiclass() { + // Test 7. Test with more than 2 classes (using i32 labels) + let x = DenseMatrix::from_2d_array(&[ + &[1., 1.], + &[1.5, 1.5], // class 10 + &[4., 4.], + &[4.5, 4.5], // class 20 + &[8., 8.], + &[8.5, 8.5], // class 30 + ]) + .unwrap(); + let y = vec![10, 10, 20, 20, 30, 30]; + + let knn = KNNClassifier::fit(&x, &y, Default::default()).unwrap(); + let test = DenseMatrix::from_2d_array(&[&[4.2, 4.2]]).unwrap(); + + let proba = knn.predict_proba(&test).unwrap(); + + assert_eq!(proba[0].len(), 3, "Should have 3 class probabilities"); + assert!((proba[0].iter().sum::() - 1.0).abs() < 1e-10); + + // Point is closest to class 20, so its probability should be highest + let max_idx = proba[0] + .iter() + .enumerate() + .max_by(|a, b| a.1.partial_cmp(b.1).unwrap()) + .unwrap() + .0; + assert_eq!(knn.classes()[max_idx], 20); + } + + #[test] + fn knn_predict_proba_batch() { + // Test 8. Batch prediction (multiple samples at once) + let x = DenseMatrix::from_2d_array(&[ + &[1., 1.], + &[2., 2.], + &[3., 3.], + &[8., 8.], + &[9., 9.], + &[10., 10.], + ]) + .unwrap(); + let y = vec![0, 0, 0, 1, 1, 1]; + + let knn = KNNClassifier::fit(&x, &y, Default::default()).unwrap(); + + // Query multiple points simultaneously + let test = DenseMatrix::from_2d_array(&[ + &[1.5, 1.5], // closer to class 0 + &[9.5, 9.5], // closer to class 1 + &[5., 5.], // middle point + ]) + .unwrap(); + + let proba = knn.predict_proba(&test).unwrap(); + + // Check 1 + assert_eq!(proba.len(), 3, "Should return probabilities for 3 samples"); + + // Check 2: Each row must be a valid probability distribution + for p in &proba { + assert_eq!(p.len(), 2); // 2 classes + assert!((p.iter().sum::() - 1.0).abs() < 1e-10); + } + + // Check 3 (Intuitive checks): first sample favors class 0, second favors class 1 + assert!( + proba[0][0] > proba[0][1], + "First sample should favor class 0" + ); + assert!( + proba[1][1] > proba[1][0], + "Second sample should favor class 1" + ); + } + #[test] #[cfg(feature = "serde")] fn serde() { @@ -357,8 +721,7 @@ mod tests { let y = vec![2, 2, 2, 3, 3]; let knn = KNNClassifier::fit(&x, &y, Default::default()).unwrap(); - - let deserialized_knn = bincode::deserialize(&bincode::serialize(&knn).unwrap()).unwrap(); + let deserialized_knn = postcard::from_bytes(&postcard::to_allocvec(&knn).unwrap()).unwrap(); assert_eq!(knn, deserialized_knn); } diff --git a/src/neighbors/knn_regressor.rs b/src/neighbors/knn_regressor.rs index b49743f8..2169f954 100644 --- a/src/neighbors/knn_regressor.rs +++ b/src/neighbors/knn_regressor.rs @@ -102,7 +102,7 @@ impl, Y: Array1, D: Distance>> self.weight.as_ref().expect("Missing parameter: weight") } - #[allow(dead_code)] + #[expect(dead_code)] fn k(&self) -> usize { self.k.unwrap() } @@ -346,7 +346,7 @@ mod tests { let knn = KNNRegressor::fit(&x, &y, Default::default()).unwrap(); - let deserialized_knn = bincode::deserialize(&bincode::serialize(&knn).unwrap()).unwrap(); + let deserialized_knn = postcard::from_bytes(&postcard::to_allocvec(&knn).unwrap()).unwrap(); assert_eq!(knn, deserialized_knn); } diff --git a/src/neighbors/tests_edge_cases.rs b/src/neighbors/tests_edge_cases.rs new file mode 100644 index 00000000..41c8c0a1 --- /dev/null +++ b/src/neighbors/tests_edge_cases.rs @@ -0,0 +1,94 @@ +//! Stage 3: edge-case & known-answer parity tests for src/neighbors. +//! +//! Covers: KNNClassifier, KNNRegressor. +//! Tracking issue: #394 / #391. + +#[cfg(test)] +mod neighbors_edge_cases { + use crate::linalg::basic::matrix::DenseMatrix; + use crate::neighbors::knn_classifier::{KNNClassifier, KNNClassifierParameters}; + use crate::neighbors::knn_regressor::{KNNRegressor, KNNRegressorParameters}; + + // ── KNNClassifier ───────────────────────────────────────────────────────── + + /// k=1: each training point should predict its own label. + #[test] + fn knn_classifier_k1_memorises() { + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64, 0.0], + &[1.0, 0.0], + &[0.0, 1.0], + &[1.0, 1.0], + ]).unwrap(); + let y: Vec = vec![0, 1, 2, 3]; + let model = KNNClassifier::fit(&x, &y, KNNClassifierParameters::default().with_k(1)).unwrap(); + assert_eq!(model.predict(&x).unwrap(), y); + } + + /// k=N (all samples): prediction is the majority class everywhere. + #[test] + fn knn_classifier_k_equals_n() { + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64], &[1.0], &[2.0], &[3.0], &[10.0], + ]).unwrap(); + // 4 class-0, 1 class-1 → majority is class 0 for k=5. + let y: Vec = vec![0, 0, 0, 0, 1]; + let model = KNNClassifier::fit(&x, &y, KNNClassifierParameters::default().with_k(5)).unwrap(); + let preds = model.predict(&x).unwrap(); + assert!(preds.iter().all(|&p| p == 0), "majority-vote should return class 0 everywhere"); + } + + /// Uniform vs distance-weighted: both must succeed and produce valid classes. + #[test] + fn knn_classifier_weighted_vs_uniform() { + use crate::neighbors::KNNWeightFunction; + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64], &[1.0], &[5.0], &[6.0], + ]).unwrap(); + let y: Vec = vec![0, 0, 1, 1]; + let uniform = KNNClassifier::fit(&x, &y, KNNClassifierParameters::default().with_k(2).with_weight(KNNWeightFunction::Uniform)).unwrap(); + let weighted = KNNClassifier::fit(&x, &y, KNNClassifierParameters::default().with_k(2).with_weight(KNNWeightFunction::Distance)).unwrap(); + let p_u = uniform.predict(&x).unwrap(); + let p_w = weighted.predict(&x).unwrap(); + assert!(p_u.iter().all(|&p| p <= 1)); + assert!(p_w.iter().all(|&p| p <= 1)); + } + + // ── KNNRegressor ────────────────────────────────────────────────────────── + + /// k=1: predictions equal training targets exactly. + #[test] + fn knn_regressor_k1_exact() { + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64], &[1.0], &[2.0], &[3.0], + ]).unwrap(); + let y: Vec = vec![0.0, 1.0, 4.0, 9.0]; + let model = KNNRegressor::fit(&x, &y, KNNRegressorParameters::default().with_k(1)).unwrap(); + let preds = model.predict(&x).unwrap(); + for (a, b) in y.iter().zip(preds.iter()) { + assert!((a - b).abs() < 1e-10, "k=1 should memorise: expected {a}, got {b}"); + } + } + + /// Uniform vs distance-weighted diverge on asymmetric neighbours. + #[test] + fn knn_regressor_uniform_vs_distance() { + use crate::neighbors::KNNWeightFunction; + // Query point at 1.1: neighbours are 1.0 (y=10) and 2.0 (y=20). + // Distance weighting will bias toward 1.0 (closer). + let x_train = DenseMatrix::from_2d_array(&[ + &[0.0_f64], &[1.0], &[2.0], + ]).unwrap(); + let y_train: Vec = vec![0.0, 10.0, 20.0]; + let x_test = DenseMatrix::from_2d_array(&[&[1.1_f64]]).unwrap(); + + let uniform = KNNRegressor::fit(&x_train, &y_train, KNNRegressorParameters::default().with_k(2).with_weight(KNNWeightFunction::Uniform)).unwrap(); + let weighted = KNNRegressor::fit(&x_train, &y_train, KNNRegressorParameters::default().with_k(2).with_weight(KNNWeightFunction::Distance)).unwrap(); + + let p_u = uniform.predict(&x_test).unwrap()[0]; + let p_w = weighted.predict(&x_test).unwrap()[0]; + // Uniform = 15.0, distance-weighted < 15.0 (biased toward y=10). + assert!((p_u - 15.0).abs() < 1e-6, "uniform expected 15.0, got {p_u}"); + assert!(p_w < p_u, "distance-weighted should be less than uniform: {p_w} vs {p_u}"); + } +} diff --git a/src/numbers/floatnum.rs b/src/numbers/floatnum.rs index 4ca7f732..b66b0790 100644 --- a/src/numbers/floatnum.rs +++ b/src/numbers/floatnum.rs @@ -38,11 +38,7 @@ impl FloatNumber for f64 { } fn ln_1pe(self) -> f64 { - if self > 15. { - self - } else { - self.exp().ln_1p() - } + if self > 15. { self } else { self.exp().ln_1p() } } fn sigmoid(self) -> f64 { @@ -56,9 +52,9 @@ impl FloatNumber for f64 { } fn rand() -> f64 { - use rand::Rng; + use rand::RngExt; let mut rng = get_rng_impl(None); - rng.gen() + rng.random() } fn two() -> Self { @@ -80,11 +76,7 @@ impl FloatNumber for f32 { } fn ln_1pe(self) -> f32 { - if self > 15. { - self - } else { - self.exp().ln_1p() - } + if self > 15. { self } else { self.exp().ln_1p() } } fn sigmoid(self) -> f32 { @@ -98,9 +90,9 @@ impl FloatNumber for f32 { } fn rand() -> f32 { - use rand::Rng; + use rand::RngExt; let mut rng = get_rng_impl(None); - rng.gen() + rng.random() } fn two() -> Self { diff --git a/src/numbers/realnum.rs b/src/numbers/realnum.rs index 8ef71555..3eb26878 100644 --- a/src/numbers/realnum.rs +++ b/src/numbers/realnum.rs @@ -3,7 +3,7 @@ //! This module defines real number and some useful functions that are used in [Linear Algebra](../../linalg/index.html) module. use rand::rngs::SmallRng; -use rand::{Rng, SeedableRng}; +use rand::{RngExt, SeedableRng}; use num_traits::Float; @@ -49,11 +49,7 @@ impl RealNumber for f64 { } fn ln_1pe(self) -> f64 { - if self > 15. { - self - } else { - self.exp().ln_1p() - } + if self > 15. { self } else { self.exp().ln_1p() } } fn sigmoid(self) -> f64 { @@ -69,10 +65,8 @@ impl RealNumber for f64 { fn rand() -> f64 { let mut small_rng = get_rng_impl(None); - let mut rngs: Vec = (0..3) - .map(|_| SmallRng::from_rng(&mut small_rng).unwrap()) - .collect(); - rngs[0].gen::() + let mut rngs: Vec = (0..3).map(|_| SmallRng::from_rng(&mut small_rng)).collect(); + rngs[0].random::() } fn two() -> Self { @@ -98,11 +92,7 @@ impl RealNumber for f32 { } fn ln_1pe(self) -> f32 { - if self > 15. { - self - } else { - self.exp().ln_1p() - } + if self > 15. { self } else { self.exp().ln_1p() } } fn sigmoid(self) -> f32 { @@ -118,10 +108,8 @@ impl RealNumber for f32 { fn rand() -> f32 { let mut small_rng = get_rng_impl(None); - let mut rngs: Vec = (0..3) - .map(|_| SmallRng::from_rng(&mut small_rng).unwrap()) - .collect(); - rngs[0].gen::() + let mut rngs: Vec = (0..3).map(|_| SmallRng::from_rng(&mut small_rng)).collect(); + rngs[0].random::() } fn two() -> Self { diff --git a/src/optimization/first_order/gradient_descent.rs b/src/optimization/first_order/gradient_descent.rs index 0be7222f..23c2929e 100644 --- a/src/optimization/first_order/gradient_descent.rs +++ b/src/optimization/first_order/gradient_descent.rs @@ -26,6 +26,20 @@ impl Default for GradientDescent { } } +/// Panic with a clear message when the gradient norm is NaN. +/// Called immediately after every `df` evaluation so degenerate inputs +/// (e.g. log(0), zero-variance features) are caught before they silently +/// corrupt the optimisation state. +#[inline] +fn assert_finite_gnorm(gnorm: T) { + if gnorm.is_nan() { + panic!( + "Gradient norm is NaN — check the objective function for \ + degenerate inputs (e.g. log(0) or a zero-variance feature)." + ); + } +} + impl FirstOrderOptimizer for GradientDescent { fn optimize<'a, X: Array1, LS: LineSearchMethod>( &self, @@ -38,13 +52,19 @@ impl FirstOrderOptimizer for GradientDescent { let mut fx = f(&x); let mut gvec = x0.clone(); + + // Evaluate the initial gradient FIRST, then compute gnorm from the + // filled gvec. Previously gnorm was computed before df() ran, so it + // was always 0.0 on entry and the NaN check inside the loop was + // never reached when df immediately produced NaN. + df(&mut gvec, &x); let mut gnorm = gvec.norm2(); + assert_finite_gnorm(gnorm); - let gtol = (gvec.norm2() * self.g_rtol).max(self.g_atol); + let gtol = (gnorm * self.g_rtol).max(self.g_atol); let mut iter = 0; let mut alpha = T::one(); - df(&mut gvec, &x); while iter < self.max_iter && (iter == 0 || gnorm > gtol) { iter += 1; @@ -55,7 +75,7 @@ impl FirstOrderOptimizer for GradientDescent { let mut dx = step.clone(); dx.mul_scalar_mut(alpha); dx.add_mut(&x); - f(&dx) // f(x) = f(x .+ gvec .* alpha) + f(&dx) }; let df_alpha = |alpha: T| -> T { @@ -63,7 +83,7 @@ impl FirstOrderOptimizer for GradientDescent { let mut dg = gvec.clone(); dx.mul_scalar_mut(alpha); dx.add_mut(&x); - df(&mut dg, &dx); //df(x) = df(x .+ gvec .* alpha) + df(&mut dg, &dx); gvec.dot(&dg) }; @@ -74,8 +94,12 @@ impl FirstOrderOptimizer for GradientDescent { fx = ls_r.f_x; step.mul_scalar_mut(alpha); x.add_mut(&step); + df(&mut gvec, &x); gnorm = gvec.norm2(); + // Guard after every df evaluation — catches NaN introduced at any + // iteration, not just the first. + assert_finite_gnorm(gnorm); } let f_x = f(&x); @@ -91,8 +115,8 @@ impl FirstOrderOptimizer for GradientDescent { #[cfg(test)] mod tests { use super::*; - use crate::optimization::line_search::Backtracking; use crate::optimization::FunctionOrder; + use crate::optimization::line_search::Backtracking; #[cfg_attr( all(target_arch = "wasm32", not(target_os = "wasi")), @@ -120,4 +144,25 @@ mod tests { assert!((result.x[0] - 1.0).abs() < 1e-2); assert!((result.x[1] - 1.0).abs() < 1e-2); } + + #[test] + #[should_panic(expected = "Gradient norm is NaN")] + fn gradient_descent_nan_gradient_panics() { + // df always writes NaN — this simulates degenerate inputs such as + // log(0) or a zero-variance feature column. The panic must be + // triggered on the very first df evaluation (before the loop), so + // the optimizer can never return a silently-corrupted result. + let x0 = vec![1.0f64]; + let f = |_x: &Vec| 0.0f64; + let df = |g: &mut Vec, _x: &Vec| { + g[0] = f64::NAN; + }; + + let ls: Backtracking = Backtracking:: { + order: FunctionOrder::THIRD, + ..Default::default() + }; + let optimizer: GradientDescent = Default::default(); + optimizer.optimize(&f, &df, &x0, &ls); + } } diff --git a/src/optimization/first_order/lbfgs.rs b/src/optimization/first_order/lbfgs.rs index b4f6c9f1..f95f976d 100644 --- a/src/optimization/first_order/lbfgs.rs +++ b/src/optimization/first_order/lbfgs.rs @@ -1,4 +1,4 @@ -#![allow(clippy::suspicious_operation_groupings)] +#![expect(clippy::suspicious_operation_groupings)] // TODO: Add documentation use std::default::Default; @@ -264,8 +264,8 @@ impl FirstOrderOptimizer for LBFGS { #[cfg(test)] mod tests { use super::*; - use crate::optimization::line_search::Backtracking; use crate::optimization::FunctionOrder; + use crate::optimization::line_search::Backtracking; #[cfg_attr( all(target_arch = "wasm32", not(target_os = "wasi")), diff --git a/src/preprocessing/categorical.rs b/src/preprocessing/categorical.rs index 7079460b..fd3d35ba 100644 --- a/src/preprocessing/categorical.rs +++ b/src/preprocessing/categorical.rs @@ -177,7 +177,9 @@ impl OneHotEncoder { match oh_vec { None => { // Since we support T types, bad value in a series causes in to be invalid - let msg = format!("At least one value in column {old_cidx} doesn't conform to category definition"); + let msg = format!( + "At least one value in column {old_cidx} doesn't conform to category definition" + ); return Err(Failed::transform(&msg[..])); } Some(v) => { @@ -196,6 +198,11 @@ impl OneHotEncoder { for (old_p, &new_p) in new_col_idx.iter().enumerate() { // if found treated varible, skip it + // nested if kept for MSRV 1.85 compatibility (let-chains stabilized in 1.88) + #[allow( + clippy::collapsible_if, + reason = "MSRV 1.85: let-chains unstable, cannot collapse" + )] if let Some(&v) = cur_skip { if v == old_p { cur_skip = skip_idx_iter.next(); diff --git a/src/preprocessing/numerical.rs b/src/preprocessing/numerical.rs index 674f6814..ba333c42 100644 --- a/src/preprocessing/numerical.rs +++ b/src/preprocessing/numerical.rs @@ -70,7 +70,7 @@ pub struct StandardScaler { _phantom: PhantomData, } -#[allow(dead_code)] +#[expect(dead_code)] impl StandardScaler { fn new(parameters: StandardScalerParameters) -> Self where @@ -296,16 +296,18 @@ mod tests { .unwrap(), ); println!("{transformed_values}"); - assert!(transformed_values.approximate_eq( - &DenseMatrix::from_2d_array(&[ - &[-1.1154020653, -0.4031985330, 0.9284605204, -0.4271473866], - &[-0.7615464283, -0.7076698384, -1.1075452562, 1.2632979631], - &[0.4832504303, -0.6106747444, 1.0630075435, 0.5494084257], - &[1.3936980634, 1.7215431158, -0.8839228078, -1.3855590021], - ]) - .unwrap(), - 1.0 - )) + assert!( + transformed_values.approximate_eq( + &DenseMatrix::from_2d_array(&[ + &[-1.1154020653, -0.4031985330, 0.9284605204, -0.4271473866], + &[-0.7615464283, -0.7076698384, -1.1075452562, 1.2632979631], + &[0.4832504303, -0.6106747444, 1.0630075435, 0.5494084257], + &[1.3936980634, 1.7215431158, -0.8839228078, -1.3855590021], + ]) + .unwrap(), + 1.0 + ) + ) } /// Test `fit` and `transform` for a column with zero variance. @@ -365,18 +367,20 @@ mod tests { vec![0.42864544605, 0.2869813741, 0.737752073825, 0.431011663625], ); - assert!(&DenseMatrix::::from_2d_vec(&vec![fitted_scaler.stds]) - .unwrap() - .approximate_eq( - &DenseMatrix::from_2d_array(&[&[ - 0.29426447500954, - 0.16758497615485, - 0.20820945786863, - 0.23329718831165 - ],]) - .unwrap(), - 0.00000000000001 - )) + assert!( + &DenseMatrix::::from_2d_vec(&vec![fitted_scaler.stds]) + .unwrap() + .approximate_eq( + &DenseMatrix::from_2d_array(&[&[ + 0.29426447500954, + 0.16758497615485, + 0.20820945786863, + 0.23329718831165 + ],]) + .unwrap(), + 0.00000000000001 + ) + ) } /// If `with_std` is set to `false` the values should not be @@ -450,18 +454,20 @@ mod tests { vec![0.42864544605, 0.2869813741, 0.737752073825, 0.431011663625], ); - assert!(&DenseMatrix::from_2d_vec(&vec![deserialized_scaler.stds]) - .unwrap() - .approximate_eq( - &DenseMatrix::from_2d_array(&[&[ - 0.29426447500954, - 0.16758497615485, - 0.20820945786863, - 0.23329718831165 - ],]) - .unwrap(), - 0.00000000000001 - )) + assert!( + &DenseMatrix::from_2d_vec(&vec![deserialized_scaler.stds]) + .unwrap() + .approximate_eq( + &DenseMatrix::from_2d_array(&[&[ + 0.29426447500954, + 0.16758497615485, + 0.20820945786863, + 0.23329718831165 + ],]) + .unwrap(), + 0.00000000000001 + ) + ) } } } diff --git a/src/preprocessing/series_encoder.rs b/src/preprocessing/series_encoder.rs index 269ef2f0..3eac2f6a 100644 --- a/src/preprocessing/series_encoder.rs +++ b/src/preprocessing/series_encoder.rs @@ -90,7 +90,7 @@ where pub fn from_category_map(category_map: HashMap) -> Self { let mut _unique_cat: Vec<(C, usize)> = category_map.iter().map(|(k, v)| (k.clone(), *v)).collect(); - _unique_cat.sort_by(|a, b| a.1.cmp(&b.1)); + _unique_cat.sort_by_key(|a| a.1); let categories: Vec = _unique_cat.into_iter().map(|a| a.0).collect(); Self { num_categories: categories.len(), @@ -297,4 +297,101 @@ mod tests { ]; assert_eq!(res, v) } + + // --- additional coverage tests --- + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn num_categories_returns_correct_count() { + let enc = build_fake_str_enc(); + assert_eq!(enc.num_categories(), 3); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn get_num_known_and_unknown() { + let enc = build_fake_str_enc(); + assert_eq!(enc.get_num(&"dog"), Some(&1)); + assert_eq!(enc.get_num(&"fish"), None); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn get_cat_round_trips() { + let enc = build_fake_str_enc(); + assert_eq!(enc.get_cat(0), &"background"); + assert_eq!(enc.get_cat(1), &"dog"); + assert_eq!(enc.get_cat(2), &"cat"); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn get_categories_slice_matches_positional_order() { + let enc = build_fake_str_enc(); + assert_eq!(enc.get_categories(), &["background", "dog", "cat"]); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn get_ordinal_unknown_returns_none() { + let enc = build_fake_str_enc(); + assert_eq!(enc.get_ordinal::(&"fish"), None); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn invert_one_hot_multi_hot_error() { + // Two positive entries should produce an error, not a panic. + let enc = build_fake_str_enc(); + let multi_hot: Vec = vec![1.0, 1.0, 0.0]; + match enc.invert_one_hot(multi_hot) { + Err(e) => { + let expected = "Expected a single positive entry, 2 entires found".to_string(); + assert_eq!(e, Failed::transform(&expected[..])); + } + Ok(_) => panic!("Expected an error for multi-hot input"), + } + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn make_one_hot_direct() { + let v: Vec = make_one_hot(1, 4); + assert_eq!(v, vec![0.0, 1.0, 0.0, 0.0]); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn from_category_map_preserves_order() { + // Verify sort_by_key produces category vec ordered by assigned index. + let category_map: HashMap<&str, usize> = + vec![("z", 2), ("a", 0), ("m", 1)].into_iter().collect(); + let enc = CategoryMapper::<&str>::from_category_map(category_map); + assert_eq!(enc.get_categories(), &["a", "m", "z"]); + assert_eq!(enc.num_categories(), 3); + } } diff --git a/src/rand_custom.rs b/src/rand_custom.rs index 936ec9e9..09e1253e 100644 --- a/src/rand_custom.rs +++ b/src/rand_custom.rs @@ -1,8 +1,8 @@ +use rand::SeedableRng; #[cfg(not(feature = "std_rand"))] pub use rand::rngs::SmallRng as RngImpl; #[cfg(feature = "std_rand")] pub use rand::rngs::StdRng as RngImpl; -use rand::SeedableRng; /// Custom switch for random fuctions pub fn get_rng_impl(seed: Option) -> RngImpl { @@ -11,8 +11,11 @@ pub fn get_rng_impl(seed: Option) -> RngImpl { None => { cfg_if::cfg_if! { if #[cfg(feature = "std_rand")] { - use rand::RngCore; - RngImpl::seed_from_u64(rand::thread_rng().next_u64()) + use rand::Rng; + // FIX: thread_rng() deprecated in rand 0.9 → use rng() + // FIX: rand 0.10 no longer re-exports RngCore at root; + // import rand::Rng (supertrait) instead so next_u64() resolves + RngImpl::seed_from_u64(rand::rng().next_u64()) } else { // no std_random feature build, use getrandom #[cfg(feature = "js")] @@ -31,3 +34,58 @@ pub fn get_rng_impl(seed: Option) -> RngImpl { } } } + +#[cfg(test)] +mod tests { + use super::*; + use rand::Rng; + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn seeded_rng_is_deterministic() { + let mut a = get_rng_impl(Some(42)); + let mut b = get_rng_impl(Some(42)); + // two RNGs seeded with the same value produce the same sequence + let va: Vec = (0..8).map(|_| a.next_u64()).collect(); + let vb: Vec = (0..8).map(|_| b.next_u64()).collect(); + assert_eq!(va, vb); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn different_seeds_produce_different_sequences() { + let mut a = get_rng_impl(Some(1)); + let mut b = get_rng_impl(Some(2)); + let va: Vec = (0..4).map(|_| a.next_u64()).collect(); + let vb: Vec = (0..4).map(|_| b.next_u64()).collect(); + assert_ne!(va, vb); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn none_seed_returns_usable_rng() { + // unseeded path: on non-wasm this uses OS entropy (rand::rng() under + // std_rand, or getrandom/zero-seed otherwise). Excluded on bare wasm + // because OS entropy is unavailable without a host shim. + let mut r = get_rng_impl(None); + let _ = r.next_u64(); + } + + #[test] + #[cfg(feature = "std_rand")] + fn std_rand_none_seed_uses_os_entropy() { + // under the std_rand feature, get_rng_impl(None) seeds via rand::rng() + // which draws from OS entropy. Verify that path returns a working RNG + // and that two draws produce distinct values (non-deterministic source). + let mut a = get_rng_impl(None); + let v1 = a.next_u64(); + let v2 = a.next_u64(); + assert_ne!(v1, v2, "two draws from an entropy-seeded RNG should differ"); + } +} diff --git a/src/readers/csv.rs b/src/readers/csv.rs index e9a88436..2014aba4 100644 --- a/src/readers/csv.rs +++ b/src/readers/csv.rs @@ -211,7 +211,7 @@ where #[cfg(test)] mod tests { mod matrix_from_csv_source { - use super::super::{read_string_from_source, CSVDefinition, ReadingError}; + use super::super::{CSVDefinition, ReadingError, read_string_from_source}; use crate::linalg::basic::matrix::DenseMatrix; use crate::readers::{csv::matrix_from_csv_source, io_testing}; @@ -262,7 +262,8 @@ mod tests { &[5.1, 3.5, 1.4, 0.2], &[4.9, 3.0, 1.4, 0.2], &[4.7, 3.2, 1.3, 0.2], - ]).unwrap()) + ]) + .unwrap()) ) } #[test] @@ -300,7 +301,7 @@ mod tests { } } mod extract_row_vectors_from_csv_text { - use super::super::{extract_row_vectors_from_csv_text, CSVDefinition, CSVRowFormat}; + use super::super::{CSVDefinition, CSVRowFormat, extract_row_vectors_from_csv_text}; #[test] fn read_default_csv() { @@ -318,7 +319,7 @@ mod tests { } } mod test_validate_csv_row { - use super::super::{validate_csv_row, CSVRowFormat, ReadingError}; + use super::super::{CSVRowFormat, ReadingError, validate_csv_row}; #[test] fn valid_row_with_comma() { @@ -363,7 +364,7 @@ mod tests { } } mod extract_fields_from_csv_row { - use super::super::{extract_fields_from_csv_row, CSVRowFormat}; + use super::super::{CSVRowFormat, extract_fields_from_csv_row}; #[test] fn read_four_values_from_csv_row() { @@ -380,7 +381,7 @@ mod tests { } } mod detect_row_format { - use super::super::{detect_row_format, CSVDefinition, CSVRowFormat, ReadingError}; + use super::super::{CSVDefinition, CSVRowFormat, ReadingError, detect_row_format}; #[test] fn detect_2_fields_with_header() { @@ -455,7 +456,7 @@ mod tests { } } mod extract_vector_from_csv_line { - use super::super::{extract_vector_from_csv_line, CSVRowFormat, ReadingError}; + use super::super::{CSVRowFormat, ReadingError, extract_vector_from_csv_line}; #[test] fn extract_five_floating_point_values() { diff --git a/src/readers/io_testing.rs b/src/readers/io_testing.rs index cb0b4b0f..0bd0e07f 100644 --- a/src/readers/io_testing.rs +++ b/src/readers/io_testing.rs @@ -1,7 +1,7 @@ //! This module contains functionality to test IO. It has both functions that write //! to the file-system for end-to-end tests, but also abstractions to avoid this by //! reading from strings instead. -use rand::distributions::{Alphanumeric, DistString}; +use rand::distr::{Alphanumeric, SampleString}; use std::fs; use std::io::Bytes; use std::io::Read; @@ -16,7 +16,7 @@ pub struct TemporaryTextFile { impl TemporaryTextFile { pub fn new(contents: &str) -> std::io::Result { let test_text_file = TemporaryTextFile { - random_path: Alphanumeric.sample_string(&mut rand::thread_rng(), 16), + random_path: Alphanumeric.sample_string(&mut rand::rng(), 16), }; string_to_file(contents, &test_text_file.random_path)?; Ok(test_text_file) @@ -103,7 +103,7 @@ impl Read for TestingDataSource { #[cfg(test)] mod test { use super::TestingDataSource; - use super::{string_to_file, TemporaryTextFile}; + use super::{TemporaryTextFile, string_to_file}; use std::fs; use std::io::Read; use std::path; diff --git a/src/svm/mod.rs b/src/svm/mod.rs index 648e8946..eca1f4c5 100644 --- a/src/svm/mod.rs +++ b/src/svm/mod.rs @@ -22,11 +22,8 @@ //! //! //! -/// search parameters pub mod svc; pub mod svr; -// search parameters space -pub mod search; use core::fmt::Debug; @@ -47,7 +44,7 @@ use crate::linalg::basic::arrays::{Array1, ArrayView1}; typetag::serde(tag = "type") )] pub trait Kernel: Debug { - #[allow(clippy::ptr_arg)] + #[expect(clippy::ptr_arg)] /// Apply kernel function to x_i and x_j fn apply(&self, x_i: &Vec, x_j: &Vec) -> Result; } @@ -390,7 +387,6 @@ mod tests { .with_gamma(0.5) .with_degree(3.0) .with_coef0(1.0) - //.with_params(3.0, 0.5, 1.0) .apply(&v1, &v2) .unwrap() .abs(); diff --git a/src/svm/search/mod.rs b/src/svm/search/mod.rs deleted file mode 100644 index d61b8326..00000000 --- a/src/svm/search/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! SVC and Grid Search - -/// SVC search parameters -pub mod svc_params; -/// SVC search parameters -pub mod svr_params; diff --git a/src/svm/search/svc_params.rs b/src/svm/search/svc_params.rs deleted file mode 100644 index 42f686b3..00000000 --- a/src/svm/search/svc_params.rs +++ /dev/null @@ -1,183 +0,0 @@ -// /// SVC grid search parameters -// #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -// #[derive(Debug, Clone)] -// pub struct SVCSearchParameters< -// TX: Number + RealNumber, -// TY: Number + Ord, -// X: Array2, -// Y: Array1, -// K: Kernel, -// > { -// #[cfg_attr(feature = "serde", serde(default))] -// /// Number of epochs. -// pub epoch: Vec, -// #[cfg_attr(feature = "serde", serde(default))] -// /// Regularization parameter. -// pub c: Vec, -// #[cfg_attr(feature = "serde", serde(default))] -// /// Tolerance for stopping epoch. -// pub tol: Vec, -// #[cfg_attr(feature = "serde", serde(default))] -// /// The kernel function. -// pub kernel: Vec, -// #[cfg_attr(feature = "serde", serde(default))] -// /// Unused parameter. -// m: PhantomData<(X, Y, TY)>, -// #[cfg_attr(feature = "serde", serde(default))] -// /// Controls the pseudo random number generation for shuffling the data for probability estimates -// seed: Vec>, -// } - -// /// SVC grid search iterator -// pub struct SVCSearchParametersIterator< -// TX: Number + RealNumber, -// TY: Number + Ord, -// X: Array2, -// Y: Array1, -// K: Kernel, -// > { -// svc_search_parameters: SVCSearchParameters, -// current_epoch: usize, -// current_c: usize, -// current_tol: usize, -// current_kernel: usize, -// current_seed: usize, -// } - -// impl, Y: Array1, K: Kernel> -// IntoIterator for SVCSearchParameters -// { -// type Item = SVCParameters<'a, TX, TY, X, Y>; -// type IntoIter = SVCSearchParametersIterator; - -// fn into_iter(self) -> Self::IntoIter { -// SVCSearchParametersIterator { -// svc_search_parameters: self, -// current_epoch: 0, -// current_c: 0, -// current_tol: 0, -// current_kernel: 0, -// current_seed: 0, -// } -// } -// } - -// impl, Y: Array1, K: Kernel> -// Iterator for SVCSearchParametersIterator -// { -// type Item = SVCParameters; - -// fn next(&mut self) -> Option { -// if self.current_epoch == self.svc_search_parameters.epoch.len() -// && self.current_c == self.svc_search_parameters.c.len() -// && self.current_tol == self.svc_search_parameters.tol.len() -// && self.current_kernel == self.svc_search_parameters.kernel.len() -// && self.current_seed == self.svc_search_parameters.seed.len() -// { -// return None; -// } - -// let next = SVCParameters { -// epoch: self.svc_search_parameters.epoch[self.current_epoch], -// c: self.svc_search_parameters.c[self.current_c], -// tol: self.svc_search_parameters.tol[self.current_tol], -// kernel: self.svc_search_parameters.kernel[self.current_kernel].clone(), -// m: PhantomData, -// seed: self.svc_search_parameters.seed[self.current_seed], -// }; - -// if self.current_epoch + 1 < self.svc_search_parameters.epoch.len() { -// self.current_epoch += 1; -// } else if self.current_c + 1 < self.svc_search_parameters.c.len() { -// self.current_epoch = 0; -// self.current_c += 1; -// } else if self.current_tol + 1 < self.svc_search_parameters.tol.len() { -// self.current_epoch = 0; -// self.current_c = 0; -// self.current_tol += 1; -// } else if self.current_kernel + 1 < self.svc_search_parameters.kernel.len() { -// self.current_epoch = 0; -// self.current_c = 0; -// self.current_tol = 0; -// self.current_kernel += 1; -// } else if self.current_seed + 1 < self.svc_search_parameters.seed.len() { -// self.current_epoch = 0; -// self.current_c = 0; -// self.current_tol = 0; -// self.current_kernel = 0; -// self.current_seed += 1; -// } else { -// self.current_epoch += 1; -// self.current_c += 1; -// self.current_tol += 1; -// self.current_kernel += 1; -// self.current_seed += 1; -// } - -// Some(next) -// } -// } - -// impl, Y: Array1, K: Kernel> Default -// for SVCSearchParameters -// { -// fn default() -> Self { -// let default_params: SVCParameters = SVCParameters::default(); - -// SVCSearchParameters { -// epoch: vec![default_params.epoch], -// c: vec![default_params.c], -// tol: vec![default_params.tol], -// kernel: vec![default_params.kernel], -// m: PhantomData, -// seed: vec![default_params.seed], -// } -// } -// } - -// #[cfg(test)] -// mod tests { -// use num::ToPrimitive; - -// use super::*; -// use crate::linalg::basic::matrix::DenseMatrix; -// use crate::metrics::accuracy; -// #[cfg(feature = "serde")] -// use crate::svm::*; - -// #[test] -// fn search_parameters() { -// let parameters: SVCSearchParameters, LinearKernel> = -// SVCSearchParameters { -// epoch: vec![10, 100], -// kernel: vec![LinearKernel {}], -// ..Default::default() -// }; -// let mut iter = parameters.into_iter(); -// let next = iter.next().unwrap(); -// assert_eq!(next.epoch, 10); -// assert_eq!(next.kernel, LinearKernel {}); -// let next = iter.next().unwrap(); -// assert_eq!(next.epoch, 100); -// assert_eq!(next.kernel, LinearKernel {}); -// assert!(iter.next().is_none()); -// } - -// #[test] -// fn search_parameters() { -// let parameters: SVCSearchParameters, LinearKernel> = -// SVCSearchParameters { -// epoch: vec![10, 100], -// kernel: vec![LinearKernel {}], -// ..Default::default() -// }; -// let mut iter = parameters.into_iter(); -// let next = iter.next().unwrap(); -// assert_eq!(next.epoch, 10); -// assert_eq!(next.kernel, LinearKernel {}); -// let next = iter.next().unwrap(); -// assert_eq!(next.epoch, 100); -// assert_eq!(next.kernel, LinearKernel {}); -// assert!(iter.next().is_none()); -// } -// } diff --git a/src/svm/search/svr_params.rs b/src/svm/search/svr_params.rs deleted file mode 100644 index 8a819354..00000000 --- a/src/svm/search/svr_params.rs +++ /dev/null @@ -1,293 +0,0 @@ -//! # SVR Grid Search Parameters -//! -//! This module provides utilities for defining and iterating over grid search parameter spaces -//! for Support Vector Regression (SVR) models in [smartcore](https://github.com/smartcorelib/smartcore). -//! -//! The main struct, [`SVRSearchParameters`], allows users to specify multiple values for each -//! SVR hyperparameter (epsilon, regularization parameter C, tolerance, and kernel function). -//! The provided iterator yields all possible combinations (the Cartesian product) of these parameters, -//! enabling exhaustive grid search for hyperparameter tuning. -//! -//! -//! ## Example -//! ``` -//! use smartcore::svm::Kernels; -//! use smartcore::svm::search::svr_params::SVRSearchParameters; -//! use smartcore::linalg::basic::matrix::DenseMatrix; -//! -//! let params = SVRSearchParameters::> { -//! eps: vec![0.1, 0.2], -//! c: vec![1.0, 10.0], -//! tol: vec![1e-3], -//! kernel: vec![Kernels::linear(), Kernels::rbf().with_gamma(0.5)], -//! m: std::marker::PhantomData, -//! }; -//! -//! // for param_set in params.into_iter() { -//! // Use param_set (of type svr::SVRParameters) to fit and evaluate your SVR model. -//! // } -//! ``` -//! -//! -//! ## Note -//! This module is intended for use with smartcore version 0.4 or later. The API is not compatible with older versions[1]. -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; - -use crate::linalg::basic::arrays::Array2; -use crate::numbers::basenum::Number; -use crate::numbers::floatnum::FloatNumber; -use crate::numbers::realnum::RealNumber; -use crate::svm::{svr, Kernels}; -use std::marker::PhantomData; - -/// ## SVR grid search parameters -/// A struct representing a grid of hyperparameters for SVR grid search in smartcore. -/// -/// Each field is a vector of possible values for the corresponding SVR hyperparameter. -/// The [`IntoIterator`] implementation yields every possible combination of these parameters -/// as an `svr::SVRParameters` struct, suitable for use in model selection routines. -/// -/// # Type Parameters -/// - `T`: Numeric type for parameters (e.g., `f64`) -/// - `M`: Matrix type implementing [`Array2`] -/// -/// # Fields -/// - `eps`: Vector of epsilon values for the epsilon-insensitive loss in SVR. -/// - `c`: Vector of regularization parameters (C) for SVR. -/// - `tol`: Vector of tolerance values for the stopping criterion. -/// - `kernel`: Vector of kernel function variants (see [`Kernels`]). -/// - `m`: Phantom data for the matrix type parameter. -/// -/// # Example -/// ``` -/// use smartcore::svm::Kernels; -/// use smartcore::svm::search::svr_params::SVRSearchParameters; -/// use smartcore::linalg::basic::matrix::DenseMatrix; -/// -/// let params = SVRSearchParameters::> { -/// eps: vec![0.1, 0.2], -/// c: vec![1.0, 10.0], -/// tol: vec![1e-3], -/// kernel: vec![Kernels::linear(), Kernels::rbf().with_gamma(0.5)], -/// m: std::marker::PhantomData, -/// }; -/// ``` -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -#[derive(Debug, Clone)] -pub struct SVRSearchParameters> { - /// Epsilon in the epsilon-SVR model. - pub eps: Vec, - /// Regularization parameter. - pub c: Vec, - /// Tolerance for stopping eps. - pub tol: Vec, - /// The kernel function. - pub kernel: Vec, - /// Unused parameter. - pub m: PhantomData, -} - -/// SVR grid search iterator -pub struct SVRSearchParametersIterator> { - svr_search_parameters: SVRSearchParameters, - current_eps: usize, - current_c: usize, - current_tol: usize, - current_kernel: usize, -} - -impl> IntoIterator - for SVRSearchParameters -{ - type Item = svr::SVRParameters; - type IntoIter = SVRSearchParametersIterator; - - fn into_iter(self) -> Self::IntoIter { - SVRSearchParametersIterator { - svr_search_parameters: self, - current_eps: 0, - current_c: 0, - current_tol: 0, - current_kernel: 0, - } - } -} - -impl> Iterator - for SVRSearchParametersIterator -{ - type Item = svr::SVRParameters; - - fn next(&mut self) -> Option { - if self.current_eps == self.svr_search_parameters.eps.len() - && self.current_c == self.svr_search_parameters.c.len() - && self.current_tol == self.svr_search_parameters.tol.len() - && self.current_kernel == self.svr_search_parameters.kernel.len() - { - return None; - } - - let next = svr::SVRParameters:: { - eps: self.svr_search_parameters.eps[self.current_eps], - c: self.svr_search_parameters.c[self.current_c], - tol: self.svr_search_parameters.tol[self.current_tol], - kernel: Some(self.svr_search_parameters.kernel[self.current_kernel].clone()), - }; - - if self.current_eps + 1 < self.svr_search_parameters.eps.len() { - self.current_eps += 1; - } else if self.current_c + 1 < self.svr_search_parameters.c.len() { - self.current_eps = 0; - self.current_c += 1; - } else if self.current_tol + 1 < self.svr_search_parameters.tol.len() { - self.current_eps = 0; - self.current_c = 0; - self.current_tol += 1; - } else if self.current_kernel + 1 < self.svr_search_parameters.kernel.len() { - self.current_eps = 0; - self.current_c = 0; - self.current_tol = 0; - self.current_kernel += 1; - } else { - self.current_eps += 1; - self.current_c += 1; - self.current_tol += 1; - self.current_kernel += 1; - } - - Some(next) - } -} - -impl> Default for SVRSearchParameters { - fn default() -> Self { - let default_params: svr::SVRParameters = svr::SVRParameters::default(); - - SVRSearchParameters { - eps: vec![default_params.eps], - c: vec![default_params.c], - tol: vec![default_params.tol], - kernel: vec![default_params.kernel.unwrap_or_else(Kernels::linear)], - m: PhantomData, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::linalg::basic::matrix::DenseMatrix; - use crate::svm::Kernels; - - type T = f64; - type M = DenseMatrix; - - #[test] - fn test_default_parameters() { - let params = SVRSearchParameters::::default(); - assert_eq!(params.eps.len(), 1); - assert_eq!(params.c.len(), 1); - assert_eq!(params.tol.len(), 1); - assert_eq!(params.kernel.len(), 1); - // Check that the default kernel is linear - assert_eq!(params.kernel[0], Kernels::linear()); - } - - #[test] - fn test_single_grid_iteration() { - let params = SVRSearchParameters:: { - eps: vec![0.1], - c: vec![1.0], - tol: vec![1e-3], - kernel: vec![Kernels::rbf().with_gamma(0.5)], - m: PhantomData, - }; - let mut iter = params.into_iter(); - let param = iter.next().unwrap(); - assert_eq!(param.eps, 0.1); - assert_eq!(param.c, 1.0); - assert_eq!(param.tol, 1e-3); - assert_eq!(param.kernel, Some(Kernels::rbf().with_gamma(0.5))); - assert!(iter.next().is_none()); - } - - #[test] - fn test_cartesian_grid_iteration() { - let params = SVRSearchParameters:: { - eps: vec![0.1, 0.2], - c: vec![1.0, 2.0], - tol: vec![1e-3], - kernel: vec![Kernels::linear(), Kernels::rbf().with_gamma(0.5)], - m: PhantomData, - }; - let expected_count = - params.eps.len() * params.c.len() * params.tol.len() * params.kernel.len(); - let results: Vec<_> = params.into_iter().collect(); - assert_eq!(results.len(), expected_count); - - // Check that all parameter combinations are present - let mut seen = vec![]; - for p in &results { - seen.push((p.eps, p.c, p.tol, p.kernel.clone().unwrap())); - } - for &eps in &[0.1, 0.2] { - for &c in &[1.0, 2.0] { - for &tol in &[1e-3] { - for kernel in &[Kernels::linear(), Kernels::rbf().with_gamma(0.5)] { - assert!(seen.contains(&(eps, c, tol, kernel.clone()))); - } - } - } - } - } - - #[test] - fn test_empty_grid() { - let params = SVRSearchParameters:: { - eps: vec![], - c: vec![], - tol: vec![], - kernel: vec![], - m: PhantomData, - }; - let mut iter = params.into_iter(); - assert!(iter.next().is_none()); - } - - #[test] - fn test_kernel_enum_variants() { - let lin = Kernels::linear(); - let rbf = Kernels::rbf().with_gamma(0.2); - let poly = Kernels::polynomial() - .with_degree(2.0) - .with_gamma(1.0) - .with_coef0(0.5); - let sig = Kernels::sigmoid().with_gamma(0.3).with_coef0(0.1); - - assert_eq!(lin, Kernels::Linear); - match rbf { - Kernels::RBF { gamma } => assert_eq!(gamma, Some(0.2)), - _ => panic!("Not RBF"), - } - match poly { - Kernels::Polynomial { - degree, - gamma, - coef0, - } => { - assert_eq!(degree, Some(2.0)); - assert_eq!(gamma, Some(1.0)); - assert_eq!(coef0, Some(0.5)); - } - _ => panic!("Not Polynomial"), - } - match sig { - Kernels::Sigmoid { gamma, coef0 } => { - assert_eq!(gamma, Some(0.3)); - assert_eq!(coef0, Some(0.1)); - } - _ => panic!("Not Sigmoid"), - } - } -} diff --git a/src/svm/svc.rs b/src/svm/svc.rs index d72ecdac..f9b74b17 100644 --- a/src/svm/svc.rs +++ b/src/svm/svc.rs @@ -253,7 +253,7 @@ impl<'a, TX: Number + RealNumber, TY: Number + Ord, X: Array2, Y: Array1 for (j, prediction) in predictions.iter().enumerate() { let prediction = prediction.to_i32().unwrap(); let poll = polls.get_mut(j).unwrap(); // Get the poll for the current data point - // Increment the vote for the predicted class + // Increment the vote for the predicted class if let Some(count) = poll.get_mut(&prediction) { *count += 1 } else { @@ -456,8 +456,7 @@ impl<'a, TX: Number + RealNumber, TY: Number + Ord, X: Array2 + 'a, Y: Array ))); } let classes = (classes[0], classes[1]); - let svc = Self::optimize_and_fit(x, y, parameters, classes, None); - svc + Self::optimize_and_fit(x, y, parameters, classes, None) } /// Fits a binary Support Vector Classifier (SVC) specifically for multi-class scenarios. @@ -488,8 +487,7 @@ impl<'a, TX: Number + RealNumber, TY: Number + Ord, X: Array2 + 'a, Y: Array ) -> Result, Failed> { let classes = multiclass_config.classes; let indices = multiclass_config.indices; - let svc = Self::optimize_and_fit(x, y, parameters, classes, Some(indices)); - svc + Self::optimize_and_fit(x, y, parameters, classes, Some(indices)) } /// Internal function to optimize and fit the Support Vector Classifier. diff --git a/src/svm/svr.rs b/src/svm/svr.rs index e912743b..778bdcbd 100644 --- a/src/svm/svr.rs +++ b/src/svm/svr.rs @@ -598,8 +598,8 @@ mod tests { use super::*; use crate::linalg::basic::matrix::DenseMatrix; use crate::metrics::mean_squared_error; - use crate::svm::search::svr_params::SVRSearchParameters; use crate::svm::Kernels; + use crate::svm::search::svr_params::SVRSearchParameters; #[test] fn search_parameters() { diff --git a/src/svm/tests_edge_cases.rs b/src/svm/tests_edge_cases.rs new file mode 100644 index 00000000..4b40ca9f --- /dev/null +++ b/src/svm/tests_edge_cases.rs @@ -0,0 +1,113 @@ +//! Stage 3: edge-case & known-answer parity tests for src/svm. +//! +//! Covers: SVC, SVR. +//! Tracking issue: #394 / #391. + +#[cfg(test)] +mod svm_edge_cases { + use crate::linalg::basic::matrix::DenseMatrix; + use crate::svm::svc::{SVC, SVCParameters}; + use crate::svm::svr::{SVR, SVRParameters}; + use crate::svm::Kernels; + + // ── SVC ─────────────────────────────────────────────────────────────────── + + /// Small linearly separable dataset: all predictions must be correct. + #[test] + fn svc_linearly_separable_perfect_accuracy() { + let x = DenseMatrix::from_2d_array(&[ + &[-2.0_f64, -1.0], + &[-1.0, -1.0], + &[1.0, 1.0], + &[2.0, 1.0], + ]).unwrap(); + let y: Vec = vec![-1, -1, 1, 1]; + let model = SVC::fit(&x, &y, SVCParameters::default().with_c(100.0)).unwrap(); + let preds = model.predict(&x).unwrap(); + assert_eq!(preds, y, "SVC failed perfect separation"); + } + + /// Decision-function sign must align with predicted class (+1 / -1). + #[test] + fn svc_decision_function_sign_consistent() { + let x = DenseMatrix::from_2d_array(&[ + &[-3.0_f64], &[-2.0], &[2.0], &[3.0], + ]).unwrap(); + let y: Vec = vec![-1, -1, 1, 1]; + let model = SVC::fit(&x, &y, SVCParameters::default().with_c(100.0)).unwrap(); + let scores = model.decision_function(&x).unwrap(); + let preds = model.predict(&x).unwrap(); + for (score, pred) in scores.iter().zip(preds.iter()) { + let expected_sign = if *pred == 1 { 1.0_f64 } else { -1.0_f64 }; + assert!( + score * expected_sign > 0.0, + "decision score sign mismatch: score={score}, pred={pred}" + ); + } + } + + /// RBF kernel on separable data must achieve 100% accuracy. + #[test] + fn svc_rbf_kernel_separable() { + let x = DenseMatrix::from_2d_array(&[ + &[-2.0_f64, 0.0], + &[-1.5, 0.0], + &[1.5, 0.0], + &[2.0, 0.0], + ]).unwrap(); + let y: Vec = vec![-1, -1, 1, 1]; + let model = SVC::fit( + &x, + &y, + SVCParameters::default().with_c(10.0).with_kernel(Kernels::rbf().with_gamma(1.0)), + ).unwrap(); + let preds = model.predict(&x).unwrap(); + assert_eq!(preds, y); + } + + /// Non-separable (XOR) should not panic; model returns some prediction. + #[test] + fn svc_non_separable_no_panic() { + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64, 0.0], + &[0.0, 1.0], + &[1.0, 0.0], + &[1.0, 1.0], + ]).unwrap(); + let y: Vec = vec![-1, 1, 1, -1]; // XOR + let result = SVC::fit(&x, &y, SVCParameters::default().with_c(1.0)); + if let Ok(model) = result { + assert!(model.predict(&x).is_ok()); + } + } + + // ── SVR ─────────────────────────────────────────────────────────────────── + + /// Perfect-fit: y = 2x; predictions within epsilon tolerance. + #[test] + fn svr_linear_known_answer() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64], &[2.0], &[3.0], &[4.0], &[5.0], + ]).unwrap(); + let y: Vec = vec![2.0, 4.0, 6.0, 8.0, 10.0]; + let model = SVR::fit(&x, &y, SVRParameters::default().with_c(100.0).with_eps(0.01)).unwrap(); + let y_hat = model.predict(&x).unwrap(); + for (a, b) in y.iter().zip(y_hat.iter()) { + assert!((a - b).abs() < 1.0, "SVR pred: expected {a}, got {b}"); + } + } + + /// Constant target: SVR should predict approximately the constant. + #[test] + fn svr_constant_target() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64], &[2.0], &[3.0], &[4.0], + ]).unwrap(); + let y: Vec = vec![5.0, 5.0, 5.0, 5.0]; + let model = SVR::fit(&x, &y, SVRParameters::default().with_c(1.0)).unwrap(); + let y_hat = model.predict(&x).unwrap(); + for b in y_hat.iter() { + assert!((b - 5.0).abs() < 1.0, "SVR constant target: got {b}"); + } + } +} diff --git a/src/tests_stage5_datasets.rs b/src/tests_stage5_datasets.rs new file mode 100644 index 00000000..b4d48851 --- /dev/null +++ b/src/tests_stage5_datasets.rs @@ -0,0 +1,180 @@ +//! Stage 5: dataset loader & generator edge-case tests. +//! +//! Gated on `#[cfg(feature = "datasets")]`. +//! Tracking issue: #396 / #391. + +#[cfg(all(test, feature = "datasets"))] +mod dataset_tests { + // ── Loaders ────────────────────────────────────────────────────────────── + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn boston_loader_shape_and_features() { + use crate::dataset::boston::load_dataset; + let ds = load_dataset(); + assert_eq!(ds.num_features, 13, "Boston: expected 13 features"); + assert_eq!(ds.num_samples, 506, "Boston: expected 506 samples"); + assert_eq!(ds.data.len(), 506 * 13); + assert_eq!(ds.target.len(), 506); + assert!(!ds.feature_names.is_empty(), "Boston: feature_names empty"); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn breast_cancer_loader_shape_and_binary_target() { + use crate::dataset::breast_cancer::load_dataset; + let ds = load_dataset(); + assert_eq!(ds.num_features, 30, "BreastCancer: expected 30 features"); + assert_eq!(ds.num_samples, 569, "BreastCancer: expected 569 samples"); + // Target is binary: only 0s and 1s + let unique_targets: std::collections::HashSet = ds.target.iter().cloned().collect(); + assert!(unique_targets.len() <= 2, "BreastCancer: more than 2 target classes"); + assert!(unique_targets.contains(&0) || unique_targets.contains(&1)); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn diabetes_loader_shape() { + use crate::dataset::diabetes::load_dataset; + let ds = load_dataset(); + assert_eq!(ds.num_features, 10, "Diabetes: expected 10 features"); + assert_eq!(ds.num_samples, 442, "Diabetes: expected 442 samples"); + assert_eq!(ds.data.len(), 442 * 10); + assert_eq!(ds.target.len(), 442); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn digits_loader_shape_and_target_cardinality() { + use crate::dataset::digits::load_dataset; + let ds = load_dataset(); + assert_eq!(ds.num_features, 64, "Digits: expected 64 features"); + assert_eq!(ds.num_samples, 1797, "Digits: expected 1797 samples"); + let unique_targets: std::collections::HashSet = ds.target.iter().cloned().collect(); + assert_eq!(unique_targets.len(), 10, "Digits: expected 10 classes (0-9)"); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn iris_loader_shape_and_target_names() { + use crate::dataset::iris::load_dataset; + let ds = load_dataset(); + assert_eq!(ds.num_features, 4, "Iris: expected 4 features"); + assert_eq!(ds.num_samples, 150, "Iris: expected 150 samples"); + assert_eq!(ds.feature_names.len(), 4); + assert_eq!(ds.target_names.len(), 3, "Iris: expected 3 target names"); + let unique_targets: std::collections::HashSet = ds.target.iter().cloned().collect(); + assert_eq!(unique_targets.len(), 3, "Iris: expected 3 target classes"); + } + + // ── Generators ─────────────────────────────────────────────────────────── + + use crate::dataset::generator::{make_blobs, make_circles, make_moons}; + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn make_blobs_standard() { + let ds = make_blobs(100, 4, 3); + assert_eq!(ds.num_samples, 100); + assert_eq!(ds.num_features, 4); + assert_eq!(ds.data.len(), 400); + assert_eq!(ds.target.len(), 100); + // Labels should be in {0.0, 1.0, 2.0} + let unique: std::collections::HashSet = + ds.target.iter().map(|&v| v as u32).collect(); + assert_eq!(unique.len(), 3); + } + + /// n_samples=1 edge-case: must not panic. + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn make_blobs_single_sample() { + let ds = make_blobs(1, 2, 1); + assert_eq!(ds.num_samples, 1); + assert_eq!(ds.data.len(), 2); + assert_eq!(ds.target.len(), 1); + } + + /// noise=0 circles: all data should be finite. + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn make_circles_zero_noise() { + let ds = make_circles(20, 0.5, 0.0); + assert_eq!(ds.num_samples, 20); + assert_eq!(ds.num_features, 2); + assert!(ds.data.iter().all(|v| v.is_finite()), "non-finite value in circles data"); + } + + /// noise=0 moons: all data should be finite. + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn make_moons_zero_noise() { + let ds = make_moons(20, 0.0); + assert_eq!(ds.num_samples, 20); + assert_eq!(ds.num_features, 2); + assert!(ds.data.iter().all(|v| v.is_finite()), "non-finite value in moons data"); + } + + /// make_circles with n_samples=2 (minimum viable: 1 outer + 1 inner). + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn make_circles_minimal_samples() { + let ds = make_circles(2, 0.5, 0.01); + assert_eq!(ds.num_samples, 2); + assert_eq!(ds.target.len(), 2); + } + + /// make_moons with n_samples=2. + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn make_moons_minimal_samples() { + let ds = make_moons(2, 0.0); + assert_eq!(ds.num_samples, 2); + assert_eq!(ds.target.len(), 2); + } + + /// Description field is always non-empty. + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn dataset_description_non_empty() { + assert!(!make_blobs(10, 2, 2).description.is_empty()); + assert!(!make_circles(10, 0.5, 0.05).description.is_empty()); + assert!(!make_moons(10, 0.05).description.is_empty()); + } +} diff --git a/src/tests_stage5_ndarray.rs b/src/tests_stage5_ndarray.rs new file mode 100644 index 00000000..0ffd2b1a --- /dev/null +++ b/src/tests_stage5_ndarray.rs @@ -0,0 +1,186 @@ +//! Stage 5: ndarray-bindings parity tests. +//! +//! Ensures `ArrayBase, Ix2>` (ndarray) behaves identically to +//! `DenseMatrix` for all operations covered in `linalg/basic/`. +//! +//! Gated on `#[cfg(feature = "ndarray-bindings")]`. +//! Tracking issue: #396 / #391. + +#[cfg(all(test, feature = "ndarray-bindings"))] +mod ndarray_parity { + use crate::linalg::basic::arrays::{Array, Array2, ArrayView1, ArrayView2}; + use crate::linalg::basic::matrix::DenseMatrix; + use ndarray::{arr2, Array2 as NdArray2}; + + fn assert_close_f64(a: f64, b: f64, tol: f64, label: &str) { + assert!((a - b).abs() < tol, "{label}: expected {b}, got {a}"); + } + + // ── shape ───────────────────────────────────────────────────────────────── + + #[test] + fn ndarray_shape_parity() { + let nd: NdArray2 = arr2(&[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]); + let dm = DenseMatrix::from_ndarray2(&nd); + assert_eq!(Array::shape(&nd), Array::shape(&dm)); + } + + // ── get ─────────────────────────────────────────────────────────────────── + + #[test] + fn ndarray_get_parity() { + let nd: NdArray2 = arr2(&[[1, 2, 3], [4, 5, 6]]); + let dm = DenseMatrix::from_ndarray2(&nd); + for r in 0..2 { + for c in 0..3 { + assert_eq!( + Array::get(&nd, (r, c)), + Array::get(&dm, (r, c)), + "get({r},{c}) mismatch" + ); + } + } + } + + // ── is_empty ───────────────────────────────────────────────────────────── + + #[test] + fn ndarray_is_empty_parity() { + let empty_nd = NdArray2::::zeros((0, 0)); + let empty_dm = DenseMatrix::from_ndarray2(&empty_nd); + assert_eq!(Array::is_empty(&empty_nd), Array::is_empty(&empty_dm)); + + let nonempty_nd: NdArray2 = arr2(&[[1.0, 2.0], [3.0, 4.0]]); + let nonempty_dm = DenseMatrix::from_ndarray2(&nonempty_nd); + assert_eq!(Array::is_empty(&nonempty_nd), Array::is_empty(&nonempty_dm)); + } + + // ── iterator axis=0 (row-major) ─────────────────────────────────────────── + + #[test] + fn ndarray_iterator_axis0_parity() { + let nd: NdArray2 = arr2(&[[1, 2, 3], [4, 5, 6]]); + let dm = DenseMatrix::from_ndarray2(&nd); + let nd_vals: Vec = nd.iterator(0).copied().collect(); + let dm_vals: Vec = dm.iterator(0).copied().collect(); + assert_eq!(nd_vals, dm_vals, "axis=0 iterator mismatch"); + } + + // ── iterator axis=1 (column-major) ──────────────────────────────────────── + + #[test] + fn ndarray_iterator_axis1_parity() { + let nd: NdArray2 = arr2(&[[1, 2, 3], [4, 5, 6]]); + let dm = DenseMatrix::from_ndarray2(&nd); + let nd_vals: Vec = nd.iterator(1).copied().collect(); + let dm_vals: Vec = dm.iterator(1).copied().collect(); + assert_eq!(nd_vals, dm_vals, "axis=1 iterator mismatch"); + } + + // ── get_row ─────────────────────────────────────────────────────────────── + + #[test] + fn ndarray_get_row_parity() { + let nd: NdArray2 = arr2(&[[1, 2, 3], [4, 5, 6], [7, 8, 9]]); + let dm = DenseMatrix::from_ndarray2(&nd); + for r in 0..3 { + let nd_row = Array2::get_row(&nd, r); + let dm_row = Array2::get_row(&dm, r); + assert_eq!(nd_row.shape(), dm_row.shape()); + for c in 0..3 { + assert_eq!(nd_row.get(c), dm_row.get(c), "row {r} col {c}"); + } + } + } + + // ── get_col ─────────────────────────────────────────────────────────────── + + #[test] + fn ndarray_get_col_parity() { + let nd: NdArray2 = arr2(&[[1, 2], [3, 4], [5, 6]]); + let dm = DenseMatrix::from_ndarray2(&nd); + for c in 0..2 { + let nd_col = Array2::get_col(&nd, c); + let dm_col = Array2::get_col(&dm, c); + assert_eq!(nd_col.shape(), dm_col.shape()); + for r in 0..3 { + assert_eq!(nd_col.get(r), dm_col.get(r), "col {c} row {r}"); + } + } + } + + // ── slice ───────────────────────────────────────────────────────────────── + + #[test] + fn ndarray_slice_parity() { + let nd: NdArray2 = arr2(&[[1, 2, 3], [4, 5, 6], [7, 8, 9]]); + let dm = DenseMatrix::from_ndarray2(&nd); + let nd_slice = Array2::slice(&nd, 1..3, 0..2); + let dm_slice = Array2::slice(&dm, 1..3, 0..2); + assert_eq!(nd_slice.shape(), dm_slice.shape()); + for r in 0..2 { + for c in 0..2 { + assert_eq!(nd_slice.get((r, c)), dm_slice.get((r, c)), "slice ({r},{c})"); + } + } + } + + // ── fill ───────────────────────────────────────────────────────────────── + + #[test] + fn ndarray_fill_parity() { + let nd = as Array2>::fill(3, 4, 7.0); + let dm = as Array2>::fill(3, 4, 7.0); + assert_eq!(Array::shape(&nd), Array::shape(&dm)); + assert_eq!(nd.iterator(0).copied().collect::>(), + dm.iterator(0).copied().collect::>()); + } + + // ── transpose ───────────────────────────────────────────────────────────── + + #[test] + fn ndarray_transpose_parity() { + let nd: NdArray2 = arr2(&[[1, 2, 3], [4, 5, 6]]); + let dm = DenseMatrix::from_ndarray2(&nd); + let nd_t = Array2::transpose(&nd); + let dm_t = Array2::transpose(&dm); + assert_eq!(Array::shape(&nd_t), Array::shape(&dm_t)); + let nd_vals: Vec = nd_t.iterator(0).copied().collect(); + let dm_vals: Vec = dm_t.iterator(0).copied().collect(); + assert_eq!(nd_vals, dm_vals, "transpose row-major values mismatch"); + } + + // ── from_ndarray2 round-trip ────────────────────────────────────────────── + + #[test] + fn from_ndarray2_round_trip_values() { + let original: NdArray2 = arr2(&[ + [1.1, 2.2, 3.3], + [4.4, 5.5, 6.6], + [7.7, 8.8, 9.9], + ]); + let dm = DenseMatrix::from_ndarray2(&original); + for r in 0..3 { + for c in 0..3 { + assert_close_f64( + *Array::get(&dm, (r, c)), + *original.get((r, c)).unwrap(), + 1e-10, + &format!("round-trip ({r},{c})"), + ); + } + } + } + + // ── Fortran-order (transposed layout) ──────────────────────────────────── + + #[test] + fn from_ndarray2_fortran_order_correct() { + let c_order: NdArray2 = arr2(&[[1, 2, 3], [4, 5, 6]]); + let f_order = c_order.t().to_owned(); // shape (3,2), Fortran layout + let dm = DenseMatrix::from_ndarray2(&f_order); + // from_ndarray2 must always read logical order, so element (0,0) == 1 + assert_eq!(*Array::get(&dm, (0, 0)), *f_order.get((0, 0)).unwrap()); + assert_eq!(*Array::get(&dm, (2, 1)), *f_order.get((2, 1)).unwrap()); + } +} diff --git a/src/tests_stage5_serde.rs b/src/tests_stage5_serde.rs new file mode 100644 index 00000000..1147849a --- /dev/null +++ b/src/tests_stage5_serde.rs @@ -0,0 +1,209 @@ +//! Stage 5: serde round-trip tests for every serializable type. +//! +//! Gated on `#[cfg(feature = "serde")]` so they only run under +//! `cargo test --features serde` (or `--all-features`). +//! +//! Tracking issue: #396 / #391. + +#[cfg(all(test, feature = "serde"))] +mod serde_round_trips { + use serde_json; + + // ── DenseMatrix ────────────────────────────────────────────────────────── + + use crate::linalg::basic::matrix::DenseMatrix; + use crate::linalg::basic::arrays::Array; + + #[test] + fn dense_matrix_serde_json_round_trip() { + let m = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0, 3.0], + &[4.0, 5.0, 6.0], + ]) + .unwrap(); + let json = serde_json::to_string(&m).expect("serialize DenseMatrix"); + let m2: DenseMatrix = serde_json::from_str(&json).expect("deserialize DenseMatrix"); + assert_eq!(m.shape(), m2.shape()); + for r in 0..2 { + for c in 0..3 { + assert!( + (m.get((r, c)) - m2.get((r, c))).abs() < 1e-10, + "mismatch at ({r},{c})" + ); + } + } + } + + #[test] + fn dense_matrix_i32_serde_json_round_trip() { + let m = DenseMatrix::from_2d_array(&[&[1_i32, 2, 3], &[4, 5, 6]]).unwrap(); + let json = serde_json::to_string(&m).unwrap(); + let m2: DenseMatrix = serde_json::from_str(&json).unwrap(); + assert_eq!(m, m2); + } + + // ── KNNClassifier ──────────────────────────────────────────────────────── + + use crate::neighbors::knn_classifier::{KNNClassifier, KNNClassifierParameters}; + use crate::metrics::distance::Distances; + use crate::algorithm::neighbour::KNNAlgorithmName; + use crate::neighbors::KNNWeightFunction; + + #[test] + fn knn_classifier_serde_json_round_trip() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0], &[2.0, 3.0], &[3.0, 4.0], + &[10.0, 11.0], &[11.0, 12.0], &[12.0, 13.0], + ]) + .unwrap(); + let y: Vec = vec![0, 0, 0, 1, 1, 1]; + let params = KNNClassifierParameters::default() + .with_k(3) + .with_algorithm(KNNAlgorithmName::LinearSearch) + .with_weight(KNNWeightFunction::Uniform); + let model = KNNClassifier::fit(&x, &y, params).unwrap(); + let json = serde_json::to_string(&model).expect("serialize KNNClassifier"); + let model2: KNNClassifier, _> = + serde_json::from_str(&json).expect("deserialize KNNClassifier"); + let pred1 = model.predict(&x).unwrap(); + let pred2 = model2.predict(&x).unwrap(); + assert_eq!(pred1, pred2, "KNNClassifier round-trip prediction mismatch"); + } + + // ── KNNRegressor ───────────────────────────────────────────────────────── + + use crate::neighbors::knn_regressor::{KNNRegressor, KNNRegressorParameters}; + + #[test] + fn knn_regressor_serde_json_round_trip() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64], &[2.0], &[3.0], &[4.0], &[5.0], + ]) + .unwrap(); + let y: Vec = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let params = KNNRegressorParameters::default().with_k(2); + let model = KNNRegressor::fit(&x, &y, params).unwrap(); + let json = serde_json::to_string(&model).expect("serialize KNNRegressor"); + let model2: KNNRegressor, _> = + serde_json::from_str(&json).expect("deserialize KNNRegressor"); + let pred1 = model.predict(&x).unwrap(); + let pred2 = model2.predict(&x).unwrap(); + for (a, b) in pred1.iter().zip(pred2.iter()) { + assert!((a - b).abs() < 1e-10, "KNNRegressor round-trip mismatch"); + } + } + + // ── DecisionTreeClassifier ─────────────────────────────────────────────── + + use crate::tree::decision_tree_classifier::{DecisionTreeClassifier, DecisionTreeClassifierParameters}; + + #[test] + fn decision_tree_classifier_serde_json_round_trip() { + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64, 0.0], &[0.0, 1.0], &[1.0, 0.0], &[1.0, 1.0], + &[0.0, 0.0], &[0.0, 1.0], &[1.0, 0.0], &[1.0, 1.0], + ]) + .unwrap(); + let y: Vec = vec![0, 0, 1, 1, 0, 0, 1, 1]; + let params = DecisionTreeClassifierParameters::default().with_max_depth(3); + let model = DecisionTreeClassifier::fit(&x, &y, params).unwrap(); + let json = serde_json::to_string(&model).expect("serialize DecisionTreeClassifier"); + let model2: DecisionTreeClassifier, Vec> = + serde_json::from_str(&json).expect("deserialize DecisionTreeClassifier"); + let pred1 = model.predict(&x).unwrap(); + let pred2 = model2.predict(&x).unwrap(); + assert_eq!(pred1, pred2); + } + + // ── DecisionTreeRegressor ──────────────────────────────────────────────── + + use crate::tree::decision_tree_regressor::{DecisionTreeRegressor, DecisionTreeRegressorParameters}; + + #[test] + fn decision_tree_regressor_serde_json_round_trip() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64], &[2.0], &[3.0], &[4.0], &[5.0], &[6.0], + ]) + .unwrap(); + let y: Vec = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; + let params = DecisionTreeRegressorParameters::default().with_max_depth(3); + let model = DecisionTreeRegressor::fit(&x, &y, params).unwrap(); + let json = serde_json::to_string(&model).expect("serialize DecisionTreeRegressor"); + let model2: DecisionTreeRegressor, Vec> = + serde_json::from_str(&json).expect("deserialize DecisionTreeRegressor"); + let pred1 = model.predict(&x).unwrap(); + let pred2 = model2.predict(&x).unwrap(); + for (a, b) in pred1.iter().zip(pred2.iter()) { + assert!((a - b).abs() < 1e-6); + } + } + + // ── LinearRegression ───────────────────────────────────────────────────── + + use crate::linear::linear_regression::{LinearRegression, LinearRegressionParameters}; + use crate::SupervisedEstimator; + + #[test] + fn linear_regression_serde_json_round_trip() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64], &[2.0], &[3.0], &[4.0], &[5.0], + ]) + .unwrap(); + let y: Vec = vec![2.0, 4.0, 6.0, 8.0, 10.0]; + let model = LinearRegression::fit(&x, &y, LinearRegressionParameters::default()).unwrap(); + let json = serde_json::to_string(&model).expect("serialize LinearRegression"); + let model2: LinearRegression, Vec> = + serde_json::from_str(&json).expect("deserialize LinearRegression"); + let pred1 = model.predict(&x).unwrap(); + let pred2 = model2.predict(&x).unwrap(); + for (a, b) in pred1.iter().zip(pred2.iter()) { + assert!((a - b).abs() < 1e-6, "LinearRegression round-trip mismatch"); + } + } + + // ── GaussianNB ─────────────────────────────────────────────────────────── + + use crate::naive_bayes::gaussian::{GaussianNB, GaussianNBParameters}; + + #[test] + fn gaussian_nb_serde_json_round_trip() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 0.0], &[1.1, 0.1], &[0.9, -0.1], + &[-1.0, 0.0], &[-1.1, 0.1], &[-0.9, -0.1], + ]) + .unwrap(); + let y: Vec = vec![0, 0, 0, 1, 1, 1]; + let model = GaussianNB::fit(&x, &y, GaussianNBParameters::default()).unwrap(); + let json = serde_json::to_string(&model).expect("serialize GaussianNB"); + let model2: GaussianNB, Vec> = + serde_json::from_str(&json).expect("deserialize GaussianNB"); + let pred1 = model.predict(&x).unwrap(); + let pred2 = model2.predict(&x).unwrap(); + assert_eq!(pred1, pred2, "GaussianNB round-trip prediction mismatch"); + } + + // ── PCA ────────────────────────────────────────────────────────────────── + + use crate::decomposition::pca::{PCA, PCAParameters}; + use crate::Transformer; + + #[test] + fn pca_serde_json_round_trip() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0, 3.0], + &[4.0, 5.0, 6.0], + &[7.0, 8.0, 9.0], + &[2.0, 3.0, 4.0], + &[5.0, 6.0, 7.0], + ]) + .unwrap(); + let params = PCAParameters::default().with_n_components(2); + let model = PCA::fit(&x, params).unwrap(); + let json = serde_json::to_string(&model).expect("serialize PCA"); + let model2: PCA> = + serde_json::from_str(&json).expect("deserialize PCA"); + let t1 = model.transform(&x).unwrap(); + let t2 = model2.transform(&x).unwrap(); + assert_eq!(t1.shape(), t2.shape()); + } +} diff --git a/src/tree/base_tree_regressor.rs b/src/tree/base_tree_regressor.rs index 87288947..46bdaea9 100644 --- a/src/tree/base_tree_regressor.rs +++ b/src/tree/base_tree_regressor.rs @@ -3,8 +3,8 @@ use std::default::Default; use std::fmt::Debug; use std::marker::PhantomData; +use rand::RngExt; use rand::seq::SliceRandom; -use rand::Rng; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -262,37 +262,28 @@ impl, Y: Array1> } pub(crate) fn predict_for_row(&self, x: &X, row: usize) -> TY { - let mut result = 0f64; - let mut queue: LinkedList = LinkedList::new(); - - queue.push_back(0); - - while !queue.is_empty() { - match queue.pop_front() { - Some(node_id) => { - let node = &self.nodes()[node_id]; - if node.true_child.is_none() && node.false_child.is_none() { - result = node.output; - } else if x.get((row, node.split_feature)).to_f64().unwrap() - <= node.split_value.unwrap_or(f64::NAN) - { - queue.push_back(node.true_child.unwrap()); - } else { - queue.push_back(node.false_child.unwrap()); - } - } - None => break, + let mut node_id = 0; + loop { + let node = &self.nodes()[node_id]; + let Some(true_child) = node.true_child else { + return TY::from_f64(node.output).unwrap(); + }; + let false_child = node.false_child.unwrap(); + node_id = if x.get((row, node.split_feature)).to_f64().unwrap() + <= node.split_value.unwrap_or(f64::NAN) + { + true_child + } else { + false_child }; } - - TY::from_f64(result).unwrap() } fn find_best_cutoff( &mut self, visitor: &mut NodeVisitor<'_, TX, TY, X, Y>, mtry: usize, - rng: &mut impl Rng, + rng: &mut impl rand::Rng, ) -> bool { let (_, n_attr) = visitor.x.shape(); @@ -336,7 +327,7 @@ impl, Y: Array1> sum: f64, parent_gain: f64, j: usize, - rng: &mut impl Rng, + rng: &mut impl rand::Rng, ) { let (min_val, max_val) = { let mut min_opt = None; @@ -363,7 +354,7 @@ impl, Y: Array1> return; } - let split_value = rng.gen_range(min_val.to_f64().unwrap()..max_val.to_f64().unwrap()); + let split_value = rng.random_range(min_val.to_f64().unwrap()..max_val.to_f64().unwrap()); let mut true_sum = 0f64; let mut true_count = 0; @@ -476,7 +467,7 @@ impl, Y: Array1> mut visitor: NodeVisitor<'a, TX, TY, X, Y>, mtry: usize, visitor_queue: &mut LinkedList>, - rng: &mut impl Rng, + rng: &mut impl rand::Rng, ) -> bool { let (n, _) = visitor.x.shape(); let mut tc = 0; diff --git a/src/tree/decision_tree_classifier.rs b/src/tree/decision_tree_classifier.rs index 96007677..984087b8 100644 --- a/src/tree/decision_tree_classifier.rs +++ b/src/tree/decision_tree_classifier.rs @@ -69,8 +69,8 @@ use std::default::Default; use std::fmt::Debug; use std::marker::PhantomData; -use rand::seq::SliceRandom; use rand::Rng; +use rand::seq::SliceRandom; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -640,30 +640,21 @@ impl, Y: Array1> } pub(crate) fn predict_for_row(&self, x: &X, row: usize) -> usize { - let mut result = 0; - let mut queue: LinkedList = LinkedList::new(); - - queue.push_back(0); - - while !queue.is_empty() { - match queue.pop_front() { - Some(node_id) => { - let node = &self.nodes()[node_id]; - if node.true_child.is_none() && node.false_child.is_none() { - result = node.output; - } else if x.get((row, node.split_feature)).to_f64().unwrap() - <= node.split_value.unwrap_or(f64::NAN) - { - queue.push_back(node.true_child.unwrap()); - } else { - queue.push_back(node.false_child.unwrap()); - } - } - None => break, + let mut node_id = 0; + loop { + let node = &self.nodes()[node_id]; + let Some(true_child) = node.true_child else { + return node.output; + }; + let false_child = node.false_child.unwrap(); + node_id = if x.get((row, node.split_feature)).to_f64().unwrap() + <= node.split_value.unwrap_or(f64::NAN) + { + true_child + } else { + false_child }; } - - result } fn find_best_cutoff( @@ -1232,7 +1223,7 @@ mod tests { let tree = DecisionTreeClassifier::fit(&x, &y, Default::default()).unwrap(); let deserialized_tree: DecisionTreeClassifier, Vec> = - bincode::deserialize(&bincode::serialize(&tree).unwrap()).unwrap(); + postcard::from_bytes(&postcard::to_allocvec(&tree).unwrap()).unwrap(); assert_eq!(tree, deserialized_tree); } diff --git a/src/tree/decision_tree_regressor.rs b/src/tree/decision_tree_regressor.rs index 86b99343..2800fdab 100644 --- a/src/tree/decision_tree_regressor.rs +++ b/src/tree/decision_tree_regressor.rs @@ -462,7 +462,7 @@ mod tests { let tree = DecisionTreeRegressor::fit(&x, &y, Default::default()).unwrap(); let deserialized_tree: DecisionTreeRegressor, Vec> = - bincode::deserialize(&bincode::serialize(&tree).unwrap()).unwrap(); + postcard::from_bytes(&postcard::to_allocvec(&tree).unwrap()).unwrap(); assert_eq!(tree, deserialized_tree); } diff --git a/src/tree/tests_edge_cases.rs b/src/tree/tests_edge_cases.rs new file mode 100644 index 00000000..047f7af7 --- /dev/null +++ b/src/tree/tests_edge_cases.rs @@ -0,0 +1,115 @@ +//! Stage 3: edge-case & known-answer parity tests for src/tree. +//! +//! Covers: DecisionTreeClassifier, DecisionTreeRegressor. +//! Tracking issue: #394 / #391. + +#[cfg(test)] +mod tree_edge_cases { + use crate::linalg::basic::matrix::DenseMatrix; + use crate::tree::decision_tree_classifier::{DecisionTreeClassifier, DecisionTreeClassifierParameters}; + use crate::tree::decision_tree_regressor::{DecisionTreeRegressor, DecisionTreeRegressorParameters}; + + // ── DecisionTreeClassifier ──────────────────────────────────────────────── + + /// Depth=1 (stump): must partition into two majority groups. + #[test] + fn dtc_depth_limit_one() { + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64], &[1.0], &[2.0], &[3.0], + ]).unwrap(); + let y: Vec = vec![0, 0, 1, 1]; + let model = DecisionTreeClassifier::fit( + &x, &y, + DecisionTreeClassifierParameters::default().with_max_depth(1), + ).unwrap(); + let preds = model.predict(&x).unwrap(); + // With depth=1 there are exactly 2 leaves → both classes present. + assert!(preds.contains(&0) && preds.contains(&1)); + } + + /// Pure-node early stopping: single-class input fits without error. + #[test] + fn dtc_pure_node_single_class() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0], + &[3.0, 4.0], + &[5.0, 6.0], + ]).unwrap(); + let y: Vec = vec![1, 1, 1]; // all same class + let model = DecisionTreeClassifier::fit(&x, &y, Default::default()).unwrap(); + let preds = model.predict(&x).unwrap(); + assert!(preds.iter().all(|&p| p == 1)); + } + + /// Single-feature split: must perfectly classify linearly separable data. + #[test] + fn dtc_single_feature_perfect_split() { + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64], &[1.0], &[10.0], &[11.0], + ]).unwrap(); + let y: Vec = vec![0, 0, 1, 1]; + let model = DecisionTreeClassifier::fit(&x, &y, Default::default()).unwrap(); + let preds = model.predict(&x).unwrap(); + assert_eq!(preds, y); + } + + /// Determinism: same data, same seed → identical predictions. + #[test] + fn dtc_deterministic_with_seed() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 0.5], + &[2.0, 1.5], + &[3.0, 2.5], + &[4.0, 3.5], + ]).unwrap(); + let y: Vec = vec![0, 0, 1, 1]; + let m1 = DecisionTreeClassifier::fit(&x, &y, Default::default()).unwrap(); + let m2 = DecisionTreeClassifier::fit(&x, &y, Default::default()).unwrap(); + assert_eq!(m1.predict(&x).unwrap(), m2.predict(&x).unwrap()); + } + + // ── DecisionTreeRegressor ───────────────────────────────────────────────── + + /// Constant target: all predictions should equal the constant. + #[test] + fn dtr_constant_target() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64], &[2.0], &[3.0], &[4.0], + ]).unwrap(); + let y: Vec = vec![7.0, 7.0, 7.0, 7.0]; + let model = DecisionTreeRegressor::fit(&x, &y, Default::default()).unwrap(); + let y_hat = model.predict(&x).unwrap(); + for b in y_hat.iter() { + assert!((b - 7.0).abs() < 1e-6, "expected 7.0, got {b}"); + } + } + + /// Known-answer: y = x (perfect staircase); deep tree should memorise. + #[test] + fn dtr_known_answer_identity() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64], &[2.0], &[3.0], &[4.0], &[5.0], + ]).unwrap(); + let y: Vec = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let model = DecisionTreeRegressor::fit(&x, &y, Default::default()).unwrap(); + let y_hat = model.predict(&x).unwrap(); + for (a, b) in y.iter().zip(y_hat.iter()) { + assert!((a - b).abs() < 1e-6, "expected {a}, got {b}"); + } + } + + /// Depth limit: capped depth should not panic and predictions are finite. + #[test] + fn dtr_depth_limit_no_panic() { + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64], &[2.0], &[3.0], &[4.0], &[5.0], + ]).unwrap(); + let y: Vec = vec![1.5, 3.5, 2.0, 4.5, 0.5]; + let model = DecisionTreeRegressor::fit( + &x, &y, + DecisionTreeRegressorParameters::default().with_max_depth(2), + ).unwrap(); + let y_hat = model.predict(&x).unwrap(); + assert!(y_hat.iter().all(|v| v.is_finite())); + } +} diff --git a/src/xgboost/xgb_regressor.rs b/src/xgboost/xgb_regressor.rs index 36993ca5..37aa7e2e 100644 --- a/src/xgboost/xgb_regressor.rs +++ b/src/xgboost/xgb_regressor.rs @@ -42,7 +42,7 @@ //! ``` //! -use rand::{seq::SliceRandom, Rng}; +use rand::{Rng, seq::SliceRandom}; use std::{iter::zip, marker::PhantomData}; use crate::{ @@ -113,7 +113,7 @@ impl Objective { /// /// # Returns /// A vector of hessians for each sample. - #[allow(unused_variables)] + #[expect(unused_variables)] pub fn hessian>(&self, y_true: &Y, y_pred: &[f64]) -> Vec { match self { Objective::MeanSquaredError => vec![1.0; y_true.shape()], @@ -248,38 +248,47 @@ impl, Y: Array1> } // A split is only valid if it results in a positive gain. - if *best_split_score > 0.0 { - let mut left_idxs = Vec::new(); - let mut right_idxs = Vec::new(); - for idx in idxs.iter() { - if data.get((*idx, *best_feature_idx)).to_f64().unwrap() <= *best_threshold { - left_idxs.push(*idx); - } else { - right_idxs.push(*idx); - } + if *best_split_score <= 0.0 { + return; + } + + let mut left_idxs = Vec::new(); + let mut right_idxs = Vec::new(); + for idx in idxs.iter() { + if data.get((*idx, *best_feature_idx)).to_f64().unwrap() <= *best_threshold { + left_idxs.push(*idx); + } else { + right_idxs.push(*idx); } + } - *left = Some(Box::new(TreeRegressor::fit( - data, - g, - h, - &left_idxs, - max_depth - 1, - min_child_weight, - lambda, - gamma, - ))); - *right = Some(Box::new(TreeRegressor::fit( - data, - g, - h, - &right_idxs, - max_depth - 1, - min_child_weight, - lambda, - gamma, - ))); + if left_idxs.is_empty() || right_idxs.is_empty() { + // A degenerate split where all samples land on one side. This can happen when feature + // values are large enough that `(x_i + x_i_next) / 2.0` overflows to +inf, + // all samples satisfy `<= +inf` and right_idxs is empty. + return; } + + *left = Some(Box::new(TreeRegressor::fit( + data, + g, + h, + &left_idxs, + max_depth - 1, + min_child_weight, + lambda, + gamma, + ))); + *right = Some(Box::new(TreeRegressor::fit( + data, + g, + h, + &right_idxs, + max_depth - 1, + min_child_weight, + lambda, + gamma, + ))); } /// Iterates through a single feature to find the best possible split point. @@ -733,6 +742,26 @@ mod tests { assert!((tree.right.unwrap().value - (-0.833333333)).abs() < 1e-9); } + /// Exercises the degenerate-split guard in insert_child_nodes. + #[test] + fn test_no_panic_on_degenerate_split_from_overflow() { + let large = f64::MAX / 1.5; + let x_vec = vec![vec![large], vec![large * 1.1]]; + let x = DenseMatrix::from_2d_vec(&x_vec).unwrap(); + let y = vec![0.0, 1.0]; + + let params = XGRegressorParameters::default() + .with_n_estimators(10) + .with_max_depth(3); + + let model = XGRegressor::fit(&x, &y, params); + assert!(model.is_ok(), "Fit panicked or failed: {:?}", model.err()); + + let predictions = model.unwrap().predict(&x); + assert!(predictions.is_ok()); + assert_eq!(predictions.unwrap().len(), 2); + } + /// A "smoke test" to ensure the main XGRegressor can fit and predict on multidimensional data. #[test] fn test_xgregressor_fit_predict_multidimensional() { diff --git a/tests/cluster_workflow.rs b/tests/cluster_workflow.rs new file mode 100644 index 00000000..9865b76c --- /dev/null +++ b/tests/cluster_workflow.rs @@ -0,0 +1,118 @@ +//! Integration test: clustering end-to-end workflow. +//! +//! `KMeans` and `DBSCAN` — inline fixtures and (optionally) the iris dataset. +//! Tracking issue: #397 / #391. +//! +//! API notes: +//! - `from_iterator()` comes from `Array2` trait; import it where used + +use smartcore::linalg::basic::matrix::DenseMatrix; + +// --------------------------------------------------------------------------- +// KMeans — inline fixture +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn kmeans_inline_workflow() { + use smartcore::cluster::kmeans::{KMeans, KMeansParameters}; + + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 1.0], + &[1.1, 1.2], + &[0.9, 0.8], + &[1.0, 1.1], + &[10.0, 10.0], + &[10.1, 9.9], + &[9.9, 10.1], + &[10.0, 9.8], + ]) + .unwrap(); + + let params = KMeansParameters::default().with_k(2); + let model = KMeans::fit(&x, params).expect("KMeans::fit"); + let labels: Vec = model.predict(&x).expect("predict"); + + let cluster_a: std::collections::HashSet = labels[..4].iter().cloned().collect(); + let cluster_b: std::collections::HashSet = labels[4..].iter().cloned().collect(); + assert_eq!(cluster_a.len(), 1, "first 4 points should share a cluster"); + assert_eq!(cluster_b.len(), 1, "last 4 points should share a cluster"); + assert_ne!( + cluster_a.iter().next(), + cluster_b.iter().next(), + "the two groups should be in different clusters" + ); +} + +// --------------------------------------------------------------------------- +// DBSCAN — inline fixture +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn dbscan_inline_workflow() { + use smartcore::cluster::dbscan::{DBSCAN, DBSCANParameters}; + use smartcore::metrics::distance::Distances; + + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 1.0], + &[1.1, 1.2], + &[0.9, 0.8], + &[1.0, 1.1], + &[10.0, 10.0], + &[10.1, 9.9], + &[9.9, 10.1], + &[10.0, 9.8], + ]) + .unwrap(); + + let params = DBSCANParameters::default() + .with_min_samples(2) + .with_eps(0.5) + .with_distance(Distances::euclidian()); + let labels: Vec = DBSCAN::fit(&x, params) + .and_then(|m| m.predict(&x)) + .expect("DBSCAN::fit_predict"); + + assert!( + labels.iter().all(|&l| l > 0), + "unexpected noise points: {labels:?}" + ); + let cluster_a: std::collections::HashSet = labels[..4].iter().cloned().collect(); + let cluster_b: std::collections::HashSet = labels[4..].iter().cloned().collect(); + assert_eq!(cluster_a.len(), 1); + assert_eq!(cluster_b.len(), 1); + assert_ne!(cluster_a.iter().next(), cluster_b.iter().next()); +} + +// --------------------------------------------------------------------------- +// KMeans on iris dataset (datasets feature) +// --------------------------------------------------------------------------- + +#[cfg(feature = "datasets")] +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn kmeans_iris_workflow() { + use smartcore::cluster::kmeans::{KMeans, KMeansParameters}; + use smartcore::dataset::iris::load_dataset; + use smartcore::linalg::basic::arrays::Array2; + + let ds = load_dataset(); + let x = DenseMatrix::from_iterator(ds.data.iter().copied(), ds.num_samples, ds.num_features, 0); + + let params = KMeansParameters::default().with_k(3); + let model = KMeans::fit(&x, params).expect("KMeans::fit on iris"); + let labels: Vec = model.predict(&x).expect("predict"); + + let unique: std::collections::HashSet = labels.iter().cloned().collect(); + assert_eq!(unique.len(), 3, "expected 3 clusters, got {}", unique.len()); +} diff --git a/tests/decomposition_workflow.rs b/tests/decomposition_workflow.rs new file mode 100644 index 00000000..e54620fa --- /dev/null +++ b/tests/decomposition_workflow.rs @@ -0,0 +1,134 @@ +//! Integration test: decomposition end-to-end workflow. +//! +//! `PCA` fit → transform → shape/column checks; +//! `SVD` singular-value ordering. +//! Tracking issue: #397 / #391. +//! +//! API notes: +//! - `shape()` comes from `smartcore::linalg::basic::arrays::Array` which must +//! be in scope wherever `.shape()` is called on a `DenseMatrix` +//! - `from_iterator()` comes from `smartcore::linalg::basic::arrays::Array2` +//! - PCA::fit / PCA::transform are inherent methods; no trait import needed + +use smartcore::linalg::basic::matrix::DenseMatrix; + +// --------------------------------------------------------------------------- +// PCA — full-rank (no information loss) +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn pca_full_rank_workflow() { + use smartcore::decomposition::pca::{PCA, PCAParameters}; + use smartcore::linalg::basic::arrays::Array; + + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0, 3.0], + &[4.0, 5.0, 6.0], + &[7.0, 8.0, 9.0], + &[2.0, 4.0, 6.0], + &[3.0, 6.0, 9.0], + ]) + .unwrap(); + + let params = PCAParameters::default().with_n_components(3); + let model = PCA::fit(&x, params).expect("PCA::fit"); + let transformed = model.transform(&x).expect("transform"); + assert_eq!(transformed.shape(), (5, 3), "PCA full-rank output shape"); +} + +// --------------------------------------------------------------------------- +// PCA — dimensionality reduction +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn pca_reduce_and_reconstruct_workflow() { + use smartcore::decomposition::pca::{PCA, PCAParameters}; + use smartcore::linalg::basic::arrays::Array; + + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0, 3.0], + &[2.0, 4.0, 6.0], + &[3.0, 6.0, 9.0], + &[4.0, 8.0, 12.0], + &[5.0, 10.0, 15.0], + ]) + .unwrap(); + + let params = PCAParameters::default().with_n_components(1); + let model = PCA::fit(&x, params).expect("PCA::fit (rank-1)"); + let transformed = model.transform(&x).expect("transform"); + assert_eq!(transformed.shape().1, 1, "expected 1 output column"); +} + +// --------------------------------------------------------------------------- +// SVD decomposition — singular values non-negative and decreasing +// (SVD is not available on wasm32) +// --------------------------------------------------------------------------- + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn svd_singular_values_ordered_workflow() { + use smartcore::linalg::traits::svd::SVDDecomposable; + + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0, 3.0], + &[4.0, 5.0, 6.0], + &[7.0, 8.0, 9.0], + &[2.0, 3.0, 4.0], + ]) + .unwrap(); + + let svd = x.svd().expect("SVD decomposition"); + let s = &svd.s; + for i in 0..s.len() { + assert!(s[i] >= -1e-10, "negative singular value at {i}: {}", s[i]); + } + for i in 1..s.len() { + assert!( + s[i - 1] >= s[i] - 1e-10, + "singular values not descending: s[{}]={} > s[{}]={}", + i - 1, + s[i - 1], + i, + s[i] + ); + } +} + +// --------------------------------------------------------------------------- +// PCA on iris dataset (datasets feature) +// --------------------------------------------------------------------------- + +#[cfg(feature = "datasets")] +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn pca_iris_reduce_workflow() { + use smartcore::dataset::iris::load_dataset; + use smartcore::decomposition::pca::{PCA, PCAParameters}; + use smartcore::linalg::basic::arrays::Array; + use smartcore::linalg::basic::arrays::Array2; + + let ds = load_dataset(); + let x = DenseMatrix::from_iterator( + ds.data.iter().map(|&v| v as f64), + ds.num_samples, + ds.num_features, + 0, + ); + + let params = PCAParameters::default().with_n_components(2); + let model = PCA::fit(&x, params).expect("PCA fit on iris"); + let transformed = model.transform(&x).expect("transform"); + assert_eq!(transformed.shape(), (150, 2), "PCA(iris) output shape"); +} diff --git a/tests/ensemble_workflow.rs b/tests/ensemble_workflow.rs new file mode 100644 index 00000000..fa854ca2 --- /dev/null +++ b/tests/ensemble_workflow.rs @@ -0,0 +1,137 @@ +//! Integration test: ensemble models end-to-end workflow. +//! +//! `RandomForestClassifier` / `RandomForestRegressor`. +//! Tracking issue: #397 / #391. + +use smartcore::linalg::basic::matrix::DenseMatrix; + +fn accuracy(predicted: &[u32], actual: &[u32]) -> f64 { + predicted + .iter() + .zip(actual.iter()) + .filter(|(p, a)| p == a) + .count() as f64 + / actual.len() as f64 +} + +fn mae(predicted: &[f64], actual: &[f64]) -> f64 { + predicted + .iter() + .zip(actual.iter()) + .map(|(p, a)| (p - a).abs()) + .sum::() + / actual.len() as f64 +} + +// --------------------------------------------------------------------------- +// RandomForestClassifier — inline fixture +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn random_forest_classifier_inline_workflow() { + use smartcore::ensemble::random_forest_classifier::{ + RandomForestClassifier, RandomForestClassifierParameters, + }; + + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0], + &[2.0, 3.0], + &[3.0, 4.0], + &[4.0, 5.0], + &[10.0, 11.0], + &[11.0, 12.0], + &[12.0, 13.0], + &[13.0, 14.0], + ]) + .unwrap(); + let y: Vec = vec![0, 0, 0, 0, 1, 1, 1, 1]; + + let params = RandomForestClassifierParameters::default() + .with_n_trees(10) + .with_max_depth(3); + let model = RandomForestClassifier::fit(&x, &y, params).expect("RandomForestClassifier::fit"); + let preds = model.predict(&x).expect("predict"); + + let acc = accuracy(&preds, &y); + assert!(acc >= 0.875, "RandomForestClassifier accuracy: {acc:.3}"); +} + +// --------------------------------------------------------------------------- +// RandomForestRegressor — inline fixture +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn random_forest_regressor_inline_workflow() { + use smartcore::ensemble::random_forest_regressor::{ + RandomForestRegressor, RandomForestRegressorParameters, + }; + + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64], + &[2.0], + &[3.0], + &[4.0], + &[5.0], + &[6.0], + &[7.0], + &[8.0], + ]) + .unwrap(); + let y: Vec = vec![2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 16.0]; + + let params = RandomForestRegressorParameters::default() + .with_n_trees(10) + .with_max_depth(4); + let model = RandomForestRegressor::fit(&x, &y, params).expect("RandomForestRegressor::fit"); + let preds = model.predict(&x).expect("predict"); + + let err = mae(&preds, &y); + assert!(err < 2.0, "RandomForestRegressor MAE too high: {err:.4}"); +} + +// --------------------------------------------------------------------------- +// RandomForestClassifier — iris dataset +// --------------------------------------------------------------------------- + +#[cfg(feature = "datasets")] +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn random_forest_classifier_iris_workflow() { + use smartcore::dataset::iris::load_dataset; + use smartcore::ensemble::random_forest_classifier::{ + RandomForestClassifier, RandomForestClassifierParameters, + }; + use smartcore::linalg::basic::arrays::Array2; + + let ds = load_dataset(); + let x = DenseMatrix::from_iterator( + ds.data.iter().map(|&v| v as f64), + ds.num_samples, + ds.num_features, + 0, + ); + let y: Vec = ds.target.clone(); + + let params = RandomForestClassifierParameters::default() + .with_n_trees(20) + .with_max_depth(5); + let model = RandomForestClassifier::fit(&x, &y, params).expect("fit on iris"); + let preds = model.predict(&x).expect("predict"); + + let acc = accuracy(&preds, &y); + assert!( + acc >= 0.90, + "RandomForestClassifier (iris) accuracy: {acc:.3}" + ); +} diff --git a/tests/linear_workflow.rs b/tests/linear_workflow.rs new file mode 100644 index 00000000..979bd2d1 --- /dev/null +++ b/tests/linear_workflow.rs @@ -0,0 +1,175 @@ +//! Integration test: linear models end-to-end workflow. +//! +//! `LinearRegression`, `RidgeRegression`, `LogisticRegression` — +//! load (or inline) data → train → predict → evaluate with non-trivial thresholds. +//! +//! Dataset-dependent paths are gated on `#[cfg(feature = "datasets")]`; +//! a tiny inline fixture is used for the no-feature path. +//! +//! Tracking issue: #397 / #391. +//! +//! API notes: +//! - `from_iterator()` comes from `Array2` trait; import it where used + +use smartcore::linalg::basic::matrix::DenseMatrix; + +fn accuracy(predicted: &[u32], actual: &[u32]) -> f64 { + assert_eq!(predicted.len(), actual.len()); + let correct = predicted + .iter() + .zip(actual.iter()) + .filter(|(p, a)| p == a) + .count(); + correct as f64 / actual.len() as f64 +} + +fn mae(predicted: &[f64], actual: &[f64]) -> f64 { + assert_eq!(predicted.len(), actual.len()); + predicted + .iter() + .zip(actual.iter()) + .map(|(p, a)| (p - a).abs()) + .sum::() + / actual.len() as f64 +} + +// --------------------------------------------------------------------------- +// LinearRegression — inline fixture +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn linear_regression_inline_workflow() { + use smartcore::linear::linear_regression::{LinearRegression, LinearRegressionParameters}; + + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64], + &[2.0], + &[3.0], + &[4.0], + &[5.0], + &[6.0], + &[7.0], + &[8.0], + &[9.0], + &[10.0], + ]) + .unwrap(); + let y: Vec = (1..=10).map(|i| 3.0 * i as f64 + 1.0).collect(); + + let model = LinearRegression::fit(&x, &y, LinearRegressionParameters::default()) + .expect("LinearRegression::fit"); + let preds = model.predict(&x).expect("LinearRegression::predict"); + + let err = mae(&preds, &y); + assert!(err < 0.5, "LinearRegression MAE too high: {err:.4}"); +} + +// --------------------------------------------------------------------------- +// RidgeRegression — inline fixture +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn ridge_regression_inline_workflow() { + use smartcore::linear::ridge_regression::{RidgeRegression, RidgeRegressionParameters}; + + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 1.0], + &[2.0, 4.0], + &[3.0, 9.0], + &[4.0, 16.0], + &[5.0, 25.0], + &[6.0, 36.0], + ]) + .unwrap(); + let y: Vec = vec![2.0, 5.0, 10.0, 17.0, 26.0, 37.0]; + + let params = RidgeRegressionParameters::default().with_alpha(0.1); + let model = RidgeRegression::fit(&x, &y, params).expect("RidgeRegression::fit"); + let preds = model.predict(&x).expect("RidgeRegression::predict"); + + let err = mae(&preds, &y); + assert!(err < 2.0, "RidgeRegression MAE too high: {err:.4}"); +} + +// --------------------------------------------------------------------------- +// LogisticRegression — inline binary fixture +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn logistic_regression_inline_workflow() { + use smartcore::linear::logistic_regression::{ + LogisticRegression, LogisticRegressionParameters, + }; + + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 1.0], + &[2.0, 1.5], + &[1.5, 2.0], + &[2.5, 2.5], + &[8.0, 8.0], + &[9.0, 8.5], + &[8.5, 9.0], + &[9.5, 9.5], + ]) + .unwrap(); + let y: Vec = vec![0, 0, 0, 0, 1, 1, 1, 1]; + + let model = LogisticRegression::fit(&x, &y, LogisticRegressionParameters::default()) + .expect("LogisticRegression::fit"); + let preds = model.predict(&x).expect("LogisticRegression::predict"); + + let acc = accuracy(&preds, &y); + assert!( + acc >= 0.875, + "LogisticRegression accuracy too low: {acc:.3}" + ); +} + +// --------------------------------------------------------------------------- +// LinearRegression on iris dataset (dataset feature) +// --------------------------------------------------------------------------- + +#[cfg(feature = "datasets")] +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn linear_regression_iris_sepal_workflow() { + use smartcore::dataset::iris::load_dataset; + use smartcore::linalg::basic::arrays::Array2; + use smartcore::linear::linear_regression::{LinearRegression, LinearRegressionParameters}; + + let ds = load_dataset(); + let x_f64: DenseMatrix = DenseMatrix::from_iterator( + ds.data + .chunks(ds.num_features) + .flat_map(|row| row[..2].iter().map(|&v| v as f64)), + ds.num_samples, + 2, + 0, + ); + let petal_len: Vec = ds + .data + .chunks(ds.num_features) + .map(|row| row[2] as f64) + .collect(); + + let model = LinearRegression::fit(&x_f64, &petal_len, LinearRegressionParameters::default()) + .expect("fit on iris"); + let preds = model.predict(&x_f64).expect("predict on iris"); + let err = mae(&preds, &petal_len); + assert!(err < 0.7, "LinearRegression (iris) MAE too high: {err:.4}"); +} diff --git a/tests/model_selection_workflow.rs b/tests/model_selection_workflow.rs new file mode 100644 index 00000000..5244b756 --- /dev/null +++ b/tests/model_selection_workflow.rs @@ -0,0 +1,165 @@ +//! Integration test: model selection end-to-end workflow. +//! +//! `train_test_split` → fit → evaluate; `cross_validate`. +//! Tracking issue: #397 / #391. +//! +//! API notes: +//! - `cross_validate` takes an estimator instance via `::new()`, not a fn pointer +//! - `::new()` is a method on the `SupervisedEstimator` trait — must be in scope +//! - `cross_validate` cv parameter is `&KFold`, not a CrossValidationParameters struct +//! - score must be passed as `&score_fn` +//! - `is_empty()` and `shape()` come from `Array` trait +//! - `from_iterator()` comes from `Array2` trait + +use smartcore::api::SupervisedEstimator; +use smartcore::linalg::basic::arrays::Array; +use smartcore::linalg::basic::matrix::DenseMatrix; + +fn accuracy(predicted: &[u32], actual: &[u32]) -> f64 { + predicted + .iter() + .zip(actual.iter()) + .filter(|(p, a)| p == a) + .count() as f64 + / actual.len() as f64 +} + +// --------------------------------------------------------------------------- +// train_test_split — inline fixture +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn train_test_split_workflow() { + use smartcore::algorithm::neighbour::KNNAlgorithmName; + use smartcore::model_selection::train_test_split; + use smartcore::neighbors::KNNWeightFunction; + use smartcore::neighbors::knn_classifier::{KNNClassifier, KNNClassifierParameters}; + + let n = 20usize; + let data: Vec> = (0..n) + .map(|i| { + if i < n / 2 { + vec![i as f64, i as f64] + } else { + vec![i as f64 + 100.0, i as f64 + 100.0] + } + }) + .collect(); + let refs: Vec<&[f64]> = data.iter().map(|r| r.as_slice()).collect(); + let x = DenseMatrix::from_2d_array(&refs).unwrap(); + let y: Vec = (0..n).map(|i| if i < n / 2 { 0 } else { 1 }).collect(); + + let (x_train, x_test, y_train, y_test) = train_test_split(&x, &y, 0.3, true, Some(42)); + + assert!(!x_train.is_empty(), "train set empty"); + assert!(!x_test.is_empty(), "test set empty"); + assert_eq!(y_train.len() + y_test.len(), n); + + let params = KNNClassifierParameters::default() + .with_k(3) + .with_algorithm(KNNAlgorithmName::LinearSearch) + .with_weight(KNNWeightFunction::Uniform); + let model = KNNClassifier::fit(&x_train, &y_train, params).expect("fit"); + let preds = model.predict(&x_test).expect("predict"); + + let acc = accuracy(&preds, &y_test); + assert!(acc >= 0.8, "train_test_split KNN accuracy: {acc:.3}"); +} + +// --------------------------------------------------------------------------- +// cross_validate — inline fixture +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn cross_validate_knn_workflow() { + use smartcore::algorithm::neighbour::KNNAlgorithmName; + use smartcore::model_selection::{KFold, cross_validate}; + use smartcore::neighbors::KNNWeightFunction; + use smartcore::neighbors::knn_classifier::{KNNClassifier, KNNClassifierParameters}; + + let n = 30usize; + let data: Vec> = (0..n) + .map(|i| { + if i < n / 2 { + vec![i as f64, 0.0] + } else { + vec![i as f64, 100.0] + } + }) + .collect(); + let refs: Vec<&[f64]> = data.iter().map(|r| r.as_slice()).collect(); + let x = DenseMatrix::from_2d_array(&refs).unwrap(); + let y: Vec = (0..n).map(|i| if i < n / 2 { 0 } else { 1 }).collect(); + + let cv = KFold::default().with_n_splits(5); + let params = KNNClassifierParameters::default() + .with_k(3) + .with_algorithm(KNNAlgorithmName::LinearSearch) + .with_weight(KNNWeightFunction::Uniform); + + let score_fn = |y_true: &Vec, y_pred: &Vec| -> f64 { + y_true + .iter() + .zip(y_pred.iter()) + .filter(|(a, b)| a == b) + .count() as f64 + / y_true.len() as f64 + }; + + let result = cross_validate(KNNClassifier::new(), &x, &y, params, &cv, &score_fn) + .expect("cross_validate"); + + let mean_score = result.mean_test_score(); + assert!( + mean_score >= 0.7, + "cross_validate mean accuracy: {mean_score:.3}" + ); +} + +// --------------------------------------------------------------------------- +// train_test_split on iris dataset (datasets feature) +// --------------------------------------------------------------------------- + +#[cfg(feature = "datasets")] +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn train_test_split_iris_workflow() { + use smartcore::dataset::iris::load_dataset; + use smartcore::linalg::basic::arrays::Array2; + use smartcore::model_selection::train_test_split; + use smartcore::tree::decision_tree_classifier::{ + DecisionTreeClassifier, DecisionTreeClassifierParameters, + }; + + let ds = load_dataset(); + let x = DenseMatrix::from_iterator( + ds.data.iter().map(|&v| v as f64), + ds.num_samples, + ds.num_features, + 0, + ); + let y: Vec = ds.target.clone(); + + let (x_train, x_test, y_train, y_test) = train_test_split(&x, &y, 0.2, true, Some(0)); + + let params = DecisionTreeClassifierParameters::default().with_max_depth(5); + let model = DecisionTreeClassifier::fit(&x_train, &y_train, params).expect("fit iris train"); + let preds = model.predict(&x_test).expect("predict iris test"); + + let acc = accuracy(&preds, &y_test); + assert!( + acc >= 0.85, + "DecisionTree (iris test split) accuracy: {acc:.3}" + ); +} diff --git a/tests/naive_bayes_workflow.rs b/tests/naive_bayes_workflow.rs new file mode 100644 index 00000000..9379d58f --- /dev/null +++ b/tests/naive_bayes_workflow.rs @@ -0,0 +1,185 @@ +//! Integration test: Naive Bayes end-to-end workflow. +//! +//! GaussianNB, BernoulliNB, CategoricalNB, MultinomialNB. +//! Tracking issue: #397 / #391. +//! +//! API notes: +//! - `CategoricalNB` requires `T: Unsigned`; use `DenseMatrix` + `Vec` +//! - `MultinomialNB` requires `TX: Unsigned + TY: Unsigned`; same constraint +//! - `GaussianNB` and `BernoulliNB` use `f64` features + `u32` labels (no Unsigned bound) +//! - `from_iterator()` comes from `Array2` trait; import it where used + +use smartcore::linalg::basic::matrix::DenseMatrix; + +fn accuracy_u32(predicted: &[u32], actual: &[u32]) -> f64 { + predicted + .iter() + .zip(actual.iter()) + .filter(|(p, a)| p == a) + .count() as f64 + / actual.len() as f64 +} + +// --------------------------------------------------------------------------- +// GaussianNB +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn gaussian_nb_inline_workflow() { + use smartcore::naive_bayes::gaussian::{GaussianNB, GaussianNBParameters}; + + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 0.0], + &[1.1, 0.1], + &[0.9, -0.1], + &[1.2, 0.2], + &[-1.0, 0.0], + &[-1.1, 0.1], + &[-0.9, -0.1], + &[-1.2, 0.2], + ]) + .unwrap(); + let y: Vec = vec![0, 0, 0, 0, 1, 1, 1, 1]; + + let model = GaussianNB::fit(&x, &y, GaussianNBParameters::default()).expect("GaussianNB::fit"); + let preds = model.predict(&x).expect("predict"); + + assert!( + accuracy_u32(&preds, &y) >= 0.875, + "GaussianNB accuracy too low" + ); +} + +// --------------------------------------------------------------------------- +// BernoulliNB — binary feature matrix +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn bernoulli_nb_inline_workflow() { + use smartcore::naive_bayes::bernoulli::{BernoulliNB, BernoulliNBParameters}; + + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 1.0, 0.0, 0.0], + &[1.0, 0.0, 1.0, 0.0], + &[1.0, 1.0, 1.0, 0.0], + &[0.0, 0.0, 1.0, 1.0], + &[0.0, 0.0, 0.0, 1.0], + &[0.0, 1.0, 0.0, 1.0], + ]) + .unwrap(); + let y: Vec = vec![0, 0, 0, 1, 1, 1]; + + let model = + BernoulliNB::fit(&x, &y, BernoulliNBParameters::default()).expect("BernoulliNB::fit"); + let preds = model.predict(&x).expect("predict"); + + assert!( + accuracy_u32(&preds, &y) >= 0.666, + "BernoulliNB accuracy too low" + ); +} + +// --------------------------------------------------------------------------- +// CategoricalNB — requires T: Unsigned; use DenseMatrix + Vec +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn categorical_nb_inline_workflow() { + use smartcore::naive_bayes::categorical::{CategoricalNB, CategoricalNBParameters}; + + let x: DenseMatrix = DenseMatrix::from_2d_array(&[ + &[0_u32, 1, 0], + &[0, 0, 1], + &[1, 0, 0], + &[2, 1, 0], + &[2, 2, 1], + &[1, 2, 2], + ]) + .unwrap(); + let y: Vec = vec![0, 0, 0, 1, 1, 1]; + + let model = + CategoricalNB::fit(&x, &y, CategoricalNBParameters::default()).expect("CategoricalNB::fit"); + let preds = model.predict(&x).expect("predict"); + + assert!( + accuracy_u32(&preds, &y) >= 0.666, + "CategoricalNB accuracy too low" + ); +} + +// --------------------------------------------------------------------------- +// MultinomialNB — requires TX: Unsigned + TY: Unsigned; use u32 throughout +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn multinomial_nb_inline_workflow() { + use smartcore::naive_bayes::multinomial::{MultinomialNB, MultinomialNBParameters}; + + let x: DenseMatrix = DenseMatrix::from_2d_array(&[ + &[3_u32, 1, 0], + &[4, 2, 0], + &[5, 0, 1], + &[0, 3, 4], + &[0, 4, 5], + &[1, 2, 6], + ]) + .unwrap(); + let y: Vec = vec![0, 0, 0, 1, 1, 1]; + + let model = + MultinomialNB::fit(&x, &y, MultinomialNBParameters::default()).expect("MultinomialNB::fit"); + let preds = model.predict(&x).expect("predict"); + + assert!( + accuracy_u32(&preds, &y) >= 0.666, + "MultinomialNB accuracy too low" + ); +} + +// --------------------------------------------------------------------------- +// GaussianNB on iris (dataset feature) +// --------------------------------------------------------------------------- + +#[cfg(feature = "datasets")] +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn gaussian_nb_iris_workflow() { + use smartcore::dataset::iris::load_dataset; + use smartcore::linalg::basic::arrays::Array2; + use smartcore::naive_bayes::gaussian::{GaussianNB, GaussianNBParameters}; + + let ds = load_dataset(); + let x = DenseMatrix::from_iterator( + ds.data.iter().map(|&v| v as f64), + ds.num_samples, + ds.num_features, + 0, + ); + let y: Vec = ds.target.clone(); + + let model = GaussianNB::fit(&x, &y, GaussianNBParameters::default()).expect("fit on iris"); + let preds = model.predict(&x).expect("predict"); + + let acc = accuracy_u32(&preds, &y); + assert!(acc >= 0.90, "GaussianNB (iris) accuracy: {acc:.3}"); +} diff --git a/tests/neighbors_workflow.rs b/tests/neighbors_workflow.rs new file mode 100644 index 00000000..70d1395a --- /dev/null +++ b/tests/neighbors_workflow.rs @@ -0,0 +1,123 @@ +//! Integration test: KNN models end-to-end workflow. +//! +//! `KNNClassifier` / `KNNRegressor`. +//! Tracking issue: #397 / #391. +//! +//! API notes: +//! - `from_iterator()` comes from `Array2` trait; import it where used + +use smartcore::linalg::basic::matrix::DenseMatrix; + +fn accuracy(predicted: &[u32], actual: &[u32]) -> f64 { + predicted + .iter() + .zip(actual.iter()) + .filter(|(p, a)| p == a) + .count() as f64 + / actual.len() as f64 +} + +fn mae(predicted: &[f64], actual: &[f64]) -> f64 { + predicted + .iter() + .zip(actual.iter()) + .map(|(p, a)| (p - a).abs()) + .sum::() + / actual.len() as f64 +} + +// --------------------------------------------------------------------------- +// KNNClassifier — inline fixture +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn knn_classifier_inline_workflow() { + use smartcore::algorithm::neighbour::KNNAlgorithmName; + use smartcore::neighbors::KNNWeightFunction; + use smartcore::neighbors::knn_classifier::{KNNClassifier, KNNClassifierParameters}; + + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 2.0], + &[1.5, 2.5], + &[2.0, 3.0], + &[8.0, 8.0], + &[8.5, 8.5], + &[9.0, 9.0], + ]) + .unwrap(); + let y: Vec = vec![0, 0, 0, 1, 1, 1]; + + let params = KNNClassifierParameters::default() + .with_k(3) + .with_algorithm(KNNAlgorithmName::LinearSearch) + .with_weight(KNNWeightFunction::Uniform); + let model = KNNClassifier::fit(&x, &y, params).expect("KNNClassifier::fit"); + let preds = model.predict(&x).expect("predict"); + + let acc = accuracy(&preds, &y); + assert!(acc >= 0.833, "KNNClassifier accuracy: {acc:.3}"); +} + +// --------------------------------------------------------------------------- +// KNNRegressor — inline fixture +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn knn_regressor_inline_workflow() { + use smartcore::neighbors::knn_regressor::{KNNRegressor, KNNRegressorParameters}; + + let x = DenseMatrix::from_2d_array(&[&[1.0_f64], &[2.0], &[3.0], &[4.0], &[5.0]]).unwrap(); + let y: Vec = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + + let params = KNNRegressorParameters::default().with_k(2); + let model = KNNRegressor::fit(&x, &y, params).expect("KNNRegressor::fit"); + let preds = model.predict(&x).expect("predict"); + + let err = mae(&preds, &y); + assert!(err < 1.0, "KNNRegressor MAE too high: {err:.4}"); +} + +// --------------------------------------------------------------------------- +// KNNClassifier — iris dataset +// --------------------------------------------------------------------------- + +#[cfg(feature = "datasets")] +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn knn_classifier_iris_workflow() { + use smartcore::algorithm::neighbour::KNNAlgorithmName; + use smartcore::dataset::iris::load_dataset; + use smartcore::linalg::basic::arrays::Array2; + use smartcore::neighbors::KNNWeightFunction; + use smartcore::neighbors::knn_classifier::{KNNClassifier, KNNClassifierParameters}; + + let ds = load_dataset(); + let x = DenseMatrix::from_iterator( + ds.data.iter().map(|&v| v as f64), + ds.num_samples, + ds.num_features, + 0, + ); + let y: Vec = ds.target.clone(); + + let params = KNNClassifierParameters::default() + .with_k(5) + .with_algorithm(KNNAlgorithmName::LinearSearch) + .with_weight(KNNWeightFunction::Uniform); + let model = KNNClassifier::fit(&x, &y, params).expect("fit on iris"); + let preds = model.predict(&x).expect("predict"); + + let acc = accuracy(&preds, &y); + assert!(acc >= 0.90, "KNNClassifier (iris) accuracy: {acc:.3}"); +} diff --git a/tests/preprocessing_workflow.rs b/tests/preprocessing_workflow.rs new file mode 100644 index 00000000..c03f821b --- /dev/null +++ b/tests/preprocessing_workflow.rs @@ -0,0 +1,121 @@ +//! Integration test: preprocessing end-to-end workflow. +//! +//! `StandardScaler` fit → transform (mean≈0, std≈1 per column). +//! `OneHotEncoder` encode → shape check. +//! Tracking issue: #397 / #391. +//! +//! API notes: +//! - `Transformer` lives at `smartcore::api::Transformer` +//! - `UnsupervisedEstimator` (provides `fit`) lives at `smartcore::api::UnsupervisedEstimator` +//! - `StandardScaler` lives at `smartcore::preprocessing::numerical` +//! - `StandardScaler` has no `inverse_transform`; round-trip invariant is +//! verified through mean/std of the scaled output instead +//! - `OneHotEncoder::fit` requires `T: Categorizable` (only f32/f64); +//! `OneHotEncoderParams` has no `Default` — use `from_cat_idx` + +use smartcore::linalg::basic::matrix::DenseMatrix; + +// --------------------------------------------------------------------------- +// StandardScaler — scaled output has mean ≈ 0 per column +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn standard_scaler_round_trip_workflow() { + use smartcore::api::{Transformer, UnsupervisedEstimator}; + use smartcore::linalg::basic::arrays::Array; + use smartcore::preprocessing::numerical::{StandardScaler, StandardScalerParameters}; + + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 10.0], + &[2.0, 20.0], + &[3.0, 30.0], + &[4.0, 40.0], + &[5.0, 50.0], + ]) + .unwrap(); + + let scaler = + StandardScaler::fit(&x, StandardScalerParameters::default()).expect("StandardScaler::fit"); + let scaled = scaler.transform(&x).expect("transform"); + + let (nr, nc) = scaled.shape(); + for c in 0..nc { + let col: Vec = (0..nr).map(|r| *scaled.get((r, c))).collect(); + let mean = col.iter().sum::() / nr as f64; + assert!(mean.abs() < 1e-10, "col {c} mean not zero: {mean}"); + } +} + +// --------------------------------------------------------------------------- +// StandardScaler — transform produces unit variance per column +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn standard_scaler_unit_variance_workflow() { + use smartcore::api::{Transformer, UnsupervisedEstimator}; + use smartcore::linalg::basic::arrays::Array; + use smartcore::preprocessing::numerical::{StandardScaler, StandardScalerParameters}; + + let x = DenseMatrix::from_2d_array(&[ + &[10.0_f64, 200.0], + &[20.0, 400.0], + &[30.0, 600.0], + &[40.0, 800.0], + ]) + .unwrap(); + + let scaler = StandardScaler::fit(&x, StandardScalerParameters::default()).expect("fit"); + let scaled = scaler.transform(&x).expect("transform"); + + let (nr, nc) = scaled.shape(); + for c in 0..nc { + let col: Vec = (0..nr).map(|r| *scaled.get((r, c))).collect(); + let mean = col.iter().sum::() / nr as f64; + let variance = col.iter().map(|v| (v - mean).powi(2)).sum::() / nr as f64; + let std = variance.sqrt(); + assert!((std - 1.0).abs() < 1e-6, "col {c} std not 1: {std}"); + } +} + +// --------------------------------------------------------------------------- +// OneHotEncoder — shape and binary values +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn one_hot_encoder_workflow() { + use smartcore::linalg::basic::arrays::Array; + use smartcore::preprocessing::categorical::{OneHotEncoder, OneHotEncoderParams}; + + let x = DenseMatrix::from_2d_array(&[&[0.0_f64, 0.0], &[1.0, 1.0], &[2.0, 0.0], &[0.0, 1.0]]) + .unwrap(); + + let params = OneHotEncoderParams::from_cat_idx(&[0, 1]); + let encoder = OneHotEncoder::fit(&x, params).expect("OneHotEncoder::fit"); + let encoded = encoder.transform(&x).expect("transform"); + + let (nr, nc) = encoded.shape(); + assert_eq!(nr, 4, "OHE: wrong number of rows"); + assert_eq!(nc, 5, "OHE: expected 5 output columns"); + + for r in 0..nr { + for c in 0..nc { + let v = *encoded.get((r, c)); + assert!( + (v - 0.0_f64).abs() < 1e-10 || (v - 1.0_f64).abs() < 1e-10, + "OHE non-binary value at ({r},{c}): {v}" + ); + } + } +} diff --git a/tests/svm_workflow.rs b/tests/svm_workflow.rs new file mode 100644 index 00000000..9c6c11d1 --- /dev/null +++ b/tests/svm_workflow.rs @@ -0,0 +1,145 @@ +//! Integration test: SVM end-to-end workflow. +//! +//! `SVC` (RBF kernel, linear kernel), `SVR` (RBF kernel). +//! Tracking issue: #397 / #391. +//! +//! API notes: +//! - `Kernels::rbf()` creates an RBF kernel with `gamma: None`; must chain `.with_gamma(f64)` +//! - `SVC::fit` / `SVR::fit` take params by reference (`¶ms`) +//! - `SVC` requires `TY: Number + Ord`; use `Vec` for labels +//! - `SVC::predict` returns `Vec` (the decision value), not `Vec` + +use smartcore::linalg::basic::matrix::DenseMatrix; + +// --------------------------------------------------------------------------- +// Helper: compare f64 predictions against i32 ground-truth labels +// --------------------------------------------------------------------------- + +fn accuracy_svc(predicted: &[f64], actual: &[i32]) -> f64 { + predicted + .iter() + .zip(actual.iter()) + .filter(|(p, a)| (**p - **a as f64).abs() < 1e-9) + .count() as f64 + / actual.len() as f64 +} + +// --------------------------------------------------------------------------- +// SVC — RBF kernel +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn svc_rbf_inline_workflow() { + use smartcore::svm::{ + Kernels, + svc::{SVC, SVCParameters}, + }; + + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64, 0.0], + &[1.1, 0.1], + &[0.9, -0.1], + &[1.2, 0.2], + &[-1.0, 0.0], + &[-1.1, 0.1], + &[-0.9, -0.1], + &[-1.2, 0.2], + ]) + .unwrap(); + let y: Vec = vec![1, 1, 1, 1, -1, -1, -1, -1]; + + let params = SVCParameters::default() + .with_c(1.0) + .with_kernel(Kernels::rbf().with_gamma(0.5)); + let model = SVC::fit(&x, &y, ¶ms).expect("SVC (RBF)::fit"); + let preds: Vec = model.predict(&x).expect("predict"); + + let acc = accuracy_svc(&preds, &y); + assert!(acc >= 0.875, "SVC (RBF) accuracy: {acc:.3}"); +} + +// --------------------------------------------------------------------------- +// SVC — linear kernel +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn svc_linear_inline_workflow() { + use smartcore::svm::{ + Kernels, + svc::{SVC, SVCParameters}, + }; + + let x = DenseMatrix::from_2d_array(&[ + &[2.0_f64, 0.0], + &[2.1, 0.1], + &[1.9, -0.1], + &[2.2, 0.2], + &[-2.0, 0.0], + &[-2.1, 0.1], + &[-1.9, -0.1], + &[-2.2, 0.2], + ]) + .unwrap(); + let y: Vec = vec![1, 1, 1, 1, -1, -1, -1, -1]; + + let params = SVCParameters::default() + .with_c(1.0) + .with_kernel(Kernels::linear()); + let model = SVC::fit(&x, &y, ¶ms).expect("SVC (linear)::fit"); + let preds: Vec = model.predict(&x).expect("predict"); + + let acc = accuracy_svc(&preds, &y); + assert!(acc >= 0.875, "SVC (linear) accuracy: {acc:.3}"); +} + +// --------------------------------------------------------------------------- +// SVR — RBF kernel +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn svr_rbf_inline_workflow() { + use smartcore::svm::{ + Kernels, + svr::{SVR, SVRParameters}, + }; + + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64, 0.0], + &[1.0, 0.0], + &[0.0, 1.0], + &[1.0, 1.0], + &[2.0, 0.0], + &[0.0, 2.0], + &[2.0, 2.0], + &[3.0, 0.0], + ]) + .unwrap(); + let y: Vec = vec![0.0, 1.0, 1.0, 2.0, 2.0, 2.0, 4.0, 3.0]; + + let params = SVRParameters::default() + .with_c(10.0) + .with_eps(0.1) + .with_kernel(Kernels::rbf().with_gamma(0.5)); + let model = SVR::fit(&x, &y, ¶ms).expect("SVR (RBF)::fit"); + let preds: Vec = model.predict(&x).expect("predict"); + + let mae: f64 = preds + .iter() + .zip(y.iter()) + .map(|(p, a)| (p - a).abs()) + .sum::() + / y.len() as f64; + assert!(mae < 1.0, "SVR (RBF) MAE too high: {mae:.3}"); +} diff --git a/tests/tree_workflow.rs b/tests/tree_workflow.rs new file mode 100644 index 00000000..44d54ee7 --- /dev/null +++ b/tests/tree_workflow.rs @@ -0,0 +1,137 @@ +//! Integration test: decision tree models end-to-end workflow. +//! +//! `DecisionTreeClassifier` / `DecisionTreeRegressor`. +//! Tracking issue: #397 / #391. +//! +//! API notes: +//! - `from_iterator()` comes from `Array2` trait; import it where used + +use smartcore::linalg::basic::matrix::DenseMatrix; + +fn accuracy(predicted: &[u32], actual: &[u32]) -> f64 { + predicted + .iter() + .zip(actual.iter()) + .filter(|(p, a)| p == a) + .count() as f64 + / actual.len() as f64 +} + +fn mae(predicted: &[f64], actual: &[f64]) -> f64 { + predicted + .iter() + .zip(actual.iter()) + .map(|(p, a)| (p - a).abs()) + .sum::() + / actual.len() as f64 +} + +// --------------------------------------------------------------------------- +// DecisionTreeClassifier — inline XOR fixture +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn decision_tree_classifier_inline_workflow() { + use smartcore::tree::decision_tree_classifier::{ + DecisionTreeClassifier, DecisionTreeClassifierParameters, + }; + + let x = DenseMatrix::from_2d_array(&[ + &[0.0_f64, 0.0], + &[0.0, 1.0], + &[1.0, 0.0], + &[1.0, 1.0], + &[0.0, 0.0], + &[0.0, 1.0], + &[1.0, 0.0], + &[1.0, 1.0], + ]) + .unwrap(); + let y: Vec = vec![0, 1, 1, 0, 0, 1, 1, 0]; + + let params = DecisionTreeClassifierParameters::default().with_max_depth(4); + let model = DecisionTreeClassifier::fit(&x, &y, params).expect("DecisionTreeClassifier::fit"); + let preds = model.predict(&x).expect("predict"); + + let acc = accuracy(&preds, &y); + assert!( + acc >= 0.875, + "DecisionTreeClassifier XOR accuracy: {acc:.3}" + ); +} + +// --------------------------------------------------------------------------- +// DecisionTreeRegressor — inline quadratic fixture +// --------------------------------------------------------------------------- + +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn decision_tree_regressor_inline_workflow() { + use smartcore::tree::decision_tree_regressor::{ + DecisionTreeRegressor, DecisionTreeRegressorParameters, + }; + + let x = DenseMatrix::from_2d_array(&[ + &[1.0_f64], + &[2.0], + &[3.0], + &[4.0], + &[5.0], + &[6.0], + &[7.0], + &[8.0], + ]) + .unwrap(); + let y: Vec = vec![1.0, 4.0, 9.0, 16.0, 25.0, 36.0, 49.0, 64.0]; + + let params = DecisionTreeRegressorParameters::default().with_max_depth(4); + let model = DecisionTreeRegressor::fit(&x, &y, params).expect("DecisionTreeRegressor::fit"); + let preds = model.predict(&x).expect("predict"); + + let err = mae(&preds, &y); + assert!(err < 5.0, "DecisionTreeRegressor MAE too high: {err:.4}"); +} + +// --------------------------------------------------------------------------- +// DecisionTreeClassifier — iris dataset +// --------------------------------------------------------------------------- + +#[cfg(feature = "datasets")] +#[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test +)] +#[test] +fn decision_tree_classifier_iris_workflow() { + use smartcore::dataset::iris::load_dataset; + use smartcore::linalg::basic::arrays::Array2; + use smartcore::tree::decision_tree_classifier::{ + DecisionTreeClassifier, DecisionTreeClassifierParameters, + }; + + let ds = load_dataset(); + let x = DenseMatrix::from_iterator( + ds.data.iter().map(|&v| v as f64), + ds.num_samples, + ds.num_features, + 0, + ); + let y: Vec = ds.target.clone(); + + let params = DecisionTreeClassifierParameters::default().with_max_depth(5); + let model = DecisionTreeClassifier::fit(&x, &y, params).expect("fit on iris"); + let preds = model.predict(&x).expect("predict"); + + let acc = accuracy(&preds, &y); + assert!( + acc >= 0.90, + "DecisionTreeClassifier (iris) accuracy: {acc:.3}" + ); +}