Skip to content

fw.pairs

Use fw.pairs to compare selected dimensions two at a time. It describes mapping cardinality, Cramér’s V, and optionally unobserved combinations relative to explicit or empirical domains.

These are different questions: a strong association is not a deterministic mapping, and an unobserved combination is not necessarily impossible. Use joint_counts to inspect the actual cells behind one pair.

Jump to the worked example or the argument reference.

fw.pairs(
df,
dimensions,
*,
dropna=False,
include_absence=False,
reference_domains=None,
pair_contexts=None,
limits=None,
scope=None,
missing=None,
table_id="table",
progress=None,
cancel=None,
timeout=None,
safe_errors=None,
)

This page describes Fieldwork 0.3.2. Import the package with import fieldwork as fw. View the documented source.

Argument Type / default Effect
df pandas.DataFrame; required The source table. Analysis preserves its values and row order; duplicate column labels are rejected.
dimensions iterable of column names; required Ordered columns to analyze. Non-string labels are matched and reported by their string form; each column must exist and be selected only once.
dropna bool; False False includes native missing as a level. True excludes missing pair values and, for contextual tests, missing context values; each result records its own population.
include_absence bool; False Include absent-combination summaries and bounded examples. A zero count is interpreted relative to the domains below, not a universal set of possibilities.
reference_domains mapping of labels to iterables or None; None Declared possible levels per dimension. Unspecified dimensions use the globally observed eligible levels for that pair. Declared domains must include every observed eligible level; additional levels expose unsupported margins.
pair_contexts iterable of mappings or None; None Additional equality-filter contexts, for example [{"season": "winter"}]. The global context is included first; requested contexts are sorted deterministically. Context columns must be disjoint from each analyzed pair.
limits mapping or None; None Work budgets, each a nonnegative int or None for no limit: max_pairs (default 15) caps unordered dimension pairs in input order, max_contexts (default 32) caps contexts including the global one, and max_absence_cells (default 1000) caps stored absent-cell examples across pairs and contexts. Omitted keys keep their defaults; unknown keys raise TypeError. Omitted work is counted, never treated as evidence.
scope Scope or None; None A saved row selection tied to this dataframe; None uses every row. Construct with fw.Scope.from_positions(df, positions). Pass the original dataframe with its scope, rather than a reordered or changed copy.
missing mapping or None; None Column names mapped to lists of sentinel values, for example {"route": ["unknown"]}. Native nulls are always missing. Sentinels join the missing level of their column without replacing source values.
table_id str; 'table' Nonempty table identity recorded with the evidence. It does not bypass source-fingerprint checks.
progress bool or callable; None Silent by default; True displays progress, or pass a synchronous ProgressEvent callback. See runtime controls.
cancel CancellationToken or None; None Cooperative cancellation token; cancellation raises AnalysisCancelled without returning a partial result.
timeout float or None; None Finite nonnegative seconds from call start; None disables the cooperative deadline. A running pandas/NumPy work item must finish before cancellation.
safe_errors bool or None; None True raises any other failure as AnalysisError, naming the operation, phase and column without echoing the original message, which can quote cell values. See runtime controls.

Returns a fieldwork.Result with kind="pairs" (schema 2.0). pairs contains one record per evaluated pair and context: its columns, context predicates, relation, cramers_v, observed_cells, marginals, domains, optional absence, and its population as flat counts: evaluated_rows, missing_excluded_rows, and restriction_excluded_rows (rows outside the context). requested_pairs, processed_pairs, omitted_pairs, and the corresponding context counts disclose unevaluated work. Like every result, it records source, scope, missing_convention, and parameters, including the effective limits. Values are plain JSON: None for missing, numbers as numbers, and dates as text.

Use dictionary access for calculations, to_dict() for a JSON archive, plaintext for terminal output, SVG for a figure, or HTML for a report. The SVG mapping view emphasizes relations; the association view encodes Cramér’s V. recompute(df) reruns the analysis on the verified source with its saved settings.

Mapping labels are oriented from the first dimension to the second: 1:n means one first value can map to multiple second values, with at most one first value per second value. Cramér’s V describes association magnitude, not causation or a significance test; it is undefined for constant dimensions or empty populations.

Absence distinguishes an unobserved cell within supported margins from a level absent from one or both margins. Domain declarations frame what was possible; they do not create observations. Invalid budgets, incomplete declared domains, or overlapping pair/context columns raise ValueError.

Walkthrough: which transit combinations are actually missing?

Section titled “Walkthrough: which transit combinations are actually missing?”

A transit analyst has 20 recorded boardings across a morning and an evening shift. They want to know whether routes and ticket types have a consistent mapping, how strongly they are associated, and which route–ticket combinations went unrecorded. Those are three different questions. fw.pairs keeps their evidence separate.

The runnable script follows the same steps and saves the figure and HTML report.

Each tuple specifies a period, route, ticket type, and number of boardings. Expanding the counts matters: pairs counts rows, so passing the eight tuples as an eight-row table would analyze a different population. There are no missing values in this example, and a boarding is not necessarily a distinct passenger.

import pandas as pd
import fieldwork as fw
cells = [
("morning", "Harbor", "single", 2),
("morning", "Harbor", "pass", 2),
("morning", "Hill", "single", 2),
("morning", "Hill", "pass", 2),
("morning", "Airport", "group", 4),
("evening", "Harbor", "single", 2),
("evening", "Harbor", "pass", 2),
("evening", "Hill", "single", 4),
]
rows = []
for period, route, ticket, count in cells:
for _ in range(count):
rows.append((period, route, ticket))
boardings = pd.DataFrame(
rows,
columns=["period", "route", "ticket"],
)
print("Rows:", len(boardings))
Rows: 20

Preserve the possible levels while asking about an evening

Section titled “Preserve the possible levels while asking about an evening”

The analyst’s catalog also lists the Ferry route and student tickets. Neither appears in this log. Declare them explicitly: an empirical domain cannot discover levels absent from every row. These domains define a Cartesian comparison grid; they do not assert that every combination is permitted by transit policy.

A context evaluates the evening while retaining the full input as its parent population. period is separate from the analyzed pair, as required.

report = fw.pairs(
boardings,
["route", "ticket"],
include_absence=True,
reference_domains={
"route": ["Harbor", "Hill", "Airport", "Ferry"],
"ticket": ["single", "pass", "group", "student"],
},
pair_contexts=[{"period": "evening"}],
)
records = report.to_dict()["pairs"]
summary = pd.DataFrame(
[
{
"population": "all" if not record["context"] else "evening",
"rows": record["evaluated_rows"],
"mapping": record["relation"],
"V": round(record["cramers_v"], 3),
"observed": record["observed_cells"],
"absent": record["absence"]["absent_cells"],
}
for record in records
]
)
print(summary.to_string(index=False))
population rows mapping V observed absent
all 20 n:m 0.730 5 11
evening 8 n:m 0.577 3 13

Both populations have a many-to-many mapping: at least one route has multiple ticket types, and at least one ticket type appears on multiple routes. That says nothing about equal frequencies. Cramér’s V summarizes association using the 20 or 8 evaluated rows and their positive-support margins; globally absent catalog levels do not enter that calculation. Its value is neither a mapping guarantee nor a test of whether an empty cell is impossible.

for label, record in zip(["all", "evening"], records):
print(label, record["absence"]["classes"])
all {'unobserved_zero_support': 7, 'level_absent_under_parent': 0, 'unobserved_within_supported_margins': 4}
evening {'unobserved_zero_support': 7, 'level_absent_under_parent': 5, 'unobserved_within_supported_margins': 1}

There are 16 declared cells in each population. The evening has three observed cells and 13 empty ones, partitioned without overlap:

  • Seven have a globally unsupported level. Ferry contributes four cells, student contributes four, and Ferry–student is counted only once: 4 + 4 − 1 = 7.
  • Five lose a level under the evening context. Airport and group both occur in the full log but neither occurs in the evening. Within the globally supported 3 × 3 grid, their union contains 3 + 3 − 1 = 5 cells.
  • One is empty despite supported evening margins: Hill–pass. Hill has four evening boardings; pass has two. Their intersection has none.

The last cell is a useful follow-up target because neither level disappeared entirely. It still does not establish a ban on passes on Hill: a short sample, a service change, or a logging issue could explain it. Four supported-margin zeros exist globally: Airport–single, Airport–pass, Harbor–group, and Hill–group.

Without reference domains, filtering the input first produces a 2 × 2 empirical domain. The result can still identify Hill–pass, but cannot describe Ferry, student, or the disappearance of Airport and group relative to the original log.

evening_only = fw.pairs(
boardings.loc[boardings["period"].eq("evening")],
["route", "ticket"],
include_absence=True,
)
local = evening_only["pairs"][0]
print(
"Filtered empirical domain:",
local["domains"]["a_size"],
"x",
local["domains"]["b_size"],
"; absent:",
local["absence"]["absent_cells"],
)
Filtered empirical domain: 2 x 2 ; absent: 1

Compare association with a directional mapping

Section titled “Compare association with a directional mapping”

Now attach each route’s zone. Harbor and Hill share the town zone; Airport has the outer zone. Every observed route has exactly one zone, while town contains two routes. Direction therefore matters: route to zone is n:1.

boardings["zone"] = boardings["route"].map(
{"Harbor": "town", "Hill": "town", "Airport": "outer"}
)
mapping = fw.pairs(boardings, ["route", "zone"])["pairs"][0]
print(
"Route to zone:",
mapping["relation"],
"; V:",
round(mapping["cramers_v"], 3),
)
Route to zone: n:1 ; V: 1.0

A V of 1 does not imply a one-to-one mapping. Here the zone was assigned by us, so the perfect association reflects construction, not a newly discovered rule.

Render the evidence and choose the next check

Section titled “Render the evidence and choose the next check”
svg = fw.render_svg(report, view="association")
html = fw.render_html(report)

Native pair association view for all boardings and the evening context.

The native SVG compares association by population. Its cells compare columns, not individual route–ticket combinations. The HTML switches between mapping and association and lets you select a context; use the dictionary above for absence classes. To inspect the direction of mappings instead, use fw.render_svg(report, view="mapping").

This small report processes its one pair and both contexts completely and retains all 24 absence examples across them. On a larger analysis, inspect omitted pairs, contexts, and absence examples before interpreting what a display leaves out. The next operational check is to compare evening Hill–pass boardings with another shift and the ticket rules. This log establishes an observed zero in a specified population; it cannot establish that the combination never happens.

Save the figure or report in your working directory:

from pathlib import Path
Path("pairs.svg").write_text(svg, encoding="utf-8")
Path("pairs.html").write_text(html, encoding="utf-8")

fw.joint_counts, fw.grain, fw.census. See results and configuration for serialization and result-method contracts.