Dictionary of Applied Machine Learning · inverse

inverse — Python demo

Numerical companion to the entry inverse: it recomputes what the entry states and prints one line per check

Checks the entry's claims about inverses in the three settings it walks through: a function on a finite domain (the four shapes of its Fig. 1), the sigmoid and the logit of its opening paragraph, and the inverse of a linear map together with what replaces it when none exists. A last block draws a realization through the inverse of a cumulative distribution function. Self-contained (numpy/matplotlib only), fixed seed, no downloads.

Run it without installing anything:
uv run https://dictionaryofml.org/terms/inverse.py
uv downloads this script and the pinned NumPy and Matplotlib it needs, then runs it; the script fetches any input file it uses. To keep the output files, download inverse.py into a folder and run uv run inverse.py there. With NumPy and Matplotlib already installed, python3 inverse.py, from any directory — it writes its output files into the current directory. Fixed seeds, so the printed numbers reproduce exactly. Download inverse.py · Notebook · Open in Colab

The script, block by block

One cell per block of the script: the code, and what that code printed when it last ran here

setup

"""
inverse.py — numerical companion to the glossary entry 'inverse'.

Purpose
-------
Checks the entry's claims about inverses in the three settings it walks
through: a function on a finite domain (the four shapes of its Fig. 1),
the sigmoid and the logit of its opening paragraph, and the inverse of a
linear map together with what replaces it when none exists. A last block
draws a realization through the inverse of a cumulative distribution
function. Self-contained (numpy/matplotlib only), fixed seed, no
downloads.

Blocks
------
[B-shapes]  The two functions of Fig. 1 on the same four shapes. Sending
            a shape to its number of corners is injective and lands on
            every value of {0, 3, 4, 5}, so reading the table backwards
            defines the inverse and recovers every shape. Recording only
            whether a shape has a straight edge sends three of the four
            shapes to "yes", so no table undoes it: the value "yes" has
            three preimages.
[B-sigmoid] The logit undoes the sigmoid and the sigmoid undoes the
            logit, both to within numerical tolerance over a grid, and the two
            numbers the entry states hold: a score of 0 becomes the
            probability 0.5, a score of 2.2 becomes 0.9.
[B-matrix]  A square matrix of full rank: its inverse satisfies
            A A^-1 = A^-1 A = I and its determinant is nonzero. A square
            matrix whose columns are not linearly independent:
            determinant zero, no inverse,
            and the pseudoinverse satisfies A A^+ A = A while A^+ A is
            not the identity. A matrix with more rows than columns, of full column
            rank: a left inverse exists (A^+ A = I) and no right inverse does
            (A A^+ is not the identity), which is the one-sided case the
            entry describes.
[B-cdf]     Inverse-CDF sampling: applying the inverse of the standard
            Gaussian cdf to draws from the uniform distribution on [0,1]
            returns a standard Gaussian sample, checked by its mean, its
            spread, and the largest gap between its
            empirical cdf and the target.

Outputs
-------
inverse.png : preview figure (checking only), two panels: the sigmoid
              and the logit as reflections of each other in the line
              y = x, and the empirical cdf of the inverse-cdf sample (thin
              dashed) against the target cdf (thick gray).

Data generated by pythondemos/inverse.py.
"""

import math

import numpy as np
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from pathlib import Path

OUT_DIR = Path(__file__).parent
SEED = 20261029
NR_DRAWS = 20000
report = []


def check(name, ok):
    report.append((name, bool(ok)))
    print(f"  [{'ok' if ok else 'FAIL'}] {name}")


def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-np.asarray(z, dtype=float)))


def logit(p):
    p = np.asarray(p, dtype=float)
    return np.log(p / (1.0 - p))


def gaussian_cdf(z):
    """The standard Gaussian cdf, from the error function of the stdlib."""
    return 0.5 * (1.0 + np.vectorize(math.erf)(np.asarray(z, dtype=float)
                                               / math.sqrt(2.0)))


# the grid that represents the cdf and, read the other way round, its inverse
Z_GRID = np.linspace(-8.0, 8.0, 4001)
CDF_GRID = gaussian_cdf(Z_GRID)


def gaussian_cdf_inverse(u):
    """The inverse of the Gaussian cdf, read off the same grid."""
    return np.interp(np.asarray(u, dtype=float), CDF_GRID, Z_GRID)

B-shapes

The two functions of Fig. 1 on the same four shapes. Sending a shape to its number of corners is injective and lands on every value of {0, 3, 4, 5}, so reading the table backwards defines the inverse and recovers every shape. Recording only whether a shape has a straight edge sends three of the four shapes to "yes", so no table undoes it: the value "yes" has three preimages.

SHAPES = ["circle", "triangle", "square", "pentagon"]
corners = {"circle": 0, "triangle": 3, "square": 4, "pentagon": 5}
straight_edge = {"circle": "no", "triangle": "yes", "square": "yes",
                 "pentagon": "yes"}
values = [corners[u] for u in SHAPES]
print(f"[B-shapes] corners: {corners}")
check("[B-shapes]  the number of corners is a different value for each of "
      "the four shapes", len(set(values)) == len(SHAPES))
corners_inverse = {v: u for u, v in corners.items()}
check("[B-shapes]  reading that table backwards is a function on "
      "{0, 3, 4, 5} and recovers every shape",
      len(corners_inverse) == len(SHAPES)
      and all(corners_inverse[corners[u]] == u for u in SHAPES))
check("[B-shapes]  and it is undone in the other direction too",
      all(corners[corners_inverse[v]] == v for v in corners_inverse))
yes_preimage = [u for u in SHAPES if straight_edge[u] == "yes"]
print(f"[B-shapes] preimage of \"yes\": {yes_preimage}")
check("[B-shapes]  the straight-edge function sends three of the four "
      "shapes to the same value, so it is not injective",
      len(yes_preimage) == 3)
check("[B-shapes]  no function undoes it: whatever shape is assigned to "
      "\"yes\", the other two are not recovered",
      all(sum(1 for u in SHAPES if straight_edge[u] == straight_edge[g]) > 1
          for g in yes_preimage))
[B-shapes] corners: {'circle': 0, 'triangle': 3, 'square': 4, 'pentagon': 5}
  [ok] [B-shapes]  the number of corners is a different value for each of the four shapes
  [ok] [B-shapes]  reading that table backwards is a function on {0, 3, 4, 5} and recovers every shape
  [ok] [B-shapes]  and it is undone in the other direction too
[B-shapes] preimage of "yes": ['triangle', 'square', 'pentagon']
  [ok] [B-shapes]  the straight-edge function sends three of the four shapes to the same value, so it is not injective
  [ok] [B-shapes]  no function undoes it: whatever shape is assigned to "yes", the other two are not recovered

B-sigmoid

The logit undoes the sigmoid and the sigmoid undoes the logit, both to within numerical tolerance over a grid, and the two numbers the entry states hold: a score of 0 becomes the probability 0.5, a score of 2.2 becomes 0.9.

z = np.linspace(-12.0, 12.0, 2401)
check("[B-sigmoid] the logit undoes the sigmoid on every score of the grid",
      np.allclose(logit(sigmoid(z)), z, atol=1e-9))
p = np.linspace(1e-6, 1 - 1e-6, 2401)
check("[B-sigmoid] and the sigmoid undoes the logit on every probability",
      np.allclose(sigmoid(logit(p)), p, atol=1e-12))
print(f"[B-sigmoid] a score of 0 becomes the probability "
      f"{float(sigmoid(0.0)):.3f}, a score of 2.2 becomes "
      f"{float(sigmoid(2.2)):.3f}")
check("[B-sigmoid] the two numbers of the opening paragraph hold",
      abs(float(sigmoid(0.0)) - 0.5) < 1e-12
      and abs(float(sigmoid(2.2)) - 0.9) < 0.005)
  [ok] [B-sigmoid] the logit undoes the sigmoid on every score of the grid
  [ok] [B-sigmoid] and the sigmoid undoes the logit on every probability
[B-sigmoid] a score of 0 becomes the probability 0.500, a score of 2.2 becomes 0.900
  [ok] [B-sigmoid] the two numbers of the opening paragraph hold

B-matrix

A square matrix of full rank: its inverse satisfies A A^-1 = A^-1 A = I and its determinant is nonzero. A square matrix whose columns are not linearly independent: determinant zero, no inverse, and the pseudoinverse satisfies A A^+ A = A while A^+ A is not the identity. A matrix with more rows than columns, of full column rank: a left inverse exists (A^+ A = I) and no right inverse does (A A^+ is not the identity), which is the one-sided case the entry describes.

rng = np.random.default_rng(SEED)
A = rng.standard_normal((3, 3))
A_inv = np.linalg.inv(A)
eye = np.eye(3)
print(f"[B-matrix] determinant of the square matrix: {np.linalg.det(A):.4f}")
check("[B-matrix]  a square matrix of full rank has an inverse with "
      "A A^-1 = A^-1 A = I",
      np.allclose(A @ A_inv, eye) and np.allclose(A_inv @ A, eye))
check("[B-matrix]  and its determinant is nonzero",
      abs(np.linalg.det(A)) > 1e-9 and np.linalg.matrix_rank(A) == 3)
singular = A.copy()
singular[:, 2] = singular[:, 0] + singular[:, 1]      # a column that repeats
print(f"[B-matrix] determinant of the matrix with a dependent column: "
      f"{np.linalg.det(singular):.2e}, rank "
      f"{np.linalg.matrix_rank(singular)}")
check("[B-matrix]  a square matrix whose columns are not linearly "
      "independent has determinant zero and no inverse",
      abs(np.linalg.det(singular)) < 1e-9
      and np.linalg.matrix_rank(singular) == 2)
singular_pinv = np.linalg.pinv(singular)
check("[B-matrix]  its pseudoinverse satisfies A A^+ A = A",
      np.allclose(singular @ singular_pinv @ singular, singular))
check("[B-matrix]  while A^+ A is not the identity, so it is no inverse",
      not np.allclose(singular_pinv @ singular, eye))
tall = rng.standard_normal((5, 2))
tall_pinv = np.linalg.pinv(tall)
check("[B-matrix]  a matrix with more rows than columns, of full column "
      "rank, has a left inverse A^+ A = I",
      np.allclose(tall_pinv @ tall, np.eye(2)))
check("[B-matrix]  and no right inverse, since A A^+ is not the identity",
      not np.allclose(tall @ tall_pinv, np.eye(5)))
[B-matrix] determinant of the square matrix: 0.8999
  [ok] [B-matrix]  a square matrix of full rank has an inverse with A A^-1 = A^-1 A = I
  [ok] [B-matrix]  and its determinant is nonzero
[B-matrix] determinant of the matrix with a dependent column: -5.70e-17, rank 2
  [ok] [B-matrix]  a square matrix whose columns are not linearly independent has determinant zero and no inverse
  [ok] [B-matrix]  its pseudoinverse satisfies A A^+ A = A
  [ok] [B-matrix]  while A^+ A is not the identity, so it is no inverse
  [ok] [B-matrix]  a matrix with more rows than columns, of full column rank, has a left inverse A^+ A = I
  [ok] [B-matrix]  and no right inverse, since A A^+ is not the identity

B-cdf

Inverse-CDF sampling: applying the inverse of the standard Gaussian cdf to draws from the uniform distribution on [0,1] returns a standard Gaussian sample, checked by its mean, its spread, and the largest gap between its empirical cdf and the target.

u = rng.uniform(0.0, 1.0, NR_DRAWS)
sample = gaussian_cdf_inverse(u)
ks = float(np.max(np.abs(np.sort(gaussian_cdf(np.sort(sample)))
                         - (np.arange(NR_DRAWS) + 0.5) / NR_DRAWS)))
print(f"[B-cdf] {NR_DRAWS} uniform draws through the inverse cdf: mean "
      f"{sample.mean():.3f}, spread {sample.std(ddof=1):.3f}, "
      f"largest gap to the target cdf {ks:.4f}")
check("[B-cdf]     the inverse of the cdf turns uniform draws into a "
      "standard Gaussian sample", abs(sample.mean()) < 0.03
      and abs(sample.std(ddof=1) - 1.0) < 0.03)
check("[B-cdf]     whose empirical cdf stays close to the target", ks < 0.02)
check("[B-cdf]     and the cdf undoes the inverse cdf on the grid",
      np.allclose(gaussian_cdf_inverse(gaussian_cdf(np.linspace(-3, 3, 101))),
                  np.linspace(-3, 3, 101), atol=1e-3))

# ------------------------------------------------------------- preview
fig, ax = plt.subplots(1, 2, figsize=(9.4, 3.8))
zz = np.linspace(-6.0, 6.0, 400)
pp = np.linspace(0.0025, 0.9975, 400)
ax[0].plot(zz, sigmoid(zz), "k-", label="sigmoid")
ax[0].plot(pp, logit(pp), "k--", label="logit")
ax[0].plot([-6, 6], [-6, 6], color="0.6", linestyle=":", label="the line y = x")
ax[0].set_xlim(-6, 6)
ax[0].set_ylim(-6, 6)
ax[0].set_xlabel("argument")
ax[0].set_ylabel("value")
ax[0].set_title("[B-sigmoid] each is the other mirrored in y = x", fontsize=10)
ax[0].legend(frameon=False, fontsize=8, loc="upper left")
order = np.sort(sample)
# the two agree so closely that one hides the other: the target is drawn
# thick and light, the sample thin and dashed on top of it
ax[1].plot(Z_GRID, CDF_GRID, color="0.7", linewidth=3.0, label="target cdf")
ax[1].plot(order, (np.arange(NR_DRAWS) + 0.5) / NR_DRAWS, "k--", linewidth=1.0,
           label="empirical cdf of the sample")
ax[1].set_xlim(-4, 4)
ax[1].set_xlabel("value")
ax[1].set_ylabel("cumulative probability")
ax[1].set_title("[B-cdf] uniform draws through the inverse cdf", fontsize=10)
ax[1].legend(frameon=False, fontsize=8, loc="upper left")
fig.tight_layout()
fig.savefig(OUT_DIR / "inverse.png", dpi=110)
print(f"\n{sum(ok for _, ok in report)}/{len(report)} checks passed")
assert all(ok for _, ok in report)
[B-cdf] 20000 uniform draws through the inverse cdf: mean 0.003, spread 0.994, largest gap to the target cdf 0.0048
  [ok] [B-cdf]     the inverse of the cdf turns uniform draws into a standard Gaussian sample
  [ok] [B-cdf]     whose empirical cdf stays close to the target
  [ok] [B-cdf]     and the cdf undoes the inverse cdf on the grid

18/18 checks passed
Preview figure produced by inverse.py
The preview figure the block B-cdf writes when the script runs