jacscanomaly 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KansukeNunota
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: jacscanomaly
3
+ Version: 0.1.0
4
+ Summary: JAX-based scan anomaly detection for time-series residuals
5
+ Author: Kansuke Nunota
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/NunotaKansuke/jacscanomaly
8
+ Project-URL: Repository, https://github.com/NunotaKansuke/jacscanomaly
9
+ Project-URL: Issues, https://github.com/NunotaKansuke/jacscanomaly/issues
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: numpy
14
+ Requires-Dist: jax
15
+ Requires-Dist: jaxopt
16
+ Requires-Dist: matplotlib
17
+ Dynamic: license-file
18
+
19
+ # jacscanomaly
20
+
21
+ **jacscanomaly** is a JAX-based framework for scan-based anomaly detection
22
+ in time-series data.
23
+
24
+ The package is designed to detect **localized, transient anomalies** by
25
+ scanning residuals after fitting a baseline model (e.g. PSPL in microlensing),
26
+ while remaining fast and differentiable thanks to JAX.
27
+
28
+ ---
29
+
30
+ ## Features
31
+
32
+ * **JAX-powered**: fast, vectorized grid scans with JIT compilation
33
+ * **Scan-based anomaly detection** on residuals
34
+ * **Model-agnostic design**: anomaly scan works on residuals of any baseline model
35
+ * **Built-in visualization**: PSPL fit, residuals, and anomaly scan summary
36
+ * Designed for **research-grade workflows** (clear statistics & reproducibility)
37
+
38
+ ---
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pip install jacscanomaly
44
+ ```
45
+
46
+ > For local development (without pip), see the example notebook.
47
+
48
+ ---
49
+
50
+ ## Quick Example
51
+
52
+ ```python
53
+ import numpy as np
54
+ from jacscanomaly import Finder, FinderConfig
55
+
56
+ # load data (time, flux, flux_err)
57
+ data = np.load("example_data.npy")
58
+ time, flux, ferr = data[:, 0], data[:, 1], data[:, 2]
59
+
60
+ # initial guess for PSPL parameters
61
+ p0 = np.array([9826.56, 8.61, 0.353])
62
+
63
+ # run anomaly finder
64
+ config = FinderConfig()
65
+ finder = Finder(config)
66
+ result = finder.run(time, flux, ferr, p0)
67
+
68
+ print("=== PSPL fit ===")
69
+
70
+ t0_pspl, tE_pspl, u0_pspl = result.fit.params
71
+ print(f" t0 = {float(t0_pspl):.3f}")
72
+ print(f" tE = {float(tE_pspl):.3f}")
73
+ print(f" u0 = {float(u0_pspl):.3f}")
74
+ print(f" chi2 / dof = {result.chi2_dof:.3f}\n")
75
+
76
+ b = result.best
77
+ print("=== Anomaly candidate ===")
78
+ print(f" t0 = {b.t0:.3f}")
79
+ print(f" teff = {b.teff:.3f}")
80
+ print(f" dchi2 = {b.dchi2:.3e}")
81
+ print(f" score = {b.score:.2f}")
82
+ ```
83
+
84
+ ---
85
+
86
+ ## Visualization
87
+
88
+ ```python
89
+ finder.plot_result()
90
+ plt.show()
91
+
92
+ finder.plot_anomaly_window()
93
+ plt.show()
94
+ ```
95
+ These commands produce two complementary visualizations:
96
+
97
+ 1. **Three-panel summary plot (`finder.plot_result`)**
98
+ - **Top:** Observed light curve with the best-fit baseline model (PSPL)
99
+ - **Middle:** Residuals after baseline fitting
100
+ - **Bottom:** Anomaly scan result (Δχ² vs. time), showing where localized
101
+ deviations from the baseline model are detected
102
+
103
+ 2. **Focused anomaly window plot (`finder.plot_anomaly_window`)**
104
+ - A zoomed-in view around the best anomaly candidate
105
+ - Residuals are shown together with the anomaly template and the flat model
106
+
107
+ ---
108
+
109
+ ## Method Overview
110
+
111
+ The workflow of `jacscanomaly` is:
112
+
113
+ 1. **Baseline fitting**
114
+ Fit a global model (e.g. PSPL) to the full light curve.
115
+
116
+ 2. **Residual analysis**
117
+ Compute residuals:
118
+
119
+ ```
120
+ residual = data − baseline_model
121
+ ```
122
+
123
+ 3. **Local anomaly scan**
124
+ For each grid point `(t0, teff)`, compare:
125
+
126
+ * a null (flat) model
127
+ * an anomaly template model
128
+
129
+ within a local time window.
130
+
131
+ 4. **Detection statistic**
132
+ The improvement is measured by:
133
+
134
+ ```
135
+ Δχ² = χ²_flat − χ²_anomaly
136
+ ```
137
+
138
+ ---
139
+
140
+ ## Anomaly Score
141
+
142
+ To quantify how significant the best anomaly candidate is relative to others,
143
+ we define a **score**:
144
+
145
+ ```
146
+ score = (Δχ²_best − median(Δχ²_others)) / std(Δχ²_others)
147
+ ```
148
+
149
+ This measures how strongly the best candidate stands out from the rest of the grid.
150
+
151
+ ---
152
+
153
+ ## Configuration
154
+
155
+ Key parameters are controlled via `FinderConfig`:
156
+
157
+ ```python
158
+ from jacscanomaly import FinderConfig
159
+
160
+ config = FinderConfig(
161
+ gap=100.0, # season gap
162
+ teff_init=0.3, # initial anomaly timescale
163
+ teff_grid_n=5, # number of teff grid points
164
+ sigma=3.0, # threshold for outlier counting
165
+ )
166
+ ```
167
+
168
+ See `FinderConfig` for the full list of options.
169
+
170
+ ---
171
+
172
+ ## Requirements
173
+
174
+ * Python ≥ 3.9
175
+ * numpy
176
+ * jax
177
+ * jaxopt
178
+ * matplotlib
179
+
180
+ ---
@@ -0,0 +1,162 @@
1
+ # jacscanomaly
2
+
3
+ **jacscanomaly** is a JAX-based framework for scan-based anomaly detection
4
+ in time-series data.
5
+
6
+ The package is designed to detect **localized, transient anomalies** by
7
+ scanning residuals after fitting a baseline model (e.g. PSPL in microlensing),
8
+ while remaining fast and differentiable thanks to JAX.
9
+
10
+ ---
11
+
12
+ ## Features
13
+
14
+ * **JAX-powered**: fast, vectorized grid scans with JIT compilation
15
+ * **Scan-based anomaly detection** on residuals
16
+ * **Model-agnostic design**: anomaly scan works on residuals of any baseline model
17
+ * **Built-in visualization**: PSPL fit, residuals, and anomaly scan summary
18
+ * Designed for **research-grade workflows** (clear statistics & reproducibility)
19
+
20
+ ---
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install jacscanomaly
26
+ ```
27
+
28
+ > For local development (without pip), see the example notebook.
29
+
30
+ ---
31
+
32
+ ## Quick Example
33
+
34
+ ```python
35
+ import numpy as np
36
+ from jacscanomaly import Finder, FinderConfig
37
+
38
+ # load data (time, flux, flux_err)
39
+ data = np.load("example_data.npy")
40
+ time, flux, ferr = data[:, 0], data[:, 1], data[:, 2]
41
+
42
+ # initial guess for PSPL parameters
43
+ p0 = np.array([9826.56, 8.61, 0.353])
44
+
45
+ # run anomaly finder
46
+ config = FinderConfig()
47
+ finder = Finder(config)
48
+ result = finder.run(time, flux, ferr, p0)
49
+
50
+ print("=== PSPL fit ===")
51
+
52
+ t0_pspl, tE_pspl, u0_pspl = result.fit.params
53
+ print(f" t0 = {float(t0_pspl):.3f}")
54
+ print(f" tE = {float(tE_pspl):.3f}")
55
+ print(f" u0 = {float(u0_pspl):.3f}")
56
+ print(f" chi2 / dof = {result.chi2_dof:.3f}\n")
57
+
58
+ b = result.best
59
+ print("=== Anomaly candidate ===")
60
+ print(f" t0 = {b.t0:.3f}")
61
+ print(f" teff = {b.teff:.3f}")
62
+ print(f" dchi2 = {b.dchi2:.3e}")
63
+ print(f" score = {b.score:.2f}")
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Visualization
69
+
70
+ ```python
71
+ finder.plot_result()
72
+ plt.show()
73
+
74
+ finder.plot_anomaly_window()
75
+ plt.show()
76
+ ```
77
+ These commands produce two complementary visualizations:
78
+
79
+ 1. **Three-panel summary plot (`finder.plot_result`)**
80
+ - **Top:** Observed light curve with the best-fit baseline model (PSPL)
81
+ - **Middle:** Residuals after baseline fitting
82
+ - **Bottom:** Anomaly scan result (Δχ² vs. time), showing where localized
83
+ deviations from the baseline model are detected
84
+
85
+ 2. **Focused anomaly window plot (`finder.plot_anomaly_window`)**
86
+ - A zoomed-in view around the best anomaly candidate
87
+ - Residuals are shown together with the anomaly template and the flat model
88
+
89
+ ---
90
+
91
+ ## Method Overview
92
+
93
+ The workflow of `jacscanomaly` is:
94
+
95
+ 1. **Baseline fitting**
96
+ Fit a global model (e.g. PSPL) to the full light curve.
97
+
98
+ 2. **Residual analysis**
99
+ Compute residuals:
100
+
101
+ ```
102
+ residual = data − baseline_model
103
+ ```
104
+
105
+ 3. **Local anomaly scan**
106
+ For each grid point `(t0, teff)`, compare:
107
+
108
+ * a null (flat) model
109
+ * an anomaly template model
110
+
111
+ within a local time window.
112
+
113
+ 4. **Detection statistic**
114
+ The improvement is measured by:
115
+
116
+ ```
117
+ Δχ² = χ²_flat − χ²_anomaly
118
+ ```
119
+
120
+ ---
121
+
122
+ ## Anomaly Score
123
+
124
+ To quantify how significant the best anomaly candidate is relative to others,
125
+ we define a **score**:
126
+
127
+ ```
128
+ score = (Δχ²_best − median(Δχ²_others)) / std(Δχ²_others)
129
+ ```
130
+
131
+ This measures how strongly the best candidate stands out from the rest of the grid.
132
+
133
+ ---
134
+
135
+ ## Configuration
136
+
137
+ Key parameters are controlled via `FinderConfig`:
138
+
139
+ ```python
140
+ from jacscanomaly import FinderConfig
141
+
142
+ config = FinderConfig(
143
+ gap=100.0, # season gap
144
+ teff_init=0.3, # initial anomaly timescale
145
+ teff_grid_n=5, # number of teff grid points
146
+ sigma=3.0, # threshold for outlier counting
147
+ )
148
+ ```
149
+
150
+ See `FinderConfig` for the full list of options.
151
+
152
+ ---
153
+
154
+ ## Requirements
155
+
156
+ * Python ≥ 3.9
157
+ * numpy
158
+ * jax
159
+ * jaxopt
160
+ * matplotlib
161
+
162
+ ---
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "jacscanomaly"
7
+ version = "0.1.0"
8
+ description = "JAX-based scan anomaly detection for time-series residuals"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Kansuke Nunota" }]
13
+ dependencies = [
14
+ "numpy",
15
+ "jax",
16
+ "jaxopt",
17
+ "matplotlib",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/NunotaKansuke/jacscanomaly"
22
+ Repository = "https://github.com/NunotaKansuke/jacscanomaly"
23
+ Issues = "https://github.com/NunotaKansuke/jacscanomaly/issues"
24
+
25
+ [tool.setuptools]
26
+ package-dir = {"" = "src"}
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
30
+ exclude = ["jacscanomaly.test_tool*"]
31
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,20 @@
1
+ # scanomaly/__init__.py
2
+ from __future__ import annotations
3
+
4
+ from jax import config as jax_config
5
+ jax_config.update("jax_enable_x64", True)
6
+
7
+ from .config import FinderConfig
8
+ from .finder import Finder
9
+ from .plot import AnomalyPlotter
10
+ from .pspl import PSPLFitter, PSPLFitResult
11
+
12
+ __all__ = [
13
+ "FinderConfig",
14
+ "Finder",
15
+ "AnomalyPlotter",
16
+ "PSPLFitter",
17
+ "PSPLFitResult",
18
+ ]
19
+
20
+ __version__ = "0.1.0"
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class FinderConfig:
8
+ """
9
+ Configuration for :class:`scanomaly.finder.Finder`.
10
+
11
+ This dataclass contains *only* hyperparameters that control the behavior of the
12
+ anomaly search. It is intentionally dependency-free (no NumPy/JAX imports) and
13
+ frozen for reproducibility.
14
+
15
+ Parameter groups
16
+ ----------------
17
+ 1) Season splitting:
18
+ Split the time series into seasons based on large time gaps.
19
+
20
+ 2) Grid construction:
21
+ Build a (t0, teff) grid per season.
22
+
23
+ 3) Grid scan:
24
+ Evaluate delta-chi2 on the grid within a local window.
25
+
26
+ 4) Cluster extraction:
27
+ Group overlapping candidates and pick the best per cluster.
28
+ """
29
+
30
+ # ==================================================
31
+ # 1) Season splitting
32
+ # ==================================================
33
+ gap: float = 100.0
34
+ """Time gap threshold for splitting seasons. A new season starts when dt > gap."""
35
+
36
+ # ==================================================
37
+ # 2) Grid construction (t0, teff)
38
+ # ==================================================
39
+ teff_init: float = 0.03
40
+ """Initial teff value for the grid (first element of the geometric series)."""
41
+
42
+ common_ratio: float = 4.0 / 3.0
43
+ """Common ratio for the geometric series of teff values."""
44
+
45
+ teff_grid_n: int = 5
46
+ """Number of teff values in the grid."""
47
+
48
+ dt0_coeff: float = 0.17
49
+ """
50
+ Grid spacing coefficient for t0:
51
+ dt0 = dt0_coeff * teff
52
+ """
53
+
54
+ # ==================================================
55
+ # 3) Grid scan (local evaluation window)
56
+ # ==================================================
57
+ sigma: float = 3.0
58
+ """
59
+ Threshold parameter used in counting per-point chi2 improvement.
60
+ (Kept for compatibility with your original `n_out` logic.)
61
+ """
62
+
63
+ teff_coeff: float = 3.0
64
+ """
65
+ Window half-width multiplier in units of teff:
66
+ window = [t0 - teff_coeff*teff, t0 + teff_coeff*teff]
67
+ """
68
+
69
+ min_pts_in_window: int = 4
70
+ """Minimum number of data points required inside the window to evaluate a grid point."""
71
+
72
+ # ==================================================
73
+ # 4) Cluster extraction
74
+ # ==================================================
75
+ overlap_sigma: float = 3.0
76
+ """
77
+ Overlap threshold multiplier used to group nearby grid points into clusters:
78
+ |t0_i - t0_j| < overlap_sigma * (teff_i + teff_j)
79
+ """
80
+
81
+ min_cluster_points: int = 3
82
+ """
83
+ Stop extracting clusters when the number of remaining grid points becomes
84
+ smaller than this value.
85
+ """
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import List, Tuple
5
+
6
+ import numpy as np
7
+
8
+
9
+ @dataclass
10
+ class ResultExtractor:
11
+ """
12
+ Cluster extractor for grid-scan candidates.
13
+
14
+ Given arrays of (t0, teff, delta_chi2) evaluated on a grid,
15
+ this class groups overlapping candidates and returns one representative
16
+ (the maximum delta_chi2 point) per cluster.
17
+
18
+ Overlap definition
19
+ ------------------
20
+ Two candidates i and j are considered overlapping if:
21
+
22
+ |t0_i - t0_j| < sigma_overlap * (teff_i + teff_j)
23
+
24
+ Notes
25
+ -----
26
+ - This operates on CPU / NumPy arrays (no JAX).
27
+ - Returned `clusters` has shape (K, 3) with rows [t0_best, teff_best, dchi2_best].
28
+ """
29
+
30
+ sigma_overlap: float = 3.0
31
+ min_points: int = 3
32
+
33
+ def _overlap_with_max(
34
+ self,
35
+ t0: np.ndarray,
36
+ teff: np.ndarray,
37
+ dchi2: np.ndarray,
38
+ ) -> Tuple[np.ndarray, int]:
39
+ """
40
+ Compute the overlap mask around the current maximum dchi2 point.
41
+
42
+ Returns
43
+ -------
44
+ overlap_mask : np.ndarray of bool, shape (N,)
45
+ Mask selecting points overlapping with the maximum point.
46
+ i_max : int
47
+ Index of the maximum point within the provided arrays.
48
+ """
49
+ i_max = int(np.nanargmax(dchi2))
50
+ t0_max = t0[i_max]
51
+ teff_max = teff[i_max]
52
+ overlap_mask = np.abs(t0 - t0_max) < self.sigma_overlap * (teff + teff_max)
53
+ return overlap_mask, i_max
54
+
55
+ def iterative_anomaly_extraction(
56
+ self,
57
+ t0_list,
58
+ teff_list,
59
+ dchi2_list,
60
+ ) -> np.ndarray:
61
+ """
62
+ Iteratively extract non-overlapping clusters from grid results.
63
+
64
+ Parameters
65
+ ----------
66
+ t0_list, teff_list, dchi2_list
67
+ 1D arrays (or array-like) of equal length.
68
+
69
+ Returns
70
+ -------
71
+ clusters : np.ndarray, shape (K, 3)
72
+ Each row is [t0, teff, dchi2] for the best (max dchi2) point
73
+ in each extracted cluster.
74
+ Returns an empty array with shape (0, 3) if nothing is extractable.
75
+
76
+ Stopping conditions
77
+ -------------------
78
+ - No remaining candidates.
79
+ - The best remaining candidate is non-finite.
80
+ - Remaining candidate count drops below `min_points`.
81
+ """
82
+ t0 = np.asarray(t0_list, dtype=float)
83
+ teff = np.asarray(teff_list, dtype=float)
84
+ dchi2 = np.asarray(dchi2_list, dtype=float)
85
+
86
+ if t0.size == 0:
87
+ return np.zeros((0, 3), dtype=float)
88
+
89
+ if not (t0.shape == teff.shape == dchi2.shape):
90
+ raise ValueError(
91
+ f"Input arrays must have the same shape, got "
92
+ f"t0={t0.shape}, teff={teff.shape}, dchi2={dchi2.shape}"
93
+ )
94
+
95
+ clusters: List[List[float]] = []
96
+ remaining = np.ones_like(dchi2, dtype=bool)
97
+
98
+ while True:
99
+ if not np.any(remaining):
100
+ break
101
+
102
+ # pick the best remaining point
103
+ dchi2_rem = np.where(remaining, dchi2, -np.inf)
104
+ i_max_global = int(np.argmax(dchi2_rem))
105
+
106
+ if not np.isfinite(dchi2[i_max_global]):
107
+ break
108
+
109
+ # overlap mask in the "compressed" remaining arrays
110
+ overlap_mask, _ = self._overlap_with_max(
111
+ t0[remaining], teff[remaining], dchi2[remaining]
112
+ )
113
+
114
+ # expand to full mask
115
+ full_mask = np.zeros_like(remaining)
116
+ full_mask[np.where(remaining)[0][overlap_mask]] = True
117
+
118
+ # choose the best representative in this cluster
119
+ cluster_dchi2 = dchi2[full_mask]
120
+ cluster_t0 = t0[full_mask]
121
+ cluster_teff = teff[full_mask]
122
+
123
+ i_local_max = int(np.argmax(cluster_dchi2))
124
+ clusters.append(
125
+ [float(cluster_t0[i_local_max]), float(cluster_teff[i_local_max]), float(cluster_dchi2[i_local_max])]
126
+ )
127
+
128
+ # remove this cluster from remaining
129
+ remaining &= ~full_mask
130
+
131
+ if int(np.sum(remaining)) < self.min_points:
132
+ break
133
+
134
+ return np.asarray(clusters, dtype=float)