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.
@@ -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