scientificfitting 0.2.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,34 @@
1
+ """Native Python fitting with a shared Julia numerical core and optional Matplotlib.
2
+
3
+ Development API: models receive `(x, **parameters)` as NumPy arrays and floats.
4
+ Matplotlib is imported only when plotting; Julia/Makie figures are never exposed.
5
+ """
6
+
7
+ from ._core import (
8
+ Result, fit_custom, fit_extended_unbinned_model, fit_histogram_density,
9
+ fit_histogram_model, fit_indexed_model, fit_likelihood_model, fit_model,
10
+ fit_multi_model, fit_poisson_model, fit_unbinned_model,
11
+ )
12
+ from ._inputs import ErrorComponent, WhiteningOperator
13
+ from ._results import (
14
+ ContourResult, DiagnosticFinding, DiagnosticReport, FitReport, ParameterEstimate,
15
+ ProfileInterval, ProfileMatrixPanelTriage, ProfileMatrixResult, ProfileResult,
16
+ )
17
+ # Renderers import Matplotlib inside calls, preserving real signatures/docstrings
18
+ # on the public API without making plotting dependencies mandatory at import.
19
+ from ._plotting import add_report, plot_fit, plot_style
20
+ from ._diagnostic_plots import (
21
+ plot_contour, plot_diagnostics, plot_profile, plot_profile_matrix, plot_residuals,
22
+ )
23
+
24
+
25
+ __all__ = [
26
+ "Result", "ErrorComponent", "WhiteningOperator", "fit_model", "fit_custom", "fit_likelihood_model", "fit_poisson_model",
27
+ "fit_histogram_model", "fit_histogram_density", "fit_unbinned_model",
28
+ "fit_extended_unbinned_model", "fit_indexed_model", "fit_multi_model", "plot_fit",
29
+ "DiagnosticFinding", "DiagnosticReport", "FitReport", "ParameterEstimate",
30
+ "ProfileResult", "ProfileInterval", "ContourResult", "ProfileMatrixResult",
31
+ "ProfileMatrixPanelTriage",
32
+ "plot_style", "add_report", "plot_profile", "plot_contour", "plot_profile_matrix",
33
+ "plot_residuals", "plot_diagnostics",
34
+ ]
@@ -0,0 +1,234 @@
1
+ using ScientificFitting, PythonCall, SparseArrays
2
+ using ScientificFitting: _TypedCallback
3
+
4
+ """One vectorized foreign call; no dual numbers or per-observation Python loops."""
5
+ vector_model(f) = _TypedCallback{Vector{Float64}}((x, p) -> pyconvert(Vector{Float64}, f(x, p)))
6
+ matrix_model(f) = _TypedCallback{Matrix{Float64}}((x, p) -> pyconvert(Matrix{Float64}, f(x, p)))
7
+ scalar_cost(f) = _TypedCallback{Float64}(p -> pyconvert(Float64, f(p)))
8
+ vector_constraint(f) = _TypedCallback{Vector{Float64}}(p -> pyconvert(Vector{Float64}, f(p)))
9
+ scalar_model(f) = _TypedCallback{Float64}((x, p) -> pyconvert(Float64, f(x, p)))
10
+ mutating_model(f) = _TypedCallback{Nothing}((out, x, p) -> (f(out, x, p); nothing))
11
+ density_model(f, options) = get(options, :vectorized, false) ? vector_model(f) : scalar_model(f)
12
+ vector(x) = pyconvert(Vector{Float64}, Py(x))
13
+ matrix(x) = pyconvert(Matrix{Float64}, Py(x))
14
+
15
+ """Reconstruct canonical CSC without allocating an n-by-n dense intermediary."""
16
+ function covariance(value::Py)
17
+ pyisinstance(value, pybuiltins.dict) || return matrix(value)
18
+ n, m = pyconvert(Tuple{Int, Int}, value["shape"])
19
+ return SparseMatrixCSC(n, m, pyconvert(Vector{Int}, value["indptr"]) .+ 1,
20
+ pyconvert(Vector{Int}, value["indices"]) .+ 1, vector(value["data"]))
21
+ end
22
+
23
+ """Copy scalar, vector, or covariance metadata once, not during fit evaluation."""
24
+ function uncertainty_values(value::Py)
25
+ pyisinstance(value, pybuiltins.dict) && return covariance(value)
26
+ ndim = pyhasattr(value, "ndim") ? pyconvert(Int, value.ndim) : 0
27
+ return ndim == 0 ? pyconvert(Float64, value) : ndim == 1 ? vector(value) : matrix(value)
28
+ end
29
+
30
+ """Convert the supported Python keyword boundary once, outside numerical loops."""
31
+ function fit_keywords(options)
32
+ result = Dict{Symbol, Any}(:derivatives => :finite)
33
+ values = pyconvert(Dict{String, Py}, Py(options))
34
+ inplace = haskey(values, "inplace") && pyconvert(Bool, values["inplace"])
35
+ for (key, value) in values
36
+ name = Symbol(key)
37
+ pyis(value, pybuiltins.None) && continue
38
+ result[name] = if name in (:backend, :cost, :scale_covariance, :cost_name, :optimizer, :parameter_covariance)
39
+ Symbol(pyconvert(String, value))
40
+ elseif name in (:cov_x, :cov_y)
41
+ covariance(value)
42
+ elseif name == :whitening
43
+ callback = value[0]
44
+ marginal = pyis(value[2], pybuiltins.None) ? nothing : uncertainty_values(value[2])
45
+ WhiteningOperator(_TypedCallback{Nothing}((out, residual) -> (callback(out, residual); nothing));
46
+ logdet_covariance=pyconvert(Float64, value[1]), marginal_sigma=marginal)
47
+ elseif name == :error_components
48
+ [ErrorComponent(Symbol(pyconvert(String, row[0])), Symbol(pyconvert(String, row[1])),
49
+ Symbol(pyconvert(String, row[2])), uncertainty_values(row[3]);
50
+ active=pyconvert(Bool, row[4])) for row in value]
51
+ elseif name in (:sigma_x, :sigma_y)
52
+ vector(value)
53
+ elseif name == :bounds
54
+ lower, upper = pyconvert(Tuple{Py, Py}, value)
55
+ (vector(lower), vector(upper))
56
+ elseif name == :constraints
57
+ callbacks = pyconvert(Dict{String, Py}, value)
58
+ ConstraintSpec(;
59
+ eq=haskey(callbacks, "eq") ? vector_constraint(callbacks["eq"]) : nothing,
60
+ ineq=haskey(callbacks, "ineq") ? vector_constraint(callbacks["ineq"]) : nothing,
61
+ )
62
+ elseif name in (:parameter_priors, :fixed_parameters)
63
+ constructor = name == :parameter_priors ? ParameterPrior : FixedParameter
64
+ [constructor(Int(row[1]), row[2:end]...) for row in pyconvert(Vector{Vector{Float64}}, value)]
65
+ elseif name == :parameter_constraints
66
+ [ParameterConstraint(pyconvert(Vector{Int}, row[0]), vector(row[1]), matrix(row[2]))
67
+ for row in value]
68
+ elseif name == :parameter_names
69
+ pyconvert(Vector{String}, value)
70
+ elseif name == :initial_guesses
71
+ pyconvert(Vector{Vector{Float64}}, value)
72
+ elseif name == :jacobian
73
+ inplace ? mutating_model(value) : matrix_model(value)
74
+ elseif name == :x_derivative
75
+ vector_model(value)
76
+ elseif name == :gof
77
+ scalar_cost(value)
78
+ elseif name == :logprob
79
+ _TypedCallback{Vector{Float64}}((y, mu, p) -> pyconvert(Vector{Float64}, value(y, mu, p)))
80
+ elseif name in (:maxiters, :multistart, :nobs)
81
+ pyconvert(Int, value)
82
+ elseif name in (:inplace, :vectorized)
83
+ pyconvert(Bool, value)
84
+ else
85
+ pyconvert(Float64, value)
86
+ end
87
+ end
88
+ return result
89
+ end
90
+
91
+ """
92
+ Dispatch to existing Julia fits; the bridge owns conversion, never statistics.
93
+
94
+ `invokelatest` is a once-per-fit inference boundary: Python's dynamic argument
95
+ conversion must not infer every solver branch. The selected Julia fit then
96
+ specializes on concrete arrays and callbacks; its numerical loops stay native.
97
+ """
98
+ function run_fit(kind::String, callback::Py, x, y, p0, options)
99
+ kwargs = fit_keywords(options)
100
+ start = vector(p0)
101
+ kind == "custom" && return Base.invokelatest(fit_custom, scalar_cost(callback); p0=start, kwargs...)
102
+ kind == "unbinned" && return Base.invokelatest(fit_unbinned_model, density_model(callback, kwargs), vector(y); p0=start, kwargs...)
103
+ if kind == "extended_unbinned"
104
+ domain = vector(x)
105
+ length(domain) == 2 || throw(ArgumentError("domain must contain exactly two endpoints"))
106
+ return Base.invokelatest(fit_extended_unbinned_model, density_model(callback, kwargs), vector(y), Tuple(domain); p0=start, kwargs...)
107
+ end
108
+ kind == "histogram_density" && return Base.invokelatest(fit_histogram_density, density_model(callback, kwargs), vector(x), vector(y); p0=start, kwargs...)
109
+ kind in ("gaussian", "poisson", "histogram", "indexed", "likelihood") ||
110
+ throw(ArgumentError("unknown fit family: $kind"))
111
+ model = kind == "gaussian" && get(kwargs, :inplace, false) ? mutating_model(callback) : vector_model(callback)
112
+ fit_function = kind == "gaussian" ? fit_model :
113
+ kind == "poisson" ? fit_poisson_model :
114
+ kind == "indexed" ? fit_indexed_model :
115
+ kind == "likelihood" ? fit_likelihood_model : fit_histogram_model
116
+ return Base.invokelatest(fit_function, model, vector(x), vector(y); p0=start, kwargs...)
117
+ end
118
+
119
+ """Preserve the single global parameter map while converting dataset arrays once."""
120
+ function run_multi(callbacks, xs, ys, sigma, maps, p0, options)
121
+ models = [vector_model(f) for f in Py(callbacks)]
122
+ scales = [pyis(s, pybuiltins.None) ? nothing : vector(s) for s in Py(sigma)]
123
+ return Base.invokelatest(fit_multi_model, models, [vector(x) for x in Py(xs)], [vector(y) for y in Py(ys)];
124
+ p0=vector(p0), sigma_y=scales, parameter_map=pyconvert(Vector{Vector{Int}}, Py(maps)),
125
+ fit_keywords(options)...)
126
+ end
127
+
128
+ """Convert scalar records only; symbols become strings and missing counts become None."""
129
+ scalar_value(value) = value isa Symbol ? String(value) : ismissing(value) ? nothing : value
130
+ scalar_fields(record) = pydict(String(name) => scalar_value(getproperty(record, name)) for name in propertynames(record))
131
+
132
+ """Stored numerical checks, with parameter names instead of one-based indices."""
133
+ function numerical_values(diagnostics, names)
134
+ return pydict(warnings=pylist(diagnostics.warnings),
135
+ covariance_condition=diagnostics.covariance_condition, hessian_condition=diagnostics.hessian_condition,
136
+ active_bounds=pylist(names[diagnostics.active_bounds]),
137
+ findings=pylist(scalar_fields(f) for f in diagnostics.findings))
138
+ end
139
+
140
+ """Transfer actual core reports; Python never infers findings by parsing text."""
141
+ function diagnostic_values(report::DiagnosticReport, max_actions::Int=5)
142
+ dashboard = diagnostic_dashboard(report; max_actions)
143
+ return pydict(findings=pylist(scalar_fields(f) for f in report.findings),
144
+ summary=report.summary, status=String(dashboard.status),
145
+ severity_counts=pydict(String(k) => v for (k, v) in dashboard.severity_counts),
146
+ next_actions=pylist(dashboard.next_actions), text=diagnose_text(report),
147
+ dashboard_text=diagnostic_dashboard_text(dashboard))
148
+ end
149
+
150
+ """Return snapshots, retaining the Julia fit privately for later refits."""
151
+ function result_values(result, names)
152
+ labels = pyconvert(Vector{String}, Py(names))
153
+ return pydict(params=Py(result.params), stderr=Py(result.param_stderr),
154
+ covariance=Py(result.param_covariance), correlation=Py(result.param_correlation),
155
+ converged=result.converged, statistics=scalar_fields(result.stats),
156
+ options=scalar_fields(result.options), backend=String(result.backend),
157
+ iterations=scalar_value(result.iterations), message=result.message,
158
+ numerical_diagnostics=numerical_values(result.diagnostics, labels),
159
+ data=result isa FitResult ? pydict(x=Py(result.problem.x), y=Py(result.problem.y),
160
+ model_y=Py(result.model_y), residuals=Py(result.residuals),
161
+ weighted_residuals=Py(result.weighted_residuals), jacobian=Py(result.jacobian)) : pybuiltins.None)
162
+ end
163
+
164
+ function report_values(report::FitReport, names, sigdigits::Int)
165
+ return pydict(parameters=pylist(pydict(name=p.name, value=p.value, uncertainty=p.uncertainty,
166
+ uncertainty_minus=p.uncertainty_minus, uncertainty_plus=p.uncertainty_plus, fixed=p.fixed)
167
+ for p in report.parameters),
168
+ statistics=scalar_fields(report.statistics), covariance=Py(report.covariance),
169
+ correlation=Py(report.correlation), backend=String(report.backend), converged=report.converged,
170
+ iterations=scalar_value(report.iterations), message=report.message,
171
+ numerical_diagnostics=numerical_values(report.diagnostics, pyconvert(Vector{String}, Py(names))),
172
+ text=report_text(report; sigdigits))
173
+ end
174
+
175
+ function run_report(result, names, errors::String, threshold::Float64, npoints::Int, nsigma::Float64)
176
+ return fit_report(result; parameter_names=pyconvert(Vector{String}, Py(names)),
177
+ errors=Symbol(errors), profile_threshold=threshold, profile_npoints=npoints, profile_nsigma=nsigma)
178
+ end
179
+
180
+ result_diagnose(result, max_actions::Int) = diagnostic_values(diagnose(result), max_actions)
181
+ prediction(result, x, uncertainty::Bool) = predict(result, vector(x); uncertainty=uncertainty)
182
+
183
+ """Pass scan controls without recomputing profile costs in Python."""
184
+ function scan_keywords(options)
185
+ result = Dict{Symbol, Any}()
186
+ for (key, value) in pyconvert(Dict{String, Py}, Py(options))
187
+ name = Symbol(key)
188
+ result[name] = if name in (:values, :xvalues, :yvalues, :levels, :contour_levels)
189
+ vector(value)
190
+ elseif name == :on_failure
191
+ Symbol(pyconvert(String, value))
192
+ else
193
+ pyconvert(Any, value)
194
+ end
195
+ end
196
+ return result
197
+ end
198
+
199
+ run_profile(result, index::Int, options) = profile(result, index; scan_keywords(options)...)
200
+ run_contour(result, i::Int, j::Int, options) = contour(result, i, j; scan_keywords(options)...)
201
+ run_interval(result, index::Int, options) = profile_interval(result, index; scan_keywords(options)...)
202
+ function run_matrix(result, indices, names, options)
203
+ return profile_matrix(result; parameters=pyconvert(Vector{Int}, Py(indices)),
204
+ parameter_names=pyconvert(Vector{String}, Py(names)), scan_keywords(options)...)
205
+ end
206
+
207
+ profile_diagnostics(scan::ProfileResult, sigma::Real, tolerance::Real=0.25, max_actions::Int=5) = diagnostic_values(
208
+ isfinite(sigma) && sigma > 0 ? diagnose(scan; local_sigma=sigma, tolerance) : diagnose(scan; tolerance), max_actions)
209
+ contour_diagnostics(scan::ContourResult, center, covariance, tolerance::Real=0.5, max_actions::Int=5) = diagnostic_values(
210
+ diagnose(scan; local_center=vector(center), local_covariance=matrix(covariance), tolerance), max_actions)
211
+
212
+ """Keep core ordering and axis orientation while replacing indices with names."""
213
+ function matrix_values(result::ProfileMatrixResult)
214
+ labels = Dict(zip(result.parameters, result.parameter_names))
215
+ triage = profile_matrix_triage(result; include_ok=true)
216
+ return pydict(parameters=pylist(result.parameter_names),
217
+ best_values=Py(result.best_values), local_stderr=Py(result.local_stderr),
218
+ local_covariance=Py(result.local_covariance), local_correlation=Py(result.local_correlation),
219
+ profiles=pylist(pytuple((labels[i], Py(scan), diagnostic_values(result.profile_diagnostics[i])))
220
+ for (i, scan) in result.profiles),
221
+ contours=pylist(pytuple((labels[i], labels[j], Py(scan), diagnostic_values(result.contour_diagnostics[(i, j)])))
222
+ for ((i, j), scan) in result.contours),
223
+ panel_status=pydict(pytuple((labels[i], labels[j])) => String(status)
224
+ for ((i, j), status) in result.panel_status),
225
+ diagnostics=diagnostic_values(result.report),
226
+ triage=pylist(pydict(parameters=pytuple(row.parameter_names), status=String(row.status),
227
+ severity_counts=pydict(String(k) => v for (k, v) in row.severity_counts),
228
+ finding_codes=pylist(String.(row.finding_codes)), next_action=row.next_action) for row in triage))
229
+ end
230
+
231
+ plot_errors(result) = (ScientificFitting._xerror_for_plot(result.problem, result.params),
232
+ ScientificFitting._yerror_for_plot(result.problem, result.params))
233
+
234
+ diagnostic_data(result::FitResult, kind::String) = ScientificFitting._diagnostic_values(result, Symbol(kind))