stdpipe 0.4.0__py3-none-any.whl
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.
- stdpipe/__init__.py +9 -0
- stdpipe/artefacts.py +370 -0
- stdpipe/astrometry.py +2001 -0
- stdpipe/astrometry_quad.py +1643 -0
- stdpipe/astrometry_wcs.py +1141 -0
- stdpipe/catalogs.py +942 -0
- stdpipe/cutouts.py +570 -0
- stdpipe/data/legacysurvey_bricks.fits.gz +0 -0
- stdpipe/data/ps1skycells.txt +199708 -0
- stdpipe/db.py +130 -0
- stdpipe/fringe_removal.py +1086 -0
- stdpipe/lcs.py +440 -0
- stdpipe/photometry.py +2340 -0
- stdpipe/photometry_background.py +998 -0
- stdpipe/photometry_iraf.py +913 -0
- stdpipe/photometry_measure.py +2268 -0
- stdpipe/photometry_model.py +931 -0
- stdpipe/photometry_psf.py +1124 -0
- stdpipe/photometry_quality.py +185 -0
- stdpipe/pipeline.py +1303 -0
- stdpipe/plots.py +1582 -0
- stdpipe/psf.py +1542 -0
- stdpipe/realbogus.py +971 -0
- stdpipe/realbogus_features.py +1657 -0
- stdpipe/reproject.py +943 -0
- stdpipe/resolve.py +267 -0
- stdpipe/sfft.py +1077 -0
- stdpipe/simulation.py +2131 -0
- stdpipe/smoothing.py +488 -0
- stdpipe/subtraction.py +1324 -0
- stdpipe/templates.py +929 -0
- stdpipe/utils.py +365 -0
- stdpipe-0.4.0.dist-info/METADATA +171 -0
- stdpipe-0.4.0.dist-info/RECORD +37 -0
- stdpipe-0.4.0.dist-info/WHEEL +5 -0
- stdpipe-0.4.0.dist-info/licenses/LICENSE.md +21 -0
- stdpipe-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1657 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Feature-Based Real-Bogus Classification for Astronomical Object Detection
|
|
3
|
+
|
|
4
|
+
This module provides a feature-based classifier to distinguish real astronomical sources
|
|
5
|
+
(stars, galaxies) from artifacts (cosmic rays, hot pixels, satellite trails) without
|
|
6
|
+
requiring deep learning dependencies.
|
|
7
|
+
|
|
8
|
+
Key Features:
|
|
9
|
+
- Catalog-only mode (like artefacts.py but more features)
|
|
10
|
+
- Cutout-based morphological feature extraction
|
|
11
|
+
- Hybrid mode combining both
|
|
12
|
+
- Multiple classifiers: IsolationForest, RandomForest, rule-based scoring
|
|
13
|
+
- Trend removal for spatially-varying features (generalized from artefacts.py)
|
|
14
|
+
- No TensorFlow/Keras dependency (uses sklearn only)
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
from stdpipe import photometry, realbogus_features as rbf
|
|
18
|
+
|
|
19
|
+
# Detect objects
|
|
20
|
+
obj = photometry.get_objects_sep(image, thresh=3.0)
|
|
21
|
+
|
|
22
|
+
# Quick catalog-only classification (like artefacts.py)
|
|
23
|
+
good = rbf.classify(obj, method='catalog', classifier='isolation')
|
|
24
|
+
|
|
25
|
+
# Cutout-based with scoring (no training needed)
|
|
26
|
+
obj = rbf.classify(obj, image, method='cutout', classifier='scoring',
|
|
27
|
+
add_score=True)
|
|
28
|
+
|
|
29
|
+
# Hybrid with RandomForest
|
|
30
|
+
obj = rbf.classify(obj, image, method='hybrid', classifier='randomforest',
|
|
31
|
+
model=trained_model)
|
|
32
|
+
|
|
33
|
+
Author: STDPipe Contributors
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
from scipy import ndimage
|
|
38
|
+
from astropy.table import Column
|
|
39
|
+
|
|
40
|
+
from . import smoothing
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Catalog flag bit marking objects classified as bogus. Dedicated bit that
|
|
44
|
+
# does not collide with detection flags or with photometry flags
|
|
45
|
+
# (0x800 = optimal extraction failed, 0x1000 = PSF fit failed / poor fit,
|
|
46
|
+
# 0x2000 = large centroid shift).
|
|
47
|
+
FLAG_BOGUS = 0x4000
|
|
48
|
+
|
|
49
|
+
# Flags marking a detection/measurement as unreliable; FLAG_BOGUS itself is
|
|
50
|
+
# excluded so that re-running the classification remains idempotent
|
|
51
|
+
FLAGS_UNRELIABLE_MASK = 0x7FFF & ~FLAG_BOGUS
|
|
52
|
+
|
|
53
|
+
_VALID_FEATURE_SETS = ("default", "minimal", "extended")
|
|
54
|
+
_FEATURE_SET_LEVELS = {
|
|
55
|
+
"minimal": {"minimal"},
|
|
56
|
+
"default": {"minimal", "default"},
|
|
57
|
+
"extended": {"minimal", "default", "extended"},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _normalize_feature_array(arr):
|
|
62
|
+
"""Cast feature values to float and replace non-finite values with NaN."""
|
|
63
|
+
arr = np.asarray(arr, dtype=float)
|
|
64
|
+
bad = ~np.isfinite(arr)
|
|
65
|
+
if np.any(bad):
|
|
66
|
+
arr = np.where(bad, np.nan, arr)
|
|
67
|
+
return arr
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _safe_ratio(numerator, denominator):
|
|
71
|
+
"""Compute ratio with NaN propagation for invalid values."""
|
|
72
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
73
|
+
return numerator / denominator
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _get_column_array(obj, candidates):
|
|
77
|
+
"""Return first available catalog column from candidate list as float array."""
|
|
78
|
+
for colname in candidates:
|
|
79
|
+
if colname in obj.colnames:
|
|
80
|
+
return np.asarray(obj[colname], dtype=float)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _get_last_available_pair(obj, pairs):
|
|
85
|
+
"""Return the last available (numerator, denominator) pair."""
|
|
86
|
+
selected = None
|
|
87
|
+
for num_col, den_col in pairs:
|
|
88
|
+
if num_col in obj.colnames and den_col in obj.colnames:
|
|
89
|
+
selected = (num_col, den_col)
|
|
90
|
+
return selected
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _compute_catalog_flux_radius(obj, _features):
|
|
94
|
+
return _get_column_array(obj, ["FLUX_RADIUS"])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _compute_catalog_fwhm(obj, _features):
|
|
98
|
+
return _get_column_array(obj, ["fwhm", "FWHM_IMAGE"])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _compute_catalog_peakiness(obj, _features):
|
|
102
|
+
pair = _get_last_available_pair(obj, [("FLUX_MAX", "FLUX_AUTO"), ("peak", "flux")])
|
|
103
|
+
if pair is None:
|
|
104
|
+
return None
|
|
105
|
+
return _safe_ratio(
|
|
106
|
+
np.asarray(obj[pair[0]], dtype=float),
|
|
107
|
+
np.asarray(obj[pair[1]], dtype=float),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _get_catalog_shape_axes(obj):
|
|
112
|
+
pair = _get_last_available_pair(obj, [("a", "b"), ("A_IMAGE", "B_IMAGE")])
|
|
113
|
+
if pair is None:
|
|
114
|
+
return None, None
|
|
115
|
+
return np.asarray(obj[pair[0]], dtype=float), np.asarray(obj[pair[1]], dtype=float)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _compute_catalog_ellipticity(obj, _features):
|
|
119
|
+
a, b = _get_catalog_shape_axes(obj)
|
|
120
|
+
if a is None:
|
|
121
|
+
return None
|
|
122
|
+
return _safe_ratio(a - b, a + b)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _compute_catalog_elongation(obj, _features):
|
|
126
|
+
a, b = _get_catalog_shape_axes(obj)
|
|
127
|
+
if a is None:
|
|
128
|
+
return None
|
|
129
|
+
return _safe_ratio(a, b)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _compute_catalog_fwhm_ratio(_obj, features):
|
|
133
|
+
fwhm = features.get("fwhm")
|
|
134
|
+
if fwhm is None:
|
|
135
|
+
return None
|
|
136
|
+
median_fwhm = np.nanmedian(fwhm)
|
|
137
|
+
if not np.isfinite(median_fwhm) or median_fwhm <= 0:
|
|
138
|
+
return None
|
|
139
|
+
return fwhm / median_fwhm
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _compute_catalog_radius_fwhm_ratio(_obj, features):
|
|
143
|
+
flux_radius = features.get("flux_radius")
|
|
144
|
+
fwhm = features.get("fwhm")
|
|
145
|
+
if flux_radius is None or fwhm is None:
|
|
146
|
+
return None
|
|
147
|
+
return _safe_ratio(flux_radius, fwhm)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _compute_catalog_snr(obj, _features):
|
|
151
|
+
if "flux" not in obj.colnames or "fluxerr" not in obj.colnames:
|
|
152
|
+
return None
|
|
153
|
+
return _safe_ratio(
|
|
154
|
+
np.asarray(obj["flux"], dtype=float),
|
|
155
|
+
np.asarray(obj["fluxerr"], dtype=float),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
_CATALOG_FEATURE_SPECS = [
|
|
160
|
+
("flux_radius", "minimal", _compute_catalog_flux_radius),
|
|
161
|
+
("fwhm", "minimal", _compute_catalog_fwhm),
|
|
162
|
+
("peakiness", "minimal", _compute_catalog_peakiness),
|
|
163
|
+
("ellipticity", "minimal", _compute_catalog_ellipticity),
|
|
164
|
+
("elongation", "minimal", _compute_catalog_elongation),
|
|
165
|
+
("fwhm_ratio", "minimal", _compute_catalog_fwhm_ratio),
|
|
166
|
+
("radius_fwhm_ratio", "default", _compute_catalog_radius_fwhm_ratio),
|
|
167
|
+
# snr is a brightness feature, not a morphology one: under IsolationForest
|
|
168
|
+
# the brightest genuine stars become outliers on this axis, so it is only
|
|
169
|
+
# included in the opt-in 'extended' set
|
|
170
|
+
("snr", "extended", _compute_catalog_snr),
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _copy_feature_dict(features):
|
|
175
|
+
"""Return deep-copied feature arrays keyed by feature name."""
|
|
176
|
+
return {name: np.array(arr, copy=True) for name, arr in features.items()}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _get_finite_rows(X, context):
|
|
180
|
+
"""Return mask of rows with finite feature values, raising if empty."""
|
|
181
|
+
valid = np.all(np.isfinite(X), axis=1)
|
|
182
|
+
if not np.any(valid):
|
|
183
|
+
raise ValueError(f"No finite feature rows available for {context}")
|
|
184
|
+
return valid
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _filter_finite_rows(X, y=None, context="fitting"):
|
|
188
|
+
"""Filter feature matrix (and optional labels) to finite rows."""
|
|
189
|
+
valid = _get_finite_rows(X, context=context)
|
|
190
|
+
if y is None:
|
|
191
|
+
return X[valid], valid
|
|
192
|
+
|
|
193
|
+
y = np.asarray(y)
|
|
194
|
+
if len(y) != len(X):
|
|
195
|
+
raise ValueError("labels length does not match number of feature rows")
|
|
196
|
+
return X[valid], y[valid], valid
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# =============================================================================
|
|
200
|
+
# Feature Extraction: Catalog-based
|
|
201
|
+
# =============================================================================
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def extract_catalog_features(obj, feature_set="default"):
|
|
205
|
+
"""
|
|
206
|
+
Extract features from object catalog (no image needed).
|
|
207
|
+
|
|
208
|
+
These features are derived from detection catalog columns, similar to
|
|
209
|
+
what artefacts.py uses but extended.
|
|
210
|
+
|
|
211
|
+
Parameters
|
|
212
|
+
----------
|
|
213
|
+
obj : astropy.table.Table
|
|
214
|
+
Object catalog with detection columns.
|
|
215
|
+
feature_set : str, optional
|
|
216
|
+
Feature set to extract: 'default', 'minimal', 'extended'.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
features : dict
|
|
221
|
+
Dictionary mapping feature names to arrays.
|
|
222
|
+
feature_names : list
|
|
223
|
+
List of feature names in order.
|
|
224
|
+
"""
|
|
225
|
+
if feature_set not in _VALID_FEATURE_SETS:
|
|
226
|
+
raise ValueError(
|
|
227
|
+
f"Unknown feature_set: {feature_set}. Expected one of {list(_VALID_FEATURE_SETS)}."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
features = {}
|
|
231
|
+
enabled_levels = _FEATURE_SET_LEVELS[feature_set]
|
|
232
|
+
for name, level, compute_fn in _CATALOG_FEATURE_SPECS:
|
|
233
|
+
if level not in enabled_levels:
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
arr = compute_fn(obj, features)
|
|
237
|
+
if arr is None:
|
|
238
|
+
continue
|
|
239
|
+
features[name] = _normalize_feature_array(arr)
|
|
240
|
+
|
|
241
|
+
feature_names = list(features.keys())
|
|
242
|
+
return features, feature_names
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# =============================================================================
|
|
246
|
+
# Feature Extraction: Cutout-based
|
|
247
|
+
# =============================================================================
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _extract_cutout(image, x, y, radius, bg=None, err=None, mask=None):
|
|
251
|
+
"""Extract a background-subtracted cutout centered on (x, y)."""
|
|
252
|
+
h, w = image.shape
|
|
253
|
+
xi, yi = int(round(x)), int(round(y))
|
|
254
|
+
|
|
255
|
+
x_min = xi - radius
|
|
256
|
+
x_max = xi + radius + 1
|
|
257
|
+
y_min = yi - radius
|
|
258
|
+
y_max = yi + radius + 1
|
|
259
|
+
|
|
260
|
+
if x_min < 0 or x_max > w or y_min < 0 or y_max > h:
|
|
261
|
+
return None, None, None
|
|
262
|
+
|
|
263
|
+
cutout = image[y_min:y_max, x_min:x_max].astype(float)
|
|
264
|
+
|
|
265
|
+
if bg is not None:
|
|
266
|
+
if np.isscalar(bg):
|
|
267
|
+
cutout = cutout - bg
|
|
268
|
+
else:
|
|
269
|
+
cutout = cutout - bg[y_min:y_max, x_min:x_max]
|
|
270
|
+
|
|
271
|
+
cutout_err = None
|
|
272
|
+
if err is not None:
|
|
273
|
+
if np.isscalar(err):
|
|
274
|
+
cutout_err = np.full_like(cutout, float(err))
|
|
275
|
+
else:
|
|
276
|
+
cutout_err = err[y_min:y_max, x_min:x_max].astype(float)
|
|
277
|
+
|
|
278
|
+
cutout_mask = None
|
|
279
|
+
if mask is not None:
|
|
280
|
+
cutout_mask = mask[y_min:y_max, x_min:x_max]
|
|
281
|
+
|
|
282
|
+
return cutout, cutout_mask, cutout_err
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _compute_sharpness(cutout):
|
|
286
|
+
"""
|
|
287
|
+
Compute sharpness: central pixel vs neighbors.
|
|
288
|
+
|
|
289
|
+
High sharpness indicates cosmic ray or hot pixel.
|
|
290
|
+
"""
|
|
291
|
+
if cutout is None or cutout.size < 9:
|
|
292
|
+
return np.nan
|
|
293
|
+
|
|
294
|
+
cy, cx = cutout.shape[0] // 2, cutout.shape[1] // 2
|
|
295
|
+
center = cutout[cy, cx]
|
|
296
|
+
|
|
297
|
+
# 8-connected neighbors via 3x3 block
|
|
298
|
+
block = cutout[cy - 1 : cy + 2, cx - 1 : cx + 2]
|
|
299
|
+
neighbor_mean = (np.sum(block) - center) / 8
|
|
300
|
+
if neighbor_mean <= 0:
|
|
301
|
+
return np.nan
|
|
302
|
+
|
|
303
|
+
return center / neighbor_mean
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class _CutoutGeometry:
|
|
307
|
+
"""Pre-computed geometry for a cutout (center, radius grid, etc.)."""
|
|
308
|
+
|
|
309
|
+
__slots__ = ("cy", "cx", "dx", "dy", "r2", "r")
|
|
310
|
+
|
|
311
|
+
def __init__(self, shape):
|
|
312
|
+
self.cy, self.cx = shape[0] // 2, shape[1] // 2
|
|
313
|
+
y, x = np.ogrid[: shape[0], : shape[1]]
|
|
314
|
+
self.dx = x - self.cx
|
|
315
|
+
self.dy = y - self.cy
|
|
316
|
+
self.r2 = self.dx**2 + self.dy**2
|
|
317
|
+
self.r = np.sqrt(self.r2)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _compute_concentration(cutout, geom, r_inner=2, r_outer=5):
|
|
321
|
+
"""Compute concentration index: flux ratio in inner/outer apertures."""
|
|
322
|
+
flux_inner = np.sum(cutout[geom.r <= r_inner])
|
|
323
|
+
flux_outer = np.sum(cutout[geom.r <= r_outer])
|
|
324
|
+
|
|
325
|
+
if flux_outer <= 0:
|
|
326
|
+
return np.nan
|
|
327
|
+
|
|
328
|
+
return flux_inner / flux_outer
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _compute_symmetry(cutout, _geom):
|
|
332
|
+
"""Compute 180-degree rotational symmetry (lower = more symmetric)."""
|
|
333
|
+
rotated = np.rot90(cutout, 2)
|
|
334
|
+
flux = np.sum(np.abs(cutout))
|
|
335
|
+
if flux <= 0:
|
|
336
|
+
return np.nan
|
|
337
|
+
return np.sum(np.abs(cutout - rotated)) / flux
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _compute_roundness(cutout, geom):
|
|
341
|
+
"""Compute roundness from image moments (0-1, 1 = perfectly round)."""
|
|
342
|
+
data = np.maximum(cutout, 0)
|
|
343
|
+
total = np.sum(data)
|
|
344
|
+
if total <= 0:
|
|
345
|
+
return np.nan
|
|
346
|
+
|
|
347
|
+
m20 = np.sum(data * geom.dx**2) / total
|
|
348
|
+
m02 = np.sum(data * geom.dy**2) / total
|
|
349
|
+
m11 = np.sum(data * geom.dx * geom.dy) / total
|
|
350
|
+
|
|
351
|
+
diff = m20 - m02
|
|
352
|
+
discriminant = np.sqrt(diff**2 + 4 * m11**2)
|
|
353
|
+
|
|
354
|
+
if discriminant == 0:
|
|
355
|
+
return 1.0
|
|
356
|
+
|
|
357
|
+
a2 = (m20 + m02 + discriminant) / 2
|
|
358
|
+
b2 = (m20 + m02 - discriminant) / 2
|
|
359
|
+
|
|
360
|
+
if a2 <= 0:
|
|
361
|
+
return np.nan
|
|
362
|
+
|
|
363
|
+
return np.sqrt(max(0, b2) / a2)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _compute_psf_match(cutout, geom, fwhm):
|
|
367
|
+
"""Compute how well the cutout matches a Gaussian PSF (lower = better)."""
|
|
368
|
+
if fwhm <= 0:
|
|
369
|
+
return np.nan
|
|
370
|
+
|
|
371
|
+
sigma = fwhm / 2.355
|
|
372
|
+
psf_model = np.exp(-geom.r2 / (2 * sigma**2))
|
|
373
|
+
|
|
374
|
+
abs_sum = np.sum(np.abs(cutout))
|
|
375
|
+
cutout_norm = cutout / abs_sum if abs_sum > 0 else cutout
|
|
376
|
+
psf_norm = psf_model / np.sum(psf_model)
|
|
377
|
+
|
|
378
|
+
scale = np.sum(cutout_norm * psf_norm) / np.sum(psf_norm**2)
|
|
379
|
+
residual = cutout_norm - scale * psf_norm
|
|
380
|
+
|
|
381
|
+
return np.sum(residual**2)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _compute_peak_offset(cutout, geom):
|
|
385
|
+
"""Compute distance from center to peak pixel."""
|
|
386
|
+
peak_y, peak_x = np.unravel_index(np.argmax(cutout), cutout.shape)
|
|
387
|
+
return np.sqrt((peak_x - geom.cx) ** 2 + (peak_y - geom.cy) ** 2)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _compute_edge_gradient(cutout, _geom):
|
|
391
|
+
"""Compute max gradient magnitude normalized by peak flux."""
|
|
392
|
+
gx = ndimage.sobel(cutout, axis=1)
|
|
393
|
+
gy = ndimage.sobel(cutout, axis=0)
|
|
394
|
+
gradient = np.sqrt(gx**2 + gy**2)
|
|
395
|
+
|
|
396
|
+
peak = np.max(np.abs(cutout))
|
|
397
|
+
if peak <= 0:
|
|
398
|
+
return np.nan
|
|
399
|
+
|
|
400
|
+
return np.max(gradient) / peak
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _compute_background_consistency(cutout, geom, bg_radius=None):
|
|
404
|
+
"""Compute coefficient of variation of background annulus."""
|
|
405
|
+
if bg_radius is None:
|
|
406
|
+
bg_radius = cutout.shape[0] // 2 - 2
|
|
407
|
+
|
|
408
|
+
bg_mask = (geom.r > bg_radius) & (geom.r <= bg_radius + 2)
|
|
409
|
+
if np.sum(bg_mask) < 4:
|
|
410
|
+
return np.nan
|
|
411
|
+
|
|
412
|
+
bg_pixels = cutout[bg_mask]
|
|
413
|
+
bg_std = np.std(bg_pixels)
|
|
414
|
+
bg_median = np.median(np.abs(bg_pixels))
|
|
415
|
+
|
|
416
|
+
if bg_median <= 0:
|
|
417
|
+
return 0.0
|
|
418
|
+
|
|
419
|
+
return bg_std / bg_median
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _compute_cutout_snr(cutout, cutout_err):
|
|
423
|
+
"""Compute integrated SNR in a cutout using provided error map."""
|
|
424
|
+
if cutout is None or cutout_err is None:
|
|
425
|
+
return np.nan
|
|
426
|
+
|
|
427
|
+
signal = np.sum(np.maximum(cutout, 0))
|
|
428
|
+
if signal <= 0:
|
|
429
|
+
return np.nan
|
|
430
|
+
|
|
431
|
+
err2 = np.sum(np.maximum(cutout_err, 0) ** 2)
|
|
432
|
+
if err2 <= 0:
|
|
433
|
+
return np.nan
|
|
434
|
+
|
|
435
|
+
return signal / np.sqrt(err2)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def extract_cutout_features(
|
|
439
|
+
obj, image, bg=None, err=None, mask=None, fwhm=None, radius=10, verbose=False
|
|
440
|
+
):
|
|
441
|
+
"""
|
|
442
|
+
Extract morphological features from image cutouts.
|
|
443
|
+
|
|
444
|
+
Parameters
|
|
445
|
+
----------
|
|
446
|
+
obj : astropy.table.Table
|
|
447
|
+
Object catalog with 'x' and 'y' columns.
|
|
448
|
+
image : ndarray
|
|
449
|
+
Science image.
|
|
450
|
+
bg : ndarray or float, optional
|
|
451
|
+
Background map or scalar value.
|
|
452
|
+
err : ndarray or float, optional
|
|
453
|
+
Error/noise map. If provided, `cutout_snr` is computed.
|
|
454
|
+
mask : ndarray, optional
|
|
455
|
+
Boolean mask (True = masked pixels).
|
|
456
|
+
fwhm : float, optional
|
|
457
|
+
Image FWHM for PSF matching. If None, estimated from catalog.
|
|
458
|
+
radius : int, optional
|
|
459
|
+
Cutout radius in pixels. Default: 10.
|
|
460
|
+
verbose : bool, optional
|
|
461
|
+
Print progress. Default: False.
|
|
462
|
+
|
|
463
|
+
Returns
|
|
464
|
+
-------
|
|
465
|
+
features : dict
|
|
466
|
+
Dictionary mapping feature names to arrays.
|
|
467
|
+
feature_names : list
|
|
468
|
+
List of feature names in order.
|
|
469
|
+
"""
|
|
470
|
+
log = print if verbose else lambda *args, **kwargs: None
|
|
471
|
+
n = len(obj)
|
|
472
|
+
|
|
473
|
+
# Estimate FWHM if not provided
|
|
474
|
+
if fwhm is None:
|
|
475
|
+
if "fwhm" in obj.colnames:
|
|
476
|
+
fwhm = np.nanmedian(obj["fwhm"])
|
|
477
|
+
elif "a" in obj.colnames:
|
|
478
|
+
fwhm = np.nanmedian(obj["a"]) * 2.35
|
|
479
|
+
else:
|
|
480
|
+
fwhm = 3.0
|
|
481
|
+
log(f"Estimated FWHM: {fwhm:.2f} pixels")
|
|
482
|
+
|
|
483
|
+
compute_snr = err is not None
|
|
484
|
+
feature_names = [
|
|
485
|
+
"sharpness",
|
|
486
|
+
"concentration",
|
|
487
|
+
"symmetry",
|
|
488
|
+
"roundness",
|
|
489
|
+
"psf_match",
|
|
490
|
+
"peak_offset",
|
|
491
|
+
"edge_gradient",
|
|
492
|
+
"bg_consistency",
|
|
493
|
+
]
|
|
494
|
+
if compute_snr:
|
|
495
|
+
feature_names.append("cutout_snr")
|
|
496
|
+
features = {name: np.full(n, np.nan) for name in feature_names}
|
|
497
|
+
|
|
498
|
+
# Plain arrays: per-row astropy Table access is much slower
|
|
499
|
+
xs = np.asarray(obj["x"], dtype=float)
|
|
500
|
+
ys = np.asarray(obj["y"], dtype=float)
|
|
501
|
+
|
|
502
|
+
# Process each object
|
|
503
|
+
for i in range(n):
|
|
504
|
+
if not (np.isfinite(xs[i]) and np.isfinite(ys[i])):
|
|
505
|
+
continue
|
|
506
|
+
|
|
507
|
+
cutout, cutout_mask, cutout_err = _extract_cutout(
|
|
508
|
+
image, xs[i], ys[i], radius, bg=bg, err=err, mask=mask
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
if cutout is None:
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
# Apply mask if present
|
|
515
|
+
if cutout_mask is not None and np.any(cutout_mask):
|
|
516
|
+
if np.sum(~cutout_mask) < 0.5 * cutout.size:
|
|
517
|
+
continue
|
|
518
|
+
median_val = np.nanmedian(cutout[~cutout_mask])
|
|
519
|
+
cutout[cutout_mask] = median_val
|
|
520
|
+
if cutout_err is not None:
|
|
521
|
+
median_err = np.nanmedian(cutout_err[~cutout_mask])
|
|
522
|
+
cutout_err[cutout_mask] = median_err
|
|
523
|
+
|
|
524
|
+
# Pre-compute geometry once per cutout (shared across features)
|
|
525
|
+
geom = _CutoutGeometry(cutout.shape)
|
|
526
|
+
|
|
527
|
+
features["sharpness"][i] = _compute_sharpness(cutout)
|
|
528
|
+
features["concentration"][i] = _compute_concentration(cutout, geom)
|
|
529
|
+
features["symmetry"][i] = _compute_symmetry(cutout, geom)
|
|
530
|
+
features["roundness"][i] = _compute_roundness(cutout, geom)
|
|
531
|
+
features["psf_match"][i] = _compute_psf_match(cutout, geom, fwhm)
|
|
532
|
+
features["peak_offset"][i] = _compute_peak_offset(cutout, geom)
|
|
533
|
+
features["edge_gradient"][i] = _compute_edge_gradient(cutout, geom)
|
|
534
|
+
features["bg_consistency"][i] = _compute_background_consistency(cutout, geom)
|
|
535
|
+
if compute_snr:
|
|
536
|
+
features["cutout_snr"][i] = _compute_cutout_snr(cutout, cutout_err)
|
|
537
|
+
|
|
538
|
+
log(f"Extracted {len(feature_names)} cutout features for {n} objects")
|
|
539
|
+
|
|
540
|
+
return features, feature_names
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def extract_features(
|
|
544
|
+
obj,
|
|
545
|
+
image=None,
|
|
546
|
+
bg=None,
|
|
547
|
+
err=None,
|
|
548
|
+
mask=None,
|
|
549
|
+
fwhm=None,
|
|
550
|
+
radius=10,
|
|
551
|
+
method="auto",
|
|
552
|
+
feature_set="default",
|
|
553
|
+
verbose=False,
|
|
554
|
+
):
|
|
555
|
+
"""
|
|
556
|
+
Extract features for real-bogus classification.
|
|
557
|
+
|
|
558
|
+
Combines catalog-based and cutout-based features depending on method.
|
|
559
|
+
|
|
560
|
+
Parameters
|
|
561
|
+
----------
|
|
562
|
+
obj : astropy.table.Table
|
|
563
|
+
Object catalog.
|
|
564
|
+
image : ndarray, optional
|
|
565
|
+
Science image (required for 'cutout' and 'hybrid' methods).
|
|
566
|
+
bg : ndarray or float, optional
|
|
567
|
+
Background map or scalar.
|
|
568
|
+
err : ndarray or float, optional
|
|
569
|
+
Error/noise map.
|
|
570
|
+
mask : ndarray, optional
|
|
571
|
+
Boolean mask.
|
|
572
|
+
fwhm : float, optional
|
|
573
|
+
Image FWHM.
|
|
574
|
+
radius : int, optional
|
|
575
|
+
Cutout radius. Default: 10.
|
|
576
|
+
method : str, optional
|
|
577
|
+
Feature extraction method:
|
|
578
|
+
- 'catalog': Catalog features only (no image needed)
|
|
579
|
+
- 'cutout': Cutout features only
|
|
580
|
+
- 'hybrid': Both catalog and cutout features
|
|
581
|
+
- 'auto': 'hybrid' if image provided, else 'catalog'
|
|
582
|
+
feature_set : str, optional
|
|
583
|
+
Catalog feature set to use: 'minimal', 'default', 'extended'.
|
|
584
|
+
verbose : bool, optional
|
|
585
|
+
Print progress.
|
|
586
|
+
|
|
587
|
+
Returns
|
|
588
|
+
-------
|
|
589
|
+
features : dict
|
|
590
|
+
Dictionary mapping feature names to arrays.
|
|
591
|
+
feature_names : list
|
|
592
|
+
List of feature names in order.
|
|
593
|
+
"""
|
|
594
|
+
valid_methods = ["catalog", "cutout", "hybrid", "auto"]
|
|
595
|
+
if method not in valid_methods:
|
|
596
|
+
raise ValueError(f"Unknown feature extraction method: {method}")
|
|
597
|
+
|
|
598
|
+
if method == "auto":
|
|
599
|
+
method = "hybrid" if image is not None else "catalog"
|
|
600
|
+
|
|
601
|
+
features = {}
|
|
602
|
+
feature_names = []
|
|
603
|
+
|
|
604
|
+
if method in ["catalog", "hybrid"]:
|
|
605
|
+
cat_features, cat_names = extract_catalog_features(obj, feature_set=feature_set)
|
|
606
|
+
features.update(cat_features)
|
|
607
|
+
feature_names.extend(cat_names)
|
|
608
|
+
|
|
609
|
+
if method in ["cutout", "hybrid"]:
|
|
610
|
+
if image is None:
|
|
611
|
+
raise ValueError("Image required for cutout/hybrid feature extraction")
|
|
612
|
+
cut_features, cut_names = extract_cutout_features(
|
|
613
|
+
obj, image, bg=bg, err=err, mask=mask, fwhm=fwhm, radius=radius, verbose=verbose
|
|
614
|
+
)
|
|
615
|
+
features.update(cut_features)
|
|
616
|
+
feature_names.extend(cut_names)
|
|
617
|
+
|
|
618
|
+
if not feature_names:
|
|
619
|
+
raise ValueError(
|
|
620
|
+
"No features could be extracted from the provided inputs. "
|
|
621
|
+
"Check catalog columns and extraction method."
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
return features, feature_names
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
# =============================================================================
|
|
628
|
+
# Trend Removal
|
|
629
|
+
# =============================================================================
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
class TrendModels(dict):
|
|
633
|
+
"""Mapping of feature name -> trend model with trend-column metadata."""
|
|
634
|
+
|
|
635
|
+
def __init__(self, *args, trend_cols=None, **kwargs):
|
|
636
|
+
super().__init__(*args, **kwargs)
|
|
637
|
+
self.trend_cols = None if trend_cols is None else tuple(trend_cols)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _coerce_trend_models(trend_models):
|
|
641
|
+
"""Normalize trend model containers to TrendModels."""
|
|
642
|
+
if trend_models is None:
|
|
643
|
+
return TrendModels()
|
|
644
|
+
|
|
645
|
+
if isinstance(trend_models, TrendModels):
|
|
646
|
+
return trend_models
|
|
647
|
+
|
|
648
|
+
if isinstance(trend_models, dict):
|
|
649
|
+
return TrendModels(trend_models, trend_cols=None)
|
|
650
|
+
|
|
651
|
+
raise TypeError("trend_models must be a dict-like object returned by remove_trends()")
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def remove_trends(
|
|
655
|
+
features, obj, trend_cols=None, trend_scales=None, k=20, robust_iters=3, verbose=False
|
|
656
|
+
):
|
|
657
|
+
"""
|
|
658
|
+
Remove smooth spatial/magnitude trends from features.
|
|
659
|
+
|
|
660
|
+
This generalizes the trend removal from artefacts.py to work with
|
|
661
|
+
any set of features.
|
|
662
|
+
|
|
663
|
+
Parameters
|
|
664
|
+
----------
|
|
665
|
+
features : dict
|
|
666
|
+
Dictionary of feature arrays.
|
|
667
|
+
obj : astropy.table.Table
|
|
668
|
+
Object catalog with position/magnitude columns.
|
|
669
|
+
trend_cols : list of str, optional
|
|
670
|
+
Columns to use for trend modeling. Default: ['x', 'y'].
|
|
671
|
+
trend_scales : list of float, optional
|
|
672
|
+
Scaling factors for each trend column. Default: auto-computed.
|
|
673
|
+
k : int, optional
|
|
674
|
+
Number of neighbors for LOESS. Default: 20.
|
|
675
|
+
robust_iters : int, optional
|
|
676
|
+
Robust iteration count. Default: 3.
|
|
677
|
+
verbose : bool, optional
|
|
678
|
+
Print progress.
|
|
679
|
+
|
|
680
|
+
Returns
|
|
681
|
+
-------
|
|
682
|
+
detrended : dict
|
|
683
|
+
Dictionary of detrended feature arrays.
|
|
684
|
+
trend_models : TrendModels
|
|
685
|
+
Mapping of trend models keyed by feature name, with trend column metadata.
|
|
686
|
+
"""
|
|
687
|
+
log = print if verbose else lambda *args, **kwargs: None
|
|
688
|
+
|
|
689
|
+
requested_trend_cols = ["x", "y"] if trend_cols is None else list(trend_cols)
|
|
690
|
+
requested_trend_scales = None if trend_scales is None else list(trend_scales)
|
|
691
|
+
if requested_trend_scales is not None and len(requested_trend_scales) != len(
|
|
692
|
+
requested_trend_cols
|
|
693
|
+
):
|
|
694
|
+
raise ValueError("trend_scales length must match trend_cols length")
|
|
695
|
+
|
|
696
|
+
# Check that trend columns exist
|
|
697
|
+
available_cols = [c for c in requested_trend_cols if c in obj.colnames]
|
|
698
|
+
if not available_cols:
|
|
699
|
+
log("No trend columns available, skipping detrending")
|
|
700
|
+
return _copy_feature_dict(features), TrendModels(trend_cols=())
|
|
701
|
+
|
|
702
|
+
trend_cols = available_cols
|
|
703
|
+
|
|
704
|
+
# Auto-compute scales if not provided
|
|
705
|
+
if requested_trend_scales is None:
|
|
706
|
+
trend_scales = []
|
|
707
|
+
for col in trend_cols:
|
|
708
|
+
data = np.array(obj[col])
|
|
709
|
+
scale = np.nanstd(data) * 3 # ~3 sigma scale
|
|
710
|
+
if not np.isfinite(scale) or scale <= 0:
|
|
711
|
+
scale = 1.0
|
|
712
|
+
trend_scales.append(scale)
|
|
713
|
+
else:
|
|
714
|
+
scales_by_col = dict(zip(requested_trend_cols, requested_trend_scales))
|
|
715
|
+
trend_scales = []
|
|
716
|
+
for col in trend_cols:
|
|
717
|
+
scale = scales_by_col[col]
|
|
718
|
+
if not np.isfinite(scale) or scale <= 0:
|
|
719
|
+
scale = 1.0
|
|
720
|
+
trend_scales.append(scale)
|
|
721
|
+
|
|
722
|
+
log(f"Removing trends using columns: {trend_cols} with scales: {trend_scales}")
|
|
723
|
+
|
|
724
|
+
# Build position array
|
|
725
|
+
pos = np.column_stack([np.array(obj[c]) for c in trend_cols])
|
|
726
|
+
|
|
727
|
+
# Rows where the trend can be evaluated at all
|
|
728
|
+
finite_pos = np.all(np.isfinite(pos), axis=1)
|
|
729
|
+
|
|
730
|
+
# Rows used for fitting: additionally require clean flags
|
|
731
|
+
if "flags" in obj.colnames:
|
|
732
|
+
base_valid_idx = (obj["flags"] & FLAGS_UNRELIABLE_MASK) == 0
|
|
733
|
+
else:
|
|
734
|
+
base_valid_idx = np.ones(len(obj), dtype=bool)
|
|
735
|
+
base_valid_idx &= finite_pos
|
|
736
|
+
|
|
737
|
+
detrended = {}
|
|
738
|
+
trend_models = TrendModels(trend_cols=trend_cols)
|
|
739
|
+
|
|
740
|
+
for name, arr in features.items():
|
|
741
|
+
arr = np.array(arr)
|
|
742
|
+
|
|
743
|
+
# Skip if all NaN
|
|
744
|
+
if np.all(~np.isfinite(arr)):
|
|
745
|
+
detrended[name] = arr.copy()
|
|
746
|
+
trend_models[name] = None
|
|
747
|
+
continue
|
|
748
|
+
|
|
749
|
+
valid_idx = base_valid_idx & np.isfinite(arr)
|
|
750
|
+
if np.sum(valid_idx) < max(3, min(k, len(arr))):
|
|
751
|
+
log(f" {name}: not enough valid points for trend removal, keeping original")
|
|
752
|
+
detrended[name] = arr.copy()
|
|
753
|
+
trend_models[name] = None
|
|
754
|
+
continue
|
|
755
|
+
|
|
756
|
+
# Fit LOESS model
|
|
757
|
+
try:
|
|
758
|
+
model = smoothing.ApproxLoessRegressor(
|
|
759
|
+
k=k, scales=trend_scales, robust_iters=robust_iters
|
|
760
|
+
)
|
|
761
|
+
model.fit(pos[valid_idx], arr[valid_idx])
|
|
762
|
+
|
|
763
|
+
# Subtract the trend from every row with finite positions -- not
|
|
764
|
+
# just the clean fit subset -- so flagged objects end up in the
|
|
765
|
+
# same residual feature space as everything else
|
|
766
|
+
detrended_arr = arr.copy()
|
|
767
|
+
trend_rms = np.nan
|
|
768
|
+
if np.any(finite_pos):
|
|
769
|
+
trend = model.predict(pos[finite_pos])
|
|
770
|
+
detrended_arr[finite_pos] = arr[finite_pos] - trend
|
|
771
|
+
trend_rms = np.nanstd(trend)
|
|
772
|
+
detrended[name] = detrended_arr
|
|
773
|
+
trend_models[name] = model
|
|
774
|
+
|
|
775
|
+
log(f" {name}: trend RMS = {trend_rms:.4f}")
|
|
776
|
+
except Exception as e:
|
|
777
|
+
log(f" {name}: trend removal failed ({e}), keeping original")
|
|
778
|
+
detrended[name] = arr.copy()
|
|
779
|
+
trend_models[name] = None
|
|
780
|
+
|
|
781
|
+
return detrended, trend_models
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def apply_trend_models(features, obj, trend_models, trend_cols=None):
|
|
785
|
+
"""
|
|
786
|
+
Apply pre-fitted trend models to new data.
|
|
787
|
+
|
|
788
|
+
Parameters
|
|
789
|
+
----------
|
|
790
|
+
features : dict
|
|
791
|
+
Dictionary of feature arrays.
|
|
792
|
+
obj : astropy.table.Table
|
|
793
|
+
Object catalog.
|
|
794
|
+
trend_models : TrendModels or dict
|
|
795
|
+
Trend models from remove_trends().
|
|
796
|
+
trend_cols : list of str, optional
|
|
797
|
+
Columns used for trend modeling. Must match original.
|
|
798
|
+
|
|
799
|
+
Returns
|
|
800
|
+
-------
|
|
801
|
+
detrended : dict
|
|
802
|
+
Dictionary of detrended feature arrays.
|
|
803
|
+
"""
|
|
804
|
+
trend_models = _coerce_trend_models(trend_models)
|
|
805
|
+
if not trend_models:
|
|
806
|
+
return _copy_feature_dict(features)
|
|
807
|
+
|
|
808
|
+
if not any(model is not None for model in trend_models.values()):
|
|
809
|
+
return _copy_feature_dict(features)
|
|
810
|
+
|
|
811
|
+
if trend_models.trend_cols is not None:
|
|
812
|
+
trend_cols = list(trend_models.trend_cols)
|
|
813
|
+
elif trend_cols is None:
|
|
814
|
+
trend_cols = ["x", "y"]
|
|
815
|
+
|
|
816
|
+
missing_cols = [c for c in trend_cols if c not in obj.colnames]
|
|
817
|
+
if missing_cols:
|
|
818
|
+
raise ValueError(
|
|
819
|
+
"Missing trend columns required by trend models: " + ", ".join(missing_cols)
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
pos = np.column_stack([np.array(obj[c]) for c in trend_cols])
|
|
823
|
+
finite_pos = np.all(np.isfinite(pos), axis=1)
|
|
824
|
+
|
|
825
|
+
detrended = {}
|
|
826
|
+
for name, arr in features.items():
|
|
827
|
+
arr = np.array(arr)
|
|
828
|
+
model = trend_models.get(name)
|
|
829
|
+
|
|
830
|
+
if model is not None:
|
|
831
|
+
detrended_arr = arr.copy()
|
|
832
|
+
if np.any(finite_pos):
|
|
833
|
+
trend = model.predict(pos[finite_pos])
|
|
834
|
+
detrended_arr[finite_pos] = arr[finite_pos] - trend
|
|
835
|
+
detrended[name] = detrended_arr
|
|
836
|
+
else:
|
|
837
|
+
detrended[name] = arr.copy()
|
|
838
|
+
|
|
839
|
+
return detrended
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
# =============================================================================
|
|
843
|
+
# Classifiers
|
|
844
|
+
# =============================================================================
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _features_to_array(features, feature_names=None, replace_nonfinite=True, fill_value=-1e6):
|
|
848
|
+
"""Convert feature dict to 2D array."""
|
|
849
|
+
if feature_names is None:
|
|
850
|
+
feature_names = list(features.keys())
|
|
851
|
+
if not feature_names:
|
|
852
|
+
raise ValueError("Feature dictionary is empty")
|
|
853
|
+
|
|
854
|
+
X = np.column_stack([features[name] for name in feature_names])
|
|
855
|
+
|
|
856
|
+
if replace_nonfinite:
|
|
857
|
+
# Replace NaN/inf with extreme values (will be flagged as outliers)
|
|
858
|
+
bad = ~np.isfinite(X)
|
|
859
|
+
X[bad] = fill_value
|
|
860
|
+
|
|
861
|
+
return X, feature_names
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _build_classifier_for_inference(classifier, model=None):
|
|
865
|
+
"""
|
|
866
|
+
Build classifier object for classify().
|
|
867
|
+
|
|
868
|
+
Returns
|
|
869
|
+
-------
|
|
870
|
+
clf : object
|
|
871
|
+
Classifier instance.
|
|
872
|
+
fit_on_current_features : bool
|
|
873
|
+
Whether classify() should fit the classifier on the current feature set.
|
|
874
|
+
"""
|
|
875
|
+
if not isinstance(classifier, str):
|
|
876
|
+
return classifier, False
|
|
877
|
+
|
|
878
|
+
if classifier == "scoring":
|
|
879
|
+
return ScoringClassifier(), False
|
|
880
|
+
if classifier == "isolation":
|
|
881
|
+
if model is not None:
|
|
882
|
+
return model, False
|
|
883
|
+
return IsolationForestClassifier(), True
|
|
884
|
+
if classifier == "randomforest":
|
|
885
|
+
if model is None:
|
|
886
|
+
raise ValueError("model required for randomforest classifier")
|
|
887
|
+
return model, False
|
|
888
|
+
|
|
889
|
+
raise ValueError(f"Unknown classifier: {classifier}")
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _build_classifier_for_training(classifier, random_state=0):
|
|
893
|
+
"""Build classifier object for train_classifier()."""
|
|
894
|
+
if classifier == "randomforest":
|
|
895
|
+
return RandomForestClassifier(random_state=random_state)
|
|
896
|
+
if classifier == "isolation":
|
|
897
|
+
return IsolationForestClassifier(random_state=random_state)
|
|
898
|
+
raise ValueError(f"Unknown classifier: {classifier}")
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _validate_stratified_split_inputs(y):
|
|
902
|
+
"""Validate labels before stratified train/test split."""
|
|
903
|
+
classes, counts = np.unique(y, return_counts=True)
|
|
904
|
+
if classes.size < 2:
|
|
905
|
+
raise ValueError("Need at least two label classes for training")
|
|
906
|
+
if np.min(counts) < 2:
|
|
907
|
+
raise ValueError("Each class needs at least 2 samples for stratified train/test split")
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
class ScoringClassifier:
|
|
911
|
+
"""
|
|
912
|
+
Rule-based scoring classifier (no training needed).
|
|
913
|
+
|
|
914
|
+
Each feature contributes to a score based on how far it deviates
|
|
915
|
+
from the "typical" real source value.
|
|
916
|
+
"""
|
|
917
|
+
|
|
918
|
+
# Scoring rules calibrated on simulated data.
|
|
919
|
+
# bad_direction: 'high' penalizes above ideal, 'low' below, 'both' either
|
|
920
|
+
# threshold: deviation at which penalty is 50%
|
|
921
|
+
DEFAULT_RULES = {
|
|
922
|
+
# Cutout features - calibrated on simulated astronomical images
|
|
923
|
+
# Thresholds are lenient to avoid over-rejection; tune for your data
|
|
924
|
+
"sharpness": {"weight": 0.15, "ideal": 1.2, "bad": "high", "threshold": 5.0},
|
|
925
|
+
"concentration": {"weight": 0.05, "ideal": 0.25, "bad": "both", "threshold": 0.3},
|
|
926
|
+
"symmetry": {"weight": 0.10, "ideal": 0.05, "bad": "high", "threshold": 0.5},
|
|
927
|
+
"roundness": {"weight": 0.15, "ideal": 0.9, "bad": "low", "threshold": 0.5},
|
|
928
|
+
"psf_match": {"weight": 0.05, "ideal": 0.002, "bad": "high", "threshold": 0.05},
|
|
929
|
+
"peak_offset": {"weight": 0.05, "ideal": 0.0, "bad": "high", "threshold": 8.0},
|
|
930
|
+
"edge_gradient": {"weight": 0.05, "ideal": 1.5, "bad": "high", "threshold": 5.0},
|
|
931
|
+
"bg_consistency": {"weight": 0.05, "ideal": 0.0, "bad": "high", "threshold": 2.0},
|
|
932
|
+
# Catalog features
|
|
933
|
+
"ellipticity": {"weight": 0.15, "ideal": 0.1, "bad": "high", "threshold": 0.6},
|
|
934
|
+
"fwhm_ratio": {"weight": 0.15, "ideal": 1.0, "bad": "both", "threshold": 1.5},
|
|
935
|
+
"peakiness": {"weight": 0.05, "ideal": None, "bad": "high", "threshold": None},
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
def __init__(self, rules=None):
|
|
939
|
+
"""
|
|
940
|
+
Initialize scoring classifier.
|
|
941
|
+
|
|
942
|
+
Parameters
|
|
943
|
+
----------
|
|
944
|
+
rules : dict, optional
|
|
945
|
+
Custom scoring rules. Default: use DEFAULT_RULES.
|
|
946
|
+
"""
|
|
947
|
+
self.rules = rules if rules is not None else self.DEFAULT_RULES.copy()
|
|
948
|
+
|
|
949
|
+
def fit(self, X, y=None):
|
|
950
|
+
"""Fit is a no-op for scoring classifier."""
|
|
951
|
+
return self
|
|
952
|
+
|
|
953
|
+
def predict_proba(self, features):
|
|
954
|
+
"""
|
|
955
|
+
Compute real-bogus scores.
|
|
956
|
+
|
|
957
|
+
Parameters
|
|
958
|
+
----------
|
|
959
|
+
features : dict
|
|
960
|
+
Feature dictionary.
|
|
961
|
+
|
|
962
|
+
Returns
|
|
963
|
+
-------
|
|
964
|
+
scores : ndarray
|
|
965
|
+
Scores from 0 (bogus) to 1 (real).
|
|
966
|
+
"""
|
|
967
|
+
if not features:
|
|
968
|
+
raise ValueError("Feature dictionary is empty")
|
|
969
|
+
|
|
970
|
+
n = len(list(features.values())[0])
|
|
971
|
+
scores = np.ones(n)
|
|
972
|
+
total_weight = 0
|
|
973
|
+
|
|
974
|
+
for name, rule in self.rules.items():
|
|
975
|
+
if name not in features:
|
|
976
|
+
continue
|
|
977
|
+
|
|
978
|
+
arr = np.array(features[name])
|
|
979
|
+
weight = rule["weight"]
|
|
980
|
+
ideal = rule["ideal"]
|
|
981
|
+
bad = rule["bad"]
|
|
982
|
+
threshold = rule["threshold"]
|
|
983
|
+
|
|
984
|
+
if ideal is None or threshold is None:
|
|
985
|
+
# Use median as ideal
|
|
986
|
+
ideal = np.nanmedian(arr)
|
|
987
|
+
threshold = np.nanstd(arr) * 2
|
|
988
|
+
if threshold <= 0:
|
|
989
|
+
continue
|
|
990
|
+
|
|
991
|
+
# Compute deviation from ideal
|
|
992
|
+
deviation = arr - ideal
|
|
993
|
+
|
|
994
|
+
if bad == "high":
|
|
995
|
+
# Penalize values above threshold
|
|
996
|
+
penalty = np.maximum(0, deviation) / threshold
|
|
997
|
+
elif bad == "low":
|
|
998
|
+
# Penalize values below threshold
|
|
999
|
+
penalty = np.maximum(0, -deviation) / threshold
|
|
1000
|
+
else: # 'both'
|
|
1001
|
+
penalty = np.abs(deviation) / threshold
|
|
1002
|
+
|
|
1003
|
+
# Clip and convert to score component
|
|
1004
|
+
penalty = np.clip(penalty, 0, 2) # Max 2x threshold penalty
|
|
1005
|
+
score_component = 1 - penalty / 2 # Maps [0,2] -> [1,0]
|
|
1006
|
+
|
|
1007
|
+
# Handle NaN
|
|
1008
|
+
score_component = np.where(np.isfinite(arr), score_component, 0.5)
|
|
1009
|
+
|
|
1010
|
+
scores *= score_component**weight
|
|
1011
|
+
total_weight += weight
|
|
1012
|
+
|
|
1013
|
+
# Normalize by total weight
|
|
1014
|
+
if total_weight > 0:
|
|
1015
|
+
scores = scores ** (1 / total_weight)
|
|
1016
|
+
|
|
1017
|
+
return scores
|
|
1018
|
+
|
|
1019
|
+
def predict(self, features, threshold=0.5):
|
|
1020
|
+
"""
|
|
1021
|
+
Predict real (1) or bogus (-1).
|
|
1022
|
+
|
|
1023
|
+
Parameters
|
|
1024
|
+
----------
|
|
1025
|
+
features : dict
|
|
1026
|
+
Feature dictionary.
|
|
1027
|
+
threshold : float
|
|
1028
|
+
Score threshold. Default: 0.5.
|
|
1029
|
+
|
|
1030
|
+
Returns
|
|
1031
|
+
-------
|
|
1032
|
+
predictions : ndarray
|
|
1033
|
+
1 for real, -1 for bogus.
|
|
1034
|
+
"""
|
|
1035
|
+
scores = self.predict_proba(features)
|
|
1036
|
+
return np.where(scores >= threshold, 1, -1)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
class IsolationForestClassifier:
|
|
1040
|
+
"""
|
|
1041
|
+
Wrapper around sklearn IsolationForest for outlier detection.
|
|
1042
|
+
|
|
1043
|
+
This is similar to what artefacts.py does but works with any features.
|
|
1044
|
+
"""
|
|
1045
|
+
|
|
1046
|
+
def __init__(self, contamination="auto", random_state=0, **kwargs):
|
|
1047
|
+
"""
|
|
1048
|
+
Initialize IsolationForest classifier.
|
|
1049
|
+
|
|
1050
|
+
Parameters
|
|
1051
|
+
----------
|
|
1052
|
+
contamination : float or 'auto'
|
|
1053
|
+
Expected proportion of outliers.
|
|
1054
|
+
random_state : int
|
|
1055
|
+
Random seed.
|
|
1056
|
+
**kwargs
|
|
1057
|
+
Additional arguments for IsolationForest.
|
|
1058
|
+
"""
|
|
1059
|
+
from sklearn.ensemble import IsolationForest
|
|
1060
|
+
|
|
1061
|
+
self.clf = IsolationForest(contamination=contamination, random_state=random_state, **kwargs)
|
|
1062
|
+
self._fitted = False
|
|
1063
|
+
self.feature_names = None
|
|
1064
|
+
|
|
1065
|
+
def fit(self, features, y=None):
|
|
1066
|
+
"""
|
|
1067
|
+
Fit the isolation forest on features.
|
|
1068
|
+
|
|
1069
|
+
Parameters
|
|
1070
|
+
----------
|
|
1071
|
+
features : dict
|
|
1072
|
+
Feature dictionary.
|
|
1073
|
+
y : ignored
|
|
1074
|
+
Not used (unsupervised).
|
|
1075
|
+
"""
|
|
1076
|
+
X, self.feature_names = _features_to_array(features, replace_nonfinite=False)
|
|
1077
|
+
X_valid, _ = _filter_finite_rows(X, context="fitting IsolationForest")
|
|
1078
|
+
self.clf.fit(X_valid)
|
|
1079
|
+
self._fitted = True
|
|
1080
|
+
return self
|
|
1081
|
+
|
|
1082
|
+
def predict(self, features):
|
|
1083
|
+
"""
|
|
1084
|
+
Predict inlier (1) or outlier (-1).
|
|
1085
|
+
|
|
1086
|
+
Parameters
|
|
1087
|
+
----------
|
|
1088
|
+
features : dict
|
|
1089
|
+
Feature dictionary.
|
|
1090
|
+
|
|
1091
|
+
Returns
|
|
1092
|
+
-------
|
|
1093
|
+
predictions : ndarray
|
|
1094
|
+
1 for inlier (real), -1 for outlier (bogus).
|
|
1095
|
+
"""
|
|
1096
|
+
if not self._fitted:
|
|
1097
|
+
raise RuntimeError("Classifier not fitted. Call fit() first.")
|
|
1098
|
+
|
|
1099
|
+
X, _ = _features_to_array(features, self.feature_names)
|
|
1100
|
+
return self.clf.predict(X)
|
|
1101
|
+
|
|
1102
|
+
def predict_proba(self, features):
|
|
1103
|
+
"""
|
|
1104
|
+
Compute anomaly scores (higher = more likely real).
|
|
1105
|
+
|
|
1106
|
+
Parameters
|
|
1107
|
+
----------
|
|
1108
|
+
features : dict
|
|
1109
|
+
Feature dictionary.
|
|
1110
|
+
|
|
1111
|
+
Returns
|
|
1112
|
+
-------
|
|
1113
|
+
scores : ndarray
|
|
1114
|
+
Scores from 0 (anomaly) to 1 (normal).
|
|
1115
|
+
"""
|
|
1116
|
+
if not self._fitted:
|
|
1117
|
+
raise RuntimeError("Classifier not fitted. Call fit() first.")
|
|
1118
|
+
|
|
1119
|
+
X, _ = _features_to_array(features, self.feature_names)
|
|
1120
|
+
|
|
1121
|
+
# decision_function returns values centered at 0
|
|
1122
|
+
# positive = inlier, negative = outlier
|
|
1123
|
+
raw_scores = self.clf.decision_function(X)
|
|
1124
|
+
|
|
1125
|
+
# Convert to 0-1 range using sigmoid with scaling
|
|
1126
|
+
# Typical decision_function range is roughly -0.5 to +0.5
|
|
1127
|
+
# Scale by 5 to make sigmoid more discriminative
|
|
1128
|
+
scores = 1 / (1 + np.exp(-5 * raw_scores))
|
|
1129
|
+
return scores
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
class RandomForestClassifier:
|
|
1133
|
+
"""
|
|
1134
|
+
Wrapper around sklearn RandomForest for supervised classification.
|
|
1135
|
+
|
|
1136
|
+
Requires labeled training data.
|
|
1137
|
+
"""
|
|
1138
|
+
|
|
1139
|
+
def __init__(self, n_estimators=100, max_depth=10, random_state=0, **kwargs):
|
|
1140
|
+
"""
|
|
1141
|
+
Initialize RandomForest classifier.
|
|
1142
|
+
|
|
1143
|
+
Parameters
|
|
1144
|
+
----------
|
|
1145
|
+
n_estimators : int
|
|
1146
|
+
Number of trees.
|
|
1147
|
+
max_depth : int
|
|
1148
|
+
Maximum tree depth.
|
|
1149
|
+
random_state : int
|
|
1150
|
+
Random seed.
|
|
1151
|
+
**kwargs
|
|
1152
|
+
Additional arguments for RandomForestClassifier.
|
|
1153
|
+
"""
|
|
1154
|
+
from sklearn.ensemble import RandomForestClassifier as RFC
|
|
1155
|
+
|
|
1156
|
+
self.clf = RFC(
|
|
1157
|
+
n_estimators=n_estimators, max_depth=max_depth, random_state=random_state, **kwargs
|
|
1158
|
+
)
|
|
1159
|
+
self._fitted = False
|
|
1160
|
+
self.feature_names = None
|
|
1161
|
+
|
|
1162
|
+
def fit(self, features, y):
|
|
1163
|
+
"""
|
|
1164
|
+
Fit the random forest on labeled features.
|
|
1165
|
+
|
|
1166
|
+
Parameters
|
|
1167
|
+
----------
|
|
1168
|
+
features : dict
|
|
1169
|
+
Feature dictionary.
|
|
1170
|
+
y : ndarray
|
|
1171
|
+
Labels (1 = real, 0 = bogus).
|
|
1172
|
+
"""
|
|
1173
|
+
X, self.feature_names = _features_to_array(features, replace_nonfinite=False)
|
|
1174
|
+
X_valid, y_valid, _ = _filter_finite_rows(X, y=y, context="fitting RandomForest")
|
|
1175
|
+
self.clf.fit(X_valid, y_valid)
|
|
1176
|
+
self._fitted = True
|
|
1177
|
+
return self
|
|
1178
|
+
|
|
1179
|
+
def predict(self, features):
|
|
1180
|
+
"""
|
|
1181
|
+
Predict real (1) or bogus (-1).
|
|
1182
|
+
|
|
1183
|
+
Parameters
|
|
1184
|
+
----------
|
|
1185
|
+
features : dict
|
|
1186
|
+
Feature dictionary.
|
|
1187
|
+
|
|
1188
|
+
Returns
|
|
1189
|
+
-------
|
|
1190
|
+
predictions : ndarray
|
|
1191
|
+
1 for real, -1 for bogus.
|
|
1192
|
+
"""
|
|
1193
|
+
if not self._fitted:
|
|
1194
|
+
raise RuntimeError("Classifier not fitted. Call fit() first.")
|
|
1195
|
+
|
|
1196
|
+
X, _ = _features_to_array(features, self.feature_names)
|
|
1197
|
+
preds = self.clf.predict(X)
|
|
1198
|
+
return np.where(preds == 1, 1, -1)
|
|
1199
|
+
|
|
1200
|
+
def predict_proba(self, features):
|
|
1201
|
+
"""
|
|
1202
|
+
Compute probability of being real.
|
|
1203
|
+
|
|
1204
|
+
Parameters
|
|
1205
|
+
----------
|
|
1206
|
+
features : dict
|
|
1207
|
+
Feature dictionary.
|
|
1208
|
+
|
|
1209
|
+
Returns
|
|
1210
|
+
-------
|
|
1211
|
+
scores : ndarray
|
|
1212
|
+
Probability of being real (0-1).
|
|
1213
|
+
"""
|
|
1214
|
+
if not self._fitted:
|
|
1215
|
+
raise RuntimeError("Classifier not fitted. Call fit() first.")
|
|
1216
|
+
|
|
1217
|
+
X, _ = _features_to_array(features, self.feature_names)
|
|
1218
|
+
probabilities = self.clf.predict_proba(X)
|
|
1219
|
+
classes = self.clf.classes_
|
|
1220
|
+
|
|
1221
|
+
# probability of class 1 (real). If class 1 was absent in training,
|
|
1222
|
+
# the model can only return class 0.
|
|
1223
|
+
if 1 in classes:
|
|
1224
|
+
idx = np.where(classes == 1)[0][0]
|
|
1225
|
+
return probabilities[:, idx]
|
|
1226
|
+
return np.zeros(X.shape[0], dtype=float)
|
|
1227
|
+
|
|
1228
|
+
@property
|
|
1229
|
+
def feature_importances_(self):
|
|
1230
|
+
"""Return feature importances."""
|
|
1231
|
+
if not self._fitted:
|
|
1232
|
+
return None
|
|
1233
|
+
return dict(zip(self.feature_names, self.clf.feature_importances_))
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
# =============================================================================
|
|
1237
|
+
# High-Level API
|
|
1238
|
+
# =============================================================================
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def classify(
|
|
1242
|
+
obj,
|
|
1243
|
+
image=None,
|
|
1244
|
+
bg=None,
|
|
1245
|
+
err=None,
|
|
1246
|
+
mask=None,
|
|
1247
|
+
fwhm=None,
|
|
1248
|
+
method="auto",
|
|
1249
|
+
feature_set="default",
|
|
1250
|
+
classifier="scoring",
|
|
1251
|
+
model=None,
|
|
1252
|
+
threshold=0.5,
|
|
1253
|
+
add_score=True,
|
|
1254
|
+
flag_bogus=True,
|
|
1255
|
+
remove_trend=True,
|
|
1256
|
+
trend_cols=None,
|
|
1257
|
+
trend_scales=None,
|
|
1258
|
+
radius=10,
|
|
1259
|
+
verbose=False,
|
|
1260
|
+
):
|
|
1261
|
+
"""
|
|
1262
|
+
Classify objects as real or bogus.
|
|
1263
|
+
|
|
1264
|
+
This is the main entry point for feature-based real-bogus classification.
|
|
1265
|
+
|
|
1266
|
+
Parameters
|
|
1267
|
+
----------
|
|
1268
|
+
obj : astropy.table.Table
|
|
1269
|
+
Object catalog with 'x', 'y' columns.
|
|
1270
|
+
image : ndarray, optional
|
|
1271
|
+
Science image. Required for 'cutout' and 'hybrid' methods.
|
|
1272
|
+
bg : ndarray or float, optional
|
|
1273
|
+
Background map or scalar.
|
|
1274
|
+
err : ndarray or float, optional
|
|
1275
|
+
Error/noise map.
|
|
1276
|
+
mask : ndarray, optional
|
|
1277
|
+
Boolean mask (True = masked).
|
|
1278
|
+
fwhm : float, optional
|
|
1279
|
+
Image FWHM. If None, estimated from catalog.
|
|
1280
|
+
method : str, optional
|
|
1281
|
+
Feature extraction method:
|
|
1282
|
+
- 'catalog': Catalog features only (no image needed)
|
|
1283
|
+
- 'cutout': Cutout features only
|
|
1284
|
+
- 'hybrid': Both catalog and cutout features
|
|
1285
|
+
- 'auto': 'hybrid' if image provided, else 'catalog'
|
|
1286
|
+
feature_set : str, optional
|
|
1287
|
+
Catalog feature set for catalog/hybrid methods:
|
|
1288
|
+
'minimal', 'default', or 'extended'.
|
|
1289
|
+
classifier : str or object, optional
|
|
1290
|
+
Classifier to use:
|
|
1291
|
+
- 'scoring': Rule-based scoring (no training needed)
|
|
1292
|
+
- 'isolation': IsolationForest (unsupervised)
|
|
1293
|
+
- 'randomforest': RandomForest (requires model)
|
|
1294
|
+
- Custom classifier object with predict/predict_proba methods
|
|
1295
|
+
model : object, optional
|
|
1296
|
+
Pre-trained classifier model. Required for 'randomforest'.
|
|
1297
|
+
threshold : float, optional
|
|
1298
|
+
Score threshold for classification. Default: 0.5.
|
|
1299
|
+
add_score : bool, optional
|
|
1300
|
+
Add 'rb_score' column to output. Default: True.
|
|
1301
|
+
flag_bogus : bool, optional
|
|
1302
|
+
Set FLAG_BOGUS (0x4000) on bogus objects. Default: True.
|
|
1303
|
+
remove_trend : bool, optional
|
|
1304
|
+
Remove spatial trends from features. Default: True. Ignored for the
|
|
1305
|
+
'scoring' classifier, whose rules expect raw feature values.
|
|
1306
|
+
trend_cols : list of str, optional
|
|
1307
|
+
Columns for trend removal. Default: ['x', 'y'].
|
|
1308
|
+
trend_scales : list of float, optional
|
|
1309
|
+
Scales for trend removal.
|
|
1310
|
+
radius : int, optional
|
|
1311
|
+
Cutout radius. Default: 10.
|
|
1312
|
+
verbose : bool, optional
|
|
1313
|
+
Print progress.
|
|
1314
|
+
|
|
1315
|
+
Returns
|
|
1316
|
+
-------
|
|
1317
|
+
obj : astropy.table.Table
|
|
1318
|
+
Input catalog with added 'rb_score' column (if add_score=True)
|
|
1319
|
+
and updated flags (if flag_bogus=True). Objects with no valid
|
|
1320
|
+
features at all (e.g. too close to the image edge for cutout
|
|
1321
|
+
extraction) get rb_score=0 and are classified as bogus.
|
|
1322
|
+
"""
|
|
1323
|
+
log = print if verbose else lambda *args, **kwargs: None
|
|
1324
|
+
|
|
1325
|
+
# Make a copy to avoid modifying input
|
|
1326
|
+
obj = obj.copy()
|
|
1327
|
+
|
|
1328
|
+
# Get or create classifier
|
|
1329
|
+
clf, fit_on_current_features = _build_classifier_for_inference(classifier, model=model)
|
|
1330
|
+
|
|
1331
|
+
# Scoring rules are calibrated on raw feature values (e.g. roundness ~ 0.9
|
|
1332
|
+
# for stars); detrended residuals are centered near zero and would be
|
|
1333
|
+
# systematically mis-scored, so trend removal is disabled for this path
|
|
1334
|
+
if remove_trend and isinstance(clf, ScoringClassifier):
|
|
1335
|
+
log("Skipping trend removal: scoring rules expect raw feature values")
|
|
1336
|
+
remove_trend = False
|
|
1337
|
+
|
|
1338
|
+
# Extract features
|
|
1339
|
+
log(f"Extracting features using method='{method}'")
|
|
1340
|
+
features, feature_names = extract_features(
|
|
1341
|
+
obj,
|
|
1342
|
+
image=image,
|
|
1343
|
+
bg=bg,
|
|
1344
|
+
err=err,
|
|
1345
|
+
mask=mask,
|
|
1346
|
+
fwhm=fwhm,
|
|
1347
|
+
radius=radius,
|
|
1348
|
+
method=method,
|
|
1349
|
+
feature_set=feature_set,
|
|
1350
|
+
verbose=verbose,
|
|
1351
|
+
)
|
|
1352
|
+
log(f"Extracted {len(feature_names)} features: {feature_names}")
|
|
1353
|
+
|
|
1354
|
+
# Remove trends if requested
|
|
1355
|
+
if remove_trend:
|
|
1356
|
+
log("Removing spatial trends from features")
|
|
1357
|
+
features, trend_models = remove_trends(
|
|
1358
|
+
features, obj, trend_cols=trend_cols, trend_scales=trend_scales, verbose=verbose
|
|
1359
|
+
)
|
|
1360
|
+
|
|
1361
|
+
X, _ = _features_to_array(features, feature_names, replace_nonfinite=False)
|
|
1362
|
+
|
|
1363
|
+
if fit_on_current_features:
|
|
1364
|
+
# Fit on objects with clean flags only, so blends and broken
|
|
1365
|
+
# measurements do not contaminate the outlier model
|
|
1366
|
+
fit_idx = np.all(np.isfinite(X), axis=1)
|
|
1367
|
+
if "flags" in obj.colnames:
|
|
1368
|
+
fit_idx &= np.asarray((obj["flags"] & FLAGS_UNRELIABLE_MASK) == 0)
|
|
1369
|
+
if np.sum(fit_idx) >= 3:
|
|
1370
|
+
log(f"Fitting IsolationForest on {np.sum(fit_idx)} clean objects")
|
|
1371
|
+
clf.fit({name: arr[fit_idx] for name, arr in features.items()})
|
|
1372
|
+
else:
|
|
1373
|
+
log("Too few clean objects, fitting IsolationForest on all data")
|
|
1374
|
+
clf.fit(features)
|
|
1375
|
+
|
|
1376
|
+
# Predict
|
|
1377
|
+
scores = np.asarray(clf.predict_proba(features), dtype=float)
|
|
1378
|
+
|
|
1379
|
+
# Objects with no valid features at all (e.g. too close to the image
|
|
1380
|
+
# edge for cutout extraction) cannot be classified; treat them as bogus
|
|
1381
|
+
# uniformly across classifiers
|
|
1382
|
+
no_valid = ~np.any(np.isfinite(X), axis=1)
|
|
1383
|
+
if np.any(no_valid):
|
|
1384
|
+
log(f"{np.sum(no_valid)} objects have no valid features, marking as bogus")
|
|
1385
|
+
scores[no_valid] = 0.0
|
|
1386
|
+
|
|
1387
|
+
predictions = np.where(scores >= threshold, 1, -1)
|
|
1388
|
+
|
|
1389
|
+
n_real = np.sum(predictions > 0)
|
|
1390
|
+
n_bogus = np.sum(predictions < 0)
|
|
1391
|
+
log(f"Classification: {n_real} real, {n_bogus} bogus")
|
|
1392
|
+
|
|
1393
|
+
# Add score column
|
|
1394
|
+
if add_score:
|
|
1395
|
+
if "rb_score" in obj.colnames:
|
|
1396
|
+
obj["rb_score"] = scores
|
|
1397
|
+
else:
|
|
1398
|
+
obj.add_column(Column(scores, name="rb_score"))
|
|
1399
|
+
|
|
1400
|
+
# Flag bogus objects
|
|
1401
|
+
if flag_bogus:
|
|
1402
|
+
if "flags" not in obj.colnames:
|
|
1403
|
+
obj.add_column(Column(np.zeros(len(obj), dtype=np.int32), name="flags"))
|
|
1404
|
+
# Reset existing bogus bit so output reflects this classification pass.
|
|
1405
|
+
obj["flags"] &= ~FLAG_BOGUS
|
|
1406
|
+
obj["flags"][predictions < 0] |= FLAG_BOGUS
|
|
1407
|
+
|
|
1408
|
+
return obj
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def train_classifier(
|
|
1412
|
+
training_features,
|
|
1413
|
+
labels,
|
|
1414
|
+
classifier="randomforest",
|
|
1415
|
+
test_size=0.2,
|
|
1416
|
+
random_state=0,
|
|
1417
|
+
verbose=False,
|
|
1418
|
+
):
|
|
1419
|
+
"""
|
|
1420
|
+
Train a classifier on labeled data.
|
|
1421
|
+
|
|
1422
|
+
Parameters
|
|
1423
|
+
----------
|
|
1424
|
+
training_features : dict
|
|
1425
|
+
Feature dictionary from extract_features().
|
|
1426
|
+
labels : ndarray
|
|
1427
|
+
Labels (1 = real, 0 = bogus).
|
|
1428
|
+
classifier : str, optional
|
|
1429
|
+
Classifier type: 'randomforest' or 'isolation'.
|
|
1430
|
+
test_size : float, optional
|
|
1431
|
+
Fraction for validation. Default: 0.2.
|
|
1432
|
+
random_state : int, optional
|
|
1433
|
+
Random seed.
|
|
1434
|
+
verbose : bool, optional
|
|
1435
|
+
Print metrics.
|
|
1436
|
+
|
|
1437
|
+
Returns
|
|
1438
|
+
-------
|
|
1439
|
+
clf : classifier object
|
|
1440
|
+
Trained classifier.
|
|
1441
|
+
metrics : dict
|
|
1442
|
+
Training/validation metrics.
|
|
1443
|
+
"""
|
|
1444
|
+
from sklearn.model_selection import train_test_split
|
|
1445
|
+
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
|
|
1446
|
+
|
|
1447
|
+
log = print if verbose else lambda *args, **kwargs: None
|
|
1448
|
+
|
|
1449
|
+
X, feature_names = _features_to_array(training_features, replace_nonfinite=False)
|
|
1450
|
+
y = np.asarray(labels)
|
|
1451
|
+
|
|
1452
|
+
# Drop non-finite rows before splitting/training.
|
|
1453
|
+
X, y, _ = _filter_finite_rows(X, y=y, context="training")
|
|
1454
|
+
_validate_stratified_split_inputs(y)
|
|
1455
|
+
|
|
1456
|
+
# Train/test split
|
|
1457
|
+
try:
|
|
1458
|
+
X_train, X_test, y_train, y_test = train_test_split(
|
|
1459
|
+
X, y, test_size=test_size, random_state=random_state, stratify=y
|
|
1460
|
+
)
|
|
1461
|
+
except ValueError as exc:
|
|
1462
|
+
raise ValueError(
|
|
1463
|
+
"Unable to perform stratified train/test split. Check class balance and test_size."
|
|
1464
|
+
) from exc
|
|
1465
|
+
|
|
1466
|
+
# Create and train classifier
|
|
1467
|
+
clf = _build_classifier_for_training(classifier, random_state=random_state)
|
|
1468
|
+
|
|
1469
|
+
# Convert back to feature dict for training
|
|
1470
|
+
train_features = {name: X_train[:, i] for i, name in enumerate(feature_names)}
|
|
1471
|
+
test_features = {name: X_test[:, i] for i, name in enumerate(feature_names)}
|
|
1472
|
+
|
|
1473
|
+
clf.fit(train_features, y_train)
|
|
1474
|
+
|
|
1475
|
+
# Evaluate
|
|
1476
|
+
y_pred_train = (clf.predict(train_features) > 0).astype(int)
|
|
1477
|
+
y_pred_test = (clf.predict(test_features) > 0).astype(int)
|
|
1478
|
+
|
|
1479
|
+
metrics = {
|
|
1480
|
+
"train_accuracy": accuracy_score(y_train, y_pred_train),
|
|
1481
|
+
"test_accuracy": accuracy_score(y_test, y_pred_test),
|
|
1482
|
+
"test_precision": precision_score(y_test, y_pred_test),
|
|
1483
|
+
"test_recall": recall_score(y_test, y_pred_test),
|
|
1484
|
+
"test_f1": f1_score(y_test, y_pred_test),
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
if hasattr(clf, "feature_importances_"):
|
|
1488
|
+
metrics["feature_importances"] = clf.feature_importances_
|
|
1489
|
+
|
|
1490
|
+
log(f"Training accuracy: {metrics['train_accuracy']:.3f}")
|
|
1491
|
+
log(f"Test accuracy: {metrics['test_accuracy']:.3f}")
|
|
1492
|
+
log(f"Test precision: {metrics['test_precision']:.3f}")
|
|
1493
|
+
log(f"Test recall: {metrics['test_recall']:.3f}")
|
|
1494
|
+
log(f"Test F1: {metrics['test_f1']:.3f}")
|
|
1495
|
+
|
|
1496
|
+
return clf, metrics
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
def generate_training_data(
|
|
1500
|
+
n_images=100,
|
|
1501
|
+
image_size=(512, 512),
|
|
1502
|
+
fwhm_range=(1.5, 6.0),
|
|
1503
|
+
n_stars_range=(30, 80),
|
|
1504
|
+
n_galaxies_range=(0, 0),
|
|
1505
|
+
n_cosmic_rays_range=(5, 20),
|
|
1506
|
+
n_hot_pixels_range=(5, 15),
|
|
1507
|
+
background_range=(500, 2000),
|
|
1508
|
+
add_gradient=False,
|
|
1509
|
+
gradient_amplitude=0.3,
|
|
1510
|
+
remove_trend=False,
|
|
1511
|
+
trend_cols=None,
|
|
1512
|
+
verbose=False,
|
|
1513
|
+
):
|
|
1514
|
+
"""
|
|
1515
|
+
Generate synthetic training data using simulation module.
|
|
1516
|
+
|
|
1517
|
+
Parameters
|
|
1518
|
+
----------
|
|
1519
|
+
n_images : int
|
|
1520
|
+
Number of images to simulate.
|
|
1521
|
+
image_size : tuple
|
|
1522
|
+
Image dimensions.
|
|
1523
|
+
fwhm_range : tuple
|
|
1524
|
+
Range of FWHM values.
|
|
1525
|
+
n_stars_range : tuple
|
|
1526
|
+
Range of number of stars per image (min, max).
|
|
1527
|
+
n_galaxies_range : tuple
|
|
1528
|
+
Range of number of galaxies per image (min, max).
|
|
1529
|
+
n_cosmic_rays_range : tuple
|
|
1530
|
+
Range of number of cosmic rays per image (min, max).
|
|
1531
|
+
n_hot_pixels_range : tuple
|
|
1532
|
+
Range of number of hot pixels per image (min, max).
|
|
1533
|
+
background_range : tuple
|
|
1534
|
+
Range of background levels (min, max).
|
|
1535
|
+
add_gradient : bool
|
|
1536
|
+
Add spatial gradients to simulated images to simulate realistic backgrounds.
|
|
1537
|
+
gradient_amplitude : float
|
|
1538
|
+
Amplitude of spatial gradient as fraction of background (0-1).
|
|
1539
|
+
remove_trend : bool
|
|
1540
|
+
Apply trend removal to features during training.
|
|
1541
|
+
trend_cols : list of str, optional
|
|
1542
|
+
Columns for trend removal. Default: ['x', 'y'].
|
|
1543
|
+
verbose : bool
|
|
1544
|
+
Print progress.
|
|
1545
|
+
|
|
1546
|
+
Returns
|
|
1547
|
+
-------
|
|
1548
|
+
features : dict
|
|
1549
|
+
Combined features from all images.
|
|
1550
|
+
labels : ndarray
|
|
1551
|
+
Labels (1 = real, 0 = bogus).
|
|
1552
|
+
"""
|
|
1553
|
+
from . import simulation, photometry
|
|
1554
|
+
|
|
1555
|
+
log = print if verbose else lambda *args, **kwargs: None
|
|
1556
|
+
|
|
1557
|
+
all_features = None
|
|
1558
|
+
all_labels = []
|
|
1559
|
+
|
|
1560
|
+
for i in range(n_images):
|
|
1561
|
+
fwhm = np.random.uniform(*fwhm_range)
|
|
1562
|
+
n_stars = np.random.randint(n_stars_range[0], n_stars_range[1] + 1)
|
|
1563
|
+
n_galaxies = np.random.randint(n_galaxies_range[0], n_galaxies_range[1] + 1)
|
|
1564
|
+
n_cosmic_rays = np.random.randint(n_cosmic_rays_range[0], n_cosmic_rays_range[1] + 1)
|
|
1565
|
+
n_hot_pixels = np.random.randint(n_hot_pixels_range[0], n_hot_pixels_range[1] + 1)
|
|
1566
|
+
background = 10 ** np.random.uniform(
|
|
1567
|
+
np.log10(background_range[0]), np.log10(background_range[1])
|
|
1568
|
+
)
|
|
1569
|
+
|
|
1570
|
+
log(
|
|
1571
|
+
f"Generating image {i + 1}/{n_images} (FWHM={fwhm:.1f}, stars={n_stars}, galaxies={n_galaxies}, bg={background:.0f})"
|
|
1572
|
+
)
|
|
1573
|
+
|
|
1574
|
+
# Simulate image with known sources
|
|
1575
|
+
sim = simulation.simulate_image(
|
|
1576
|
+
width=image_size[0],
|
|
1577
|
+
height=image_size[1],
|
|
1578
|
+
n_stars=n_stars,
|
|
1579
|
+
star_fwhm=fwhm,
|
|
1580
|
+
n_galaxies=n_galaxies,
|
|
1581
|
+
n_cosmic_rays=n_cosmic_rays,
|
|
1582
|
+
n_hot_pixels=n_hot_pixels,
|
|
1583
|
+
background=background,
|
|
1584
|
+
readnoise=10,
|
|
1585
|
+
return_catalog=True,
|
|
1586
|
+
verbose=False,
|
|
1587
|
+
)
|
|
1588
|
+
|
|
1589
|
+
image = sim["image"]
|
|
1590
|
+
catalog = sim["catalog"]
|
|
1591
|
+
|
|
1592
|
+
# Add spatial gradient if requested
|
|
1593
|
+
if add_gradient:
|
|
1594
|
+
# Create smooth gradient across the image
|
|
1595
|
+
y_grid, x_grid = np.mgrid[0 : image_size[0], 0 : image_size[1]]
|
|
1596
|
+
# Random gradient direction and amplitude
|
|
1597
|
+
angle = np.random.uniform(0, 2 * np.pi)
|
|
1598
|
+
amplitude = np.random.uniform(0, gradient_amplitude)
|
|
1599
|
+
gradient = (
|
|
1600
|
+
amplitude
|
|
1601
|
+
* background
|
|
1602
|
+
* (
|
|
1603
|
+
np.cos(angle) * (x_grid / image_size[1] - 0.5)
|
|
1604
|
+
+ np.sin(angle) * (y_grid / image_size[0] - 0.5)
|
|
1605
|
+
)
|
|
1606
|
+
)
|
|
1607
|
+
image = image + gradient
|
|
1608
|
+
|
|
1609
|
+
# Detect objects
|
|
1610
|
+
obj = photometry.get_objects_sep(image, thresh=3.0, aper=2 * fwhm, verbose=False)
|
|
1611
|
+
|
|
1612
|
+
if len(obj) == 0:
|
|
1613
|
+
continue
|
|
1614
|
+
|
|
1615
|
+
# Extract features
|
|
1616
|
+
features, _ = extract_features(
|
|
1617
|
+
obj, image, bg=sim.get("background"), fwhm=fwhm, method="hybrid", verbose=False
|
|
1618
|
+
)
|
|
1619
|
+
|
|
1620
|
+
# Apply trend removal if requested
|
|
1621
|
+
if remove_trend:
|
|
1622
|
+
if trend_cols is None:
|
|
1623
|
+
trend_cols = ["x", "y"]
|
|
1624
|
+
features, _ = remove_trends(features, obj, trend_cols=trend_cols, verbose=False)
|
|
1625
|
+
|
|
1626
|
+
# Match detected objects to catalog to get labels
|
|
1627
|
+
from scipy.spatial import cKDTree
|
|
1628
|
+
|
|
1629
|
+
real_mask = [row["type"] in ("star", "galaxy") for row in catalog]
|
|
1630
|
+
labels = np.zeros(len(obj))
|
|
1631
|
+
if any(real_mask):
|
|
1632
|
+
cat_real = catalog[real_mask]
|
|
1633
|
+
tree = cKDTree(np.column_stack([cat_real["x"], cat_real["y"]]))
|
|
1634
|
+
dists, _ = tree.query(np.column_stack([obj["x"], obj["y"]]))
|
|
1635
|
+
labels = (dists < fwhm).astype(float)
|
|
1636
|
+
|
|
1637
|
+
# Accumulate arrays for later concatenation
|
|
1638
|
+
if all_features is None:
|
|
1639
|
+
all_features = {k: [v] for k, v in features.items()}
|
|
1640
|
+
else:
|
|
1641
|
+
for k, v in features.items():
|
|
1642
|
+
all_features[k].append(v)
|
|
1643
|
+
all_labels.append(labels)
|
|
1644
|
+
|
|
1645
|
+
# Convert to arrays
|
|
1646
|
+
if all_features is None:
|
|
1647
|
+
log("No detections accumulated from simulations")
|
|
1648
|
+
return {}, np.array([], dtype=np.int8)
|
|
1649
|
+
|
|
1650
|
+
all_features = {k: np.concatenate(v) for k, v in all_features.items()}
|
|
1651
|
+
all_labels = np.concatenate(all_labels)
|
|
1652
|
+
|
|
1653
|
+
n_real = np.sum(all_labels == 1)
|
|
1654
|
+
n_bogus = np.sum(all_labels == 0)
|
|
1655
|
+
log(f"Generated {n_real} real and {n_bogus} bogus examples")
|
|
1656
|
+
|
|
1657
|
+
return all_features, all_labels
|