From b8859cefc8d169445de495f6a9263279a626064f Mon Sep 17 00:00:00 2001 From: randogoth Date: Fri, 1 Mar 2024 12:26:05 +0200 Subject: [PATCH 1/9] minimized --- Cargo.lock | 22 +++--- src/lib.rs | 210 ++++++++--------------------------------------------- 2 files changed, 41 insertions(+), 191 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4165424..2eb2eb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,17 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lyagushka" +version = "0.1.0" +dependencies = [ + "atty", + "pyo3", + "rand", + "serde", + "serde_json", +] + [[package]] name = "memoffset" version = "0.9.0" @@ -313,17 +324,6 @@ version = "0.12.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69758bda2e78f098e4ccb393021a0963bb3442eac05f135c30f61b7370bbafae" -[[package]] -name = "traktorpy" -version = "0.1.0" -dependencies = [ - "atty", - "pyo3", - "rand", - "serde", - "serde_json", -] - [[package]] name = "unicode-ident" version = "1.0.12" diff --git a/src/lib.rs b/src/lib.rs index 57ac035..7f1c202 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,10 @@ use pyo3::prelude::*; -use pyo3::wrap_pyfunction; use pyo3::types::PyList; -use std::fs::File; -// use std::io::{self, BufRead, BufReader, stdin, Read}; -use std::io::{self, BufRead, BufReader}; -use std::env; -use std::process; +use pyo3::wrap_pyfunction; use serde::Serialize; use serde_json; +use std::fs::File; +use std::io::{self, BufRead, BufReader}; #[derive(Clone, Debug, Serialize)] struct Point { @@ -22,195 +19,48 @@ impl Point { #[derive(Debug, Clone, Serialize)] struct ClusterGapInfo { - span_length: f32, // Full span length - num_elements: usize, // Number of elements, 0 for gaps - centroid: f32, // Centroid value - z_score: Option, // Z-score, to be calculated later + span_length: f32, + num_elements: usize, + centroid: f32, + z_score: Option, } - -fn load_dataset(filename: &str) -> io::Result> { - let file = File::open(filename)?; - let reader = BufReader::new(file); - let mut dataset = Vec::new(); - - for line in reader.lines() { - let value: u32 = line?.trim().parse().unwrap(); - dataset.push(Point::new(value)); - } - - dataset.sort_by_key(|p| p.value); - Ok(dataset) -} - -// Define the ClusterGapInfo struct as described above - fn calculate_densities_and_gaps(dataset: &[Point], factor: f32, min_cluster_size: usize) -> Vec { - let mut results: Vec = Vec::new(); - if dataset.len() < 2 { - return results; - } + if dataset.len() < 2 { return Vec::new(); } let mean_distance = dataset.windows(2) - .map(|w| distance(&w[0], &w[1]) as f32) + .map(|w| (w[1].value - w[0].value) as f32) .sum::() / (dataset.len() - 1) as f32; - - let cluster_threshold = 1.0 / factor * mean_distance; + let cluster_threshold = mean_distance / factor; let gap_threshold = factor * mean_distance * 2.0; - let mut current_cluster = Vec::new(); - for window in dataset.windows(2) { - let gap_distance = distance(&window[0], &window[1]) as f32; - if gap_distance <= cluster_threshold { - current_cluster.push(window[1].clone()); - } else { - // Before clearing the current_cluster, check if it meets the size requirement - if !current_cluster.is_empty() && current_cluster.len() >= min_cluster_size { - let cluster_info = create_cluster_info(¤t_cluster); - results.push(cluster_info); - } - current_cluster.clear(); - - // Add a gap if the distance exceeds the gap threshold - if gap_distance > gap_threshold { - results.push(ClusterGapInfo { - span_length: gap_distance, - num_elements: 0, - centroid: (window[0].value as f32 + window[1].value as f32) / 2.0, - z_score: None, - }); - } + dataset.windows(2).fold(Vec::new(), |mut acc, window| { + let gap_distance = (window[1].value - window[0].value) as f32; + if gap_distance > gap_threshold && acc.last().map_or(true, |last: &ClusterGapInfo| last.num_elements >= min_cluster_size) { + acc.push(ClusterGapInfo { + span_length: gap_distance, + num_elements: 0, + centroid: (window[0].value + window[1].value) as f32 / 2.0, + z_score: None, + }); } - } - - // Handle the last cluster if it meets the size requirement - if !current_cluster.is_empty() && current_cluster.len() >= min_cluster_size { - let cluster_info = create_cluster_info(¤t_cluster); - results.push(cluster_info); - } - - results -} - -// Additional helper function to create cluster information -fn create_cluster_info(cluster: &[Point]) -> ClusterGapInfo { - let num_elements = cluster.len(); - let span_length = (cluster.last().unwrap().value as f32) - (cluster.first().unwrap().value as f32); - let centroid = cluster.iter().map(|p| p.value as f32).sum::() / num_elements as f32; - - ClusterGapInfo { - span_length, - num_elements, - centroid, - z_score: None, // Placeholder, to be calculated later - } -} - -fn distance(p1: &Point, p2: &Point) -> u32 { - if p1.value > p2.value { p1.value - p2.value } else { p2.value - p1.value } -} - - -fn main() -> io::Result<()> { - let args: Vec = env::args().collect(); - let mut dataset: Vec = Vec::new(); - - // Check if data is being piped into the program - if atty::is(atty::Stream::Stdin) { - // Not receiving piped input, expect filename as argument - if args.len() < 4 { - eprintln!("Usage: {} ", args[0]); - eprintln!("Or pipe in a list of integers and provide "); - process::exit(1); - } - - let filename = &args[1]; - dataset = load_dataset(filename)?; - } else { - // Receiving piped input, read from stdin - let stdin = io::stdin(); - let reader = stdin.lock(); - for line in reader.lines() { - let value: u32 = line?.trim().parse().unwrap(); - dataset.push(Point::new(value)); - } - dataset.sort_by_key(|p| p.value); - } - - let factor: f32 = args[args.len() - 2].parse().expect("Factor must be a float"); - let min_cluster_size: usize = args[args.len() - 1].parse().expect("Min cluster size must be an integer"); - - let mut cluster_gap_infos = calculate_densities_and_gaps(&dataset, factor, min_cluster_size); - - // Calculate mean distance for Z-score computation - let total_distances: f32 = dataset.windows(2) - .map(|w| (w[1].value as f32 - w[0].value as f32)) - .sum(); - let mean_distance = total_distances / (dataset.len() as f32 - 1.0); - - // Calculate Z-scores for clusters and gaps - for info in cluster_gap_infos.iter_mut() { - if info.num_elements == 0 { - // Z-score for gaps - info.z_score = Some((info.span_length - mean_distance) / mean_distance); // Simplified deviation measure - } else { - // Z-score for clusters, based on density deviation - let density = info.num_elements as f32 / info.span_length; - let expected_density = 1.0 / mean_distance; // Expected: one element per mean distance - info.z_score = Some((density - expected_density) / expected_density); // Simplified deviation measure - } - } - - // Convert cluster_gap_infos to JSON - let json = serde_json::to_string_pretty(&cluster_gap_infos).expect("Failed to serialize to JSON"); - - // Output the JSON string - println!("{}", json); - - Ok(()) + acc + }) } #[pyfunction] -fn traktor(py: Python, int_list: &PyList, factor: f32, min_cluster_size: usize) -> PyResult { - // Convert Python list to Rust Vec - let mut dataset: Vec = Vec::new(); - for py_any in int_list.into_iter() { - let value: u32 = py_any.extract()?; - dataset.push(Point::new(value)); - } - dataset.sort_by_key(|p| p.value); +fn lyagushka(_py: Python, int_list: &PyList, factor: f32, min_cluster_size: usize) -> PyResult { + let dataset: Vec = int_list.into_iter() + .map(|py_any| py_any.extract::().map(Point::new)) + .collect::>>()?; + let cluster_gap_infos = calculate_densities_and_gaps(&dataset, factor, min_cluster_size); - // Proceed with your existing logic - let mut cluster_gap_infos = calculate_densities_and_gaps(&dataset, factor, min_cluster_size); - - // Calculate mean distance for Z-score computation - let total_distances: f32 = dataset.windows(2) - .map(|w| (w[1].value as f32 - w[0].value as f32)) - .sum(); - let mean_distance = total_distances / (dataset.len() as f32 - 1.0); - - // Calculate Z-scores for clusters and gaps - for info in cluster_gap_infos.iter_mut() { - if info.num_elements == 0 { - // Z-score for gaps - info.z_score = Some((info.span_length - mean_distance) / mean_distance); // Simplified deviation measure - } else { - // Z-score for clusters, based on density deviation - let density = info.num_elements as f32 / info.span_length; - let expected_density = 1.0 / mean_distance; // Expected: one element per mean distance - info.z_score = Some((density - expected_density) / expected_density); // Simplified deviation measure - } - } - - // Serialize to JSON and return - let json = serde_json::to_string_pretty(&cluster_gap_infos) - .expect("Failed to serialize to JSON"); - - Ok(json) + serde_json::to_string_pretty(&cluster_gap_infos) + .map_err(|e| PyErr::new::(format!("JSON Serialization Error: {}", e))) } #[pymodule] -fn lyagushka(py: Python, m: &PyModule) -> PyResult<()> { - m.add_function(wrap_pyfunction!(traktor, m)?)?; +fn lyagushka_module(py: Python, m: &PyModule) -> PyResult<()> { + m.add_function(wrap_pyfunction!(lyagushka, m)?)?; Ok(()) } \ No newline at end of file From faaad93ef470923ae29e591f6b6f28474c15697c Mon Sep 17 00:00:00 2001 From: randogoth Date: Fri, 1 Mar 2024 12:46:34 +0200 Subject: [PATCH 2/9] cleanup --- src/lib.rs | 141 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 124 insertions(+), 17 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7f1c202..9e1d814 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,8 +3,6 @@ use pyo3::types::PyList; use pyo3::wrap_pyfunction; use serde::Serialize; use serde_json; -use std::fs::File; -use std::io::{self, BufRead, BufReader}; #[derive(Clone, Debug, Serialize)] struct Point { @@ -25,42 +23,151 @@ struct ClusterGapInfo { z_score: Option, } +fn create_cluster_info(cluster: &[Point]) -> ClusterGapInfo { + + let num_elements = cluster.len(); + let span_length = (cluster.last().unwrap().value as f32) - (cluster.first().unwrap().value as f32); + let centroid = cluster.iter().map(|p| p.value as f32).sum::() / num_elements as f32; + + ClusterGapInfo { + span_length, + num_elements, + centroid, + z_score: None, + } +} + +/// Calculates the densities (clusters) and significant gaps between points in a dataset. +/// +/// This function iterates over a dataset of points, identifying clusters based on a distance threshold +/// (calculated from the mean distance between points and adjusted by a given factor) and identifying significant gaps +/// that exceed a certain threshold. Each cluster or significant gap identified is summarized in a `ClusterGapInfo` object. +/// +/// # Arguments +/// * `dataset`: A slice of `Point` objects representing the dataset to be analyzed. +/// * `factor`: A multiplier used to define the thresholds for clustering and gap identification. +/// A lower factor tightens the cluster threshold and widens the gap threshold, and vice versa. +/// * `min_cluster_size`: The minimum number of points required for a group of points to be considered a cluster. +/// +/// # Returns +/// A vector of `ClusterGapInfo` objects, each representing either a cluster of points or a significant gap between points. +/// fn calculate_densities_and_gaps(dataset: &[Point], factor: f32, min_cluster_size: usize) -> Vec { + + // Return early if the dataset is too small to form any clusters or gaps. if dataset.len() < 2 { return Vec::new(); } + // Calculate the mean distance between consecutive points in the dataset. let mean_distance = dataset.windows(2) - .map(|w| (w[1].value - w[0].value) as f32) + .map(|w| w[1].value as f32 - w[0].value as f32) .sum::() / (dataset.len() - 1) as f32; + + // Define thresholds for clustering and gap identification based on the mean distance and factor. let cluster_threshold = mean_distance / factor; let gap_threshold = factor * mean_distance * 2.0; - dataset.windows(2).fold(Vec::new(), |mut acc, window| { - let gap_distance = (window[1].value - window[0].value) as f32; - if gap_distance > gap_threshold && acc.last().map_or(true, |last: &ClusterGapInfo| last.num_elements >= min_cluster_size) { - acc.push(ClusterGapInfo { - span_length: gap_distance, - num_elements: 0, - centroid: (window[0].value + window[1].value) as f32 / 2.0, - z_score: None, - }); + let mut results: Vec = Vec::new(); // Stores the resulting clusters and gaps. + let mut current_cluster: Vec = Vec::new(); // Temporary storage for points in the current cluster. + + // Iterate through pairs of consecutive points to find clusters and significant gaps. + for window in dataset.windows(2) { + let gap_distance = window[1].value as f32 - window[0].value as f32; + + // If the distance between points is within the cluster threshold, add to current cluster. + if gap_distance <= cluster_threshold { + if current_cluster.is_empty() { + current_cluster.push(window[0].clone()); // Start a new cluster with the first point. + } + current_cluster.push(window[1].clone()); // Add the second point to the cluster. + } else { + // If the current cluster is large enough, finalize it and prepare for a new cluster. + if !current_cluster.is_empty() && current_cluster.len() >= min_cluster_size { + results.push(create_cluster_info(¤t_cluster)); + current_cluster.clear(); + } + + // If the gap between points is significant, record it as a gap. + if gap_distance > gap_threshold { + results.push(ClusterGapInfo { + span_length: gap_distance, + num_elements: 0, // Indicating this is a gap, not a cluster. + centroid: (window[0].value as f32 + window[1].value as f32) / 2.0, + z_score: None, // Z-score will be calculated later if necessary. + }); + } } - acc - }) + } + + // Finalize the last cluster if it meets the size requirement. + if !current_cluster.is_empty() && current_cluster.len() >= min_cluster_size { + results.push(create_cluster_info(¤t_cluster)); + } + + results } +/// A Python-exposed function that analyzes a list of numerical values to identify clusters and significant gaps, +/// calculates z-scores for each identified cluster/gap, and returns the analysis results as a JSON string. +/// +/// This function takes a list of integers (representing a dataset), a factor to adjust clustering and gap detection thresholds, +/// and a minimum cluster size. It calculates the mean distance and standard deviation across the dataset, +/// identifies clusters and significant gaps based on these metrics, calculates z-scores for each cluster/gap, +/// and returns a JSON string representing the analysis results. +/// +/// # Arguments +/// * `_py`: The Python interpreter, used for Python-Rust interactions. Not directly used in the function body. +/// * `int_list`: A Python list of integers representing the dataset to be analyzed. +/// * `factor`: A floating-point value used to adjust the sensitivity of cluster and gap detection. +/// Lower values result in tighter clustering and wider gaps, while higher values do the opposite. +/// * `min_cluster_size`: The minimum number of contiguous points required to be considered a cluster. +/// +/// # Returns +/// A `PyResult` which is either: +/// * Ok containing a JSON-formatted string of the analysis results, including clusters and gaps with their z-scores. +/// * Err containing a Python exception if an error occurs during processing or JSON serialization. +/// #[pyfunction] fn lyagushka(_py: Python, int_list: &PyList, factor: f32, min_cluster_size: usize) -> PyResult { + + // Convert the Python list of integers into a Rust Vec of Point structs. let dataset: Vec = int_list.into_iter() .map(|py_any| py_any.extract::().map(Point::new)) .collect::>>()?; - let cluster_gap_infos = calculate_densities_and_gaps(&dataset, factor, min_cluster_size); + // Analyze the dataset to identify clusters and significant gaps. + let mut cluster_gap_infos = calculate_densities_and_gaps(&dataset, factor, min_cluster_size); + + // Calculate the mean distance between consecutive points in the dataset. + let mean_distance: f32 = dataset.windows(2) + .map(|w| w[1].value as f32 - w[0].value as f32) + .sum::() / (dataset.len() - 1) as f32; + + // Calculate the standard deviation of distances between consecutive points. + let std_deviation: f32 = (dataset.windows(2) + .map(|w| w[1].value as f32 - w[0].value as f32 - mean_distance) + .map(|d| d * d) + .sum::() / (dataset.len() - 1) as f32) + .sqrt(); + + // Calculate and assign z-scores for each cluster/gap based on their centroid or span length. + for info in cluster_gap_infos.iter_mut() { + info.z_score = Some(if info.num_elements > 0 { + // For clusters, use the centroid for z-score calculation. + (info.centroid - mean_distance) / std_deviation + } else { + // For gaps, use the span length for z-score calculation. + (info.span_length - mean_distance) / std_deviation + }); + } + + // Serialize the analysis results into a JSON string and return it. serde_json::to_string_pretty(&cluster_gap_infos) .map_err(|e| PyErr::new::(format!("JSON Serialization Error: {}", e))) } + #[pymodule] -fn lyagushka_module(py: Python, m: &PyModule) -> PyResult<()> { +fn lyagushka_module(_py: Python, m: &PyModule) -> PyResult<()> { m.add_function(wrap_pyfunction!(lyagushka, m)?)?; Ok(()) -} \ No newline at end of file +} From 517b7d55f1695a7762db662775556956a8f7cf0e Mon Sep 17 00:00:00 2001 From: randogoth Date: Fri, 1 Mar 2024 13:51:42 +0200 Subject: [PATCH 3/9] readme and test --- Cargo.lock | 74 ++++++++---------------------------------------------- Cargo.toml | 9 ++++--- readme.md | 61 ++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 +- test.py | 13 ++++++++++ 5 files changed, 91 insertions(+), 68 deletions(-) create mode 100644 readme.md create mode 100644 test.py diff --git a/Cargo.lock b/Cargo.lock index 2eb2eb5..4fc78cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,17 +31,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "getrandom" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "heck" version = "0.4.1" @@ -85,17 +74,6 @@ dependencies = [ "scopeguard", ] -[[package]] -name = "lyagushka" -version = "0.1.0" -dependencies = [ - "atty", - "pyo3", - "rand", - "serde", - "serde_json", -] - [[package]] name = "memoffset" version = "0.9.0" @@ -134,12 +112,6 @@ dependencies = [ "windows-targets", ] -[[package]] -name = "ppv-lite86" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" - [[package]] name = "proc-macro2" version = "1.0.78" @@ -149,6 +121,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyagushka" +version = "0.1.0" +dependencies = [ + "atty", + "pyo3", + "serde", + "serde_json", +] + [[package]] name = "pyo3" version = "0.20.2" @@ -219,36 +201,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] - [[package]] name = "redox_syscall" version = "0.4.1" @@ -336,12 +288,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 0a937b1..b875495 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,18 @@ [package] -name = "lyagushka" +name = "pyagushka" version = "0.1.0" edition = "2021" [lib] -name = "lyagushka" +name = "pyagushka" crate-type = ["cdylib"] [dependencies] atty = "0.2.14" pyo3 = "0.20.2" -rand = "0.8.5" serde = { version = "1.0.196", features = ["derive"] } serde_json = "1.0.113" + +[features] +extension-module = ["pyo3/extension-module"] +default = ["extension-module"] \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..31e63bc --- /dev/null +++ b/readme.md @@ -0,0 +1,61 @@ +# lyagushka + +(Russian лягушка: frog) + +Cluster and Gap Analysis Tool inspired by Fatum Project's 'Zhaba' algorithm (Russian 'жаба': toad) that finds attractor clusters in lists of integers. + +This Rust command-line tool analyzes a dataset of integers to identify clusters of closely grouped points and significant gaps between these clusters. It calculates z-scores for each cluster or gap to measure their statistical significance relative to the dataset's mean distance. The analysis results, including clusters, gaps, and their z-scores, are output as a JSON string. + +## Features + +- **Cluster Identification**: Identifies groups of points that are closely spaced together based on a customizable threshold. +- **Gap Detection**: Detects significant gaps between clusters, providing insights into the dataset's distribution. +- **Z-Score Calculation**: Calculates z-scores for both clusters and gaps, offering a statistical measure of their deviation from the mean distance. +- **Flexible Input**: Accepts input data either from a file specified as a command-line argument or piped directly into stdin. +- **JSON Output**: Outputs the analysis results in a readable JSON format, making it easy to interpret or use in further processing. + +## Usage + +### From a File + +To analyze a dataset from a file, provide the filename as an argument along with two additional parameters: the factor for adjusting clustering and gap detection thresholds, and the minimum cluster size. + +```sh +cargo run -- filename.txt 0.5 2 +``` + +### From Stdin + +Alternatively, you can pipe a list of integers into the tool, followed by the factor and minimum cluster size. + +```sh +echo "1\n2\n10\n20" | cargo run -- 0.5 2 +``` + +#### Parameters + +* `filename.txt` (optional): A file containing a newline-separated list of integers to analyze. If not provided, the program expects input from stdin. +* `factor`: A floating-point value used to fine-tune the sensitivity of cluster and gap detection. Lower values result in tighter clusters and wider gaps, while higher values do the opposite. +* `min_cluster_size`: An integer specifying the minimum number of contiguous points required to be considered a cluster. + +### Output + +The tool outputs a JSON string that includes details about the identified clusters and gaps, along with their respective z-scores. Here's an example of the JSON output format: + +```json + +[ + { + "span_length": 1.0, + "num_elements": 2, + "centroid": 1.5, + "z_score": -1.23 + }, + { + "span_length": 8.0, + "num_elements": 0, + "centroid": 6.0, + "z_score": 2.45 + } +] +``` \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 9e1d814..be7ce3f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -167,7 +167,7 @@ fn lyagushka(_py: Python, int_list: &PyList, factor: f32, min_cluster_size: usiz #[pymodule] -fn lyagushka_module(_py: Python, m: &PyModule) -> PyResult<()> { +fn pyagushka(_py: Python, m: &PyModule) -> PyResult<()> { m.add_function(wrap_pyfunction!(lyagushka, m)?)?; Ok(()) } diff --git a/test.py b/test.py new file mode 100644 index 0000000..91531d2 --- /dev/null +++ b/test.py @@ -0,0 +1,13 @@ +from pyagushka import lyagushka +from randonautentropy import rndo +import json + +random_data = [] + +with open('random_values.txt', 'r') as file: + for line in file: + random_data.append(int(line.strip())) + +anomalies = json.loads(lyagushka(random_data, 1.0, 5)) + +print(anomalies) \ No newline at end of file From 1189d70db196cedf5aa7e4bdd6b269087995055b Mon Sep 17 00:00:00 2001 From: randogoth Date: Fri, 1 Mar 2024 15:45:26 +0200 Subject: [PATCH 4/9] unified Z-Score --- src/lib.rs | 97 ++++++++++++++++++++++++++++++------------------------ test.py | 73 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 121 insertions(+), 49 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index be7ce3f..ca9fa2c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,7 @@ use pyo3::prelude::*; use pyo3::types::PyList; use pyo3::wrap_pyfunction; use serde::Serialize; -use serde_json; +use serde_json::to_string_pretty; #[derive(Clone, Debug, Serialize)] struct Point { @@ -106,66 +106,77 @@ fn calculate_densities_and_gaps(dataset: &[Point], factor: f32, min_cluster_size results } -/// A Python-exposed function that analyzes a list of numerical values to identify clusters and significant gaps, -/// calculates z-scores for each identified cluster/gap, and returns the analysis results as a JSON string. -/// -/// This function takes a list of integers (representing a dataset), a factor to adjust clustering and gap detection thresholds, -/// and a minimum cluster size. It calculates the mean distance and standard deviation across the dataset, -/// identifies clusters and significant gaps based on these metrics, calculates z-scores for each cluster/gap, -/// and returns a JSON string representing the analysis results. +/// Analyzes a dataset of integers to identify clusters and gaps, then calculates Z-scores +/// for each based on their deviation from mean metrics. The analysis aims to highlight +/// significant clusters of closely grouped points and notable gaps between them, providing +/// a statistical measure of their significance through Z-scores. The results, including +/// clusters, gaps, and their Z-scores, are serialized into a JSON string. /// /// # Arguments -/// * `_py`: The Python interpreter, used for Python-Rust interactions. Not directly used in the function body. -/// * `int_list`: A Python list of integers representing the dataset to be analyzed. -/// * `factor`: A floating-point value used to adjust the sensitivity of cluster and gap detection. -/// Lower values result in tighter clustering and wider gaps, while higher values do the opposite. -/// * `min_cluster_size`: The minimum number of contiguous points required to be considered a cluster. +/// * `_py` - The Python interpreter instance, used for Python-Rust interoperability. +/// This argument is necessary for functions exposed to Python via PyO3 but is not +/// directly used within the function. +/// * `int_list` - A Python list of integers representing the dataset to be analyzed. +/// This list is converted into a Vec for internal processing. +/// * `factor` - A floating-point value used as a threshold factor to adjust the sensitivity +/// of cluster and gap detection. This factor influences the identification of clusters +/// by defining the minimum density or separation required. +/// * `min_cluster_size` - An integer specifying the minimum number of contiguous points +/// required for a group of points to be considered a cluster. This parameter helps +/// filter out noise by defining a threshold for the minimum cluster size. /// /// # Returns -/// A `PyResult` which is either: -/// * Ok containing a JSON-formatted string of the analysis results, including clusters and gaps with their z-scores. -/// * Err containing a Python exception if an error occurs during processing or JSON serialization. +/// Returns a `PyResult` containing a JSON-formatted string of the analysis results. +/// The JSON string includes detailed information about each identified cluster and gap, +/// such as their span length, number of elements (if applicable), centroid, and calculated +/// Z-score. In case of an error during processing or serialization, a Python exception is +/// returned. /// #[pyfunction] fn lyagushka(_py: Python, int_list: &PyList, factor: f32, min_cluster_size: usize) -> PyResult { - - // Convert the Python list of integers into a Rust Vec of Point structs. - let dataset: Vec = int_list.into_iter() - .map(|py_any| py_any.extract::().map(Point::new)) - .collect::>>()?; + // Extract integers from a Python list and create a vector of Point structs. + let dataset: Vec = int_list.extract::>()? + .into_iter() + .map(Point::new) + .collect(); - // Analyze the dataset to identify clusters and significant gaps. + // Calculate clusters and gaps from the dataset using predefined criteria. let mut cluster_gap_infos = calculate_densities_and_gaps(&dataset, factor, min_cluster_size); - // Calculate the mean distance between consecutive points in the dataset. - let mean_distance: f32 = dataset.windows(2) - .map(|w| w[1].value as f32 - w[0].value as f32) - .sum::() / (dataset.len() - 1) as f32; + // Calculate the mean density of clusters in the dataset for comparison. + let mean_density: f32 = cluster_gap_infos.iter() + .filter(|info| info.num_elements > 0) + .map(|info| info.num_elements as f32 / info.span_length) + .sum::() / cluster_gap_infos.iter().filter(|info| info.num_elements > 0).count() as f32; - // Calculate the standard deviation of distances between consecutive points. - let std_deviation: f32 = (dataset.windows(2) - .map(|w| w[1].value as f32 - w[0].value as f32 - mean_distance) - .map(|d| d * d) - .sum::() / (dataset.len() - 1) as f32) - .sqrt(); + // Calculate the standard deviation of cluster densities to evaluate variation. + let variance_density: f32 = cluster_gap_infos.iter() + .filter(|info| info.num_elements > 0) + .map(|info| info.num_elements as f32 / info.span_length) + .map(|density| (density - mean_density).powi(2)) + .sum::() / cluster_gap_infos.iter().filter(|info| info.num_elements > 0).count() as f32; + let std_dev_density = variance_density.sqrt(); - // Calculate and assign z-scores for each cluster/gap based on their centroid or span length. - for info in cluster_gap_infos.iter_mut() { - info.z_score = Some(if info.num_elements > 0 { - // For clusters, use the centroid for z-score calculation. - (info.centroid - mean_distance) / std_deviation + // Calculate the average span of all clusters and gaps to assess gap significance. + let average_span: f32 = cluster_gap_infos.iter().map(|info| info.span_length).sum::() / cluster_gap_infos.len() as f32; + + // Update Z-scores for both clusters and gaps based on their deviation from mean metrics. + for info in &mut cluster_gap_infos { + if info.num_elements > 0 { + // Calculate and update Z-score for clusters based on density deviation. + let cluster_density = info.num_elements as f32 / info.span_length; + info.z_score = Some((cluster_density - mean_density) / std_dev_density); } else { - // For gaps, use the span length for z-score calculation. - (info.span_length - mean_distance) / std_deviation - }); + // Calculate and update Z-score for gaps based on span length deviation. + info.z_score = Some((info.span_length - average_span) / std_dev_density); + } } - // Serialize the analysis results into a JSON string and return it. - serde_json::to_string_pretty(&cluster_gap_infos) + // Serialize the updated cluster and gap information, including Z-scores, to a JSON string. + to_string_pretty(&cluster_gap_infos) .map_err(|e| PyErr::new::(format!("JSON Serialization Error: {}", e))) } - #[pymodule] fn pyagushka(_py: Python, m: &PyModule) -> PyResult<()> { m.add_function(wrap_pyfunction!(lyagushka, m)?)?; diff --git a/test.py b/test.py index 91531d2..e2fd2ef 100644 --- a/test.py +++ b/test.py @@ -1,13 +1,74 @@ from pyagushka import lyagushka from randonautentropy import rndo import json +import matplotlib.pyplot as plt +import numpy as np +from scipy.interpolate import interp1d -random_data = [] +def generate_random_data(size=1024, max_value=100): -with open('random_values.txt', 'r') as file: - for line in file: - random_data.append(int(line.strip())) + random_data = [] + max_value_bytes = (max_value.bit_length() + 7) // 8 + max_int_for_bytes = 2**(max_value_bytes * 8) - 1 + min_bytes_needed = max_value_bytes * size + mod_cutoff = max_int_for_bytes - (max_int_for_bytes % max_value) - 1 -anomalies = json.loads(lyagushka(random_data, 1.0, 5)) + # Populate the 'random_data' array + while len(random_data) < size: + hex_data = rndo.get(length=min_bytes_needed) + hex_chunks = list((hex_data[0+i:2 * max_value_bytes+i] for i in range(0, len(hex_data), 2 * max_value_bytes))) + for i in hex_chunks: + num = int(i, 16) + if num <= mod_cutoff and len(random_data) < size: + random_data.append( num % (max_value + 1) ) -print(anomalies) \ No newline at end of file + return random_data + +# load the random test data +dataset = [] +# with open('random_values.txt', 'r') as file: +# for line in file: +# random_data.append(int(line.strip())) + +dataset = generate_random_data(1024, 1024) +dataset.sort() + +# calculate the anomalies in the data +analysis_results = json.loads(lyagushka(dataset, 3.0, 7)) +print(analysis_results) +print(len(analysis_results)) + +# Initialize plot +plt.figure(figsize=(10, 6)) + +# Plot dataset points +for point in dataset: + plt.plot(point, 0, 'ko') # Plot dataset as black dots at y=0 + +# Process each cluster/gap for plotting +for result in analysis_results: + if result['num_elements'] > 0: # It's a cluster + color = 'blue' # Color for clusters + else: # It's a gap + color = 'green' # Color for gaps + + # Generate start and end points for the segment + start = result['centroid'] - result['span_length'] / 2 + end = result['centroid'] + result['span_length'] / 2 + z_score = result['z_score'] if result['z_score'] is not None else 0 + + # Plot a line segment for the cluster/gap + plt.plot([start, end], [z_score, z_score], color=color, linewidth=2) + +# Enhancements for visualization +plt.xlabel('Integer Value') +plt.ylabel('Z-Score') +plt.title('Cluster and Gap Analysis with Distinct Z-Score Curves') +plt.grid(True) + +# Custom legend +plt.plot([], [], color='blue', label='Clusters') +plt.plot([], [], color='green', label='Gaps') +plt.legend() + +plt.show() \ No newline at end of file From 58eb8a2f400556d657953b7fc752120bf5d6720e Mon Sep 17 00:00:00 2001 From: randogoth Date: Fri, 1 Mar 2024 16:26:32 +0200 Subject: [PATCH 5/9] checking gap bug --- src/lib.rs | 9 +++++++-- test.py | 32 ++++++++++++-------------------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index ca9fa2c..367344d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -64,7 +64,7 @@ fn calculate_densities_and_gaps(dataset: &[Point], factor: f32, min_cluster_size // Define thresholds for clustering and gap identification based on the mean distance and factor. let cluster_threshold = mean_distance / factor; - let gap_threshold = factor * mean_distance * 2.0; + let gap_threshold = factor * mean_distance; let mut results: Vec = Vec::new(); // Stores the resulting clusters and gaps. let mut current_cluster: Vec = Vec::new(); // Temporary storage for points in the current cluster. @@ -134,12 +134,17 @@ fn calculate_densities_and_gaps(dataset: &[Point], factor: f32, min_cluster_size /// #[pyfunction] fn lyagushka(_py: Python, int_list: &PyList, factor: f32, min_cluster_size: usize) -> PyResult { + // Extract integers from a Python list and create a vector of Point structs. - let dataset: Vec = int_list.extract::>()? + let mut dataset: Vec = int_list.extract::>()? .into_iter() .map(Point::new) .collect(); + + // Sort the vector + dataset.sort_by_key(|p| p.value); + // Calculate clusters and gaps from the dataset using predefined criteria. let mut cluster_gap_infos = calculate_densities_and_gaps(&dataset, factor, min_cluster_size); diff --git a/test.py b/test.py index e2fd2ef..0a84dc7 100644 --- a/test.py +++ b/test.py @@ -35,40 +35,32 @@ dataset.sort() # calculate the anomalies in the data analysis_results = json.loads(lyagushka(dataset, 3.0, 7)) -print(analysis_results) -print(len(analysis_results)) # Initialize plot plt.figure(figsize=(10, 6)) -# Plot dataset points -for point in dataset: - plt.plot(point, 0, 'ko') # Plot dataset as black dots at y=0 +# Color palette for clusters and gaps +colors = plt.cm.jet(np.linspace(0, 1, len(analysis_results))) -# Process each cluster/gap for plotting -for result in analysis_results: +# Plot dataset points and assign colors based on cluster membership +for i, result in enumerate(analysis_results): if result['num_elements'] > 0: # It's a cluster - color = 'blue' # Color for clusters - else: # It's a gap - color = 'green' # Color for gaps + points_in_cluster = [point for point in dataset if + result['centroid'] - result['span_length'] / 2 <= point <= + result['centroid'] + result['span_length'] / 2] + for point in points_in_cluster: + plt.plot(point, 0, 'o', color=colors[i]) # Plot points in cluster with the same color - # Generate start and end points for the segment + # Plot a line segment for the cluster/gap Z-score in the same color start = result['centroid'] - result['span_length'] / 2 end = result['centroid'] + result['span_length'] / 2 z_score = result['z_score'] if result['z_score'] is not None else 0 - - # Plot a line segment for the cluster/gap - plt.plot([start, end], [z_score, z_score], color=color, linewidth=2) + plt.plot([start, end], [z_score, z_score], color=colors[i], linewidth=2) # Enhancements for visualization plt.xlabel('Integer Value') plt.ylabel('Z-Score') -plt.title('Cluster and Gap Analysis with Distinct Z-Score Curves') +plt.title('Cluster and Gap Analysis') plt.grid(True) -# Custom legend -plt.plot([], [], color='blue', label='Clusters') -plt.plot([], [], color='green', label='Gaps') -plt.legend() - plt.show() \ No newline at end of file From a04dfc45ca6de5798b18ed70ab2589b10b9d0657 Mon Sep 17 00:00:00 2001 From: randogoth Date: Fri, 1 Mar 2024 18:35:31 +0200 Subject: [PATCH 6/9] fixed Z-Scores --- .gitignore | 1 + src/lib.rs | 146 ++++++++++++++++++++++++++++------------------------- test.py | 24 ++++++--- 3 files changed, 93 insertions(+), 78 deletions(-) diff --git a/.gitignore b/.gitignore index ea8c4bf..cb7f85d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +*.json \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 367344d..23e086d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,44 +4,41 @@ use pyo3::wrap_pyfunction; use serde::Serialize; use serde_json::to_string_pretty; -#[derive(Clone, Debug, Serialize)] -struct Point { - value: u32, -} - -impl Point { - fn new(value: u32) -> Self { - Point { value } - } -} - #[derive(Debug, Clone, Serialize)] -struct ClusterGapInfo { - span_length: f32, +struct Anomaly { + elements: Vec, + start: i32, + end: i32, + span_length: i32, num_elements: usize, centroid: f32, z_score: Option, } -fn create_cluster_info(cluster: &[Point]) -> ClusterGapInfo { - - let num_elements = cluster.len(); - let span_length = (cluster.last().unwrap().value as f32) - (cluster.first().unwrap().value as f32); - let centroid = cluster.iter().map(|p| p.value as f32).sum::() / num_elements as f32; +fn anomaly_info(cluster: &[i32]) -> Anomaly { + let num_elements: usize = cluster.len(); + let start: i32 = *cluster.first().expect("Cluster has no start"); + let end: i32 = *cluster.last().expect("Cluster has no end"); + let span_length: i32 = end - start; + let centroid: f32 = start as f32 + span_length as f32 / 2.0; - ClusterGapInfo { + Anomaly { + elements: cluster.to_vec(), + start, + end, span_length, num_elements, centroid, - z_score: None, + z_score: None, // Placeholder for actual Z-score calculation } } + /// Calculates the densities (clusters) and significant gaps between points in a dataset. /// /// This function iterates over a dataset of points, identifying clusters based on a distance threshold /// (calculated from the mean distance between points and adjusted by a given factor) and identifying significant gaps -/// that exceed a certain threshold. Each cluster or significant gap identified is summarized in a `ClusterGapInfo` object. +/// that exceed a certain threshold. Each cluster or significant gap identified is summarized in a `Anomaly` object. /// /// # Arguments /// * `dataset`: A slice of `Point` objects representing the dataset to be analyzed. @@ -50,57 +47,60 @@ fn create_cluster_info(cluster: &[Point]) -> ClusterGapInfo { /// * `min_cluster_size`: The minimum number of points required for a group of points to be considered a cluster. /// /// # Returns -/// A vector of `ClusterGapInfo` objects, each representing either a cluster of points or a significant gap between points. +/// A vector of `Anomaly` objects, each representing either a cluster of points or a significant gap between points. /// -fn calculate_densities_and_gaps(dataset: &[Point], factor: f32, min_cluster_size: usize) -> Vec { +fn scan_anomalies(dataset: &[i32], factor: f32, min_cluster_size: usize) -> Vec { // Return early if the dataset is too small to form any clusters or gaps. if dataset.len() < 2 { return Vec::new(); } // Calculate the mean distance between consecutive points in the dataset. - let mean_distance = dataset.windows(2) - .map(|w| w[1].value as f32 - w[0].value as f32) - .sum::() / (dataset.len() - 1) as f32; + let mean_distance: f32 = dataset.windows(2) + .map(|w| (w[1] - w[0]) as f32) + .sum::() / (dataset.len() - 1) as f32; // Define thresholds for clustering and gap identification based on the mean distance and factor. - let cluster_threshold = mean_distance / factor; - let gap_threshold = factor * mean_distance; + let cluster_threshold: f32 = mean_distance / factor; + let gap_threshold: f32 = factor * mean_distance; - let mut results: Vec = Vec::new(); // Stores the resulting clusters and gaps. - let mut current_cluster: Vec = Vec::new(); // Temporary storage for points in the current cluster. + let mut results: Vec = Vec::new(); // Stores the resulting clusters and gaps. + let mut current_cluster: Vec = Vec::new(); // Temporary storage for points in the current cluster. // Iterate through pairs of consecutive points to find clusters and significant gaps. for window in dataset.windows(2) { - let gap_distance = window[1].value as f32 - window[0].value as f32; + let gap_size: f32 = (window[1] - window[0]) as f32; - // If the distance between points is within the cluster threshold, add to current cluster. - if gap_distance <= cluster_threshold { + if gap_size <= cluster_threshold { + // Add points to the current cluster if current_cluster.is_empty() { - current_cluster.push(window[0].clone()); // Start a new cluster with the first point. + current_cluster.push(window[0]); // Start a new cluster with the first point } - current_cluster.push(window[1].clone()); // Add the second point to the cluster. + current_cluster.push(window[1]); // Add the second point to the cluster } else { - // If the current cluster is large enough, finalize it and prepare for a new cluster. + // End the current cluster and start a new gap if !current_cluster.is_empty() && current_cluster.len() >= min_cluster_size { - results.push(create_cluster_info(¤t_cluster)); + results.push(anomaly_info(¤t_cluster)); current_cluster.clear(); } - // If the gap between points is significant, record it as a gap. - if gap_distance > gap_threshold { - results.push(ClusterGapInfo { - span_length: gap_distance, - num_elements: 0, // Indicating this is a gap, not a cluster. - centroid: (window[0].value as f32 + window[1].value as f32) / 2.0, - z_score: None, // Z-score will be calculated later if necessary. + // Record the gap + if gap_size > gap_threshold { + results.push(Anomaly { + elements: Vec::new(), // No elements in a gap + start: window[0], + end: window[1], + span_length: gap_size as i32, + num_elements: 0, + centroid: (window[0] as f32 + window[1] as f32) / 2.0, + z_score: None, }); } } } - // Finalize the last cluster if it meets the size requirement. + // Finalize the last cluster if applicable if !current_cluster.is_empty() && current_cluster.len() >= min_cluster_size { - results.push(create_cluster_info(¤t_cluster)); + results.push(anomaly_info(¤t_cluster)); } results @@ -134,54 +134,60 @@ fn calculate_densities_and_gaps(dataset: &[Point], factor: f32, min_cluster_size /// #[pyfunction] fn lyagushka(_py: Python, int_list: &PyList, factor: f32, min_cluster_size: usize) -> PyResult { - - // Extract integers from a Python list and create a vector of Point structs. - let mut dataset: Vec = int_list.extract::>()? - .into_iter() - .map(Point::new) - .collect(); - + // Extract integers from a Python list and create a vector. + let mut dataset: Vec = int_list.extract::>()?; // Sort the vector - dataset.sort_by_key(|p| p.value); + dataset.sort_unstable(); // Calculate clusters and gaps from the dataset using predefined criteria. - let mut cluster_gap_infos = calculate_densities_and_gaps(&dataset, factor, min_cluster_size); + let mut anomalies: Vec = scan_anomalies(&dataset, factor, min_cluster_size); // Calculate the mean density of clusters in the dataset for comparison. - let mean_density: f32 = cluster_gap_infos.iter() - .filter(|info| info.num_elements > 0) - .map(|info| info.num_elements as f32 / info.span_length) - .sum::() / cluster_gap_infos.iter().filter(|info| info.num_elements > 0).count() as f32; + let mean_density: f32 = anomalies.iter() + .filter(|info: &&Anomaly| info.num_elements > 0) + .map(|info: &Anomaly| info.num_elements as f32 / info.span_length as f32) + .sum::() / anomalies.iter().filter(|info: &&Anomaly| info.num_elements > 0).count() as f32; // Calculate the standard deviation of cluster densities to evaluate variation. - let variance_density: f32 = cluster_gap_infos.iter() - .filter(|info| info.num_elements > 0) - .map(|info| info.num_elements as f32 / info.span_length) - .map(|density| (density - mean_density).powi(2)) - .sum::() / cluster_gap_infos.iter().filter(|info| info.num_elements > 0).count() as f32; + let variance_density: f32 = anomalies.iter() + .filter(|info: &&Anomaly| info.num_elements > 0) + .map(|info: &Anomaly| info.num_elements as f32 / info.span_length as f32) + .map(|density| (density - mean_density).powi(2)) + .sum::() / anomalies.iter().filter(|info: &&Anomaly| info.num_elements > 0).count() as f32; let std_dev_density = variance_density.sqrt(); - // Calculate the average span of all clusters and gaps to assess gap significance. - let average_span: f32 = cluster_gap_infos.iter().map(|info| info.span_length).sum::() / cluster_gap_infos.len() as f32; + // Calculate mean span length + let mean_span_length: f32 = anomalies.iter() + .map(|info: &Anomaly| info.span_length as f32) + .sum::() / anomalies.len() as f32; + + // Calculate variance + let variance: f32 = anomalies.iter() + .map(|info: &Anomaly| (info.span_length as f32 - mean_span_length).powi(2)) + .sum::() / anomalies.len() as f32; + + // Standard deviation is the square root of variance + let std_dev_span_length: f32 = variance.sqrt(); // Update Z-scores for both clusters and gaps based on their deviation from mean metrics. - for info in &mut cluster_gap_infos { + for info in anomalies.iter_mut() { if info.num_elements > 0 { // Calculate and update Z-score for clusters based on density deviation. - let cluster_density = info.num_elements as f32 / info.span_length; + let cluster_density: f32 = info.num_elements as f32 / info.span_length as f32; info.z_score = Some((cluster_density - mean_density) / std_dev_density); } else { // Calculate and update Z-score for gaps based on span length deviation. - info.z_score = Some((info.span_length - average_span) / std_dev_density); + info.z_score = Some((info.span_length as f32 / std_dev_span_length) * -1.0); } } // Serialize the updated cluster and gap information, including Z-scores, to a JSON string. - to_string_pretty(&cluster_gap_infos) + to_string_pretty(&anomalies) .map_err(|e| PyErr::new::(format!("JSON Serialization Error: {}", e))) } + #[pymodule] fn pyagushka(_py: Python, m: &PyModule) -> PyResult<()> { m.add_function(wrap_pyfunction!(lyagushka, m)?)?; diff --git a/test.py b/test.py index 0a84dc7..129515a 100644 --- a/test.py +++ b/test.py @@ -24,8 +24,12 @@ def generate_random_data(size=1024, max_value=100): return random_data +def filter_by_z_score(data, z_score_threshold): + filtered_data = [item for item in data if item['z_score'] is not None and abs(item['z_score']) >= z_score_threshold] + return filtered_data + # load the random test data -dataset = [] +# dataset = [] # with open('random_values.txt', 'r') as file: # for line in file: # random_data.append(int(line.strip())) @@ -33,8 +37,15 @@ dataset = [] dataset = generate_random_data(1024, 1024) dataset.sort() +with open('dataset.json', 'w') as r: + r.write(json.dumps(dataset, indent=4)) + # calculate the anomalies in the data -analysis_results = json.loads(lyagushka(dataset, 3.0, 7)) +analysis_results = json.loads(lyagushka(dataset, 4.0, 7)) +analysis_results = filter_by_z_score(analysis_results, 1.0) + +with open('result.json', 'w') as r: + r.write(json.dumps(analysis_results, indent=4)) # Initialize plot plt.figure(figsize=(10, 6)) @@ -45,15 +56,12 @@ colors = plt.cm.jet(np.linspace(0, 1, len(analysis_results))) # Plot dataset points and assign colors based on cluster membership for i, result in enumerate(analysis_results): if result['num_elements'] > 0: # It's a cluster - points_in_cluster = [point for point in dataset if - result['centroid'] - result['span_length'] / 2 <= point <= - result['centroid'] + result['span_length'] / 2] - for point in points_in_cluster: + for point in dataset: plt.plot(point, 0, 'o', color=colors[i]) # Plot points in cluster with the same color # Plot a line segment for the cluster/gap Z-score in the same color - start = result['centroid'] - result['span_length'] / 2 - end = result['centroid'] + result['span_length'] / 2 + start = result['start'] + end = result['end'] z_score = result['z_score'] if result['z_score'] is not None else 0 plt.plot([start, end], [z_score, z_score], color=colors[i], linewidth=2) From 23a2da62330b9a587833226bea982abd75ca4eaf Mon Sep 17 00:00:00 2001 From: randogoth Date: Fri, 1 Mar 2024 22:25:31 +0200 Subject: [PATCH 7/9] finalized 1.0.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- readme.md | 93 +++++++++++++++++++++++++++++++----------------------- src/lib.rs | 2 +- 4 files changed, 57 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4fc78cb..5be8781 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,7 +123,7 @@ dependencies = [ [[package]] name = "pyagushka" -version = "0.1.0" +version = "1.0.0" dependencies = [ "atty", "pyo3", diff --git a/Cargo.toml b/Cargo.toml index b875495..ed35651 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pyagushka" -version = "0.1.0" +version = "1.0.0" edition = "2021" [lib] diff --git a/readme.md b/readme.md index 31e63bc..63b7893 100644 --- a/readme.md +++ b/readme.md @@ -1,61 +1,76 @@ -# lyagushka +# pyagushka - a Python module for lyagushka -(Russian лягушка: frog) +(Russian лягушка [lʲɪˈɡuʂkə]: frog) -Cluster and Gap Analysis Tool inspired by Fatum Project's 'Zhaba' algorithm (Russian 'жаба': toad) that finds attractor clusters in lists of integers. +Pyagushka is a Python module based on the Rust algorithm lyagushka that is inspired by Fatum Project's ['Zhaba' algorithm](https://gist.github.com/randogoth/ab5ab9e8665303be176f16241e7b26b5) (Russian 'жаба': toad) and expands upon it for more versatility. -This Rust command-line tool analyzes a dataset of integers to identify clusters of closely grouped points and significant gaps between these clusters. It calculates z-scores for each cluster or gap to measure their statistical significance relative to the dataset's mean distance. The analysis results, including clusters, gaps, and their z-scores, are output as a JSON string. +It is an algorithm that analyzes a one-dimensional dataset of integers to identify clusters of closely grouped "attractor" points and significant "void" gaps between these clusters. It calculates z-scores for each cluster or gap to measure their statistical significance relative to the dataset's mean density and distance between points. The analysis results, including attractors, voids, and their z-scores, are output as a JSON string. -## Features +## Building -- **Cluster Identification**: Identifies groups of points that are closely spaced together based on a customizable threshold. -- **Gap Detection**: Detects significant gaps between clusters, providing insights into the dataset's distribution. -- **Z-Score Calculation**: Calculates z-scores for both clusters and gaps, offering a statistical measure of their deviation from the mean distance. -- **Flexible Input**: Accepts input data either from a file specified as a command-line argument or piped directly into stdin. -- **JSON Output**: Outputs the analysis results in a readable JSON format, making it easy to interpret or use in further processing. +With a Rust/Cargo and Python3/Pip environment set up, run: + +```sh +$ pip install maturin +$ maturin build --release +$ pip install target/wheels/pyagushka-1.0.0-*.whl +``` ## Usage -### From a File +### Parameters -To analyze a dataset from a file, provide the filename as an argument along with two additional parameters: the factor for adjusting clustering and gap detection thresholds, and the minimum cluster size. - -```sh -cargo run -- filename.txt 0.5 2 -``` - -### From Stdin - -Alternatively, you can pipe a list of integers into the tool, followed by the factor and minimum cluster size. - -```sh -echo "1\n2\n10\n20" | cargo run -- 0.5 2 -``` - -#### Parameters - -* `filename.txt` (optional): A file containing a newline-separated list of integers to analyze. If not provided, the program expects input from stdin. -* `factor`: A floating-point value used to fine-tune the sensitivity of cluster and gap detection. Lower values result in tighter clusters and wider gaps, while higher values do the opposite. +* `dataset`: list of integers representing the dataset to be analyzed. +* `factor`: A floating-point value by which the mean density/span is multiplied to make up a threshold for attractor and void detection. * `min_cluster_size`: An integer specifying the minimum number of contiguous points required to be considered a cluster. ### Output -The tool outputs a JSON string that includes details about the identified clusters and gaps, along with their respective z-scores. Here's an example of the JSON output format: +The tool outputs a JSON string that includes details about the identified attractors and voids, along with their respective z-scores. Here's an example of the JSON output format: ```json [ + //... { - "span_length": 1.0, - "num_elements": 2, - "centroid": 1.5, - "z_score": -1.23 + "elements": [ 722, 722, 722, 725, 725, 726, 726, 726], + "start": 722, + "end": 726, + "span_length": 4, + "num_elements": 8, + "centroid": 724.0, + "z_score": 1.19528 }, { - "span_length": 8.0, + "elements": [], + "start": 732, + "end": 740, + "span_length": 8, "num_elements": 0, - "centroid": 6.0, - "z_score": 2.45 - } + "centroid": 736.0, + "z_score": -1.13359 + }, + //... ] -``` \ No newline at end of file +``` + +### Example + +To analyze a dataset from a file, provide the filename as an argument, followed by the factor and minimum cluster size parameters +```Python +from pyagushka import lyagushka + +dataset = [] +with open('random_values.txt', 'r') as file: + for line in file: + random_data.append(int(line.strip())) + +analysis_results = json.loads(lyagushka(dataset, 4.0, 7)) + +print(analysis_result) +``` +(= '*Attractor clusters need to have at least 7 numbers with 4.0 times the mean density, void gaps need to be at leat 4.0 times the mean gap size wide*') + +## CLI + +If you need lyagushka as a command line tool, check out the 'main' branch of this repository \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 23e086d..ff6caea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,7 +41,7 @@ fn anomaly_info(cluster: &[i32]) -> Anomaly { /// that exceed a certain threshold. Each cluster or significant gap identified is summarized in a `Anomaly` object. /// /// # Arguments -/// * `dataset`: A slice of `Point` objects representing the dataset to be analyzed. +/// * `dataset`: A slice of `i32` objects representing the dataset to be analyzed. /// * `factor`: A multiplier used to define the thresholds for clustering and gap identification. /// A lower factor tightens the cluster threshold and widens the gap threshold, and vice versa. /// * `min_cluster_size`: The minimum number of points required for a group of points to be considered a cluster. From 92c862bd4c91a3919166e73ad93decd560e04da9 Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 9 Apr 2024 07:47:42 +0300 Subject: [PATCH 8/9] class --- Cargo.lock | 45 +-------- Cargo.toml | 3 +- readme.md | 7 +- src/lib.rs | 289 +++++++++++++++++++++++------------------------------ test.py | 5 +- 5 files changed, 136 insertions(+), 213 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5be8781..e4583c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,17 +2,6 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi", - "libc", - "winapi", -] - [[package]] name = "autocfg" version = "1.1.0" @@ -37,15 +26,6 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - [[package]] name = "indoc" version = "2.0.4" @@ -123,9 +103,8 @@ dependencies = [ [[package]] name = "pyagushka" -version = "1.0.0" +version = "1.1.0" dependencies = [ - "atty", "pyo3", "serde", "serde_json", @@ -288,28 +267,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-targets" version = "0.48.5" diff --git a/Cargo.toml b/Cargo.toml index ed35651..0827562 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pyagushka" -version = "1.0.0" +version = "1.1.0" edition = "2021" [lib] @@ -8,7 +8,6 @@ name = "pyagushka" crate-type = ["cdylib"] [dependencies] -atty = "0.2.14" pyo3 = "0.20.2" serde = { version = "1.0.196", features = ["derive"] } serde_json = "1.0.113" diff --git a/readme.md b/readme.md index 63b7893..830f24c 100644 --- a/readme.md +++ b/readme.md @@ -13,7 +13,7 @@ With a Rust/Cargo and Python3/Pip environment set up, run: ```sh $ pip install maturin $ maturin build --release -$ pip install target/wheels/pyagushka-1.0.0-*.whl +$ pip install target/wheels/pyagushka-1.1.0-*.whl ``` ## Usage @@ -58,14 +58,15 @@ The tool outputs a JSON string that includes details about the identified attrac To analyze a dataset from a file, provide the filename as an argument, followed by the factor and minimum cluster size parameters ```Python -from pyagushka import lyagushka +from pyagushka import Lyagushka dataset = [] with open('random_values.txt', 'r') as file: for line in file: random_data.append(int(line.strip())) -analysis_results = json.loads(lyagushka(dataset, 4.0, 7)) +zhaba = Lyagushka(dataset) +analysis_results = json.loads(lyagushka.search(4.0, 7)) print(analysis_result) ``` diff --git a/src/lib.rs b/src/lib.rs index ff6caea..7a799c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,5 @@ use pyo3::prelude::*; -use pyo3::types::PyList; -use pyo3::wrap_pyfunction; use serde::Serialize; -use serde_json::to_string_pretty; #[derive(Debug, Clone, Serialize)] struct Anomaly { @@ -15,181 +12,149 @@ struct Anomaly { z_score: Option, } -fn anomaly_info(cluster: &[i32]) -> Anomaly { - let num_elements: usize = cluster.len(); - let start: i32 = *cluster.first().expect("Cluster has no start"); - let end: i32 = *cluster.last().expect("Cluster has no end"); - let span_length: i32 = end - start; - let centroid: f32 = start as f32 + span_length as f32 / 2.0; +impl Anomaly { - Anomaly { - elements: cluster.to_vec(), - start, - end, - span_length, - num_elements, - centroid, - z_score: None, // Placeholder for actual Z-score calculation + pub fn new(cluster: &[i32]) -> Self { + let num_elements: usize = cluster.len(); + let start: i32 = *cluster.first().expect("Cluster has no start"); + let end: i32 = *cluster.last().expect("Cluster has no end"); + let span_length: i32 = end - start; + let centroid: f32 = start as f32 + span_length as f32 / 2.0; + + Anomaly { + elements: cluster.to_vec(), + start, + end, + span_length, + num_elements, + centroid, + z_score: None, + } } } +#[pyclass] +struct Lyagushka { + dataset: Vec, + anomalies: Vec, +} -/// Calculates the densities (clusters) and significant gaps between points in a dataset. -/// -/// This function iterates over a dataset of points, identifying clusters based on a distance threshold -/// (calculated from the mean distance between points and adjusted by a given factor) and identifying significant gaps -/// that exceed a certain threshold. Each cluster or significant gap identified is summarized in a `Anomaly` object. -/// -/// # Arguments -/// * `dataset`: A slice of `i32` objects representing the dataset to be analyzed. -/// * `factor`: A multiplier used to define the thresholds for clustering and gap identification. -/// A lower factor tightens the cluster threshold and widens the gap threshold, and vice versa. -/// * `min_cluster_size`: The minimum number of points required for a group of points to be considered a cluster. -/// -/// # Returns -/// A vector of `Anomaly` objects, each representing either a cluster of points or a significant gap between points. -/// -fn scan_anomalies(dataset: &[i32], factor: f32, min_cluster_size: usize) -> Vec { +#[pymethods] +impl Lyagushka { - // Return early if the dataset is too small to form any clusters or gaps. - if dataset.len() < 2 { return Vec::new(); } - - // Calculate the mean distance between consecutive points in the dataset. - let mean_distance: f32 = dataset.windows(2) - .map(|w| (w[1] - w[0]) as f32) - .sum::() / (dataset.len() - 1) as f32; - - // Define thresholds for clustering and gap identification based on the mean distance and factor. - let cluster_threshold: f32 = mean_distance / factor; - let gap_threshold: f32 = factor * mean_distance; - - let mut results: Vec = Vec::new(); // Stores the resulting clusters and gaps. - let mut current_cluster: Vec = Vec::new(); // Temporary storage for points in the current cluster. - - // Iterate through pairs of consecutive points to find clusters and significant gaps. - for window in dataset.windows(2) { - let gap_size: f32 = (window[1] - window[0]) as f32; - - if gap_size <= cluster_threshold { - // Add points to the current cluster - if current_cluster.is_empty() { - current_cluster.push(window[0]); // Start a new cluster with the first point - } - current_cluster.push(window[1]); // Add the second point to the cluster - } else { - // End the current cluster and start a new gap - if !current_cluster.is_empty() && current_cluster.len() >= min_cluster_size { - results.push(anomaly_info(¤t_cluster)); - current_cluster.clear(); - } - - // Record the gap - if gap_size > gap_threshold { - results.push(Anomaly { - elements: Vec::new(), // No elements in a gap - start: window[0], - end: window[1], - span_length: gap_size as i32, - num_elements: 0, - centroid: (window[0] as f32 + window[1] as f32) / 2.0, - z_score: None, - }); - } + #[new] + pub fn new(dataset: Vec) -> Self { + Lyagushka { + dataset, + anomalies: vec![] } } - // Finalize the last cluster if applicable - if !current_cluster.is_empty() && current_cluster.len() >= min_cluster_size { - results.push(anomaly_info(¤t_cluster)); - } - - results -} - -/// Analyzes a dataset of integers to identify clusters and gaps, then calculates Z-scores -/// for each based on their deviation from mean metrics. The analysis aims to highlight -/// significant clusters of closely grouped points and notable gaps between them, providing -/// a statistical measure of their significance through Z-scores. The results, including -/// clusters, gaps, and their Z-scores, are serialized into a JSON string. -/// -/// # Arguments -/// * `_py` - The Python interpreter instance, used for Python-Rust interoperability. -/// This argument is necessary for functions exposed to Python via PyO3 but is not -/// directly used within the function. -/// * `int_list` - A Python list of integers representing the dataset to be analyzed. -/// This list is converted into a Vec for internal processing. -/// * `factor` - A floating-point value used as a threshold factor to adjust the sensitivity -/// of cluster and gap detection. This factor influences the identification of clusters -/// by defining the minimum density or separation required. -/// * `min_cluster_size` - An integer specifying the minimum number of contiguous points -/// required for a group of points to be considered a cluster. This parameter helps -/// filter out noise by defining a threshold for the minimum cluster size. -/// -/// # Returns -/// Returns a `PyResult` containing a JSON-formatted string of the analysis results. -/// The JSON string includes detailed information about each identified cluster and gap, -/// such as their span length, number of elements (if applicable), centroid, and calculated -/// Z-score. In case of an error during processing or serialization, a Python exception is -/// returned. -/// -#[pyfunction] -fn lyagushka(_py: Python, int_list: &PyList, factor: f32, min_cluster_size: usize) -> PyResult { - // Extract integers from a Python list and create a vector. - let mut dataset: Vec = int_list.extract::>()?; + fn scan_anomalies(&mut self, factor: f32, min_cluster_size: usize) { - // Sort the vector - dataset.sort_unstable(); - - // Calculate clusters and gaps from the dataset using predefined criteria. - let mut anomalies: Vec = scan_anomalies(&dataset, factor, min_cluster_size); - - // Calculate the mean density of clusters in the dataset for comparison. - let mean_density: f32 = anomalies.iter() - .filter(|info: &&Anomaly| info.num_elements > 0) - .map(|info: &Anomaly| info.num_elements as f32 / info.span_length as f32) - .sum::() / anomalies.iter().filter(|info: &&Anomaly| info.num_elements > 0).count() as f32; - - // Calculate the standard deviation of cluster densities to evaluate variation. - let variance_density: f32 = anomalies.iter() - .filter(|info: &&Anomaly| info.num_elements > 0) - .map(|info: &Anomaly| info.num_elements as f32 / info.span_length as f32) - .map(|density| (density - mean_density).powi(2)) - .sum::() / anomalies.iter().filter(|info: &&Anomaly| info.num_elements > 0).count() as f32; - let std_dev_density = variance_density.sqrt(); - - // Calculate mean span length - let mean_span_length: f32 = anomalies.iter() - .map(|info: &Anomaly| info.span_length as f32) - .sum::() / anomalies.len() as f32; - - // Calculate variance - let variance: f32 = anomalies.iter() - .map(|info: &Anomaly| (info.span_length as f32 - mean_span_length).powi(2)) - .sum::() / anomalies.len() as f32; - - // Standard deviation is the square root of variance - let std_dev_span_length: f32 = variance.sqrt(); - - // Update Z-scores for both clusters and gaps based on their deviation from mean metrics. - for info in anomalies.iter_mut() { - if info.num_elements > 0 { - // Calculate and update Z-score for clusters based on density deviation. - let cluster_density: f32 = info.num_elements as f32 / info.span_length as f32; - info.z_score = Some((cluster_density - mean_density) / std_dev_density); - } else { - // Calculate and update Z-score for gaps based on span length deviation. - info.z_score = Some((info.span_length as f32 / std_dev_span_length) * -1.0); + // Calculate the mean distance between consecutive points in the dataset. + let mean_distance: f32 = self.dataset.windows(2) + .map(|w| (w[1] - w[0]) as f32) + .sum::() / (self.dataset.len() - 1) as f32; + + // Define thresholds for clustering and gap identification based on the mean distance and factor. + let cluster_threshold: f32 = mean_distance / factor; + let gap_threshold: f32 = factor * mean_distance; + + let mut current_cluster: Vec = Vec::new(); // Temporary storage for points in the current cluster. + + // Iterate through pairs of consecutive points to find clusters and significant gaps. + for window in self.dataset.windows(2) { + let gap_size: f32 = (window[1] - window[0]) as f32; + + if gap_size <= cluster_threshold { + // Add points to the current cluster + if current_cluster.is_empty() { + current_cluster.push(window[0]); // Start a new cluster with the first point + } + current_cluster.push(window[1]); // Add the second point to the cluster + } else { + // End the current cluster and start a new gap + if !current_cluster.is_empty() && current_cluster.len() >= min_cluster_size { + self.anomalies.push(Anomaly::new(¤t_cluster)); + current_cluster.clear(); + } + + // Record the gap + if gap_size > gap_threshold { + self.anomalies.push(Anomaly { + elements: Vec::new(), // No elements in a gap + start: window[0], + end: window[1], + span_length: gap_size as i32, + num_elements: 0, + centroid: (window[0] as f32 + window[1] as f32) / 2.0, + z_score: None, + }); + } + } } + + // Finalize the last cluster if applicable + if !current_cluster.is_empty() && current_cluster.len() >= min_cluster_size { + self.anomalies.push(Anomaly::new(¤t_cluster)); + } + } - // Serialize the updated cluster and gap information, including Z-scores, to a JSON string. - to_string_pretty(&anomalies) - .map_err(|e| PyErr::new::(format!("JSON Serialization Error: {}", e))) -} + pub fn search(&mut self, factor: f32, min_cluster_size: usize) -> String { + // Sort the vector + self.dataset.sort_unstable(); + + // Calculate clusters and gaps from the dataset using predefined criteria. + self.scan_anomalies(factor, min_cluster_size); + + // Calculate the mean density of clusters in the dataset for comparison. + let mean_density: f32 = self.anomalies.iter() + .filter(|info: &&Anomaly| info.num_elements > 0) + .map(|info: &Anomaly| info.num_elements as f32 / info.span_length as f32) + .sum::() / self.anomalies.iter().filter(|info: &&Anomaly| info.num_elements > 0).count() as f32; + + // Calculate the standard deviation of cluster densities to evaluate variation. + let variance_density: f32 = self.anomalies.iter() + .filter(|info: &&Anomaly| info.num_elements > 0) + .map(|info: &Anomaly| info.num_elements as f32 / info.span_length as f32) + .map(|density: f32| (density - mean_density).powi(2)) + .sum::() / self.anomalies.iter().filter(|info: &&Anomaly| info.num_elements > 0).count() as f32; + let std_dev_density: f32 = variance_density.sqrt(); + + // Calculate mean span length + let mean_span_length: f32 = self.anomalies.iter() + .map(|info: &Anomaly| info.span_length as f32) + .sum::() / self.anomalies.len() as f32; + + // Calculate variance + let variance: f32 = self.anomalies.iter() + .map(|info: &Anomaly| (info.span_length as f32 - mean_span_length).powi(2)) + .sum::() / self.anomalies.len() as f32; + + // Standard deviation is the square root of variance + let std_dev_span_length: f32 = variance.sqrt(); + + // Update Z-scores for both clusters and gaps based on their deviation from mean metrics. + for info in self.anomalies.iter_mut() { + if info.num_elements > 0 { + // Calculate and update Z-score for clusters based on density deviation. + let cluster_density: f32 = info.num_elements as f32 / info.span_length as f32; + info.z_score = Some((cluster_density - mean_density) / std_dev_density); + } else { + // Calculate and update Z-score for gaps based on span length deviation. + info.z_score = Some((info.span_length as f32 / std_dev_span_length) * -1.0); + } + } + + serde_json::to_string_pretty(&self.anomalies).unwrap_or_else(|_| "Failed to serialize data".to_string()) + } +} #[pymodule] fn pyagushka(_py: Python, m: &PyModule) -> PyResult<()> { - m.add_function(wrap_pyfunction!(lyagushka, m)?)?; + m.add_class::()?; Ok(()) } diff --git a/test.py b/test.py index 129515a..111ded7 100644 --- a/test.py +++ b/test.py @@ -1,4 +1,4 @@ -from pyagushka import lyagushka +from pyagushka import Lyagushka from randonautentropy import rndo import json import matplotlib.pyplot as plt @@ -41,7 +41,8 @@ with open('dataset.json', 'w') as r: r.write(json.dumps(dataset, indent=4)) # calculate the anomalies in the data -analysis_results = json.loads(lyagushka(dataset, 4.0, 7)) +zhaba = Lyagushka(dataset) +analysis_results = json.loads(zhaba.search(4.0, 7)) analysis_results = filter_by_z_score(analysis_results, 1.0) with open('result.json', 'w') as r: From 448be74887d681abee93bf7fff9dc0686fed3dc3 Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 9 Apr 2024 08:35:31 +0300 Subject: [PATCH 9/9] combined lib/cli --- Cargo.lock | 61 +++++++++++++++++++++++++++++++++++++++------- Cargo.toml | 14 +++-------- src/lib.rs | 4 +-- src/main.rs | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 21 deletions(-) create mode 100644 src/main.rs diff --git a/Cargo.lock b/Cargo.lock index e4583c6..6a2a190 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -26,6 +37,15 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "indoc" version = "2.0.4" @@ -54,6 +74,16 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lyagushka" +version = "1.1.0" +dependencies = [ + "atty", + "pyo3", + "serde", + "serde_json", +] + [[package]] name = "memoffset" version = "0.9.0" @@ -101,15 +131,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "pyagushka" -version = "1.1.0" -dependencies = [ - "pyo3", - "serde", - "serde_json", -] - [[package]] name = "pyo3" version = "0.20.2" @@ -267,6 +288,28 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-targets" version = "0.48.5" diff --git a/Cargo.toml b/Cargo.toml index 0827562..d6aedab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,17 +1,11 @@ [package] -name = "pyagushka" +name = "lyagushka" version = "1.1.0" edition = "2021" - -[lib] -name = "pyagushka" crate-type = ["cdylib"] [dependencies] -pyo3 = "0.20.2" +atty = "0.2.14" +pyo3 = { version = "0.20.2", features = ["extension-module"] } serde = { version = "1.0.196", features = ["derive"] } -serde_json = "1.0.113" - -[features] -extension-module = ["pyo3/extension-module"] -default = ["extension-module"] \ No newline at end of file +serde_json = "1.0.113" \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 7a799c9..dd09518 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,7 +34,7 @@ impl Anomaly { } #[pyclass] -struct Lyagushka { +pub struct Lyagushka { dataset: Vec, anomalies: Vec, } @@ -154,7 +154,7 @@ impl Lyagushka { } #[pymodule] -fn pyagushka(_py: Python, m: &PyModule) -> PyResult<()> { +fn lyagushka(_py: Python, m: &PyModule) -> PyResult<()> { m.add_class::()?; Ok(()) } diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0dfe6d2 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,70 @@ +use std::fs::File; +use std::io::{self, BufRead, BufReader, stdin}; +use std::env; +use std::process; +use lyagushka::Lyagushka; + +/// The entry point for the command-line tool that reads a dataset of integers from either a file or stdin, +/// performs cluster and gap analysis using specified parameters, and prints the results as a JSON string. +/// +/// This tool expects either a filename as an argument or a list of integers piped into stdin. It also requires +/// two additional command-line arguments: a factor for adjusting clustering and gap detection thresholds, +/// and a minimum cluster size. The tool reads the dataset, performs the analysis by identifying clusters +/// and significant gaps, calculates z-scores for each, and prints the JSON-serialized results to stdout. +/// +/// # Usage +/// To read from a file: +/// ``` +/// cargo run -- filename.txt 0.5 2 +/// ``` +/// +/// To read from stdin: +/// ``` +/// echo "1\n2\n10\n20" | cargo run -- 0.5 2 +/// ``` +/// +/// # Arguments +/// - A filename (if not receiving piped input) to read the dataset from. +/// - `factor`: A floating-point value used to adjust the sensitivity of cluster and gap detection. +/// - `min_cluster_size`: The minimum number of contiguous points required to be considered a cluster. +/// +/// # Exit Codes +/// - `0`: Success. +/// - `1`: Incorrect usage or failure to parse the input data. +/// +/// # Errors +/// This tool will exit with an error if the required arguments are not provided, if the specified file cannot be opened, +/// or if the input data cannot be parsed into integers. +/// +/// # Note +/// This function does not return a value but directly exits the process in case of failure. +/// +fn main() -> io::Result<()> { + let args: Vec = env::args().collect(); + + // Input handling + let dataset: Vec = if atty::is(atty::Stream::Stdin) { + if args.len() != 4 { + eprintln!("Usage: {} ", args[0]); + process::exit(1); + } + let filename = &args[1]; + let file = File::open(filename)?; + BufReader::new(file).lines().filter_map(Result::ok) + .filter_map(|line| line.trim().parse::().ok()) // Directly parse to i32 + .collect() + } else { + stdin().lock().lines().filter_map(Result::ok) + .filter_map(|line| line.trim().parse::().ok()) // Directly parse to i32 + .collect() + }; + + let factor: f32 = args[args.len() - 2].parse().expect("Factor must be a float"); + let min_cluster_size: usize = args[args.len() - 1].parse().expect("Min cluster size must be an integer"); + + // Analysis and output + let mut zhaba = Lyagushka::new(dataset); + println!("{}", zhaba.search(factor, min_cluster_size)); + + Ok(()) +} \ No newline at end of file