Truncate Paths

Trim each path to the window between two anchors (inclusive).

Each anchor is an event name, an anchor spec, or a list of either. Events outside the resulting window are dropped, and a path with no resolvable anchor on either side is dropped entirely.

The end anchor is searched for after the resolved start, so a path whose end event only occurs before its start event is dropped rather than producing an inverted window.

An anchor spec is a dict:

  • pattern (required) — an event name, or a "->"-separated pattern such as "add_to_cart->.*->purchase"; see Path Patterns for the full syntax. The reserved names "path_start" / "path_end" refer to a path's own first / last event.
  • at — which of the pattern's event names is the anchor point: "start", "end" (default), or an integer index over the pattern's event names (.* is not a position, so it is not counted). For "a->b->.*->c" the names are ["a", "b", "c"] and at=1 is b.
  • occurrence — which match to use when the pattern has more than one. "first" (default) puts every event name of the pattern as early as it can be in any valid match, "last" as late as it can be. Note that "last" is not "the last occurrence of the anchor event": an occurrence that is part of no valid match is not a candidate, so on catalog, cart, purchase, cart the last match of "catalog->.*->cart->.*->purchase" anchors on the second event, not the fourth. "first" is the same match step_matrix centres on, given the same path_pattern. "all" is rejected here — a window bound has to be a single position; it is for add_events(anchor=...).
  • offset — move the anchor off the matched event: an int shifts it that many events, a duration string or pd.Timedelta ("30m") shifts it in time and then snaps to the nearest event inside the window. An offset that runs past the path's own boundary clamps to it.
  • offset_side — which way a time offset rounds to a real event, "start" (forward) or "end" (backward). Defaults to the side of the window being resolved, which rounds inward; set it to widen instead.
  • event_col — the column the pattern is matched against; defaults to schema.event_col. Naming a coarser column ("screen", "category") cuts the window at that grain while the events inside it stay atomic — the column is only read, never written to. Note that such a column usually holds runs of one value, so an anchor lands on the run's first row.

A list of anchors keeps the narrowest window they imply — the latest start, the earliest end. That expresses both "whichever comes first" (["purchase", {"pattern": "add_to_cart", "offset": 10}] cuts at the purchase or 10 events after the cart, whichever is sooner) and a fallback (["purchase", "path_end"] cuts at the purchase, or at the end of the path for those who never purchased).

Usage

stream.truncate_paths(start_anchor="registration", end_anchor="purchase")
stream.truncate_paths(start_anchor="registration", end_anchor="path_end")

# keep every path, cutting the converted ones at their purchase
stream.truncate_paths(
    start_anchor="path_start", end_anchor=["purchase", "path_end"]
)

# 10 events after the cart that completed catalog -> ... -> add_to_cart,
# or the purchase if it comes sooner
stream.truncate_paths(
    start_anchor={"pattern": "catalog->.*->add_to_cart"},
    end_anchor=[
        "purchase",
        {"pattern": "catalog->.*->add_to_cart", "offset": 10},
    ],
)

# the half hour that follows a user's last support chat
stream.truncate_paths(
    start_anchor={"pattern": "support_chat", "occurrence": "last"},
    end_anchor={
        "pattern": "support_chat",
        "occurrence": "last",
        "offset": "30m",
    },
)

# the visit to the cart screen, atomic events inside it kept as they are
stream.truncate_paths(
    start_anchor={"pattern": "cart", "event_col": "screen"},
    end_anchor={"pattern": "checkout", "event_col": "screen"},
)

How it works

For each path, truncate_paths finds the first start_anchor and the first end_anchor after it, and keeps only the window between them, both anchors included. A path missing either anchor — or containing them in the wrong order — is dropped entirely, so the output holds only paths that actually made the journey you asked about.

Before — three paths, only one of which registers and then buys:

  • u1: home → registration → browse → purchase → logout
  • u2: home → browse → purchase (never registered)
  • u3: home → registration → browse (never purchased)
stream.truncate_paths(start_anchor="registration", end_anchor="purchase")

After — u1 is trimmed to the window; u2 and u3 are gone:

  • u1: registration → browse → purchase

That "dropped entirely" behaviour is the point: the result is a clean population of converting paths, ready for a Transition Graph or Step Sankey that answers "how do users who did convert get there?"

Use path_start / path_end to anchor on the real ends of a path — for example, everything a user did before their first purchase:

stream.truncate_paths(start_anchor="path_start", end_anchor="purchase")

Anchoring on a sequence, not just an event

An anchor can be a spec — a dict — rather than a bare event name. Four keys build it up, and it is worth seeing all four at once, because each answers a different question that a bare name cannot:

keyquestion it answers
patternwhich occurrences count — only those that fit a -> sequence
atwhich event of that sequence is the anchor point
occurrencewhich match, when the sequence happens more than once
offsethow far off the matched event the bound actually sits

A worked example

Take one path, timestamps 10 minutes apart:

 1  home
 2  catalog
 3  add_to_cart       ← a cart that never converted
 4  support_chat
 5  catalog
 6  add_to_cart
 7  shipping_details
 8  purchase
 9  catalog           ┐
10  add_to_cart       │ ← the last cart that DID lead to a purchase
11  shipping_details  │
12  purchase          │
13  review_page       ┘  window
14  catalog
15  add_to_cart       ← the last cart overall — abandoned
16  logout

The question: what surrounded the last cart that actually converted? Note that neither "the last cart" (15, abandoned) nor "the first cart" (3, also abandoned) is the one you want.

CONVERTING_CART = {
    "pattern": "catalog->.*->add_to_cart->.*->purchase",
    "at": 1,                 # anchor on add_to_cart, the 2nd event name
    "occurrence": "last",    # of the last such sequence in the path
}

stream.truncate_paths(
    start_anchor={**CONVERTING_CART, "offset": -1},     # one event before it
    end_anchor={**CONVERTING_CART, "offset": "30m"},    # half an hour after it
)

Result — events 9 to 13:

  • u1: catalog → add_to_cart → shipping_details → purchase → review_page

Reading the spec left to right:

  1. pattern finds sequences of catalog → … → add_to_cart → … → purchase. The carts at 3 and 15 are excluded — no purchase follows them within a matching sequence.
  2. at: 1 makes the anchor the add_to_cart of that sequence, not the purchase that terminates it. The event names are ["catalog", "add_to_cart", "purchase"].* is not a position, so it is not counted and index 1 is the cart.
  3. occurrence: "last" picks the sequence at 9–12 over the one at 5–8, landing the anchor on event 10.
  4. offset turns that single point into a window: -1 opens it one event earlier (9), "30m" closes it half an hour later. Event 13 is exactly 30 minutes after event 10, and an exact hit is inside the window.

What each key is holding up

Drop one key at a time from the same call, and the window moves somewhere else entirely — which is the quickest way to see what each is for:

changewindowwhy
(all four, as above)catalog → add_to_cart → shipping_details → purchase → review_pageevents 9–13
pattern → bare "add_to_cart"catalog → add_to_cart → logoutanchors on the abandoned cart at 15
no atshipping_details → purchase → review_page → catalog → add_to_cartanchor moves to the purchase at 12, the pattern's last event name
no occurrencecatalog → add_to_cart → support_chat → catalog → add_to_cartthe leftmost match pairs catalog@2 with the cart@3 and the purchase@8 — a valid sequence, just not the one you meant
no offsetadd_to_cartboth bounds collapse onto the anchor itself: a one-event window

What occurrence is choosing between

That fourth row is the subtle one, and it is worth being precise about what the alternatives even are.

A pattern usually matches a path in more than one way. A match is any assignment of positions to the pattern's event names that keeps them in order. On the path above, catalog->.*->add_to_cart->.*->purchase matches these ways (positions of catalog / cart / purchase):

2, 3, 8      2, 3, 12      2, 6, 8       2, 6, 12      2, 10, 12
5, 6, 8      5, 6, 12      5, 10, 12     9, 10, 12

occurrence chooses between them by a simple rule:

  • "first" puts every event name as early as it can be across all these matches — here 2, 3, 8, the first row.
  • "last" puts every one as late as it can be — 9, 10, 12, the last row.

Both results are themselves valid matches, and either finds one whenever one exists at all. Two things follow that catch people out:

"last" is not "the last add_to_cart in the path." The cart at 15 appears in no row above, because no purchase follows it — it is not a candidate. This is exactly why the spec finds the converting cart and a bare {"pattern": "add_to_cart", "occurrence": "last"} does not.

"first" is not "the earliest complete run of the sequence." It minimises each event name independently, so it pairs catalog@2 and add_to_cart@3 with purchase@8 — three events that are nowhere near each other. When you mean "the cart that led to this purchase", say "last", or narrow the pattern.

"first" is also exactly the match that Step Matrix centres its blocks on, given the same sequence as path_pattern — the two share one implementation, so a window cut here lines up with the neighbourhoods you looked at there.

Offsets in detail

An int offset counts events; a duration ("30m", or a pd.Timedelta) counts time and then snaps to the nearest event inside the window — the first event at or after the mark for a start bound, the last at or before it for an end bound. Either kind clamps at the path's own boundary, so "10 events after the cart" on a path with 4 events left is those 4, not an empty window and not a dropped path.

Two anchors on one side

A list of anchors keeps the narrowest window they imply — the latest start, the earliest end. Two quite different things fall out of that one rule.

A fallback, when one group has no end event at all:

stream.truncate_paths(start_anchor="path_start", end_anchor=["purchase", "path_end"])

Converters are cut at their purchase; everyone else keeps their whole path instead of being dropped. This is the counterpart to the drop behaviour above: use the bare form when you want a clean population of converters, the list form when the non-converters are half the question.

And a budget, when you are comparing two groups:

stream.truncate_paths(
    start_anchor={"pattern": "catalog->.*->add_to_cart"},
    end_anchor=["purchase", {"pattern": "catalog->.*->add_to_cart", "offset": 10}],
)

Every path is cut at its purchase or 10 events past the cart, whichever comes first. This matters more than it looks. If you cut converters at their purchase but let non-converters run to the end of their path, the two groups end up with windows of very different length, and a diff over them partly measures how much path each group has rather than how the two behaved. Bounding both sides by the same budget removes that: non-converters have no purchase, so the step bound is what applies to them, and both windows are at most 10 events long.

Parameters

ParameterTypeDescription
start_anchorstr or dict or listWhere the window opens.
end_anchorstr or dict or listWhere the window closes.
path_colstr, optionalPath ID column override; defaults to schema.path_col.