cost-core 1.0.1__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.
- cost_core/__init__.py +5 -0
- cost_core/cer/__init__.py +45 -0
- cost_core/cer/diagnostics.py +235 -0
- cost_core/cer/model.py +575 -0
- cost_core/cli.py +400 -0
- cost_core/data_io.py +148 -0
- cost_core/fitting.py +860 -0
- cost_core/ingest/__init__.py +66 -0
- cost_core/ingest/crosswalk.py +293 -0
- cost_core/ingest/inflation.py +229 -0
- cost_core/ingest/pipeline.py +939 -0
- cost_core/learning_curve.py +1111 -0
- cost_core/lotmodel/__init__.py +72 -0
- cost_core/lotmodel/chartdata.py +158 -0
- cost_core/lotmodel/config.py +39 -0
- cost_core/lotmodel/engine.py +669 -0
- cost_core/lotmodel/enrich.py +657 -0
- cost_core/lotmodel/mathx.py +87 -0
- cost_core/lotmodel/models.py +398 -0
- cost_core/lotmodel/provenance.py +83 -0
- cost_core/lotmodel/summary.py +549 -0
- cost_core/lots.py +1345 -0
- cost_core/monte_carlo.py +1374 -0
- cost_core/program/__init__.py +68 -0
- cost_core/program/rollup.py +1061 -0
- cost_core/reporting/__init__.py +65 -0
- cost_core/reporting/assumptions.py +183 -0
- cost_core/reporting/charts.py +637 -0
- cost_core/reporting/lot_workbook.py +472 -0
- cost_core/reporting/pipeline.py +615 -0
- cost_core/reporting/program_workbook.py +300 -0
- cost_core/synth/__init__.py +62 -0
- cost_core/synth/generator.py +618 -0
- cost_core/synth/reports.py +488 -0
- cost_core/synth/spec.py +420 -0
- cost_core-1.0.1.dist-info/METADATA +634 -0
- cost_core-1.0.1.dist-info/RECORD +41 -0
- cost_core-1.0.1.dist-info/WHEEL +5 -0
- cost_core-1.0.1.dist-info/entry_points.txt +2 -0
- cost_core-1.0.1.dist-info/licenses/LICENSE +202 -0
- cost_core-1.0.1.dist-info/top_level.txt +1 -0
cost_core/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cost_core.cer - Parametric cost estimating relationships.
|
|
3
|
+
|
|
4
|
+
Fits cost against technical drivers across a set of past programs, in either
|
|
5
|
+
of the two forms in common use, by any of three methods, with the diagnostics
|
|
6
|
+
and guardrails a small sample demands.
|
|
7
|
+
|
|
8
|
+
Typical use::
|
|
9
|
+
|
|
10
|
+
from cost_core.cer import fit_cer
|
|
11
|
+
|
|
12
|
+
cer = fit_cer(
|
|
13
|
+
programs, response="t1_cost", predictors=["empty_weight_lb"],
|
|
14
|
+
form="log_log", method="mupe",
|
|
15
|
+
)
|
|
16
|
+
cer.equation()
|
|
17
|
+
cer.predict({"empty_weight_lb": [31_000]}, kind="prediction", level=0.80)
|
|
18
|
+
cer.diagnostics().to_frame()
|
|
19
|
+
|
|
20
|
+
Two things this module insists on. The interval is a *prediction* interval by
|
|
21
|
+
default, because a cost estimate forecasts one new program rather than the
|
|
22
|
+
mean of the fitted line, and the two differ by exactly the residual scatter.
|
|
23
|
+
And a prediction outside the range of the fitting data raises
|
|
24
|
+
:class:`ExtrapolationWarning` -- including hidden extrapolation, where every
|
|
25
|
+
individual predictor is in range but their combination is not.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from cost_core.cer.diagnostics import Diagnostics, compute_diagnostics
|
|
29
|
+
from cost_core.cer.model import (CER, MIN_OBS_PER_PARAM, ExtrapolationWarning,
|
|
30
|
+
Form, cer_comparison_table,
|
|
31
|
+
compare_cer_forms, compare_cer_methods,
|
|
32
|
+
fit_cer)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"CER",
|
|
36
|
+
"Diagnostics",
|
|
37
|
+
"ExtrapolationWarning",
|
|
38
|
+
"Form",
|
|
39
|
+
"MIN_OBS_PER_PARAM",
|
|
40
|
+
"cer_comparison_table",
|
|
41
|
+
"compare_cer_forms",
|
|
42
|
+
"compare_cer_methods",
|
|
43
|
+
"compute_diagnostics",
|
|
44
|
+
"fit_cer",
|
|
45
|
+
]
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
diagnostics.py - Residual, leverage and influence diagnostics for a CER.
|
|
3
|
+
|
|
4
|
+
A CER is usually fitted to somewhere between six and twenty programs. At that
|
|
5
|
+
size a single unusual program can set the slope on its own, and the summary
|
|
6
|
+
statistics will not say so: the R^2 stays high, the standard error stays
|
|
7
|
+
plausible, and the one point doing all the work is invisible. Leverage and
|
|
8
|
+
influence are the diagnostics that find it.
|
|
9
|
+
|
|
10
|
+
Three quantities, all computed on the scale the fit actually minimised on --
|
|
11
|
+
log space for a log-log fit, percentage-error space for MUPE and ZMPE, level
|
|
12
|
+
space for a linear OLS:
|
|
13
|
+
|
|
14
|
+
**Leverage** (``h_ii``) is how unusual observation *i* is in the *predictor*
|
|
15
|
+
space, ignoring its response entirely. It is bounded in [0, 1] and the
|
|
16
|
+
leverages sum to exactly the number of parameters, which is asserted in the
|
|
17
|
+
tests -- it falls straight out of the hat matrix being a projection.
|
|
18
|
+
|
|
19
|
+
**Cook's distance** combines leverage with the size of the residual: how much
|
|
20
|
+
the whole fitted surface moves if observation *i* is dropped. Computed in
|
|
21
|
+
closed form, and the tests check it against an actual leave-one-out refit,
|
|
22
|
+
because the closed form is exact and there is no reason to accept an
|
|
23
|
+
approximation of something verifiable.
|
|
24
|
+
|
|
25
|
+
**DFFITS** is the same idea scaled to a single fitted value. It needs a
|
|
26
|
+
leave-one-out residual scale, which does not exist below two degrees of
|
|
27
|
+
freedom, so it is reported as zero there.
|
|
28
|
+
|
|
29
|
+
The conventional thresholds (``2p/n`` for leverage, ``4/n`` for Cook's D) are
|
|
30
|
+
flags, not verdicts. A high-leverage program is often the most informative one
|
|
31
|
+
in the sample -- the biggest airframe in a weight-based CER has high leverage
|
|
32
|
+
by construction and dropping it would be indefensible. The diagnostics exist to
|
|
33
|
+
make sure that judgement gets made deliberately.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import logging
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
|
|
41
|
+
import numpy as np
|
|
42
|
+
import pandas as pd
|
|
43
|
+
|
|
44
|
+
from cost_core import fitting
|
|
45
|
+
from cost_core.fitting import FitResult
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Diagnostics:
|
|
52
|
+
"""Per-observation regression diagnostics.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
residuals: Residuals on the fitting scale.
|
|
56
|
+
standardized_residuals: Residuals divided by their own standard error,
|
|
57
|
+
which varies with leverage -- a high-leverage point pulls the line
|
|
58
|
+
toward itself and so has an artificially small raw residual.
|
|
59
|
+
leverage: ``h_ii``, the diagonal of the hat matrix.
|
|
60
|
+
cooks_distance: Influence on the whole fitted surface.
|
|
61
|
+
dffits: Influence on the observation's own fitted value.
|
|
62
|
+
labels: Row labels, typically program names.
|
|
63
|
+
n_obs, n_params, df: Sizes.
|
|
64
|
+
sigma: Residual scale on the fitting scale.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
residuals: np.ndarray
|
|
68
|
+
standardized_residuals: np.ndarray
|
|
69
|
+
leverage: np.ndarray
|
|
70
|
+
cooks_distance: np.ndarray
|
|
71
|
+
dffits: np.ndarray
|
|
72
|
+
labels: tuple[str, ...]
|
|
73
|
+
n_obs: int
|
|
74
|
+
n_params: int
|
|
75
|
+
df: int
|
|
76
|
+
sigma: float
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------ thresholds
|
|
79
|
+
@property
|
|
80
|
+
def leverage_threshold(self) -> float:
|
|
81
|
+
"""``2p/n``: the conventional flag for an unusual predictor value."""
|
|
82
|
+
return 2.0 * self.n_params / self.n_obs
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def cooks_threshold(self) -> float:
|
|
86
|
+
"""``4/n``: the conventional flag for an influential observation."""
|
|
87
|
+
return 4.0 / self.n_obs
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def high_leverage(self) -> list[str]:
|
|
91
|
+
return [
|
|
92
|
+
self.labels[i]
|
|
93
|
+
for i in np.flatnonzero(self.leverage > self.leverage_threshold)
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def influential(self) -> list[str]:
|
|
98
|
+
return [
|
|
99
|
+
self.labels[i]
|
|
100
|
+
for i in np.flatnonzero(self.cooks_distance > self.cooks_threshold)
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
def to_frame(self) -> pd.DataFrame:
|
|
104
|
+
"""One row per observation, sorted by influence."""
|
|
105
|
+
frame = pd.DataFrame(
|
|
106
|
+
{
|
|
107
|
+
"observation": list(self.labels),
|
|
108
|
+
"residual": self.residuals,
|
|
109
|
+
"std_residual": self.standardized_residuals,
|
|
110
|
+
"leverage": self.leverage,
|
|
111
|
+
"cooks_distance": self.cooks_distance,
|
|
112
|
+
"dffits": self.dffits,
|
|
113
|
+
"high_leverage": self.leverage > self.leverage_threshold,
|
|
114
|
+
"influential": self.cooks_distance > self.cooks_threshold,
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
return frame.sort_values("cooks_distance", ascending=False).reset_index(
|
|
118
|
+
drop=True
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def narrative(self) -> str:
|
|
122
|
+
"""A sentence for the assumptions log."""
|
|
123
|
+
parts = [
|
|
124
|
+
f"{self.n_obs} observations, {self.n_params} parameters, "
|
|
125
|
+
f"{self.df} degrees of freedom."
|
|
126
|
+
]
|
|
127
|
+
if self.high_leverage:
|
|
128
|
+
parts.append(
|
|
129
|
+
f"High leverage (>{self.leverage_threshold:.2f}): "
|
|
130
|
+
f"{', '.join(self.high_leverage)}."
|
|
131
|
+
)
|
|
132
|
+
if self.influential:
|
|
133
|
+
parts.append(
|
|
134
|
+
f"Influential (Cook's D >{self.cooks_threshold:.2f}): "
|
|
135
|
+
f"{', '.join(self.influential)}. These set the fit; confirm "
|
|
136
|
+
f"each belongs in the sample before relying on it."
|
|
137
|
+
)
|
|
138
|
+
if not self.high_leverage and not self.influential:
|
|
139
|
+
parts.append("No observation exceeds the conventional flags.")
|
|
140
|
+
return " ".join(parts)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def compute_diagnostics(
|
|
144
|
+
result: FitResult, labels: list[str] | tuple[str, ...] | None = None
|
|
145
|
+
) -> Diagnostics:
|
|
146
|
+
"""Compute leverage and influence for a fit.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
result: Any fit from :mod:`cost_core.fitting`.
|
|
150
|
+
labels: Row labels, usually program names. Defaults to indices.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
ValueError: If ``labels`` does not match the number of observations.
|
|
154
|
+
"""
|
|
155
|
+
n, p = result.n_obs, result.n_params
|
|
156
|
+
if labels is None:
|
|
157
|
+
labels = tuple(str(i) for i in range(n))
|
|
158
|
+
elif len(labels) != n:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"Got {len(labels)} labels for {n} observations."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
jac = fitting.design_matrix(result)
|
|
164
|
+
residuals = fitting.fitting_residuals(result)
|
|
165
|
+
|
|
166
|
+
# Hat matrix diagonal, via the pseudo-inverse so a rank-deficient design
|
|
167
|
+
# still reports rather than raising.
|
|
168
|
+
gram_inv = np.linalg.pinv(jac.T @ jac)
|
|
169
|
+
leverage = np.einsum("ij,jk,ik->i", jac, gram_inv, jac)
|
|
170
|
+
leverage = np.clip(leverage, 0.0, 1.0)
|
|
171
|
+
|
|
172
|
+
sigma = result.sigma
|
|
173
|
+
one_minus_h = np.maximum(1.0 - leverage, 1e-12)
|
|
174
|
+
|
|
175
|
+
# A fit that passes exactly through every point -- noiseless synthetic
|
|
176
|
+
# data, or an interpolating model -- has residuals and a sigma that are
|
|
177
|
+
# both at the floating-point floor. Standardising one by the other is 0/0,
|
|
178
|
+
# and the O(1) garbage it produces would be flagged as influential. The
|
|
179
|
+
# honest answer is that a perfect fit has no influential observations, so
|
|
180
|
+
# the residual-based diagnostics are reported as zero. Leverage is
|
|
181
|
+
# unaffected: it depends only on the predictors.
|
|
182
|
+
scale = float(np.max(np.abs(fitting.design_matrix(result) @ result.theta)))
|
|
183
|
+
degenerate = sigma <= 1e-10 * max(scale, 1.0)
|
|
184
|
+
if degenerate:
|
|
185
|
+
logger.info(
|
|
186
|
+
"Residual scale is at the floating-point floor (sigma=%.3g); the "
|
|
187
|
+
"fit is effectively exact, so influence measures are reported as "
|
|
188
|
+
"zero rather than as ratios of rounding error.",
|
|
189
|
+
sigma,
|
|
190
|
+
)
|
|
191
|
+
standardized = np.zeros_like(residuals)
|
|
192
|
+
cooks = np.zeros_like(residuals)
|
|
193
|
+
else:
|
|
194
|
+
standardized = residuals / (sigma * np.sqrt(one_minus_h))
|
|
195
|
+
cooks = (standardized**2 / p) * (leverage / one_minus_h)
|
|
196
|
+
|
|
197
|
+
# DFFITS uses the leave-one-out sigma, which is available in closed form.
|
|
198
|
+
# At one degree of freedom that closed form is 0/0: dropping the single
|
|
199
|
+
# spare observation leaves nothing to estimate a residual scale from, so
|
|
200
|
+
# the numerator and the denominator are both at the floating-point floor
|
|
201
|
+
# and their ratio is whatever the last bits happen to say. Perturbing the
|
|
202
|
+
# design by 1e-13 flips those values by order one while every other
|
|
203
|
+
# diagnostic stays put. There is no information in the ratio, so it is
|
|
204
|
+
# reported as zero rather than as an amplified rounding error.
|
|
205
|
+
if degenerate or result.df < 2:
|
|
206
|
+
dffits = np.zeros_like(residuals)
|
|
207
|
+
else:
|
|
208
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
209
|
+
s_minus_i = np.sqrt(
|
|
210
|
+
np.maximum(
|
|
211
|
+
(result.df * sigma**2 - residuals**2 / one_minus_h)
|
|
212
|
+
/ max(result.df - 1, 1),
|
|
213
|
+
0.0,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
dffits = np.where(
|
|
217
|
+
s_minus_i > 0,
|
|
218
|
+
residuals
|
|
219
|
+
/ (s_minus_i * np.sqrt(one_minus_h))
|
|
220
|
+
* np.sqrt(leverage / one_minus_h),
|
|
221
|
+
0.0,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return Diagnostics(
|
|
225
|
+
residuals=residuals,
|
|
226
|
+
standardized_residuals=standardized,
|
|
227
|
+
leverage=leverage,
|
|
228
|
+
cooks_distance=cooks,
|
|
229
|
+
dffits=np.nan_to_num(dffits),
|
|
230
|
+
labels=tuple(str(x) for x in labels),
|
|
231
|
+
n_obs=n,
|
|
232
|
+
n_params=p,
|
|
233
|
+
df=result.df,
|
|
234
|
+
sigma=sigma,
|
|
235
|
+
)
|