Skip to content

fw.compare

Use fw.compare to compare per-feature populated fractions from two missingness results. It keeps each side’s population and missing-value conventions so an apparent improvement can be interpreted in context.

This is an availability comparison. It does not compare individual cell values, match records across deliveries, or establish why a rate changed.

Jump to the worked example or the argument reference.

fw.compare(
before,
after,
)

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

Argument Type / default Effect
before Result; required Earlier missingness result (restore a saved export with fw.Result.from_dict), with its own source, scope, denominator, features, and missing conventions.
after Result; required Later missingness result. Counting units must agree; for entity units, entity keys and any/all aggregation must also agree. Row counts and source identities may differ.

Returns a fieldwork.Result with kind="comparison" (schema 2.0). changes contains the union of feature names with before, after, and populated_fraction_delta. The delta is after minus before; 0.2 means 20 percentage points. before_scope, after_scope, analysis units, sources, and conventions preserve context. Each change also becomes an availability_change finding.

Use plaintext for a text report, SVG for a static figure, or HTML for a shareable report. to_dict() retains analytical evidence; visualization_data provides the labels and values used to draw a figure. A comparison cannot be recomputed; recompute the two missingness results instead.

A delta is None when a feature is absent from one result or either denominator is zero. This is not evidence of no change. Different missing conventions are retained rather than harmonized automatically; align them deliberately if you want comparable rates.

Use to_frame("changes") for a table. Comparison findings contain no representative source rows; return to the original missingness results for row investigation. Inputs of another result kind or incompatible counting units raise ValueError.

Walkthrough: did the registration form get better?

Section titled “Walkthrough: did the registration form get better?”

A neighborhood arts festival receives two registration deliveries. Email availability jumps in the second delivery. Before attributing that change to a better form, the organizers want to know whether the mix of online and walk-in registrations changed.

This example compares two missingness results, then repeats the comparison within each registration channel. Download the runnable script.

Each row is a distinct registration. The deliveries contain different registrations; this is a comparison of populations, not a before-and-after match of individuals. Online registrations have email addresses, while walk-in registrations have phone numbers. None represents missing data, using Fieldwork’s default convention.

import pandas as pd
import fieldwork as fw
columns = ["registration", "channel", "email", "phone"]
before_df = pd.DataFrame(
[
("B01", "online", "b01@example.test", None),
("B02", "online", "b02@example.test", None),
("B03", "walk-in", None, "555-0103"),
("B04", "walk-in", None, "555-0104"),
("B05", "walk-in", None, "555-0105"),
("B06", "walk-in", None, "555-0106"),
("B07", "walk-in", None, "555-0107"),
("B08", "walk-in", None, "555-0108"),
],
columns=columns,
)
after_df = pd.DataFrame(
[
("A01", "online", "a01@example.test", None),
("A02", "online", "a02@example.test", None),
("A03", "online", "a03@example.test", None),
("A04", "online", "a04@example.test", None),
("A05", "online", "a05@example.test", None),
("A06", "online", "a06@example.test", None),
("A07", "walk-in", None, "555-0207"),
("A08", "walk-in", None, "555-0208"),
],
columns=columns,
)
after_df["sms_opt_in"] = [
False,
False,
False,
False,
False,
False,
True,
False,
]

The second delivery introduces sms_opt_in. A recorded False is populated data: availability of an answer is different from permission to send a message.

Select the contact fields explicitly so registration identifiers and channel labels do not enter the comparison. Each result counts rows, with eight rows per delivery.

before = fw.missingness(
before_df,
features=["email", "phone"],
table_id="registrations-before",
)
after = fw.missingness(
after_df,
features=["email", "phone", "sms_opt_in"],
table_id="registrations-after",
)
change = fw.compare(before, after)
changes = change.to_frame("changes")
print(
changes[["feature", "populated_fraction_delta"]].to_string(
index=False
)
)
feature populated_fraction_delta
email 0.5
phone -0.5
sms_opt_in NaN

Email availability rises from 25% to 75%: the fraction delta 0.5 means 50 percentage points, not a 50% relative increase. Phone availability falls by the same number of points. Every registration still has at least one contact field. These counts describe populated values, not whether the contact details work.

compare includes the union of analyzed feature names. There is no before result for sms_opt_in, so its delta is None. That does not mean it improved from zero: the earlier result has no availability measurement for that feature.

The table displays an undefined numeric delta as NaN; the dictionary returned by to_dict() uses None. Neither supplies a baseline for the new field.

The native text renderer also keeps both populations visible. This code prints only its first five lines, an excerpt of the full report:

report = fw.render_plaintext(change)
print("\n".join(report.splitlines()[:5]))
Fieldwork · comparison
Before: input; 8 evaluated rows
Before analysis: 8 rows; presence=per_row
After: input; 8 evaluated rows
After analysis: 8 rows; presence=per_row

In the first delivery, two registrations are online and six are walk-ins. In the second, those counts reverse. Start with online registrations. Create a separate row selection for each delivery, then compare their email and phone availability. Scopes select source row positions; a scope from one delivery cannot be reused on the other delivery.

before_positions = []
for position, channel in enumerate(before_df["channel"]):
if channel == "online":
before_positions.append(position)
after_positions = []
for position, channel in enumerate(after_df["channel"]):
if channel == "online":
after_positions.append(position)
before_scope = fw.Scope.from_positions(
before_df, before_positions, name="Online before"
)
after_scope = fw.Scope.from_positions(
after_df, after_positions, name="Online after"
)
before_online = fw.missingness(
before_df, features=["email", "phone"], scope=before_scope
)
after_online = fw.missingness(
after_df, features=["email", "phone"], scope=after_scope
)
online_change = fw.compare(before_online, after_online)
online_changes = online_change.to_frame("changes")
print(
online_changes[["feature", "populated_fraction_delta"]].to_string(
index=False
)
)
feature populated_fraction_delta
email 0.0
phone 0.0

Within the online channel, email remains 100% populated and phone remains 0%. Repeat the same steps with channel == "walk-in" to check the other channel: email remains 0% and phone 100%. Each fraction uses its own scope’s denominator, so two rows can be compared with six rows. The aggregate shift in this constructed dataset follows entirely from the changed channel mix. It provides no evidence that either form collected contact details more consistently.

Return to the registrations behind the result

Section titled “Return to the registrations behind the result”

The next practical question is which registrations still need an email address. Inspect the email availability finding from the after missingness result. The comparison holds summary changes, so use the underlying analysis for row evidence.

after_findings = after.to_frame("findings")
print(after_findings[["id", "statement"]].to_string(index=False))
id statement
f0 email: populated in some rows
f1 phone: populated in some rows
f2 Present: email, sms_opt_in; absent: phone
f3 Present: phone, sms_opt_in; absent: email
f4 email and phone are mutually exclusive

Here f0 is the email availability finding. Use that ID to retrieve every row missing an email address:

missing_email = after.inspect(
after_df, "f0", exceptions=True, all_matches=True
)
print(
missing_email[["registration", "channel", "phone"]].to_string(
index=False
)
)
registration channel phone
A07 walk-in 555-0207
A08 walk-in 555-0208

exceptions=True selects missing email values; all_matches=True requests the complete matching population rather than saved examples. Both registrations have phone values. The organizers can now check whether email is needed for walk-in registrations at all, instead of treating a channel-specific collection choice as a universal defect.

fw.missingness, fw.render_html, fw.visualization_data. See results and configuration for serialization and result-method contracts.