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.

Aaron Byrne  ·  aaronbyrnephd.com  ·  github.com/aaronbyrnephd/arbital  ·  API reference  ·  aaron@aaronbyrnephd.com
arbital = Association Radii By Information-Theoretic Association Learning. This page is the usage vignette: a short summary of the method, then a worked tour of the package, feature by feature. Every figure and number on this page is computed live by demo/make_demo.py.

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

Radius: total association with the target, on a logarithmic-in-information scale; closer to the centre is stronger.
Ghost marker and tether: the hollow marker shows where correlation alone would place the variable; the tether measures the association that correlation does not capture.
Orbit curvature: the eccentricity of the orbit equals the nonlinear share ν; a circle indicates a purely monotone relationship.
Angle: variables associated with each other are placed at nearby angles, and by default the gaps keep their relative meaning: a tight huddle is a redundant group, and a variable sitting alone carries independent information.
Marker size: the relative marginal gain from feature selection; larger markers are stronger, less redundant variables.
Colour: direction from the signed Spearman ρ: red for positive, blue for negative, near-white for none or for a nominal category.
Arc reach: each variable's arc reaches toward its angular neighbours in proportion to how associated they are: overlapping arcs mean the two variables share information, a clear gap means they do not (qualitative).
Uncertainty band: with 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).
Chance boundary: the dashed circle and shaded outer band: anything beyond it is indistinguishable from noise at this sample size (estimated by re-measuring against shuffled copies of the target).
Hover: hovering a marker rings it with a halo, traces the variable's complete orbit (clipped where it would leave the plot), and shows its full metric profile.

What the numbers mean

The hover box, table(), and to_df() report the same quantities:

quantitymeaning
r_infototal 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.
distancethe 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 boundaryThe 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_spearmanthe 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 sharethe 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, gainthe 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 gainthe 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:

featurecatrρI (nats)r_infoνpickgain
monotone+0.82+0.931.2630.966%1+0.96
sine+0.16+0.290.9870.9390%2+0.18
parabola-0.22-0.181.0150.9394%3+0.29
linear_copy+0.89+0.890.8270.902%4+0.19
linear+0.94+0.941.1220.950%5+0.15
noise-0.01-0.010.0000.010%6-0.07
weak+0.38+0.370.0330.380%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:

pickfeaturerelevanceredundancy to selectedgain
1sqrt0.990.00+0.99
log0.991.00-0.01
raw0.991.00-0.01
capped0.971.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:

pickfeaturerelevanceredundancy to selectedgain
1sex0.490.00+0.49
2pclass0.300.16+0.14
age0.190.24-0.05
sibsp0.160.29-0.13
embarked0.000.14-0.14
parch0.190.29-0.10
fare0.420.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.

featurecatrρI (nats)r_infoνpickgain
flipper_length_mm+0.87+0.840.7020.870%1+0.86
sex+0.42+0.440.2470.6251%2+0.09
species+0.00+0.000.4950.790%3+0.35
island+0.00+0.000.2580.640%4+0.09
bill_depth_mm-0.47-0.430.6570.8670%5+0.11
bill_length_mm+0.59+0.580.3870.7335%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.

pick t = argmaxj [ relevance(xj) − means ∈ selected r_info(xj, xs) ] relevance(xj) is r_info_adj(xj, y), the chance-subtracted quantity from section 13, when 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:

pickfeaturerelevanceredundancy to selectedgain
1weight0.870.00+0.87
2model_year0.620.54+0.07
3displacement0.870.72+0.15
4cylinders0.820.77+0.05
5horsepower0.860.83+0.02
acceleration0.470.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$.)

$\nu = 1 - (r_{\text{mono}} / r_{\text{info}})^2, \quad r_{\text{mono}} = \max(|r|, |\rho|), \quad \text{eccentricity} = \nu$ The nonlinear share ν is the fraction of the association that the best monotone coefficient does not capture, and it is used directly as the orbit's eccentricity, so shape and statistic are the same quantity. ν is a composite defined for this package rather than a standard named statistic. The solid marker is placed at the closest point of the orbit and the ghost at the farthest, so the tether between them corresponds to ν.

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.

  1. 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).
  2. 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, changing confidence moves every channel together, so a marker cannot clear chance on one channel at a looser implicit standard than another.
  3. 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_chance verdict (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.
  4. 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.
  5. The quantile itself is estimated from a limited number of draws. n_shuffles draws (200 by default) give a confidence-quantile estimate with only n_shuffles × (1 − confidence) draws expected beyond it: 10 at the defaults, fewer if you lower n_shuffles or raise confidence. That is itself a noisy number with no reported uncertainty of its own (unlike marker positions, which do carry a bootstrap SE when uncertainty=True). A warning fires when the expected count drops below 10; raise n_shuffles if you see it and need a more stable boundary.
  6. No correction for testing many features at once. Each feature clears its own chance level at a one-sided confidence threshold 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 given confidence gives 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, raise confidence (there is no automatic correction to fall back on).
  7. 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.
  8. 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 about confidence of 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.
  9. 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 of calibrate. 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.
  10. 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.
  11. Rows are subsampled above max_samples, once, and everything downstream reuses that same subsample. The estimator is O(n²), so above max_samples rows a single random subset of exactly that size is drawn (seeded by random_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 same random_state always picks the same rows, so results are reproducible on a dataset far larger than max_samples; and both uncertainty=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, raise max_samples (or call orbits() again with a different random_state to 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.
  12. 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_samples caps the rows actually measured (the default, 2000, is already a subsample for most real datasets); lower n_shuffles speeds up calibration at the cost of a noisier chance-level estimate (watch for the stability warning if you go far below the default); lower n_bootstrap speeds up uncertainty=True the same way; and n_neighbors mainly 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 smaller max_samples with 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.

columnmeaning
thetaangular position in radians; sets where the marker sits around the circle (see section 4).
sizethe 0 to 1 value actually plotted as marker size: relative gain, relative r_info, or a constant, depending on the size argument to orbits().
pick1-based greedy selection order (see "pick, gain" in the glossary above).
gainmarginal gain at the moment this feature was picked: relevance minus the mean redundancy to the features already selected.
pearson, spearmanthe signed coefficients described in the glossary above; 0.0 for a nominal (multi-level) categorical feature.
mimutual information in nats: the "nats" row above.
r_infothe estimated total association that sets the radius: the "r_info" row above.
r_monomax(|pearson|, |spearman|), the best monotone description of the association.
nonlinearityν, the "nonlinear share" row above.
categoricalTrue when the feature is discrete, whether binary or nominal.
nominalTrue 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_apothe 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.
athe orbit's semi-major axis, (r_peri + r_apo) / 2.
eorbit eccentricity, equal to nonlinearity by construction.
chance, chance_natsthe chance level this feature is compared against, on the r_info scale and in nats: the "chance boundary" row above.
mi_adj, r_info_adjthe chance-subtracted relevance used only for selection, never for the marker's position: the "pick, gain" row above.
below_chanceTrue when the estimated MI does not clear chance_nats.
pearson_chance, spearman_chancethe chance level each coefficient is compared against, from the same permutation draws as chance.
pearson_sig, spearman_sigTrue when the coefficient's absolute value exceeds its own chance level.
r_info_sebootstrap 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:

parameterdefaultmeaning
targetNonetarget 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".
selectionFalseif True, label each marker with its selection pick number.
uncertaintyFalseif 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.
calibrateTruemeasure 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).
confidence0.95the 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_shuffles200shuffle 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_bootstrap100bootstrap resamples used when uncertainty is enabled.
n_neighbors5neighbours for the k-NN mutual-information estimators.
random_state0seed for subsampling, calibration shuffles, and the bootstrap; results are deterministic given the same seed.
categoricalNoneadditional 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.