Skip to content

API

labo1.to_significant_figures

to_significant_figures(x: float, dx: float | None = None, /, n: int = 2)

Rounds to n significant figures based on the uncertainty dx.

Parameters:

Name Type Description Default
n int

Number of significant figures.

2

Examples:

>>> to_significant_figures(0.1234, n=2)
'0.12'
>>> to_significant_figures(12.34, n=2)
'12'
>>> to_significant_figures(1234, n=2)
'1200'
>>> to_significant_figures(12.34, 5.678, n=2)
('12.3', '5.7')
Source code in src/labo1/round.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def to_significant_figures(
    x: float,
    dx: float | None = None,
    /,
    n: int = 2,
):
    """Rounds to `n` significant figures based on the uncertainty `dx`.

    Parameters:
        n: Number of significant figures.

    Examples:
        >>> to_significant_figures(0.1234, n=2)
        '0.12'
        >>> to_significant_figures(12.34, n=2)
        '12'
        >>> to_significant_figures(1234, n=2)
        '1200'
        >>> to_significant_figures(12.34, 5.678, n=2)
        ('12.3', '5.7')
    """
    if dx is None:
        return to_significant_figures(x, x, n=n)[0]

    if dx == 0:
        decimals = n - 1
    else:
        decimals = n - np.ceil(np.log10(dx)).astype(int)

    if decimals < 0:
        x = round(x, decimals)
        dx = round(dx, decimals)
        decimals = 0

    return f"{x:.{decimals}f}", f"{dx:.{decimals}f}"

labo1.curve_fit

curve_fit(
    func: Callable[..., NDArray],
    /,
    x: ArrayLike,
    y: ArrayLike,
    y_err: ArrayLike | None = None,
    *,
    initial_params: Sequence[float] | Mapping[str, float] | None = None,
    estimate_errors: bool = False,
    **kwargs,
)

Use non-linear least squares to fit a function to data.

Returns a Result object with the parameters, errors, and methods to quickly plot the fit.

Parameters:

Name Type Description Default
func Callable[..., NDArray]

The function to fit. Its signature must start with the independent

required
variable `x` followed by its N parameters to fit

f(x, p_0, p_1, ...).

required
y_err ArrayLike | None

Errors or uncertainties for y.

None
initial_params Sequence[float] | Mapping[str, float] | None

Initial guess for the parameters.

None
estimate_errors bool

Whether to estimate a global scale factor for the errors

False
**kwargs

Passed to scipy.optimize.curve_fit.

{}

Examples:

>>> def f(x, a, b):
...     return a * x + b
...
>>> x = np.array([0.0, 1.0, 2.0])
>>> y = np.array([0.0, 0.9, 2.1])
>>> curve_fit(f, x, y, estimate_errors=True)
Result(a=1.050 ± 0.087, b=-0.05 ± 0.11)
Source code in src/labo1/fit.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
def curve_fit(
    func: Callable[..., NDArray],
    /,
    x: ArrayLike,
    y: ArrayLike,
    y_err: ArrayLike | None = None,
    *,
    initial_params: Sequence[float] | Mapping[str, float] | None = None,
    estimate_errors: bool = False,
    **kwargs,
):
    """Use non-linear least squares to fit a function to data.

    Returns a `Result` object with the parameters, errors,
    and methods to quickly plot the fit.

    Parameters:
        func: The function to fit. Its signature must start with the independent
        variable `x` followed by its N parameters to fit: `f(x, p_0, p_1, ...)`.
        y_err: Errors or uncertainties for `y`.
        initial_params: Initial guess for the parameters.
        A sequence of length N or a mapping of names to values,
        where omitted values default to 1.
        estimate_errors: Whether to estimate a global scale factor for the errors
        based on the residuals.
        **kwargs: Passed to scipy.optimize.curve_fit.

    Examples:
        >>> def f(x, a, b):
        ...     return a * x + b
        ...
        >>> x = np.array([0.0, 1.0, 2.0])
        >>> y = np.array([0.0, 0.9, 2.1])
        >>> curve_fit(f, x, y, estimate_errors=True)
        Result(a=1.050 ± 0.087, b=-0.05 ± 0.11)
    """
    # accept ArrayLike
    x = np.asarray(x)
    y = np.asarray(y)
    if y_err is not None:
        y_err = np.asarray(y_err)
    elif estimate_errors is False:
        raise ValueError("y_err cannot be None when estimate_errors is False.")
    else:
        y_err = np.ones_like(y)

    if isinstance(initial_params, Mapping):
        names = _get_parameter_names(func)
        unused_params = initial_params.keys() - names
        if len(unused_params) > 0:
            raise ValueError(
                f"some initial_params are not function parameters: {unused_params}"
            )

        initial_params = [initial_params.get(name, 1) for name in names]  # type: ignore

    p, cov = scipy.optimize.curve_fit(
        func,
        x,
        y,
        p0=initial_params,
        sigma=y_err,
        absolute_sigma=not estimate_errors,
        **kwargs,
    )
    r = Result(func, p, cov, x=x, y=y, y_err=y_err)
    if estimate_errors:
        # Errors have already been rescaled inside scipy's curve_fit
        # to estimate the parameters' errors.
        # Here, we rescale `y_err` to use later when plotting.
        r = replace(r, y_err=y_err * r.reduced_chi2**0.5)
    return r

labo1.fit.Result dataclass

Source code in src/labo1/fit.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
@dataclass(frozen=True)
class Result:
    func: Callable[..., NDArray]
    params: NDArray
    "Optimal parameters found by least squares."
    covariance: NDArray
    "Covariance matrix of the parameters."
    x: NDArray
    y: NDArray
    y_err: NDArray

    @property
    def names(self) -> Sequence[str]:
        """Names of the parameters.

        Extracted from the function signature."""
        return _get_parameter_names(self.func)

    @property
    def errors(self) -> NDArray:
        """Standard deviation for the parameters.

        The square-root of the diagonal of the covariance matrix."""
        return np.sqrt(np.diag(self.covariance))

    def __getitem__(self, item: int | str) -> tuple[float, float]:
        if isinstance(item, str):
            item = self.names.index(item)
        return self.params[item], self.errors[item]

    def eval(self, x: NDArray, /) -> NDArray:
        """Evaluates the function with the parameters."""
        return self.func(x, *self.params)

    @property
    def residuals(self):
        """The difference between the measured and predicted `y`.

        $$ r_i = y_i - f(x_i) $$
        """
        return self.y - self.eval(self.x)

    @property
    def standardized_residuals(self):
        """Residuals divided by their corresponding error."""
        return self.residuals / self.y_err

    @property
    def chi2(self):
        r"""Sum of the standardized squared residuals.

        $$ \chi^2 = \sum_i (\frac{r_i}{y_{err}_i})^2 $$
        """
        return np.sum(self.standardized_residuals**2)

    @property
    def reduced_chi2(self):
        """χ² divided by the degree of freedom.

        The degree of freedom is the number of measuments
        minus the number of fitted parameters.
        """
        return self.chi2 / (np.size(self.y) - np.size(self.params))

    def __str__(self):
        values = {
            name: to_significant_figures(x, dx)
            for name, x, dx in zip(self.names, self.params, self.errors)
        }
        values = [f"{name}={x} ± {dx}" for name, (x, dx) in values.items()]
        return ", ".join(values)

    def __repr__(self):
        return f"{self.__class__.__name__}({self})"

    def plot(
        self,
        *,
        x_err: ArrayLike | None = None,
        x_eval: int | ArrayLike | None = None,
        label: str | None = None,
        fig: Figure | SubFigure | None = None,
        axes: Axes | None = None,
    ) -> tuple[Figure | SubFigure, Axes]:
        """Errorbar plot of the data and line plot of the function.

        Parameters:
            x_eval: Evaluation points for the line plot of the function. For an `int`,
            it generates equispaced points between the minimum and maximum of `x`.
            By default, `x_eval = x`.
            x_err: Error bars for `x`.
            label: Name of the line plot for the legend.
            axes: Axes on which to plot.
            By default, creates a new axes on `fig`.
            fig: Figure on which to create the `axes`.
            By default, creates a new figure.

        Returns:
            The axes on which it plotted and its corresponding figure.
        """
        if axes is None:
            if fig is None:
                fig = plt.figure()
            axes = cast(Axes, fig.subplots())
        elif fig is not None:
            raise ValueError("specify either `fig` or `axes`")

        if x_eval is None:
            x_eval = self.x
        elif isinstance(x_eval, int):
            x_eval = cast(NDArray, np.linspace(self.x.min(), self.x.max(), x_eval))
        else:
            x_eval = np.asarray(x_eval)

        (line,) = axes.plot(x_eval, self.eval(x_eval), label=label)
        axes.errorbar(
            self.x, self.y, xerr=x_err, yerr=self.y_err, fmt="o", color=line.get_color()
        )
        fig = cast(Union[Figure, SubFigure], axes.figure)
        return fig, axes

    def plot_with_residuals(
        self,
        *,
        x_err: ArrayLike | None = None,
        x_eval: int | ArrayLike | None = None,
        label: str | None = None,
        fig: Figure | SubFigure | None = None,
        axes: Sequence[Axes] | None = None,
    ) -> tuple[Figure | SubFigure, Sequence[Axes]]:
        """Errorbar plot of the data and residuals, and line plot of the function.

        Parameters:
            x_eval: Evaluation points for the line plot of the function. For an `int`,
            it generates equispaced points between the minimum and maximum of `x`.
            By default, `x_eval = x`.
            x_err: Error bars for `x`.
            label: Name of the line plot for the legend.
            axes: Axes on which to plot.
            By default, creates a new axes on `fig`.
            fig: Figure on which to create the `axes`.
            By default, creates a new figure.

        Returns:
            The axes on which it plotted and its corresponding figure.
        """
        if axes is None:
            if fig is None:
                fig = plt.figure()
            axes = cast(
                Sequence[Axes],
                fig.subplots(
                    nrows=2,
                    sharex=True,
                    gridspec_kw={"height_ratios": [2, 1]},
                ),
            )
        elif fig is not None:
            raise ValueError("specify either `fig` or `axes`")
        elif len(axes) != 2:
            raise TypeError("`axes` must be a two-element sequence")

        if x_eval is None:
            x_eval = self.x
        elif isinstance(x_eval, int):
            x_eval = cast(NDArray, np.linspace(self.x.min(), self.x.max(), x_eval))
        else:
            x_eval = np.asarray(x_eval)

        residuals = self.y - self.eval(self.x)

        (line,) = axes[0].plot(x_eval, self.eval(x_eval), label=label)
        axes[1].axhline(0, color="gray")

        color = line.get_color()

        axes[0].errorbar(
            self.x, self.y, xerr=x_err, yerr=self.y_err, fmt="o", color=color
        )
        axes[1].errorbar(
            self.x, residuals, xerr=x_err, yerr=self.y_err, fmt="o", color=color
        )

        fig = cast(Union[Figure, SubFigure], axes[0].figure)
        return fig, axes

chi2 property

chi2

Sum of the standardized squared residuals.

\[ \chi^2 = \sum_i (\frac{r_i}{y_{err}_i})^2 \]

covariance instance-attribute

covariance: NDArray

Covariance matrix of the parameters.

errors property

errors: NDArray

Standard deviation for the parameters.

The square-root of the diagonal of the covariance matrix.

names property

names: Sequence[str]

Names of the parameters.

Extracted from the function signature.

params instance-attribute

params: NDArray

Optimal parameters found by least squares.

reduced_chi2 property

reduced_chi2

χ² divided by the degree of freedom.

The degree of freedom is the number of measuments minus the number of fitted parameters.

residuals property

residuals

The difference between the measured and predicted y.

\[ r_i = y_i - f(x_i) \]

standardized_residuals property

standardized_residuals

Residuals divided by their corresponding error.

eval

eval(x: NDArray) -> NDArray

Evaluates the function with the parameters.

Source code in src/labo1/fit.py
47
48
49
def eval(self, x: NDArray, /) -> NDArray:
    """Evaluates the function with the parameters."""
    return self.func(x, *self.params)

plot

plot(
    *,
    x_err: ArrayLike | None = None,
    x_eval: int | ArrayLike | None = None,
    label: str | None = None,
    fig: Figure | SubFigure | None = None,
    axes: Axes | None = None
) -> tuple[Figure | SubFigure, Axes]

Errorbar plot of the data and line plot of the function.

Parameters:

Name Type Description Default
x_eval int | ArrayLike | None

Evaluation points for the line plot of the function. For an int,

None
x_err ArrayLike | None

Error bars for x.

None
label str | None

Name of the line plot for the legend.

None
axes Axes | None

Axes on which to plot.

None
fig Figure | SubFigure | None

Figure on which to create the axes.

None

Returns:

Type Description
tuple[Figure | SubFigure, Axes]

The axes on which it plotted and its corresponding figure.

Source code in src/labo1/fit.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def plot(
    self,
    *,
    x_err: ArrayLike | None = None,
    x_eval: int | ArrayLike | None = None,
    label: str | None = None,
    fig: Figure | SubFigure | None = None,
    axes: Axes | None = None,
) -> tuple[Figure | SubFigure, Axes]:
    """Errorbar plot of the data and line plot of the function.

    Parameters:
        x_eval: Evaluation points for the line plot of the function. For an `int`,
        it generates equispaced points between the minimum and maximum of `x`.
        By default, `x_eval = x`.
        x_err: Error bars for `x`.
        label: Name of the line plot for the legend.
        axes: Axes on which to plot.
        By default, creates a new axes on `fig`.
        fig: Figure on which to create the `axes`.
        By default, creates a new figure.

    Returns:
        The axes on which it plotted and its corresponding figure.
    """
    if axes is None:
        if fig is None:
            fig = plt.figure()
        axes = cast(Axes, fig.subplots())
    elif fig is not None:
        raise ValueError("specify either `fig` or `axes`")

    if x_eval is None:
        x_eval = self.x
    elif isinstance(x_eval, int):
        x_eval = cast(NDArray, np.linspace(self.x.min(), self.x.max(), x_eval))
    else:
        x_eval = np.asarray(x_eval)

    (line,) = axes.plot(x_eval, self.eval(x_eval), label=label)
    axes.errorbar(
        self.x, self.y, xerr=x_err, yerr=self.y_err, fmt="o", color=line.get_color()
    )
    fig = cast(Union[Figure, SubFigure], axes.figure)
    return fig, axes

plot_with_residuals

plot_with_residuals(
    *,
    x_err: ArrayLike | None = None,
    x_eval: int | ArrayLike | None = None,
    label: str | None = None,
    fig: Figure | SubFigure | None = None,
    axes: Sequence[Axes] | None = None
) -> tuple[Figure | SubFigure, Sequence[Axes]]

Errorbar plot of the data and residuals, and line plot of the function.

Parameters:

Name Type Description Default
x_eval int | ArrayLike | None

Evaluation points for the line plot of the function. For an int,

None
x_err ArrayLike | None

Error bars for x.

None
label str | None

Name of the line plot for the legend.

None
axes Sequence[Axes] | None

Axes on which to plot.

None
fig Figure | SubFigure | None

Figure on which to create the axes.

None

Returns:

Type Description
tuple[Figure | SubFigure, Sequence[Axes]]

The axes on which it plotted and its corresponding figure.

Source code in src/labo1/fit.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
def plot_with_residuals(
    self,
    *,
    x_err: ArrayLike | None = None,
    x_eval: int | ArrayLike | None = None,
    label: str | None = None,
    fig: Figure | SubFigure | None = None,
    axes: Sequence[Axes] | None = None,
) -> tuple[Figure | SubFigure, Sequence[Axes]]:
    """Errorbar plot of the data and residuals, and line plot of the function.

    Parameters:
        x_eval: Evaluation points for the line plot of the function. For an `int`,
        it generates equispaced points between the minimum and maximum of `x`.
        By default, `x_eval = x`.
        x_err: Error bars for `x`.
        label: Name of the line plot for the legend.
        axes: Axes on which to plot.
        By default, creates a new axes on `fig`.
        fig: Figure on which to create the `axes`.
        By default, creates a new figure.

    Returns:
        The axes on which it plotted and its corresponding figure.
    """
    if axes is None:
        if fig is None:
            fig = plt.figure()
        axes = cast(
            Sequence[Axes],
            fig.subplots(
                nrows=2,
                sharex=True,
                gridspec_kw={"height_ratios": [2, 1]},
            ),
        )
    elif fig is not None:
        raise ValueError("specify either `fig` or `axes`")
    elif len(axes) != 2:
        raise TypeError("`axes` must be a two-element sequence")

    if x_eval is None:
        x_eval = self.x
    elif isinstance(x_eval, int):
        x_eval = cast(NDArray, np.linspace(self.x.min(), self.x.max(), x_eval))
    else:
        x_eval = np.asarray(x_eval)

    residuals = self.y - self.eval(self.x)

    (line,) = axes[0].plot(x_eval, self.eval(x_eval), label=label)
    axes[1].axhline(0, color="gray")

    color = line.get_color()

    axes[0].errorbar(
        self.x, self.y, xerr=x_err, yerr=self.y_err, fmt="o", color=color
    )
    axes[1].errorbar(
        self.x, residuals, xerr=x_err, yerr=self.y_err, fmt="o", color=color
    )

    fig = cast(Union[Figure, SubFigure], axes[0].figure)
    return fig, axes