Home Course Concepts About

Simple descriptive statistics

This notebook is an element of the free risk-engineering.org courseware. It can be distributed under the terms of the Creative Commons Attribution-ShareAlike licence.

Author: Eric Marsden eric.marsden@risk-engineering.org.


This notebook contains an introduction to use of Python and the NumPy library for simple descriptive statistics. See the associated course materials for background material and to download this content as a Jupyter/Python notebook.

In [1]:
import numpy
import pandas
import scipy.stats
import matplotlib.pyplot as plt
plt.style.use("bmh")
%config InlineBackend.figure_formats=["svg"]

import warnings
warnings.simplefilter(action="ignore", category=FutureWarning)

We will start by examining some data on fatigue life of strips of aluminium sheeting. The data is expressed in thousands of cycles until rupture. It compes from the article Birnbaum, Z. W. and Saunders, S. C. (1958), A statistical model for life-length of materials, Journal of the American Statistical Association, 53(281).

In [2]:
cycles = numpy.array([370, 1016, 1235, 1419, 1567, 1820, 706, 1018, 1238, 1420,
                          1578, 1868, 716, 1020, 1252, 1420, 1594, 1881, 746,
                          1055, 1258, 1450, 1602, 1890, 785, 1085, 1262, 1452,
                          1604, 1893, 797, 1102, 1269, 1475, 1608, 1895, 844,
                          1102, 1270, 1478, 1630, 1910, 855, 1108, 1290, 1481,
                          1642, 1923, 858, 1115, 1293, 1485, 1674, 1940, 886,
                          1120, 1300, 1502, 1730, 1945, 886, 1134, 1310, 1505,
                          1750, 2023, 930, 1140, 1313, 1513, 1750, 2100, 960,
                          1199, 1315, 1522, 1763, 2130, 988, 1200, 1330, 1522,
                          1768, 2215, 990, 1200, 1355, 1530, 1781, 2268, 1000,
                          1203, 1390, 1540, 1782, 2440, 1010, 1222, 1416, 1560,
                          1792])
In [3]:
cycles.mean()
Out[3]:
np.float64(1400.9108910891089)
In [4]:
numpy.median(cycles)
Out[4]:
np.float64(1416.0)
In [5]:
numpy.std(cycles)
Out[5]:
np.float64(389.3820211517677)
In [6]:
numpy.var(cycles)
Out[6]:
np.float64(151618.35839623565)

Let’s generate a few different plots to examine the distribution of the data.

In [7]:
# start with a histogram
plt.hist(cycles, alpha=0.5)
plt.xlabel("Cycles until failure");
No description has been provided for this image
In [8]:
# now a box and whisker plot. Note that there is an outlier point (the circle)
plt.boxplot(cycles)
plt.ylabel("Cycles until failure");
No description has been provided for this image
In [9]:
# Install the extra Python package called seaborn for statistical plots
%pip install seaborn
Requirement already satisfied: seaborn in /home/emarsden/tmp/RE-venv/lib/python3.14/site-packages (0.13.2)
Requirement already satisfied: numpy!=1.24.0,>=1.20 in /home/emarsden/tmp/RE-venv/lib/python3.14/site-packages (from seaborn) (2.5.2)
Requirement already satisfied: pandas>=1.2 in /home/emarsden/tmp/RE-venv/lib/python3.14/site-packages (from seaborn) (3.0.5)
Requirement already satisfied: matplotlib!=3.6.1,>=3.4 in /home/emarsden/tmp/RE-venv/lib/python3.14/site-packages (from seaborn) (3.11.1)
Requirement already satisfied: contourpy>=1.0.1 in /home/emarsden/tmp/RE-venv/lib/python3.14/site-packages (from matplotlib!=3.6.1,>=3.4->seaborn) (1.3.3)
Requirement already satisfied: cycler>=0.10 in /home/emarsden/tmp/RE-venv/lib/python3.14/site-packages (from matplotlib!=3.6.1,>=3.4->seaborn) (0.12.1)
Requirement already satisfied: fonttools>=4.28.2 in /home/emarsden/tmp/RE-venv/lib/python3.14/site-packages (from matplotlib!=3.6.1,>=3.4->seaborn) (4.63.0)
Requirement already satisfied: kiwisolver>=1.3.1 in /home/emarsden/tmp/RE-venv/lib/python3.14/site-packages (from matplotlib!=3.6.1,>=3.4->seaborn) (1.5.1)
Requirement already satisfied: packaging>=20.0 in /home/emarsden/tmp/RE-venv/lib/python3.14/site-packages (from matplotlib!=3.6.1,>=3.4->seaborn) (26.3)
Requirement already satisfied: pillow>=9 in /home/emarsden/tmp/RE-venv/lib/python3.14/site-packages (from matplotlib!=3.6.1,>=3.4->seaborn) (12.3.0)
Requirement already satisfied: pyparsing>=3 in /home/emarsden/tmp/RE-venv/lib/python3.14/site-packages (from matplotlib!=3.6.1,>=3.4->seaborn) (3.3.2)
Requirement already satisfied: python-dateutil>=2.7 in /home/emarsden/tmp/RE-venv/lib/python3.14/site-packages (from matplotlib!=3.6.1,>=3.4->seaborn) (2.9.0.post0)
Requirement already satisfied: six>=1.5 in /home/emarsden/tmp/RE-venv/lib/python3.14/site-packages (from python-dateutil>=2.7->matplotlib!=3.6.1,>=3.4->seaborn) (1.17.0)
Note: you may need to restart the kernel to use updated packages.
In [10]:
import seaborn as sns

sns.violinplot(cycles)
plt.xlabel("Cycles until failure");
No description has been provided for this image
In [11]:
# a barplot with confidence interval "error bars"
plt.figure(figsize=(8,2))    
sns.barplot(y=cycles, errorbar=("ci", 95), capsize=0.1)
plt.xlabel("Cycles until failure (95% CI)");
No description has been provided for this image

Exploring insecticide data

Let’s explore data collected by Beall in the 1940s concerning the effect of different insecticides on insects. The dataset gives the count of insects in agricultural experimental units treated with different insecticides.

Data comes from the article Beall, G., (1942), The Transformation of data from entomological field experiments, Biometrika, 29, 243–262. It’s available via the statsmodels package.

In [12]:
import statsmodels.api as sm
insect = sm.datasets.get_rdataset("InsectSprays").data
insect.head()
Out[12]:
count spray
0 10 A
1 7 A
2 20 A
3 14 A
4 14 A
In [13]:
# total number of observations
len(insect)
Out[13]:
72
In [14]:
# how many observations for each spray? groupby is a function provided by the pandas library.
insect.groupby("spray").count()
Out[14]:
count
spray
A 12
B 12
C 12
D 12
E 12
F 12

We want to analyze the relative effectiveness of the different insecticide sprays tested. A first level of analysis is to compare their means.

In [15]:
# relative effectiveness of each spray
insect.groupby("spray").mean()
Out[15]:
count
spray
A 14.500000
B 15.333333
C 2.083333
D 4.916667
E 3.500000
F 16.666667

However, relying only on the mean can provide us with a misleading impression of the relative effectiveness; we also want to know the variability for each spray. A violinplot is a good way of showing the distribution of each insecticide effectiveness, on a single plot.

In [16]:
sns.violinplot(data=insect, x="spray", y="count")
plt.ylabel("Remaining insects");
No description has been provided for this image

Assuming that we really want to kill all insects (indiscriminitely), spray C seems to be the option tested with the highest effectiveness.

Exploring material strength data

We will now examine a third dataset, material strength data collected by Vangel from US NIST.

In [17]:
vangel = pandas.read_csv("https://www.itl.nist.gov/div898/software/dataplot/data/VANGEL5.DAT", header=None, skiprows=25)
vangel = vangel.squeeze("columns")
vangel.head()
---------------------------------------------------------------------------
HTTPError                                 Traceback (most recent call last)
Cell In[17], line 1
----> 1 vangel = pandas.read_csv("https://www.itl.nist.gov/div898/software/dataplot/data/VANGEL5.DAT", header=None, skiprows=25)
      2 vangel = vangel.squeeze("columns")
      3 vangel.head()

File ~/tmp/RE-venv/lib/python3.14/site-packages/pandas/io/parsers/readers.py:873, in read_csv(filepath_or_buffer, sep, delimiter, header, names, index_col, usecols, dtype, engine, converters, true_values, false_values, skipinitialspace, skiprows, skipfooter, nrows, na_values, keep_default_na, na_filter, skip_blank_lines, parse_dates, date_format, dayfirst, cache_dates, iterator, chunksize, compression, thousands, decimal, lineterminator, quotechar, quoting, doublequote, escapechar, comment, encoding, encoding_errors, dialect, on_bad_lines, low_memory, memory_map, float_precision, storage_options, dtype_backend)
    861 kwds_defaults = _refine_defaults_read(
    862     dialect,
    863     delimiter,
   (...)    869     dtype_backend=dtype_backend,
    870 )
    871 kwds.update(kwds_defaults)
--> 873 return _read(filepath_or_buffer, kwds)

File ~/tmp/RE-venv/lib/python3.14/site-packages/pandas/io/parsers/readers.py:300, in _read(filepath_or_buffer, kwds)
    297 _validate_names(kwds.get("names", None))
    299 # Create the parser.
--> 300 parser = TextFileReader(filepath_or_buffer, **kwds)
    302 if chunksize or iterator:
    303     return parser

File ~/tmp/RE-venv/lib/python3.14/site-packages/pandas/io/parsers/readers.py:1645, in TextFileReader.__init__(self, f, engine, **kwds)
   1642     self.options["has_index_names"] = kwds["has_index_names"]
   1644 self.handles: IOHandles | None = None
-> 1645 self._engine = self._make_engine(f, self.engine)

File ~/tmp/RE-venv/lib/python3.14/site-packages/pandas/io/parsers/readers.py:1904, in TextFileReader._make_engine(self, f, engine)
   1902     if "b" not in mode:
   1903         mode += "b"
-> 1904 self.handles = get_handle(
   1905     f,
   1906     mode,
   1907     encoding=self.options.get("encoding", None),
   1908     compression=self.options.get("compression", None),
   1909     memory_map=self.options.get("memory_map", False),
   1910     is_text=is_text,
   1911     errors=self.options.get("encoding_errors", "strict"),
   1912     storage_options=self.options.get("storage_options", None),
   1913 )
   1914 assert self.handles is not None
   1915 f = self.handles.handle

File ~/tmp/RE-venv/lib/python3.14/site-packages/pandas/io/common.py:776, in get_handle(path_or_buf, mode, encoding, compression, memory_map, is_text, errors, storage_options)
    773     codecs.lookup_error(errors)
    775 # open URLs
--> 776 ioargs = _get_filepath_or_buffer(
    777     path_or_buf,
    778     encoding=encoding,
    779     compression=compression,
    780     mode=mode,
    781     storage_options=storage_options,
    782 )
    784 handle = ioargs.filepath_or_buffer
    785 handles: list[BaseBuffer]

File ~/tmp/RE-venv/lib/python3.14/site-packages/pandas/io/common.py:405, in _get_filepath_or_buffer(filepath_or_buffer, encoding, compression, mode, storage_options)
    403 # assuming storage_options is to be interpreted as headers
    404 req_info = urllib.request.Request(filepath_or_buffer, headers=storage_options)
--> 405 with urlopen(req_info) as req:
    406     content_encoding = req.headers.get("Content-Encoding", None)
    407     if content_encoding == "gzip":
    408         # Override compression based on Content-Encoding header

File ~/tmp/RE-venv/lib/python3.14/site-packages/pandas/io/common.py:282, in urlopen(*args, **kwargs)
    276 """
    277 Lazy-import wrapper for stdlib urlopen, as that imports a big chunk of
    278 the stdlib.
    279 """
    280 import urllib.request
--> 282 return urllib.request.urlopen(*args, **kwargs)

File /usr/lib/python3.14/urllib/request.py:187, in urlopen(url, data, timeout, context)
    185 else:
    186     opener = _opener
--> 187 return opener.open(url, data, timeout)

File /usr/lib/python3.14/urllib/request.py:493, in OpenerDirector.open(self, fullurl, data, timeout)
    491 for processor in self.process_response.get(protocol, []):
    492     meth = getattr(processor, meth_name)
--> 493     response = meth(req, response)
    495 return response

File /usr/lib/python3.14/urllib/request.py:602, in HTTPErrorProcessor.http_response(self, request, response)
    599 # According to RFC 2616, "2xx" code indicates that the client's
    600 # request was successfully received, understood, and accepted.
    601 if not (200 <= code < 300):
--> 602     response = self.parent.error(
    603         'http', request, response, code, msg, hdrs)
    605 return response

File /usr/lib/python3.14/urllib/request.py:531, in OpenerDirector.error(self, proto, *args)
    529 if http_err:
    530     args = (dict, 'default', 'http_error_default') + orig_args
--> 531     return self._call_chain(*args)

File /usr/lib/python3.14/urllib/request.py:464, in OpenerDirector._call_chain(self, chain, kind, meth_name, *args)
    462 for handler in handlers:
    463     func = getattr(handler, meth_name)
--> 464     result = func(*args)
    465     if result is not None:
    466         return result

File /usr/lib/python3.14/urllib/request.py:611, in HTTPDefaultErrorHandler.http_error_default(self, req, fp, code, msg, hdrs)
    610 def http_error_default(self, req, fp, code, msg, hdrs):
--> 611     raise HTTPError(req.full_url, code, msg, hdrs, fp)

HTTPError: HTTP Error 403: Forbidden
In [ ]:
plt.hist(vangel, density=True, alpha=0.5)
plt.title("Vangel material data (n={})".format(len(vangel)))
plt.xlabel("Specimen strength");

Let's try to fit a Weibull distribution to this data.

In [ ]:
plt.hist(vangel, density=True, alpha=0.5)
shape, loc, scale = scipy.stats.weibull_min.fit(vangel, floc=0)
support = numpy.linspace(vangel.min(), vangel.max(), 100)
plt.plot(support, scipy.stats.weibull_min(shape, loc, scale).pdf(support), lw=3)
plt.title("Weibull fit on Vangel data")
plt.xlabel("Specimen strength");

Sometimes it’s clearer to plot the cumulative failure-intensity function (the empirical CDF of our dataset).

In [ ]:
import statsmodels.distributions

ecdf = statsmodels.distributions.ECDF(vangel)
plt.plot(support, ecdf(support), label="Empirical CDF")
plt.plot(support, scipy.stats.weibull_min(shape,loc,scale).cdf(support), lw=3, label="Weibull fit")
plt.title("Vangel cumulative failure intensity")
plt.xlabel("Specimen strength")
plt.legend();

A good way of assessing goodness of fit, in particular in the distribution tails, is to generate a probability plot.

In [ ]:
scipy.stats.probplot(vangel, \
                     dist=scipy.stats.weibull_min(shape,loc,scale),\
                     plot=plt.figure().add_subplot(111))
plt.title("Weibull prob-plot of Vangel data");

This data follows a Weibull distribution quite well.

If we wish to estimate a particular quantile measure for this data, we can do that on the fitted distribution.

In [ ]:
# the first percentile
scipy.stats.weibull_min(shape,loc,scale).ppf(0.01)
In [ ]: