arbital
Every variable in your dataset, drawn as an orbit around the one you care about, so that the strength, shape, direction, redundancy, and trustworthiness of each association can be read from a single figure.
Summary
The first look at a new dataset is usually a correlation matrix, and a correlation matrix can only see straight lines (or, at best, monotone curves). It scores a parabola at zero, misses interactions entirely, and can even report the wrong sign when groups are pooled. Mutual information detects dependence of any shape, but it arrives in nats, unbounded and unreadable in bulk, which is why it has never displaced the correlation matrix as the first look.
arbital closes that gap by measuring every variable against a chosen target with both families of statistics and drawing the comparison as an orbital system. Total association sets the radius (closer is stronger, one nat per step). The share of that association which correlation cannot see becomes the orbit's eccentricity, with a ghost marker showing exactly where correlation alone would have (mis)placed the variable. Mutually redundant variables huddle at nearby angles. A built-in mRMR feature selection sizes each marker by what the variable adds beyond those already chosen, and a shuffled-target calibration marks the boundary beyond which an apparent association is indistinguishable from noise at your sample size. The intended use is exploratory: reading the dependence structure of a dataset before committing to a model.
1Installation and quick start
The core package depends only on NumPy. Plotly is optional and used only to render figures. The example datasets are optional and load through seaborn, so any seaborn dataset is available; they are not part of the core install.
# from PyPI pip install arbital # or the latest development version from GitHub pip install git+https://github.com/aaronbyrnephd/arbital # optional extras: live plotly Figure objects, extra seaborn example data pip install "arbital[plotly,datasets]"
import arbital from arbital import datasets # mpg, penguins, titanic, tips are bundled cars = datasets.load_mpg() # or datasets.load("<any seaborn dataset>") space = arbital.orbits(cars, target="mpg") # "space" as in feature space space.to_html("mpg.html") # standalone interactive figure space.table() # per-feature metrics as dicts space.to_df() # the same as a pandas DataFrame
Any 2-D NumPy array or object exposing .columns and array semantics (including a
pandas DataFrame) is accepted as input, so the datasets module is a convenience rather than a
requirement.
2Reading the figure
uncertainty=True, a radial segment spanning r_info ± one bootstrap standard error; a diamond marks where a band crosses the chance boundary (that association could be noise).What the numbers mean
The hover box, table(), and to_df() report the same quantities:
| quantity | meaning |
|---|---|
| r_info | total association with the target on a 0 to 1 scale: the informational coefficient of correlation (Linfoot, 1957), computed only from mutual information as $r_{\text{info}} = \sqrt{1 - e^{-2I}}$. This is the number that sets the radius, and it is always unadjusted: computed directly from the measured I (see the "MI" row below) via the Linfoot formula, with no chance level subtracted and no contribution from r_pearson or r_spearman. That holds whether or not the result is calibrated for chance uncertainty, so every marker's distance uses one formula and sits on the same coordinate system as the associated chance boundary described below. |
| MI (nats) | the mutual information $I(X;Y)$ between the feature and the target, in natural-log units (nats). Defined as $I(X;Y) = \iint p(x,y) \log\frac{p(x,y)}{p(x)\,p(y)}\,dx\,dy$ (a sum in place of the integral over any discrete side): the expected reduction in uncertainty about one variable from observing the other. One nat is the information in an event of probability 1/e; each additional nat moves a marker one fixed step closer to the centre. Always unadjusted, matching r_info above through the Linfoot formula. |
| distance | the variable's distance from the centre, on a 0 to 1 scale: $d = \sqrt{1 - r_{\text{info}}^2}$, which equals $e^{-I}$ (I as defined in the "MI" row above). It is the residual uncertainty about the target after observing the variable: 1 means no association (the rim), 0 would mean a perfect one (the centre). |
| chance boundary | The dashed circle sits at the r_info level this estimator would report for a truly unrelated continuous variable at this sample size (`chance` in the table: the `confidence`-quantile, 0.95 by default, against shuffled targets, converted to the r_info scale). Every continuous feature shares this exact value, and each feature's own unadjusted r_info is compared against it directly: `below_chance` marks a feature whose r_info sits at or under that value. This comparison never moves the marker itself. The hover names the level (`near chance boundary (0.XX)` / `below chance boundary (0.XX)`) only when a variable sits within 0.2 of it, since well above it the flag adds nothing. Categorical features are calibrated separately (their noise floor scales with cardinality) and are compared against their own level, not this circle: their hover says "its own chance level" instead, and if every feature is categorical no circle is drawn at all. table()/to_df() also carry `chance_nats` (the same level in nats), `mi_adj` and `r_info_adj` (see "pick, gain" below). See "Assumptions and calibration caveats" below for the full list of what this does and does not guarantee statistically. |
| r_pearson, r_spearman | the classical signed coefficients: straight-line fit and any-monotone-curve fit, reported as measured and never folded into r_info or the radius. Colour is driven by r_spearman. Each is checked against `pearson_chance`/`spearman_chance`, the same `confidence`-quantile from the same shuffled-target draws as the MI chance level above (not a separate analytic formula). A coefficient that does not clear its own level is marked not significant in the hover, independently of what r_info and its associated chance boundary say: the two checks answer different questions, whether there is any dependence versus whether there is a linear or monotone one. |
| nonlinear share | the fraction of the total association that the best monotone coefficient does not capture: $\nu = 1 - (r_{\text{mono}}/r_{\text{info}})^2$ with $r_{\text{mono}} = \max(|r_{\text{pearson}}|, |r_{\text{spearman}}|)$. Drawn as the tether to the ghost marker. profile() floors r_info at r_mono (mutual information cannot genuinely fall below what a monotone fit already captures, so a kNN estimate that dips under r_mono is treated as estimator noise, not signal), and this ratio never needs clipping as a result. |
| ± (uncertainty) | one bootstrap standard error of r_info, drawn as the radial band. It is computed the same way r_info itself is, from the same uncalibrated estimator, so the band's centre and width are always on the same scale. |
| pick, gain | the greedy mRMR selection, run on r_info_adj (`mi_adj = max(0, I - chance)`, then Linfoot), never on the plotted r_info: pick is the 1-based selection order, and gain is that adjusted relevance minus the mean redundancy to the variables already picked. A gain of zero or below means the variable adds nothing beyond earlier picks. For a below-chance feature, r_info_adj is 0, so it can never win a pick. Its marker is not pulled out to the rim of the orbit system to show this: the visual position still reflects the unadjusted r_info, even though a zero-relevance feature is likely just noise. |
| relative gain | the marker-size value: this variable's gain relative to the first pick's. This is what the "pick, gain" row above describes, i.e. it reflects r_info_adj, not the plotted r_info. |
3Tutorial: the basic behaviour on synthetic data
The clearest way to see what the figure encodes is to construct variables with known relationships to the target. The system below contains a linear feature and a near-duplicate of it, two non-monotone features (a parabola and a sine wave), a monotone but curved feature, a weak linear feature, and pure noise.
space = arbital.orbits(X, target="target") space.to_plotly()
Hover a marker for its full profile; drag to zoom; double-click to reset.
Three behaviours are visible. First, radius tracks strength: the noise feature sits at the rim and the informative features sit close in. Second, the two non-monotone features have low correlation but high mutual information, so they are placed close to the centre with a ghost marker far outside them; the tether is the dependence that a correlation matrix would miss. Third, the monotone but curved feature has a Pearson correlation of r = 0.82, already fairly strong but still measurably lower than its rank correlation (ρ = 0.93), which agrees with mutual information (r_info = 0.96): so it is drawn on a close, nearly circular orbit with no ghost, since monotone curvature is captured by rank correlation and is not flagged as non-monotone structure.
The underlying relationships, shown directly:
The corresponding numbers, in selection order:
| feature | cat | r | ρ | I (nats) | r_info | ν | pick | gain |
|---|---|---|---|---|---|---|---|---|
| monotone | +0.82 | +0.93 | 1.263 | 0.96 | 6% | 1 | +0.96 | |
| sine | +0.16 | +0.29 | 0.987 | 0.93 | 90% | 2 | +0.18 | |
| parabola | -0.22 | -0.18 | 1.015 | 0.93 | 94% | 3 | +0.29 | |
| linear_copy | +0.89 | +0.89 | 0.827 | 0.90 | 2% | 4 | +0.19 | |
| linear | +0.94 | +0.94 | 1.122 | 0.95 | 0% | 5 | +0.15 | |
| noise | -0.01 | -0.01 | 0.000 | 0.01 | 0% | 6 | -0.07 | |
| weak | +0.38 | +0.37 | 0.033 | 0.38 | 0% | 7 | -0.16 |
4Tutorial: the angle axis, reading redundancy groups
The radius answers "how strong?"; the angle answers "strong together with whom?". Variables are placed by embedding the feature-to-feature association matrix, so mutually redundant variables huddle at nearby angles while unrelated ones sit far apart. The dataset below makes the structure explicit: two latent factors each drive a group of observed proxies (three for the first factor, two for the second), one further variable is informative on its own, and two are pure noise.
f1, f2, g = # three independent latent factors y = f1 + 0.8*f2 + 0.6*g + noise cols = {"a1": f1+e, "a2": f1+e, "a3": -f1+e, # group A: proxies of f1 "b1": f2+e, "b2": f2+e, # group B: proxies of f2 "lone": g+e, "noise1": e, "noise2": e} arbital.orbits(cols, target="y", selection=True).to_plotly()
The two groups are visible directly: the three a proxies huddle in one angular
sector (note that a3, the negated proxy, is placed with its group but coloured
blue, since direction is carried by colour, not position), the two b proxies
huddle in another, and lone sits by itself. The selection numbering confirms the
reading: picks 1–3 take one variable from each group before any duplicate is considered.
What the angular gaps mean: the three layouts
The default layout, angle_layout="spread", uses the embedding angles but widens
every gap to a readable minimum, so clusters stay visible without labels piling up. Two
alternatives are available: "embed" uses the raw embedding angles (gaps are most
faithful, labels may collide), and "ordered" spaces variables evenly (only the
order around the circle is meaningful, the conservative choice). The same system under each:
Left to right: spread (default), embed, ordered.
5Tutorial: transformed variables
A common situation in practice is to hold several transformations of the same underlying quantity: a raw value together with its logarithm, square root, or a capped version. Because these are monotone transformations of one another, mutual information does not distinguish between them (r_info is a property of the copula alone, see section 13), so they carry almost the same information about the target and are largely redundant. The figure below uses a target equal to log(raw) plus noise, with the raw variable and three transformations of it.
cols = {"y": np.log(raw) + noise,
"raw": raw, "log": np.log(raw),
"sqrt": np.sqrt(raw), "capped": np.minimum(raw, 30)}
arbital.orbits(cols, target="y").to_plotly()
All four variables sit at similar radii and are grouped at nearby angles, which identifies them
as mutually redundant. Their r_info values are so close that which one the feature selection
retains first is decided by estimator noise rather than a meaningful ranking: here it is
sqrt, which receives the largest marker, while the other three receive
substantially smaller, often negative, marginal gains once it is selected. The selection
walkthrough makes this explicit:
| pick | feature | relevance | redundancy to selected | gain |
|---|---|---|---|---|
| 1 | sqrt | 0.99 | 0.00 | +0.99 |
| – | log | 0.99 | 1.00 | -0.01 |
| – | raw | 0.99 | 1.00 | -0.01 |
| – | capped | 0.97 | 1.00 | -0.03 |
This is the intended reading for feature engineering: when several derived versions of a quantity are present, the angular cluster flags them as redundant and the marker sizes show that only one is worth keeping. Which specific transformation wins that near-tie is not itself meaningful; what matters is that the group collapses to one representative.
6Tutorial: lagged variables and time dependence
The same idea applies to time series. Given a series and several of its own lags as candidate predictors, the orbits show which lags carry information about the present value. The example below is a first-order autoregressive series, whose present value depends strongly on the previous step and progressively less on earlier ones.
cols = {"series_t": s, "lag_1": np.roll(s, 1),
"lag_2": np.roll(s, 2), "lag_3": np.roll(s, 3), "lag_8": np.roll(s, 8)}
arbital.orbits(cols, target="series_t").to_plotly()
The lag-1 variable is closest to the centre and is selected first; the more distant lags orbit progressively farther out, reflecting the decay of temporal dependence. Reading the radii gives a direct, model-free view of the memory in the series, comparable to an autocorrelation plot but using mutual information, so that non-linear temporal dependence would be visible in the same way.
7Tutorial: dependence a correlation scan cannot see at all
The synthetic tutorial showed non-monotone shapes (a parabola, a sine). A harsher case is an interaction: if the target is the product of two variables, each parent is individually uncorrelated with it, not just nonlinearly related, but invisible to both Pearson and Spearman, because the other parent randomly flips the sign of its effect. Screening features with a correlation matrix silently discards both parents.
y = x1 * x2 + noise
cols = {"x1": x1, "x2": x2, "product": x1*x2 + e, "noise": e}
arbital.orbits(cols, target="y").to_plotly()
Both parents show r ≈ ρ ≈ 0.06 yet r_info = 0.53: mutual information detects the dependence through the sign-flipping (knowing x1 narrows the spread of y even though it cannot say which direction y moves). Their orbits are maximally eccentric (ν ≈ 99%), with ghost markers stranded at the rim where a correlation matrix would have left them, near the noise variable. The near-white marker colour makes the same point through the colour channel: there is association, but no direction to report.
8Tutorial: categorical and mixed data
When the target or a feature is categorical, arbital selects the appropriate mutual-information
estimator automatically: a k-nearest-neighbour estimator for continuous pairs, the Ross
estimator for a continuous variable against a categorical one, and a plug-in estimator for two
categorical variables. String columns in a bundled Table are detected as categorical; other
columns can be marked with the categorical argument.
arbital.orbits(datasets.load_titanic(), target="survived").to_plotly()
The target, survival, is binary, and sex and embarked are categorical,
but the two behave differently. embarked has more than two levels, so its integer
codes are arbitrary labels with no meaningful sign: it is drawn on a circular orbit with a
near-white marker, strength only, no direction. sex is binary, so it still gets a
real point-biserial correlation and keeps both a direction (colour) and orbit curvature, exactly
like a continuous variable. sex has the strongest association with survival
(r_info = 0.54) and a clear direction (r = -0.54, women were more likely to
survive). pclass and
fare overlap substantially, so once pclass is selected the marginal
gain of fare falls to -0.17:
| pick | feature | relevance | redundancy to selected | gain |
|---|---|---|---|---|
| 1 | sex | 0.49 | 0.00 | +0.49 |
| 2 | pclass | 0.30 | 0.16 | +0.14 |
| – | age | 0.19 | 0.24 | -0.05 |
| – | sibsp | 0.16 | 0.29 | -0.13 |
| – | embarked | 0.00 | 0.14 | -0.14 |
| – | parch | 0.19 | 0.29 | -0.10 |
| – | fare | 0.42 | 0.59 | -0.17 |
titanic: 712 rows (bundled copy); categorical columns: sex, embarked
A second mixed example: restaurant tips
The tips dataset asks a plainer question: what does the size of a tip actually depend on? Four of the six candidate variables are categorical (sex, smoker, day, time) and two are numeric (the bill, the party size).
arbital.orbits(datasets.load_tips(), target="tip").to_plotly()
The figure gives the answer at a glance: the bill dominates (r_info = 0.72),
party size adds a little, and the entire social context, who paid, whether they smoked,
which day, lunch or dinner, orbits out at the rim, nearly uninformative. Note also that
size sits at a nearby angle to total_bill: bigger parties run up
bigger bills, so the two are partly redundant, and the selection charges size for
that overlap before crediting what remains.
tips: 244 rows (bundled copy); categorical columns: sex, smoker, day, time
9Case study: penguins and a correlation that points the wrong way
The Palmer penguins data with all its categorical fields kept (species, island, sex) shows most of the package's behaviours in one figure, including the most treacherous failure mode of a correlation matrix: a coefficient whose sign is an artefact of pooling groups (Simpson's paradox).
arbital.orbits(datasets.load("penguins"), target="body_mass_g").to_plotly()
Look at bill_depth_mm. Its correlation with body mass is negative
(r = -0.47): pooled across species, deeper bills seem to mean lighter birds, because the
heaviest species (Gentoo) happens to have shallow bills. Mutual information is immune to the
pooling artefact and reports r_info = 0.86, among the strongest
associations in the data, so the marker swings in close while its ghost is stranded far
outside (ν = 70%). A correlation matrix would have reported a moderate negative
relationship; the orbit shows a strong relationship that correlation is misreading, and the
within-species scatter confirms it: bill depth and body mass rise together inside every
species.
bill_depth_mm against body_mass_g, coloured by species: negative pooled slope, positive within each species.
The categorical fields behave as section 8 described: species is a strong nominal
predictor (r_info = 0.79) drawn without direction, and the angular layout places
it beside island, which is largely a proxy for species. The selection resolves the
redundancies: flipper length is picked first, and species, despite its high individual
relevance, is charged for overlapping with the body measurements already selected.
| feature | cat | r | ρ | I (nats) | r_info | ν | pick | gain |
|---|---|---|---|---|---|---|---|---|
| flipper_length_mm | +0.87 | +0.84 | 0.702 | 0.87 | 0% | 1 | +0.86 | |
| sex | ✓ | +0.42 | +0.44 | 0.247 | 0.62 | 51% | 2 | +0.09 |
| species | ✓ | +0.00 | +0.00 | 0.495 | 0.79 | 0% | 3 | +0.35 |
| island | ✓ | +0.00 | +0.00 | 0.258 | 0.64 | 0% | 4 | +0.09 |
| bill_depth_mm | -0.47 | -0.43 | 0.657 | 0.86 | 70% | 5 | +0.11 | |
| bill_length_mm | +0.59 | +0.58 | 0.387 | 0.73 | 35% | 6 | +0.06 |
10Tutorial: feature selection
Feature selection underlies the marker sizes and can also be used on its own. The procedure is a greedy minimum-redundancy maximum-relevance (mRMR) forward selection: it selects the most relevant variable first, then repeatedly selects the variable with the largest relevance minus its mean redundancy with the variables already selected. Redundancy is charged only against selected variables, so a variable is not penalised for overlapping with others that are never chosen.
calibrate=True (the default), or the plain r_info(xj, y) when calibrate=False. The redundancy term, r_info(xj, xs) between a candidate and an already-selected feature, is always the plain, unadjusted feature-feature r_info: it never has a chance level subtracted, regardless of calibrate (see the caveat below). The marginal gain recorded at each step is the quantity mapped to marker size (relative to the first pick). A gain of zero or below indicates that a variable adds nothing beyond the selected set, which is a natural stopping point.
The ranking is available directly, without producing a figure:
for row in arbital.select_features(cars, target="mpg"):
print(row["pick"], row["name"], round(row["gain"], 3))
Passing selection=True to orbits additionally labels each marker with
its pick number:
| pick | feature | relevance | redundancy to selected | gain |
|---|---|---|---|---|
| 1 | weight | 0.87 | 0.00 | +0.87 |
| 2 | model_year | 0.62 | 0.54 | +0.07 |
| 3 | displacement | 0.87 | 0.72 | +0.15 |
| 4 | cylinders | 0.82 | 0.77 | +0.05 |
| 5 | horsepower | 0.86 | 0.83 | +0.02 |
| – | acceleration | 0.47 | 0.61 | -0.14 |
Here the engine-related variables (weight, displacement,
horsepower, cylinders) are mutually redundant, so after
weight is selected the others contribute little and model_year, which
is nearly independent of them, is selected second despite a lower individual association.
mpg: 392 rows (bundled copy)
11Tutorial: estimation uncertainty
Setting uncertainty=True computes a bootstrap standard error of r_info for each
variable and draws it as a radial band through the marker, spanning
r_info ± one standard error. The band is radial because that is the direction the
uncertainty acts in: it shows directly how much closer in or farther out the marker could
plausibly sit. This makes one question immediately decidable by eye: whenever a band crosses
the dashed chance boundary, a gold diamond marks the crossing, and that variable's
association could be noise. Useful on smaller samples, where the k-nearest-neighbour
mutual-information estimator is itself noisy.
A dataset where every variable is strongly associated (Palmer penguins' body measurements, say) never shows a crossing, so it is a poor illustration: the bands sit nowhere near the boundary. The weak-driver system from the next section is a better one, since several of its four genuine drivers start out close to chance. Same process, same target, two sample sizes:
arbital.orbits(cols, target="outcome", scale="linear",
uncertainty=True, n_bootstrap=200) # n = 250
Left: n = 250, bands crossing the boundary (diamonds) on driver_c, driver_d. Right: the same process at n = 2,000, where all four genuine drivers have cleared it; noise1 (0.20 ± 0.02, chance 0.19) sits right at it instead, a reminder that the boundary is a 95th-percentile threshold, not a guarantee, so a genuinely independent variable will occasionally land this close by chance. Either way, this is the pattern the next section's boundary-recedes-with-n panels show directly: more data pulls genuine signals clear, one at a time, while the boundary itself stays honest about the 5% of independent variables that will look borderline purely by chance.
12Tutorial: weak associations and the radial scale
The default radial scale is logarithmic in mutual information: each nat of shared information
is one fixed step inward. That deliberately spends the plot's area on the strong end -
it separates r_info = 0.90 from 0.99 clearly, and the price is paid at the weak
end, where everything below r_info ≈ 0.5 is compressed against the rim. In datasets
where no variable is strongly associated (common in noisy domains: marketing response,
epidemiology, finance), the default view is honest but unhelpful: every orbit hugs the rim.
For those datasets, pass scale="linear", which maps distance to 1 −
r_info and resolves the weak end instead. The same system of four genuine-but-weak
drivers and two noise variables, under both scales:
y = 0.6*u1 + 0.5*u2 + 0.4*u3 + 0.3*u4 + noise # nothing stronger than r_info ≈ 0.43 arbital.orbits(cols, target="outcome") # scale="info" (default) arbital.orbits(cols, target="outcome", scale="linear")
Left: scale="info" (default), the strongest driver reaches only d = 0.90, so all six variables crowd the rim. Right: scale="linear", the same driver sits at d = 0.57 and the three real drivers separate cleanly from the noise. Note the guide rings also change: the linear scale draws them at 0.25 / 0.5 / 0.75.
Rule of thumb: keep the default when anything in the data clears r_info ≈ 0.7
(the log scale is what stops strong features piling onto the target); switch to
scale="linear" when the whole system lives below ≈ 0.5. The weakest of the
four genuine drivers here, driver_d (built with coefficient
0.3, measured r = +0.15, r_info = 0.15), is barely
distinguishable from the noise variables under either scale. That gap between the built-in
coefficient and the measured r is itself the point: at n = 700 with this much noise, sampling
variation alone can shrink a real but weak driver's measured correlation well below the value
it was generated with. That is a fact about the sample, not the display: at this noise level
driver_d's association genuinely sits at the estimator's chance level
(the calibration of section 13 flags it below_chance whenever its mutual
information does not clear that level), and uncertainty=True will show its radial
band crossing the chance boundary.
More data pushes the chance boundary out
For a continuous feature, the drawn boundary mainly tracks sample size: with more rows the
estimator's output under independence shrinks toward zero, the dashed circle moves outward
toward the rim, and genuinely weak signals emerge from the shaded band. Two parameters move it
independently of sample size. Raising confidence pulls the boundary inward at a
fixed n (a stricter quantile of the same shuffled draws is a larger chance I, and a larger
chance r_info sits closer to the centre, since distance is a decreasing function of r_info): a
more conservative test, only stronger associations clear it. Lowering confidence
pushes the boundary outward toward the rim, letting weaker associations count as clearing
chance, at the cost of more false positives (see the multiple-comparisons caveat below).
n_neighbors moves it by changing the k-NN estimator's own noise floor, in the
opposite direction from what you might expect at first: fewer neighbours means a noisier,
higher floor, which by the same r_info-to-distance relationship pulls the boundary inward,
toward the centre, so more of the plot counts as noise. More neighbours generally lowers the
floor and pushes the boundary outward toward the rim, letting weaker associations clear
chance, at the cost of blurring together points that are genuinely close together (the usual
variance/bias trade-off for k-NN estimators). Categorical features are not
part of this picture at all; each is compared against its own, separately-computed level (see
the caveats below), not the drawn circle. The same weak-driver process drawn at three sample
sizes, the boundary sits at r_info ≈ 0.31 with 250 rows, 0.25 with 1,000, and
0.19 with 2,000:
Left to right: n = 250, 1,000, 2,000 draws of the same process, drawn on the linear radial scale (these are weak associations, so the linear scale of the previous section is the right lens). Watch driver_d, the weakest of the four genuine drivers, cross the boundary as the sample grows.
This is the practical answer to "is this weak association real?": collect more rows and the
boundary will either recede past the marker (real) or follow it (noise). When more data is not
available, uncertainty=True shows whether the estimate's own spread straddles the
boundary. Note that max_samples (default 2000) caps the rows actually measured;
raise it when precision at the weak end matters more than compute time.
13How the measures map to the figure
Mutual information $I(X;Y)$ is estimated for each variable against the target and converted to a bounded scale using Linfoot's informational coefficient of correlation, $r_{\text{info}} = \sqrt{1 - e^{-2I}}$, which equals $|\rho|$ for a bivariate Gaussian. The radius is the residual uncertainty $\sqrt{1 - r_{\text{info}}^2} = e^{-I}$, so the radial axis is logarithmic in information: one nat corresponds to a fixed step inward. (A nat is a unit of information using the natural logarithm; one nat is the information in an event of probability $1/e$.)
Assumptions and calibration caveats
The core estimators (KSG and Ross's k-NN mutual information, the categorical plug-in estimator, Linfoot's identity for the Gaussian case) are standard and are not what this section is about. Everything below lives in the calibration and visualization layer: what the chance boundary is allowed to mean, what confidence level backs it, and where a design choice is a reasonable, explicitly-flagged heuristic rather than something statistically exact. Full statistical rigour is not the goal here; usefulness as an exploratory screen is. Every assumption below is one you are implicitly relying on when you read the figure, so it is listed rather than left to be discovered.
- The Gaussian anchor is a calibration, not an assumption. "r_info equals |ρ| for a bivariate Gaussian" only fixes where the scale's zero and one live. Mutual information is defined for any distribution, and r_info = √(1 − e−2I) is a fixed monotone transform of it, so ranking variables by r_info is ranking them by mutual information whatever the true distribution: r_info is 0 exactly under independence and approaches 1 as the relationship becomes deterministic, for any margins. What non-Gaussianity changes is only whether r_info numerically matches the |ρ| you would compute directly. For continuous variables, r_info is in fact a property of the copula alone: reparametrise either margin monotonically and it does not move, since mutual information for a continuous pair is exactly the negative entropy of their copula density (Joe, 1989; see references below).
- One confidence level, applied identically to every channel. A single parameter,
confidence(default 0.95), is the quantile behind the MI chance level and the Pearson/Spearman chance levels (pearson_chance,spearman_chance). All three are the same quantile of draws from the same permutation procedure: shuffle the target, recompute I, |r|, and |ρ|, and repeat. Because the three channels share one parameter and one set of draws, changingconfidencemoves every channel together, so a marker cannot clear chance on one channel at a looser implicit standard than another. - The drawn boundary circle is for continuous features only. Continuous features
share one pooled chance level (below); categorical features are each calibrated separately,
because the plug-in estimator's chance value scales with a column's cardinality
(≈(Kx−1)(Ky−1)/(2n), the classic small-sample bias term
for plug-in entropy estimates: Miller, 1955; see references below), and a 20-level column has a
materially higher noise floor than a binary one. Drawing one circle at
the maximum over both groups would mean a marker's
below_chanceverdict (always computed against its own level) could disagree with which side of the circle it visually sits on, for every feature except whichever one happened to set that maximum. So the circle is drawn at the continuous pooled level only, which every continuous feature's own hover value matches exactly; categorical markers are compared against their own (generally different) level shown in their hover text instead, worded "its own chance level" rather than "chance boundary" precisely so the two are not read as the same line. With an all-categorical feature set, no circle is drawn at all, since there is no single honest threshold to draw. - Pooling continuous features assumes a near-identical null, and that is asserted, not verified on your data. After the KSG estimator's internal standardisation, its null distribution is taken to depend on the sample size and k and only weakly on a continuous feature's marginal shape, and the same is assumed of a shuffled correlation coefficient. So every continuous feature shares one pooled chance level per channel, with shuffle draws cycling round-robin across them to average out what little marginal dependence remains. This keeps the cost at ~n_shuffles draws total rather than n_shuffles × features, but it is an approximation: a skewed, heavy-tailed, or near-duplicate-valued column (rounded or discretised continuous data) can have a true null that differs from the pooled estimate, and arbital does not check for this. If your continuous features have very different marginal shapes, treat the shared chance level as approximate.
- The quantile itself is estimated from a limited number of draws.
n_shufflesdraws (200 by default) give aconfidence-quantile estimate with onlyn_shuffles × (1 − confidence)draws expected beyond it: 10 at the defaults, fewer if you lowern_shufflesor raiseconfidence. That is itself a noisy number with no reported uncertainty of its own (unlike marker positions, which do carry a bootstrap SE whenuncertainty=True). A warning fires when the expected count drops below 10; raisen_shufflesif you see it and need a more stable boundary. - No correction for testing many features at once. Each feature clears its own chance
level at a one-sided
confidencethreshold independently, with no Bonferroni (Dunn, 1961) or Benjamini–Hochberg (1995) adjustment across the p features screened together (see references below), which is the package's whole use case. That has a direct consequence: screening p genuinely unassociated features at a givenconfidencegives an expected $p \times (1 - \text{confidence})$ of them "clears chance" purely by accident. At the default confidence of 0.95, screening 20 unrelated features means roughly one is expected to clear chance by luck alone; screening 100 means roughly five. The figure does not flag this on its own. If you are screening many candidates, raiseconfidence(there is no automatic correction to fall back on). - When the target is categorical, the pooled continuous level is doing more work. The continuous features' shuffle draws then go through the Ross continuous-discrete estimator, whose finite-sample bias depends on the target's class counts and balance, not just n and k. So the "depends only on n and k" story is weaker for a skewed or many-class categorical target than it is for a continuous one.
- r_info_adj is a one-sided shrinkage clip, not a bias-corrected estimate.
mi_adj = max(0, I − chance)subtracts a 95th-percentile (at the default confidence) null value, not a null mean. So an honestly-independent feature's r_info_adj is pushed to exactly 0 aboutconfidenceof the time and to something positive the rest, by construction, regardless of the true bias. That is a reasonable, conservative guard for a greedy selection, since it errs toward discarding noise rather than keeping it, but it should not be read as recovering "the" bias-corrected mutual information. It is a clip, not a correction. - Feature selection's redundancy term is never chance-adjusted, only its relevance term
is. The greedy selection in section 10 ranks candidates on relevance minus mean redundancy.
Relevance is r_info_adj (chance-subtracted, above) when
calibrate=True, but redundancy, the feature-feature r_info between a candidate and the features already selected, always comes from the plain feature-feature association matrix, with no chance level ever subtracted from it, regardless ofcalibrate. Giving every feature pair its own chance level would mean shuffling every candidate/selected pair rather than shuffling once against the target, multiplying the cost by roughly p/2 for p features. In practice this means part of any redundancy charge can be the k-NN estimator's own noise floor (the same ≈0.1–0.25 r_info baseline discussed above) rather than real overlap, which very slightly inflates the redundancy penalty on later picks. It does not affect which feature is picked first, since the first pick is relevance alone with no redundancy term yet, and its effect on later picks shrinks as real redundancy grows relative to the noise floor. - Population-level mutual information cannot fall below what a monotone fit already captures (this one is a genuine floor, not a heuristic). Among all joint distributions sharing a given correlation, the Gaussian copula uniquely minimises mutual information, which is the reason r_info = |ρ| exactly for a bivariate Gaussian and r_info ≥ r_mono for everything else (Linfoot 1957's original motivation for the coefficient). So a well-estimated r_info should never sit below r_mono. When a k-NN estimate does dip under r_mono anyway, it is estimator error, not a real data phenomenon: more likely at small n, with too few or too many neighbours (n_neighbors trades variance against bias), on heavy-tailed margins that stretch neighbour distances unevenly, on data with many repeated or tied values (the estimator assumes continuity), or on very strong, near-deterministic relationships where the density concentrates onto a thin manifold and local neighbourhoods stop behaving like the estimator's assumptions. profile() floors r_info at r_mono for exactly this reason (see "nonlinear share" in the glossary above), so the orbit never reports less association than plain correlation already demonstrates.
- Rows are subsampled above max_samples, once, and everything downstream reuses that
same subsample. The estimator is O(n²), so above
max_samplesrows a single random subset of exactly that size is drawn (seeded byrandom_state), and every feature's r_info, the boundary circle, the permutation-based chance levels, and the bootstrap standard errors are all computed from that one fixed subsample, not from fresh draws of the full dataset each time. Two consequences follow: the samerandom_statealways picks the same rows, so results are reproducible on a dataset far larger thanmax_samples; and bothuncertainty=True's bootstrap and the chance calibration's permutations describe sampling variability at n =max_samples, not at the size of your actual dataset, since the excluded rows are never revisited within that call. If you have far more rows available and want the estimate to reflect that, raisemax_samples(or callorbits()again with a differentrandom_stateto check how much a given result moves across subsamples) rather than relying on repeated calls to average it out automatically, since arbital does not do that for you. - Tuning for large datasets. The cost centres are the k-NN mutual-information estimator
(O(n²) per pair) and the shuffle-based calibration (each shuffle re-runs that estimator).
Four parameters trade precision against runtime: lower
max_samplescaps the rows actually measured (the default, 2000, is already a subsample for most real datasets); lowern_shufflesspeeds up calibration at the cost of a noisier chance-level estimate (watch for the stability warning if you go far below the default); lowern_bootstrapspeeds upuncertainty=Truethe same way; andn_neighborsmainly trades estimator variance against bias rather than speed, so tune it for accuracy, not runtime. For an initial look at a very large dataset, a smallermax_sampleswith the other defaults left alone is usually the fastest way to a representative figure.
Two of the items above are unconditional and apply either way: the r_mono floor and
subsampling above max_samples both happen regardless of calibrate,
and so does the performance-tuning guidance. Everything else in the list is what
calibrate=False switches off: no chance, chance_nats,
below_chance, mi_adj, r_info_adj,
pearson_chance/spearman_chance,
pearson_sig/spearman_sig, and no boundary circle in the figure. One
thing calibration never touches either way: r_info itself and every marker's position stay
exactly the same whether or not you calibrate (see the "chance boundary" row in the glossary
above for why).
14Every column in table() and to_df()
table() returns a list of dicts, one per feature; to_df() is the same
data as a pandas DataFrame indexed by feature name. Every value used anywhere in the figure or
the hover text lives in one of these columns: nothing the plot reads is hidden from the table.
space = arbital.orbits(datasets.load_mpg(), target="mpg", selection=True)
space.to_df()[["r_info", "mi", "chance", "below_chance", "mi_adj",
"r_info_adj", "pearson", "pearson_sig", "pick", "gain"]].round(3)
r_info mi chance below_chance mi_adj r_info_adj pearson pearson_sig pick gain name weight 0.886 0.768 0.312 False 0.717 0.873 -0.832 True 1 0.873 model_year 0.663 0.290 0.312 False 0.238 0.616 0.581 True 2 0.073 displacement 0.885 0.763 0.312 False 0.712 0.871 -0.805 True 3 0.151 cylinders 0.843 0.621 0.312 False 0.569 0.824 -0.778 True 4 0.054 horsepower 0.872 0.713 0.312 False 0.662 0.857 -0.778 True 5 0.025 acceleration 0.542 0.174 0.312 False 0.123 0.466 0.423 True 6 -0.144
A subset of columns from the Auto MPG example above, chosen to fit the page width. The full column list on this system is: theta, size, pick, gain, pearson, spearman, mi, r_info, r_mono, nonlinearity, categorical, nominal, chance, chance_nats, mi_adj, r_info_adj, below_chance, pearson_chance, spearman_chance, pearson_sig, spearman_sig, r_peri, r_apo, a, e.
| column | meaning |
|---|---|
| theta | angular position in radians; sets where the marker sits around the circle (see section 4). |
| size | the 0 to 1 value actually plotted as marker size: relative gain, relative r_info, or a constant, depending on the size argument to orbits(). |
| pick | 1-based greedy selection order (see "pick, gain" in the glossary above). |
| gain | marginal gain at the moment this feature was picked: relevance minus the mean redundancy to the features already selected. |
| pearson, spearman | the signed coefficients described in the glossary above; 0.0 for a nominal (multi-level) categorical feature. |
| mi | mutual information in nats: the "nats" row above. |
| r_info | the estimated total association that sets the radius: the "r_info" row above. |
| r_mono | max(|pearson|, |spearman|), the best monotone description of the association. |
| nonlinearity | ν, the "nonlinear share" row above. |
| categorical | True when the feature is discrete, whether binary or nominal. |
| nominal | True only when a discrete feature has more than two levels, so it has no meaningful sign: pearson and spearman are 0 and the orbit is circular. False for a binary categorical feature (e.g. Titanic's sex), which keeps a real direction, see section 8. |
| r_peri, r_apo | the orbit's closest and farthest point from the centre (periastron / apastron), on the underlying 0 to 1 distance scale, computed from r_info and r_mono. The plotted marker applies a further small display margin on top of this for legibility; the underlying ranking is unaffected. |
| a | the orbit's semi-major axis, (r_peri + r_apo) / 2. |
| e | orbit eccentricity, equal to nonlinearity by construction. |
| chance, chance_nats | the chance level this feature is compared against, on the r_info scale and in nats: the "chance boundary" row above. |
| mi_adj, r_info_adj | the chance-subtracted relevance used only for selection, never for the marker's position: the "pick, gain" row above. |
| below_chance | True when the estimated MI does not clear chance_nats. |
| pearson_chance, spearman_chance | the chance level each coefficient is compared against, from the same permutation draws as chance. |
| pearson_sig, spearman_sig | True when the coefficient's absolute value exceeds its own chance level. |
| r_info_se | bootstrap standard error of r_info; present only when uncertainty=True. |
Every column above except theta, size, pick, and
gain comes either from profile()/orbit geometry (always present) or
from calibration (calibrate=True, the default). Passing calibrate=False
removes chance, chance_nats, mi_adj, r_info_adj,
below_chance, pearson_chance, spearman_chance,
pearson_sig, and spearman_sig from every row.
15Parameter reference
The main entry point is orbits(X, target=None, ...). The parameters are explicit
and have the following defaults:
| parameter | default | meaning |
|---|---|---|
| target | None | target column name or index; None selects the column most associated with the others. |
| angle_layout | "spread" | "spread" keeps the embedding's gap structure with a minimum separation enforced; "embed" uses the raw embedding angles; "ordered" spaces variables evenly, keeping only the order. |
| scale | "info" | "info" makes radius logarithmic in mutual information (resolves the strong end); "linear" uses 1 − r_info (resolves the weak end, see section 12). |
| size | "gain" | marker area: "gain" (relative selection gain), "rinfo" (total association), or "uniform". |
| selection | False | if True, label each marker with its selection pick number. |
| uncertainty | False | if True, draw a bootstrap standard error of r_info as a radial band through each marker; a diamond flags bands that cross the chance boundary. |
| calibrate | True | measure each estimator's chance level (its output against shuffled targets) and use it for the boundary circle, the below_chance flag, and a separately chance-subtracted selection relevance (r_info_adj); r_info itself and the marker's position are never adjusted (see section 13). |
| confidence | 0.95 | the quantile behind every chance level (MI, Pearson, and Spearman alike, from the same permutation draws); raise it to be more conservative, especially when screening many features at once (no multiple-comparison correction is applied automatically). |
| n_shuffles | 200 | shuffle draws behind the chance levels (continuous features share one pooled level per channel, so the cost is ~n_shuffles draws total, not n_shuffles × features). A warning fires if n_shuffles × (1 − confidence) < 10: too few draws beyond the quantile for a stable estimate. |
| n_bootstrap | 100 | bootstrap resamples used when uncertainty is enabled. |
| n_neighbors | 5 | neighbours for the k-NN mutual-information estimators. |
| random_state | 0 | seed for subsampling, calibration shuffles, and the bootstrap; results are deterministic given the same seed. |
| categorical | None | additional column names or indices to treat as categorical. |
16Reproducing this page
import arbital from arbital import datasets # mpg, penguins, titanic, tips are bundled (offline) # association view arbital.orbits(datasets.load_mpg(), target="mpg").to_html("mpg.html") # selection ranking, without a figure arbital.select_features(datasets.load_mpg(), target="mpg") # categorical target, and the uncertainty arcs arbital.orbits(datasets.load_titanic(), target="survived", selection=True, uncertainty=True) # mixed types, and the full categorical penguins table arbital.orbits(datasets.load_tips(), target="tip") arbital.orbits(datasets.load("penguins"), target="body_mass_g")
17References
The estimators and procedures implement published methods:
Pearson, K. (1895). Notes on regression and inheritance in the case of two parents. Proceedings of the Royal Society of London, 58, 240–242.
Spearman, C. (1904). The proof and measurement of association between two things. American Journal of Psychology, 15(1), 72–101.
Shannon, C. E. (1948). A mathematical theory of communication. Bell System Technical Journal, 27, 379–423.
Torgerson, W. S. (1952). Multidimensional scaling: I. Theory and method. Psychometrika, 17(4), 401–419.
Miller, G. A. (1955). Note on the bias of information estimates. In Information Theory in Psychology: Problems and Methods, 95–100.
Linfoot, E. H. (1957). An informational measure of correlation. Information and Control, 1(1), 85–89.
Dunn, O. J. (1961). Multiple comparisons among means. Journal of the American Statistical Association, 56(293), 52–64.
Efron, B. (1979). Bootstrap methods: another look at the jackknife. The Annals of Statistics, 7(1), 1–26.
Joe, H. (1989). Relative entropy measures of multivariate dependence. Journal of the American Statistical Association, 84(405), 157–164.
Benjamini, Y., & Hochberg, Y. (1995). Controlling the false discovery rate: a practical and powerful approach to multiple testing. Journal of the Royal Statistical Society: Series B, 57(1), 289–300.
Kraskov, A., Stögbauer, H., & Grassberger, P. (2004). Estimating mutual information. Physical Review E, 69(6), 066138.
Peng, H., Long, F., & Ding, C. (2005). Feature selection based on mutual information: criteria of max-dependency, max-relevance, and min-redundancy. IEEE TPAMI, 27(8), 1226–1238.
Ross, B. C. (2014). Mutual information between discrete and continuous data sets. PLoS ONE, 9(2), e87357.