What this article contains
A complete fit, from raw sample to conclusion, with nothing hidden. Every number below was produced by the script at the end of this page running against Phitter 1.0.4, NumPy 2.4 and Python 3.12. Run it yourself and you will get the same values — the sample is seeded.
The example is deliberately built so that the correct answer is known in advance. That makes it possible to check not just whether the fit works, but whether the ranking it produces means what a first reading suggests.
Step 1 — the dataset
The sample stands in for repair times: 500 observations, strictly positive, right-skewed. It is drawn from a lognormal distribution with mean=1.2 and sigma=0.55, so the true answer is known.
import numpy as np
rng = np.random.default_rng(42)
data = rng.lognormal(mean=1.2, sigma=0.55, size=500)
np.savetxt("repair_times.csv", data, fmt="%.4f",
header="repair_time_hours", comments="")
The resulting repair_times.csv is a single column with a header — the shape Phitter Web accepts directly.
Step 2 — look before fitting
| Measure | Value |
|---|---|
| n | 500 |
| Mean | 3.7963 |
| Standard deviation | 2.2013 |
| Minimum | 0.8092 |
| Median | 3.3260 |
| Maximum | 16.4879 |
| Skewness | 1.9242 |
Three things are already decided by this table. The data are continuous and strictly positive, so any candidate whose support includes negative values is wrong regardless of its score. The skewness of 1.92 rules out symmetric models. And the mean sits well above the median, the signature of a right tail that will dominate any simulation built on this sample.
Step 3 — the fit
import phitter
phi = phitter.Phitter(data=data, fit_type="continuous",
num_bins=20, confidence_level=0.95)
phi.fit(n_workers=4)
One practical note for Windows users: n_workers greater than 1 starts a process pool, and on Windows the child processes re-import the calling module. If the fit runs at module level the pool dies with BrokenProcessPool. Wrap the call in if __name__ == "__main__": — or use n_workers=1.
Phitter fitted 63 continuous distributions to this sample.
Step 4 — the ranking
These are the top rows of phi.df_sorted_distributions, unedited:
| Distribution | AIC | BIC | KS | KS p | AD | AD p | χ² p |
|---|---|---|---|---|---|---|---|
| inverse_gaussian | 1976.32 | 1984.75 | 0.0352 | 0.5541 | 0.5356 | 0.7107 | 0.0832 |
| lognormal | 1976.43 | 1984.86 | 0.0305 | 0.7273 | 0.3296 | 0.9143 | 0.1678 |
| inverse_gamma_3p | 1978.62 | 1991.27 | 0.0281 | 0.8149 | 0.3105 | 0.9301 | 0.3054 |
| beta_prime | 1983.41 | 1991.84 | 0.0418 | 0.3373 | 1.3122 | 0.2284 | 0.0619 |
| frechet | 1980.44 | 1993.08 | 0.0299 | 0.7527 | 0.3453 | 0.9006 | 0.3309 |
| burr_4p | 1976.44 | 1993.30 | 0.0266 | 0.8612 | 0.2124 | 0.9866 | 0.1856 |
The data were generated from a lognormal. The lognormal came second.
This is the part of the example worth sitting with. The first-ranked model is not wrong — but neither is it the answer. The AIC gap between first and second place is 0.11, on values near 1976. That is not a difference; it is noise. And on all three goodness-of-fit statistics the second-place model scores better than the first.
The ordering is not a mistake either. Phitter sorts by information criteria, which trade fit against parameter count; the test statistics measure something else. Reading the table as a leaderboard hides that distinction. Reading a ranking properly takes up this problem in full.
Step 5 — check the parameters
phi.sorted_distributions["lognormal"]["parameters"]
# {'mu': 1.1891, 'sigma': 0.5384}
The generating parameters were mu=1.2 and sigma=0.55. Estimation recovered them to within about 1% and 2%. This is the check that matters most and the one most often skipped: a model whose parameters are implausible for the process you are describing is not rescued by a good score.
Step 6 — check the diagnostics, not just the numbers
Twenty of the 63 fitted distributions were rejected by none of the three tests at the 95% level. That is the single most useful number in this entire run. “Passes the tests” is not a distinguishing property here — it is the common case. With n=500 the tests simply lack the power to separate models that differ only in their tails.
Plots do work that the statistics cannot:
phi.plot_histogram_distributions() # shape agreement in the body
phi.qq_plot_regression() # where the model departs, and in which direction
phi.plot_ecdf_distribution() # cumulative agreement
The Q–Q plot is the one to read carefully. It shows where a model fails, which the single-number statistics cannot.
What to record
For a fit to be reproducible six months later, keep: the Phitter version, the raw sample or the seed that generates it, fit_type, num_bins, confidence_level, the candidate list if you restricted it, and the full ranking table rather than the winner alone. num_bins in particular is worth writing down — it changes the chi-square result, and chi-square is the only one of the three tests that depends on it.
The complete script
import numpy as np
import phitter
def main():
rng = np.random.default_rng(42)
data = rng.lognormal(mean=1.2, sigma=0.55, size=500)
np.savetxt("repair_times.csv", data, fmt="%.4f",
header="repair_time_hours", comments="")
phi = phitter.Phitter(data=data, fit_type="continuous",
num_bins=20, confidence_level=0.95)
phi.fit(n_workers=4)
print(phi.best_distribution)
print(phi.df_sorted_distributions.head(10))
print(len(phi.df_not_rejected_distributions), "not rejected")
if __name__ == "__main__":
main()
To run the same analysis without installing anything, paste the CSV into Phitter Web.