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.
Files changed (41) hide show
  1. cost_core/__init__.py +5 -0
  2. cost_core/cer/__init__.py +45 -0
  3. cost_core/cer/diagnostics.py +235 -0
  4. cost_core/cer/model.py +575 -0
  5. cost_core/cli.py +400 -0
  6. cost_core/data_io.py +148 -0
  7. cost_core/fitting.py +860 -0
  8. cost_core/ingest/__init__.py +66 -0
  9. cost_core/ingest/crosswalk.py +293 -0
  10. cost_core/ingest/inflation.py +229 -0
  11. cost_core/ingest/pipeline.py +939 -0
  12. cost_core/learning_curve.py +1111 -0
  13. cost_core/lotmodel/__init__.py +72 -0
  14. cost_core/lotmodel/chartdata.py +158 -0
  15. cost_core/lotmodel/config.py +39 -0
  16. cost_core/lotmodel/engine.py +669 -0
  17. cost_core/lotmodel/enrich.py +657 -0
  18. cost_core/lotmodel/mathx.py +87 -0
  19. cost_core/lotmodel/models.py +398 -0
  20. cost_core/lotmodel/provenance.py +83 -0
  21. cost_core/lotmodel/summary.py +549 -0
  22. cost_core/lots.py +1345 -0
  23. cost_core/monte_carlo.py +1374 -0
  24. cost_core/program/__init__.py +68 -0
  25. cost_core/program/rollup.py +1061 -0
  26. cost_core/reporting/__init__.py +65 -0
  27. cost_core/reporting/assumptions.py +183 -0
  28. cost_core/reporting/charts.py +637 -0
  29. cost_core/reporting/lot_workbook.py +472 -0
  30. cost_core/reporting/pipeline.py +615 -0
  31. cost_core/reporting/program_workbook.py +300 -0
  32. cost_core/synth/__init__.py +62 -0
  33. cost_core/synth/generator.py +618 -0
  34. cost_core/synth/reports.py +488 -0
  35. cost_core/synth/spec.py +420 -0
  36. cost_core-1.0.1.dist-info/METADATA +634 -0
  37. cost_core-1.0.1.dist-info/RECORD +41 -0
  38. cost_core-1.0.1.dist-info/WHEEL +5 -0
  39. cost_core-1.0.1.dist-info/entry_points.txt +2 -0
  40. cost_core-1.0.1.dist-info/licenses/LICENSE +202 -0
  41. cost_core-1.0.1.dist-info/top_level.txt +1 -0
cost_core/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """cost_core - Cost estimating library: learning curves, CERs, risk and the lot cost engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "1.0.1"
@@ -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
+ )