Skip to main content

Ill-conditioning in data

Nearly collinear features make the condition number of a design matrix explode, so that one nudged data point swings the fitted coefficients. Measure it on Hilbert and polynomial matrices, count the digits it costs, and test three remedies: standardising, QR and ridge.
M03.8.3L2 Intermediate25 min
In review (what this means)

The maths and code on this page are checked by automated tests on every change, but an independent reader has not signed it off yet. Spot a mistake or a confusing step? Tell us.

Before this lesson: M03.8.2 The condition number M03.7 Least squares and the pseudoinverse

What you will be able to do
  • Explain why nearly collinear columns give a small singular value and a large condition number.
  • Measure how fast kappa grows for Hilbert and polynomial design matrices, and apply the rule that about log10 kappa digits are lost.
  • Derive kappa(A-transpose A + alpha I) = (sigma_1^2 + alpha) / (sigma_n^2 + alpha) and use it to choose a ridge strength.
  • Judge which remedy (standardising, QR or ridge) treats which cause of ill-conditioning.

The question

Two sensors measure the same quantity and agree to within 0.02. A regression on both fits the targets exactly with weights \({(1, 1)}\). Raise one of the four targets by 0.1, and the weights become \({(-0.25, 2.25)}\): the first sensor now counts against the answer. Nothing about the data changed in any way that matters. What went wrong, and how do practitioners protect a fit from it?

From the last lesson

1. State the bound that the condition number gives for \({\mat{A}\vect{x} = \vect{b}}\).

Show answer

\({\norm{\delta\vect{x}}/\norm{\vect{x}}} \le {\kappa(\mat{A})\,\norm{\delta\vect{b}}/\norm{\vect{b}}}\).

2. What is \({\kappa(\mat{A}\T\mat{A})}\) in terms of \({\kappa(\mat{A})}\)?

Show answer

\({\kappa(\mat{A})^2}\), because \(\mat{A}\T\mat{A} = \mat{V}\mat{\Sigma}^2\mat{V}\T\) has singular values \(\sigma_i^2\).

Intuition

Put the two sensor readings side by side as the columns of a design matrix \(\mat{A}\). The first column is \({\vect{x}_1 = (1, 2, 3, 4)}\) and the second is \({\vect{x}_2 = \vect{x}_1 + (0.02, -0.02, -0.02, 0.02)}\). The targets are \({\vect{y} = \vect{x}_1 + \vect{x}_2}\).

Two panels. Left, titled Two features, nearly one: four blue points whose coordinates are the two sensor readings, lying on the dashed diagonal line, labelled x2 equals x1 plus or minus 0.02. Right, titled The error over (w1, w2): reddish-purple contours of the squared error, so narrow that they look like a single line running from upper left to lower right, labelled valley of near-equal fits. An orange dot marks the fit w-hat equals (1, 1) on the valley; a curved sky-blue arrow leads along the valley to a sky-blue dot at (minus 0.25, 2.25), labelled after y1 moves by 0.1.
M03.8.3-F1

Left: each row of the data as a point; the two features almost coincide. Right: the squared error over all weight pairs is a valley 274 times longer than it is wide. Nudging one target slides the best fit far along the floor.

Because \(\vect{x}_1\) and \(\vect{x}_2\) are nearly equal, the model can trade weight between them almost for free: \({(1, 1)}\), \({(0, 2)}\) and \({(2, 0)}\) all predict nearly the same values. The squared error is a long, narrow valley along the line \({w_1 + w_2 = 2}\). The data pins the fit down across the valley, but hardly at all along it, so a tiny change in the data moves the bottom a long way. That valley is ill-conditioning, seen from the side of the weights.

Build it up

Step 1. A near-repeat makes a small singular value. The smallest stretch \(\sigma_n\) is at most the stretch of any particular vector. Try \({\vect{z} = (1, -1)}\), which subtracts the columns:

\[ \begin{aligned} \sigma_2 &\le \frac{\norm{\mat{A}\vect{z}}}{\norm{\vect{z}}} = \frac{\norm{\vect{x}_1 - \vect{x}_2}}{\sqrt2} \\ &= \frac{0.04}{\sqrt2} \approx 0.0283 . \end{aligned} \]

Here the bound is almost exact: \({\sigma_2 \approx 0.0283}\). With \({\sigma_1 \approx 7.75}\), the condition number is \({\kappa(\mat{A}) \approx 274}\), and \({\kappa(\mat{A}\T\mat{A}) \approx 75\,000}\).

Step 2. The weak direction is the valley. The right singular vector for \(\sigma_2\) is close to \({(1, -1)/\sqrt2}\). Along it, a large change of weights makes a tiny change of predictions, which is exactly why the data cannot fix it.

Step 3. It gets worse with size. Real designs have many columns, and some families are famous for near-dependence. The Hilbert matrix \(\mat{H}_n\) has entries \({1/(i + j - 1)}\). A polynomial design has columns \(1, x, x^2, \dots\) evaluated at data points; np.vander builds it. Both have columns that look more and more alike as \(n\) grows.

A line graph on a logarithmic vertical axis from 1 to 10 to the 19. The horizontal axis is the number of columns n. A vermillion line with dots, Hilbert H n, climbs steeply from about 19 at n equals 2 to about 1.7 times 10 to the 16 at n equals 12. A sky-blue line with squares, polynomial design, np.vander, 21 points, climbs more gently from about 4 at 2 columns to about 4 times 10 to the 11 at 16 columns. A dashed reddish-purple line at 4.5 times 10 to the 15 is labelled 1 over epsilon: all 16 digits at risk.
M03.8.3-F2

Condition numbers of Hilbert matrices and of polynomial designs on 21 equally spaced points in \([0, 1]\), on a log scale. Each extra column multiplies \(\kappa\) by a roughly constant factor.

On a log scale both lines are nearly straight, so \(\kappa\) grows exponentially. Higham gives \({\kappa_2(\mat{H}_n) \sim e^{3.5n}}\) for large \(n\), so each extra column multiplies \(\kappa\) by about \({e^{3.5} \approx 33}\) (the measured factors climb from about 31 to 32.5 on the way from \({n = 4}\) to \({n = 10}\)): \({\kappa(\mat{H}_5)} \approx {4.8 \times 10^5}\) and \({\kappa(\mat{H}_{10})} \approx {1.6 \times 10^{13}}\). The polynomial design reaches \({2.3 \times 10^7}\) at degree 10 (11 columns).

Step 4. Count the cost in digits. A float64 number carries about 16 significant decimal digits. Moler shows that a good solver returns \(\tilde{\vect{x}}\) with relative error at most about \({\rho\,\kappa(\mat{A})\,\varepsilon}\), where \({\varepsilon \approx 2.2 \times 10^{-16}}\) and \(\rho\) is rarely above 10. So the rule of thumb is: about \(\log_{10}\kappa\) of the sixteen digits are lost. For \(\mat{H}_{10}\) that is 13 digits, leaving about 3. How floating point produces these errors is the subject of M10.1.

The formal statement

Three remedies are in common use. Each treats a different cause.

Remedy 1: standardise the features. Centre each column and divide it by its standard deviation. This cures ill-conditioning that comes from units: a column in millimetres beside one in metres has \(\kappa\) in the thousands for no real reason. It cannot cure a near-repeat, because two columns that are almost equal stay almost equal after the same kind of rescaling.

Remedy 2: work with an orthonormal basis (QR). Write \({\mat{A} = \mat{Q}\mat{R}}\) with orthonormal columns in \(\mat{Q}\) (M02.10.4). Multiplying \(\mat{R}\) by a matrix with orthonormal columns changes no singular value (the argument of exercise 2 of M03.8.2), so \(\mat{R}\) has the same singular values as \(\mat{A}\), and the fit solves \({\mat{R}\hat{\vect{x}} = \mat{Q}\T\vect{y}}\), a system with condition number \({\kappa(\mat{A})}\), not \({\kappa(\mat{A})^2}\). The fitted values \(\mat{Q}\mat{Q}\T\vect{y}\) use only \(\mat{Q}\), whose condition number is 1. QR stops the squaring; it cannot remove the near-repeat itself.

Remedy 3: ridge. Ridge regression (ML01.9.1) solves \({(\mat{A}\T\mat{A} + \alpha\mat{I})\hat{\vect{x}}} = {\mat{A}\T\vect{y}}\) with \({\alpha > 0}\).

NoteResult: the condition number of ridge

For \(\mat{A}\) with \(n\) columns, singular values \({\sigma_1 \ge \dots \ge \sigma_n}\), and \({\alpha > 0}\),

\[ \kappa(\mat{A}\T\mat{A} + \alpha\mat{I}) = \frac{\sigma_1^2 + \alpha}{\sigma_n^2 + \alpha} . \]

Derivation. The \(n\) by \(n\) matrix \(\mat{V}\) is orthogonal, so \({\mat{I} = \mat{V}\mat{V}\T}\), and

\[ \begin{aligned} \mat{A}\T\mat{A} + \alpha\mat{I} &= \mat{V}\mat{\Sigma}^2\mat{V}\T + \alpha\,\mat{V}\mat{V}\T \\ &= \mat{V}\,(\mat{\Sigma}^2 + \alpha\mat{I})\,\mat{V}\T . \end{aligned} \]

This is a spectral decomposition with eigenvalues \({\sigma_i^2 + \alpha}\), all positive. For a symmetric matrix with positive eigenvalues these are also its singular values, so the condition number is the largest over the smallest. Adding \(\alpha\) lifts the small end far more, in proportion, than the large end: the valley gets wider.

A log-log graph of the condition number against the ridge strength alpha, from 10 to the minus 6 up to 10. The orange curve starts flat near 75,000, matching a dashed grey line labelled no ridge: kappa of A transpose A about 75,000, then bends down past alpha equals 0.01, kappa about 5,557, and alpha equals 0.1, kappa about 596, falling towards single figures at alpha equals 10. The formula kappa of A transpose A plus alpha I equals sigma 1 squared plus alpha over sigma 2 squared plus alpha is written beside the curve.
M03.8.3-F3

For the two-sensor design, the condition number of the ridge system falls from 75 000 to about 5 600 at \({\alpha = 0.01}\) and to about 600 at \({\alpha = 0.1}\).

The price is bias: ridge no longer solves the original least-squares problem. M03.7.4 shows which parts of the answer it shrinks.

Go deeper: where the Hilbert matrix comes from

Fit a polynomial \({c_1 + c_2 t + \dots + c_n t^{n-1}}\) to a function on \([0, 1]\) by least squares, with the integral \({\int_0^1 (\cdot)^2\,dt}\) in place of a sum of squares. The normal equations have the Gram matrix with entries \({\int_0^1 t^{i-1}t^{j-1}\,dt = 1/(i + j - 1)}\): the Hilbert matrix. On \([0, 1]\) the powers \(t^5\) and \(t^6\) have nearly the same shape, and the matrix records how alike all the powers are. A polynomial design on many evenly spread points is a sampled version of the same thing, which is why its line in the figure climbs the same way.

Worked example

Solve the two-sensor fit by hand, before and after the nudge.

The normal equations need \(\mat{A}\T\mat{A}\) and \(\mat{A}\T\vect{y}\). Since \({\vect{x}_1 \cdot \vect{x}_1 = 30}\), \({\vect{x}_1 \cdot \vect{x}_2 = 30}\) and \({\vect{x}_2 \cdot \vect{x}_2 = 30.0016}\), and \({\vect{y} = (2.02, 3.98, 5.98, 8.02)}\),

\[ \begin{bmatrix} 30 & 30 \\ 30 & 30.0016 \end{bmatrix} \begin{bmatrix} w_1 \\ w_2 \end{bmatrix} = \begin{bmatrix} 60 \\ 60.0016 \end{bmatrix} . \]

Subtract the first equation from the second: \({0.0016\,w_2 = 0.0016}\), so \({w_2 = 1}\), and then \({w_1 = 1}\). Now raise \(y_1\) from 2.02 to 2.12. The right side gains \({0.1\,(1, 1.02)}\) and becomes \({(60.1,\ 60.1036)}\). The same subtraction gives

\[ 0.0016\,w_2 = 0.0036, \qquad w_2 = 2.25 , \]

and then \({w_1} = {(60.1 - 30 \times 2.25)/30} \approx {-0.247}\). All the information about how to split the weight sits in a difference of 0.0016 between two numbers near 60, and a nudge of 0.002 to that difference overwhelms it. The fitted values, though, move by at most 0.04.

With ridge, \({\alpha = 0.01}\) is added to both diagonal entries. Before the nudge the weights stay at about \({(1.00, 1.00)}\); after it they move only to about \({(0.91, 1.09)}\).

A table built from boxes. A blue box at the top: A has columns x1 equals (1, 2, 3, 4) and x2 equals x1 plus or minus 0.02; kappa of A about 274. Two column headings: targets as given, and y1 raised by 0.1. Row least squares: (1.00, 1.00) becomes (minus 0.25, 2.25). Row fitted values: a dash, then move by at most 0.04. Row ridge, alpha equals 0.01: (1.00, 1.00) becomes (0.91, 1.09).
M03.8.3-F4

The worked example in one table. The coefficients swing; the predictions hardly move; ridge holds the coefficients still.

Code

The two-sensor fit, the three remedies, and the digit count, from scratch.

import numpy as np
from scipy.linalg import hilbert

x1 = np.array([1.0, 2.0, 3.0, 4.0])
x2 = x1 + 0.02 * np.array([1.0, -1.0, -1.0, 1.0])
A = np.column_stack([x1, x2])
y = x1 + x2
y_moved = y.copy()
y_moved[0] += 0.1

s = np.linalg.svd(A, compute_uv=False)
print("singular values:", s.round(5), " kappa(A):", round(s[0] / s[1], 1),
      " kappa(A^T A):", round((s[0] / s[1]) ** 2))

def least_squares(A, y):
    return np.linalg.lstsq(A, y, rcond=None)[0]

def ridge(A, y, alpha):
    return np.linalg.solve(A.T @ A + alpha * np.eye(A.shape[1]), A.T @ y)

w, w_moved = least_squares(A, y), least_squares(A, y_moved)
print("least squares:", w.round(3), "->", w_moved.round(3),
      " largest change in fitted values:", np.abs(A @ (w_moved - w)).max().round(4))
print("ridge 0.01:   ", ridge(A, y, 0.01).round(3), "->", ridge(A, y_moved, 0.01).round(3))
singular values: [7.74602 0.02828]  kappa(A): 273.9  kappa(A^T A): 75002
least squares: [1. 1.] -> [-0.247  2.25 ]  largest change in fitted values: 0.0383
ridge 0.01:    [1. 1.] -> [0.909 1.094]

Now the remedies, each measured by the condition number of the system it actually solves.

def standardise(X):
    centred = X - X.mean(axis=0)
    return centred / centred.std(axis=0, ddof=1)

mixed = np.column_stack([x1, 1000 * np.array([2.0, 1.0, 4.0, 3.0])])   # metres beside millimetres
print("standardise, mixed units:  ", round(np.linalg.cond(mixed)), "->", round(np.linalg.cond(standardise(mixed)), 2))
print("standardise, near-repeat:  ", round(np.linalg.cond(A)), "->", round(np.linalg.cond(standardise(A))))

Q, R = np.linalg.qr(A)
print("normal equations solve with kappa =", round(np.linalg.cond(A.T @ A)), "; QR solves with kappa =", round(np.linalg.cond(R)))

for alpha in [0.01, 0.1]:
    formula = (s[0] ** 2 + alpha) / (s[1] ** 2 + alpha)
    print(f"ridge alpha={alpha}: kappa = {np.linalg.cond(A.T @ A + alpha * np.eye(2)):.1f}, formula {formula:.1f}")
standardise, mixed units:   2785 -> 2.0
standardise, near-repeat:   274 -> 112
normal equations solve with kappa = 75002 ; QR solves with kappa = 274
ridge alpha=0.01: kappa = 5556.6, formula 5556.6
ridge alpha=0.1: kappa = 596.2, formula 596.2

Last, the digit count. Solve \({\mat{H}_n\vect{x} = \mat{H}_n\vect{1}}\), whose exact answer is all ones, and count the correct digits. The exact errors depend on the machine’s arithmetic library, so only their size is checked.

for n in [4, 6, 8, 10, 12]:
    H = hilbert(n)
    kappa = np.linalg.cond(H)
    x_hat = np.linalg.solve(H, H @ np.ones(n))
    error = max(np.linalg.norm(x_hat - 1) / np.sqrt(n), 1e-17)   # never exactly zero, for the log
    print(f"n={n:2d}  kappa={kappa:8.1e}  digits lost by the rule: {np.log10(kappa):4.1f}"
          f"   digits correct: {-np.log10(error):4.1f}")
n= 4  kappa= 1.6e+04  digits lost by the rule:  4.2   digits correct: 13.5
n= 6  kappa= 1.5e+07  digits lost by the rule:  7.2   digits correct:  9.9
n= 8  kappa= 1.5e+10  digits lost by the rule: 10.2   digits correct:  7.1
n=10  kappa= 1.6e+13  digits lost by the rule: 13.2   digits correct:  3.4
n=12  kappa= 1.7e+16  digits lost by the rule: 16.2   digits correct:  1.0

The correct digits track \({16 - \log_{10}\kappa}\) within a digit or two, and by \({n = 12}\) almost nothing is left.

Explore

This module’s interactive, the condition explorer, lives in lesson M03.8.2. Press Nearly parallel: its two columns are a two-dimensional version of the two sensors, and the ellipse of solutions stretches along the same kind of valley.

Pitfalls

Two panels of standardised data, each with four blue points. The left panel, marked with a cross and titled Cures a repeated feature, shows the two-sensor design after standardising: the points still lie on one diagonal line, labelled still on one line, with the note kappa: 274 before, 112 after. The right panel, marked with a tick and titled Cures mixed units, shows a design with one column in metres and one in millimetres after standardising: the points are spread round the origin, labelled metres and millimetres, now on one scale, with the note kappa: 2,785 before, 2 after.
M03.8.3-F5

Wrong beside right. Standardising takes the mixed-units design from \({\kappa \approx 2785}\) to 2, but leaves the two sensors nearly on one line.

“Standardising the features fixes ill-conditioning.”

It fixes the kind that comes from units and scales. Two features that carry the same information still carry the same information after each is rescaled: the two-sensor design goes from \({\kappa \approx 274}\) only to 112. For a near-repeat, drop or merge a feature, or use ridge.

“A large condition number means the model predicts badly.”

The two-sensor fit predicts the training targets exactly and moves its predictions by at most 0.04 when a target moves by 0.1. What is unreliable is the split of the weight between the two features. That matters whenever the coefficients are read as explanations (“feature 1 lowers the answer”), and it is the instability that variance inflation measures in S03.

“To fit, invert \(\mat{A}\T\mat{A}\).”

Forming \(\mat{A}\T\mat{A}\) squares the condition number: 274 becomes 75 000, and the Hilbert-like designs above would lose every digit. Use np.linalg.lstsq or a QR factorisation, which work with \(\mat{A}\) itself, and never form an explicit inverse to solve a system.

Check yourself

Recall

1. Why do two nearly equal columns force a small singular value?

Show answer

The smallest singular value is the smallest stretch. The vector \({(1, -1)}\) (padded with zeros) subtracts the two columns, so its image is their tiny difference, and \(\sigma_n\) can be no larger than that stretch.

2. How fast does \({\kappa(\mat{H}_n)}\) grow?

Show answer

Exponentially, roughly like \(e^{3.5n}\): about 33 times larger for each extra column, from about \({4.8 \times 10^5}\) at \({n = 5}\) to about \({1.6 \times 10^{13}}\) at \({n = 10}\).

3. State the rule of thumb for the digits lost in solving a system.

Show answer

About \(\log_{10}\kappa\) of the roughly 16 significant digits of float64 are lost.

4. What is \({\kappa(\mat{A}\T\mat{A} + \alpha\mat{I})}\)?

Show answer

\({(\sigma_1^2 + \alpha)/(\sigma_n^2 + \alpha)}\).

5. Which cause of ill-conditioning does standardising cure, and which does it not?

Show answer

It cures features on very different scales or in different units. It does not cure features that nearly repeat one another.

Exercises

1. ★ A design has columns \({(1, 0)}\) and \({(1, 0.01)}\). Use \({\sigma_1\sigma_2 = \abs{\det\mat{A}}}\) and \({\sigma_1^2 + \sigma_2^2 = \norm{\mat{A}}_F^2}\) to find its condition number, and estimate the digits lost.

Show solution

The determinant is \(0.01\) and \({\norm{\mat{A}}_F^2 = 1 + 1 + 0.0001 = 2.0001}\). The product of the singular values is tiny, so \({\sigma_1^2 \approx 2.0001}\), \({\sigma_1 \approx 1.4142}\) and \({\sigma_2 \approx 0.01/1.4142 \approx 0.00707}\). Then \({\kappa \approx 1.4142/0.00707 \approx 200}\), and about \({\log_{10}200 \approx 2.3}\) digits are lost. (The first identity holds because \({\det(\mat{A}\T\mat{A}) = \det(\mat{A})^2}\) is the product of the eigenvalues \(\sigma_1^2\sigma_2^2\).)

2. ★★ For the two-sensor design, \({\sigma_1^2 \approx 60}\) and \({\sigma_2^2 = 0.0008}\). Find the smallest \(\alpha\) that makes \({\kappa(\mat{A}\T\mat{A} + \alpha\mat{I}) \le 100}\).

Show solution

We need \({(60 + \alpha)/(0.0008 + \alpha) \le 100}\), that is \({60 + \alpha \le 0.08 + 100\alpha}\), or \({99\alpha \ge 59.92}\). So \({\alpha \ge 0.605}\), roughly. The condition number falls quickly once \(\alpha\) passes \(\sigma_2^2\) and then slowly: reaching 100 needs an \(\alpha\) near one hundredth of \(\sigma_1^2\).

3. ★★★ Two columns \(\vect{a}_i\) and \(\vect{a}_j\) of \(\mat{A}\) satisfy \({\norm{\vect{a}_i - \vect{a}_j} = \delta}\). Prove that \({\kappa(\mat{A}) \ge \sqrt2\,\sigma_1/\delta}\), and check it on the two-sensor design.

Show solution

Let \(\vect{z}\) have \({+1}\) in place \(i\), \({-1}\) in place \(j\) and zeros elsewhere. Then \({\mat{A}\vect{z} = \vect{a}_i - \vect{a}_j}\) and \({\norm{\vect{z}} = \sqrt2}\). The smallest stretch is at most this one: \({\sigma_n \le \delta/\sqrt2}\). Divide \(\sigma_1\) by both sides: \(\kappa = \sigma_1/\sigma_n \ge \sqrt2\,\sigma_1/\delta\). For the two sensors, \({\delta = 0.04}\) and \({\sigma_1 \approx 7.7460}\), so \(\kappa \ge 1.4142 \times 7.7460/0.04 \approx 273.86\). The true value is also 273.86 to two decimals (the bound is 273.863, the truth 273.865), so the bound is almost exact here.

Summary card

Features that nearly repeat make a long valley of almost equal fits, and a large \(\kappa\).

The anchor figure again: the two-sensor data on one diagonal line, and the narrow valley of the squared error along which the best fit slides from (1, 1) to (minus 0.25, 2.25) when one target moves by 0.1.
M03.8.3-F1

Same predictions, very different weights.

  • Near-repeat columns: \({\sigma_n} \le {\norm{\vect{a}_i - \vect{a}_j}/\sqrt2}\), so \(\kappa\) is large.
  • Hilbert and polynomial designs: \(\kappa\) grows exponentially, \({\kappa(\mat{H}_n) \sim e^{3.5n}}\).
  • About \(\log_{10}\kappa\) of 16 digits are lost.
  • Standardise for units; QR to avoid squaring; ridge: \({\kappa} = {(\sigma_1^2 + \alpha)/(\sigma_n^2 + \alpha)}\).

Sources and next steps

Sources. Each was opened and checked against this lesson on 15 November 2026.

  • Nicholas Higham, What is the Hilbert matrix? (2020): the entries \({1/(i + j - 1)}\), the growth \({\kappa_2(\mat{H}_n) \sim e^{3.5n}}\), and that \(\mat{H}_{12}\) is numerically singular in double precision.
  • SciPy reference, scipy.linalg.hilbert: hilbert(n) returns the \(n\) by \(n\) matrix with entries 1 / (i + j + 1), counting from zero.
  • NumPy reference, numpy.vander: vander(x, N) has N columns, powers decreasing from left to right unless increasing=True.
  • Cleve Moler, Numerical Computing with MATLAB, chapter 2, section 2.9: the computed solution’s relative error is at most about \({\rho\,\kappa(\mat{A})\,\varepsilon}\), with \(\rho\) rarely larger than about 10, the basis of the digits rule.

Next. M03.8.4 Conditioning and gradient descent finds the same number setting the speed of the most used algorithm in machine learning.

This lesson unlocks

M03.8.4 Conditioning and gradient descent

Your progress is saved in this browser only. No account, no tracking.
Back to top