Hello again. You now have the key raw evidence for a single FlyWire neuron: a materialization-specific root ID and a directional_synapses table in which every row is explicitly labelled as incoming or outgoing.
This lesson turns those individual synapse rows into an analysis-ready partner ranking. You will use pandas to answer two deliberately distinct questions for each direction:
- How many synapse rows does the neuron have in total?
- Which partner root IDs account for the most of those rows?
The result is a compact, reproducible table of major presynaptic partners and major postsynaptic partners, ready for the single-neuron profile you will build next. Plan for about 40 minutes.
From individual synapses to weighted partners
In the previous lesson, each row represented one synapse record. For your target neuron :
- An incoming row has
post == R; its partner is inpre. - An outgoing row has
pre == R; its partner is inpost.
Suppose a particular partner occurs in 18 incoming rows. That does not mean there are 18 distinct partners; it means that partner has 18 detected synapses onto the target neuron in the queried materialization.
For a direction , let be the number of synapse rows involving partner . The total row count in that direction is:
Your ranking will sort partners by , from largest to smallest. It is useful to calculate each partner’s directional share as well:
This is the fraction of the target neuron’s observed incoming or outgoing synapse rows associated with that partner. It is not a claim about the fraction of all biological influence, and it is not a proof that the reconstruction or synapse detection is error-free.
Before writing the reusable workflow, a quick pandas refresher may help frame what follows.
Python Pandas Tutorial (Part 8): Grouping and Aggregating - Analyzing and Exploring Your Data
Watch “Python Pandas Tutorial (Part 8): Grouping and Aggregating” by Corey Schafer. It gives a concise mental model for the grouping operation that will turn many synapse rows into one row per connectivity partner.
Watch the groupby walkthrough. Focus on the split, apply, combine idea: here, pandas will split synapse rows by partner_root_id, count rows in each group, then combine those counts into a ranking table.
For a quick exploratory check, value_counts() is enough:
incoming_synapses["partner_root_id"].value_counts().head(10)
But for a notebook you intend to keep, share, and extend, create a named DataFrame with direction, counts, shares, and a reproducible tie policy.
Build partner rankings from the saved synapse table
Start from the variables created in the previous lesson:
TARGET_ROOT_IDincoming_synapsesoutgoing_synapsesdirectional_synapses
First, confirm that the direction labels and target-ID relationships still agree. This validation is deliberately placed before aggregation: a clean-looking grouped table can still be wrong if the upstream direction assignment was reversed.
import pandas as pd
expected_directions = {"incoming", "outgoing"}
observed_directions = set(directional_synapses["direction"].dropna().unique())
if observed_directions != expected_directions:
raise RuntimeError(
f"Expected directions {expected_directions}, "
f"but found {observed_directions}."
)
incoming_errors = directional_synapses.loc[
directional_synapses["direction"].eq("incoming")
& directional_synapses["post"].ne(TARGET_ROOT_ID)
]
outgoing_errors = directional_synapses.loc[
directional_synapses["direction"].eq("outgoing")
& directional_synapses["pre"].ne(TARGET_ROOT_ID)
]
if not incoming_errors.empty or not outgoing_errors.empty:
raise RuntimeError(
"Direction labels do not match the target root-ID conditions. "
"Return to the previous synapse-direction step."
)
if directional_synapses["partner_root_id"].isna().any():
raise RuntimeError(
"Some synapse rows have no partner_root_id. Inspect the raw schema "
"rather than silently dropping these rows."
)
print("Direction and partner-ID checks passed.")
Now define one function that works for either direction. It uses groupby(...).size() rather than count(): size() counts rows regardless of whether some unrelated column has missing values.
def summarize_partners(directional_df, direction, target_root_id):
"""Return one row per connectivity partner for one synaptic direction."""
subset = directional_df.loc[
directional_df["direction"].eq(direction)
].copy()
if direction == "incoming":
invalid_rows = subset.loc[
subset["post"].ne(target_root_id)
]
elif direction == "outgoing":
invalid_rows = subset.loc[
subset["pre"].ne(target_root_id)
]
else:
raise ValueError("direction must be 'incoming' or 'outgoing'")
if not invalid_rows.empty:
raise RuntimeError(
f"Found {len(invalid_rows)} invalid {direction} rows."
)
if subset["partner_root_id"].isna().any():
raise RuntimeError(
f"Cannot summarize {direction} rows with missing partner IDs."
)
summary = (
subset.groupby("partner_root_id", as_index=False, sort=False)
.size()
.rename(columns={"size": "synapse_count"})
.sort_values(
by=["synapse_count", "partner_root_id"],
ascending=[False, True],
kind="stable",
)
.reset_index(drop=True)
)
total_synapse_rows = len(subset)
summary.insert(0, "direction", direction)
summary["rank_by_count"] = (
summary["synapse_count"]
.rank(method="min", ascending=False)
.astype("int64")
)
summary["share_of_direction"] = (
summary["synapse_count"] / total_synapse_rows
if total_synapse_rows
else 0.0
)
summary["cumulative_share"] = summary["share_of_direction"].cumsum()
return summary
A few design choices in this function are worth noticing:
- One row per partner: grouping by
partner_root_idcollapses many individual synapse rows into one partner-level record. synapse_count: the edge weight between the target and that partner for this direction.rank_by_count: equal counts receive the same rank. For example, counts of 20, 12, 12, and 4 receive ranks 1, 2, 2, and 4.- Stable sorting: ties are displayed in increasing root-ID order so rerunning the same input produces the same table order. Root-ID order has no biological meaning.
cumulative_share: shows how concentrated the direction is among the leading partners.
Run the function for both directions.
incoming_partner_summary = summarize_partners(
directional_synapses,
direction="incoming",
target_root_id=TARGET_ROOT_ID,
)
outgoing_partner_summary = summarize_partners(
directional_synapses,
direction="outgoing",
target_root_id=TARGET_ROOT_ID,
)
partner_rankings = pd.concat(
[incoming_partner_summary, outgoing_partner_summary],
ignore_index=True,
)
display(incoming_partner_summary.head(15))
display(outgoing_partner_summary.head(15))
The two tables have the same schema, but their biological reading differs:
| Table | partner_root_id represents | synapse_count represents |
|---|---|---|
incoming_partner_summary | A presynaptic partner | Detected synapses from that partner onto your target |
outgoing_partner_summary | A postsynaptic partner | Detected synapses from your target onto that partner |
Do not merge the directions into one count when ranking “major partners.” A neuron can strongly receive from one partner and strongly send to a different one. Even for the same partner, incoming and outgoing counts answer different questions.
Reconcile the grouped tables with the raw rows
Aggregation should be auditable. The sum of all partner-level counts must equal the original number of directional synapse rows.
def validate_partner_summary(summary, directional_df, direction):
expected_rows = directional_df["direction"].eq(direction).sum()
summarized_rows = summary["synapse_count"].sum()
if summarized_rows != expected_rows:
raise RuntimeError(
f"{direction}: summary contains {summarized_rows} synapse rows, "
f"but raw directional data contains {expected_rows}."
)
print(
f"{direction.capitalize()}: "
f"{expected_rows} synapse rows across {len(summary)} unique partners."
)
validate_partner_summary(
incoming_partner_summary,
directional_synapses,
"incoming",
)
validate_partner_summary(
outgoing_partner_summary,
directional_synapses,
"outgoing",
)
You can create a compact direction-level overview for the top of the notebook:
direction_overview = pd.DataFrame(
{
"synapse_rows": [
incoming_partner_summary["synapse_count"].sum(),
outgoing_partner_summary["synapse_count"].sum(),
],
"unique_partners": [
len(incoming_partner_summary),
len(outgoing_partner_summary),
],
},
index=["incoming", "outgoing"],
)
direction_overview.index.name = "direction"
display(direction_overview)
Interpret this overview carefully:
- Many synapse rows, few unique partners suggests concentrated connectivity in that direction.
- Many synapse rows, many unique partners suggests more distributed connectivity.
- Few rows in one direction may be real, but it can also reflect a truncated reconstruction, an outdated ID, a segmentation problem, or a data-version limitation.
A self-connection, if present, is expected to appear once in each directional summary. That is appropriate: its incoming and outgoing roles are analytically distinct. Do not deduplicate the combined directional table merely because its raw id occurs in both directional subsets.
Define “major partner” transparently
“Major” should be an operational label, not an unexplained biological claim. For a first-pass single-neuron profile, use a top- ranking and retain the exact counts.
TOP_K = 15
top_incoming = incoming_partner_summary.head(TOP_K).copy()
top_outgoing = outgoing_partner_summary.head(TOP_K).copy()
display(top_incoming)
display(top_outgoing)
For reporting, format the shares as percentages without discarding the underlying numeric columns:
def format_partner_table(summary):
table = summary.copy()
table["share_percent"] = (
100 * table["share_of_direction"]
).map(lambda value: f"{value:.1f}%")
table["cumulative_percent"] = (
100 * table["cumulative_share"]
).map(lambda value: f"{value:.1f}%")
return table[
[
"rank_by_count",
"partner_root_id",
"synapse_count",
"share_percent",
"cumulative_percent",
]
]
display(format_partner_table(top_incoming))
display(format_partner_table(top_outgoing))
A practical notebook note might use wording such as:
At materialization , the selected root had incoming synapse rows distributed across partner roots, and outgoing rows distributed across partner roots. Major partners were ranked separately by detected synapse-row count; partner identity refers to root IDs at this materialization.
This wording correctly describes the table while avoiding an overclaim that a high count establishes a definitive circuit role.
When to aggregate with pandas and when to request an edge list
Your pandas workflow is the right default here because it begins with the raw synapse rows you already saved. You can inspect an individual synapse, revisit its coordinates, or examine an unusual score before accepting a partner-level conclusion.
For larger cohorts, the FlyWire API also provides flywire.get_connectivity(), which returns an already aggregated edge list with pre, post, and weight columns. Its weight is the analogous partner-level synapse count.
Fetching connectivity - fafbseg 3.2.2 documentation
Read the “Connections” section of the fafbseg documentation to see the alternative edge-list representation and its use for a group of neurons. This will be useful when you move from a single-neuron profile to cohort analysis.
In the “Connections” section, read from the explanation following the connector plot through the section’s final adjacency-matrix example. Locate the flywire.get_connectivity(da1_roots) example, then follow how its pre, post, and weight columns are used to select downstream partners. The linked range covers the connection example; pay particular attention to the distinction between an edge list and an adjacency matrix.
For this notebook, do not silently replace your pandas results with a fresh get_connectivity() query. Two results can differ if their materialization, filters, selected roots, or API defaults differ. If you later compare them, record and align those query conditions first.
Save analysis-ready rankings
Save the partner summaries separately from the raw synapse CSV produced in the previous lesson. Root IDs should be exported as strings so spreadsheet software does not round them.
from pathlib import Path
import json
def export_partner_summary(summary, filename):
export_table = summary.copy()
export_table["partner_root_id"] = (
export_table["partner_root_id"].map(str)
)
export_table.to_csv(filename, index=False)
return Path(filename)
incoming_path = export_partner_summary(
incoming_partner_summary,
"flywire_single_neuron_incoming_partner_ranking.csv",
)
outgoing_path = export_partner_summary(
outgoing_partner_summary,
"flywire_single_neuron_outgoing_partner_ranking.csv",
)
ranking_record = {
"dataset": DATASET,
"materialization_version": int(MAT_VERSION),
"target_root_id": str(TARGET_ROOT_ID),
"input_table": "directional_synapses",
"aggregation_method": (
"groupby(partner_root_id).size(), separately for incoming "
"and outgoing directional rows"
),
"incoming_rule": (
"direction == incoming; post == target_root_id; "
"partner_root_id == pre"
),
"outgoing_rule": (
"direction == outgoing; pre == target_root_id; "
"partner_root_id == post"
),
"incoming_synapse_rows": int(incoming_partner_summary["synapse_count"].sum()),
"incoming_unique_partners": int(len(incoming_partner_summary)),
"outgoing_synapse_rows": int(outgoing_partner_summary["synapse_count"].sum()),
"outgoing_unique_partners": int(len(outgoing_partner_summary)),
"incoming_ranking_csv": str(incoming_path),
"outgoing_ranking_csv": str(outgoing_path),
}
ranking_provenance_path = Path(
"flywire_single_neuron_partner_ranking.json"
)
with ranking_provenance_path.open("w", encoding="utf-8") as file:
json.dump(ranking_record, file, indent=2)
print(f"Saved incoming ranking: {incoming_path.resolve()}")
print(f"Saved outgoing ranking: {outgoing_path.resolve()}")
print(f"Saved ranking provenance: {ranking_provenance_path.resolve()}")
Keep the three levels of data together:
- Raw synapse rows support spatial inspection and later re-analysis.
- Directional synapse rows make the incoming/outgoing rule explicit.
- Partner summaries support ranking, visualization, and concise reporting.
Key takeaways
You can now transform a versioned FlyWire synapse query into interpretable partner-level evidence:
- Group
partner_root_idvalues separately for incoming and outgoing synapse rows. - Use
groupby(...).size()to count synapse rows per partner reliably. - Keep synapse count and unique partner count separate; they measure different properties.
- Rank directions independently, since a partner’s inputs to a neuron and outputs from it are different relationships.
- Use shares and cumulative shares to describe concentration, but do not equate them with validated functional strength.
- Reconcile every grouped summary with its raw directional row count and save the aggregation rules with materialization provenance.
Next, you will assemble these outputs into a reproducible single-neuron profile notebook, including a partner-distribution visualization and a clear provenance record.
Can't find a good explanation? Sign up and we'll make it for you
Sign up