Source code for pylorenzmie.analysis.MLPEstimator

'''Fast parameter estimator using a pre-trained radial-profile MLP.'''

from dataclasses import dataclass, field
from importlib.resources import files
from pathlib import Path

import numpy as np
import pandas as pd
import joblib

from pylorenzmie.lib import Azimuthal
from pylorenzmie.lib.lmtypes import Properties, Result
from pylorenzmie.analysis.BaseEstimator import BaseEstimator
from pylorenzmie.analysis.Hologram import Hologram
from pylorenzmie.theory import LorenzMie


def _default_weights() -> Path:
    return Path(str(files('pylorenzmie.analysis').joinpath('mlp_estimator.joblib')))


def _log_targets(y: np.ndarray) -> np.ndarray:
    '''Log-scale z_p (col 0) and a_p (col 1); n_p (col 2) unchanged.

    Defined here (not in the training script) so that joblib can locate
    this function when deserializing the pipeline from mlp_estimator.joblib.
    '''
    out = y.astype(float, copy=True)
    out[:, 0] = np.log(out[:, 0])
    out[:, 1] = np.log(out[:, 1])
    return out


def _exp_targets(y: np.ndarray) -> np.ndarray:
    out = y.astype(float, copy=True)
    out[:, 0] = np.exp(out[:, 0])
    out[:, 1] = np.exp(out[:, 1])
    return out


[docs] @dataclass class MLPEstimator(BaseEstimator): '''Fast particle parameter estimator using a pre-trained MLP. Computes the azimuthal average of the hologram, pads or truncates it to a fixed length, and runs a single forward pass through a scikit-learn :class:`MLPRegressor` to predict *z_p*, *a_p*, and *n_p* in one shot. Inference takes < 1 ms regardless of image size. The MLP was trained on synthetic radial profiles generated by the 1-D radial trick: ``LorenzMie`` evaluated at ``(r, 0)`` for ``r = 0 .. n_features-1``, which equals the azimuthal average for a rotationally symmetric scatterer. Parameters are drawn log-uniformly over *z_p* ∈ [20, 500] px, *a_p* ∈ [0.2, 4] μm and uniformly over *n_p* ∈ [1.3, 2.5]. Each profile is randomly truncated during training so the network handles both large and small crops gracefully. Pre-trained weights cover the default instrument (447 nm laser, 0.048 μm/px, water). Retrain with ``devel/train_mlp_estimator.py`` for other configurations. Inherits from :class:`BaseEstimator`. Parameters ---------- model : LorenzMie Generative scattering model shared with :class:`Optimizer`. Particle parameters are updated in-place on each :meth:`estimate` call; instrument parameters are read but not modified. weights : Path or str, optional Path to a ``joblib``-serialized scikit-learn ``Pipeline`` (``StandardScaler`` → ``TransformedTargetRegressor(MLPRegressor)``). Default: the pre-trained weights bundled with the package. n_features : int, optional Fixed input length passed to the MLP. Must match the value used during training. Default: 100. Notes ----- ``x_p`` and ``y_p`` are pinned to the pixel-coordinate means of the hologram (same convention as :class:`DEEstimator` and :class:`RadialEstimator`). The profile from :func:`~pylorenzmie.lib.Azimuthal.avg` is truncated to the first *n_features* values. Positions beyond the profile end are padded with ``0.0`` — a sentinel that is clearly outside the normalised hologram range (which is positive and O(1)). The training data uses the same sentinel after random truncation, so the network has seen this pattern and uses the zero tail to infer crop size. Predicted values are clipped to the training bounds before being written to the model; the :class:`Optimizer` will refine them further. ''' model: LorenzMie weights: Path = field(default_factory=_default_weights) n_features: int = 100 def __post_init__(self) -> None: self._pipeline = joblib.load(self.weights) @BaseEstimator.properties.getter def properties(self) -> Properties: '''MLPEstimator configuration.''' return dict(weights=str(self.weights), n_features=self.n_features)
[docs] def estimate(self, hologram: Hologram) -> Result: '''Estimate particle parameters from the azimuthal radial profile. Parameters ---------- hologram : Hologram Normalised hologram crop to analyse. Returns ------- result : pandas.Series Estimated particle properties (same keys as :attr:`~pylorenzmie.theory.Particle.properties`). ''' x_p = float(hologram.coordinates[0].mean()) y_p = float(hologram.coordinates[1].mean()) cx, cy = hologram.corner profile = Azimuthal.avg(hologram.data, center=(x_p - cx, y_p - cy)) features = np.zeros(self.n_features) # 0 = sentinel for "no data" n = min(len(profile), self.n_features) features[:n] = profile[:n] z_p, a_p, n_p = self._pipeline.predict( features.reshape(1, -1))[0] self.model.particle.x_p = x_p self.model.particle.y_p = y_p self.model.particle.z_p = float(np.clip(z_p, 10., 600.)) self.model.particle.a_p = float(np.clip(a_p, 0.1, 10.)) self.model.particle.n_p = float(np.clip(n_p, 1.0, 3.0)) return pd.Series(self.model.particle.properties)
[docs] @classmethod def example(cls) -> None: # pragma: no cover from time import perf_counter from pylorenzmie.utilities import example_hologram model = LorenzMie() model.instrument.wavelength = 0.447 model.instrument.magnification = 0.048 model.instrument.n_m = 1.34 estimator = cls(model=model) print(f'{cls.__name__} example') start = perf_counter() result = estimator.estimate(example_hologram()) print(f'Time: {perf_counter() - start:.3f} s') print(result)
if __name__ == '__main__': # pragma: no cover MLPEstimator.example()