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.
- scientificfitting/__init__.py +34 -0
- scientificfitting/_bridge.jl +234 -0
- scientificfitting/_core.py +463 -0
- scientificfitting/_diagnostic_plots.py +302 -0
- scientificfitting/_inputs.py +120 -0
- scientificfitting/_plotting.py +171 -0
- scientificfitting/_results.py +263 -0
- scientificfitting/_runtime.py +21 -0
- scientificfitting/juliapkg.json +9 -0
- scientificfitting-0.2.0.dist-info/METADATA +86 -0
- scientificfitting-0.2.0.dist-info/RECORD +14 -0
- scientificfitting-0.2.0.dist-info/WHEEL +5 -0
- scientificfitting-0.2.0.dist-info/licenses/LICENSE +21 -0
- scientificfitting-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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))
|