Claude Skill

retentioneering-product-analytics

Analyze event logs, clickstreams, user paths, product funnels, retention, behavioral segments, transition graphs, step matrices, sequence patterns, and customer journeys using Retentioneering. Use when the user provides CSV, Parquet, pandas, or database event data containing user

LLM Mart · 0 points · 1 views 17 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download retentioneering-retentioneering-tools-.agents_skills_retentioneering-product-analytics-21f6991.zip · 18 KB
Part of retentioneering/retentioneering-tools — 2 skills

Install

skills CLI npx skills add https://github.com/retentioneering/retentioneering-tools/tree/master/.agents/skills/retentioneering-product-analytics
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install retentioneering-retentioneering-tools@llmmart
Git git clone https://github.com/retentioneering/retentioneering-tools.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole retentioneering/retentioneering-tools collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Retentioneering product analytics

Objective

Turn event-level behavioral data into a reproducible answer to a product question — why users convert, churn, loop, or abandon — using user trajectories, transitions, funnels, and behavioral segments.

Do not merely generate visualizations. Connect each output to the question, separate observation from interpretation, and never present path correlations as causal effects.

Bundled references (read on demand, not upfront)

File Read it when
references/api-map.md before writing any Retentioneering call — verified signatures, argument conventions, return shapes for 5.x
references/analysis-recipes.md after the question is clear — 10 field-tested patterns (R1–R10) with skeletons and pitfalls
references/gotchas-and-validation.md before executing (API gotchas G1–G10) and before presenting (integrity checklist B1–B10)
scripts/inspect_event_log.py step 2 — automated data profiling and schema suggestion

Required event-log semantics

Minimum: a path identifier (user or session), an event name, a timestamp (or a reliable order column — see gotcha G2 for order-only data). Useful extras: session id, segment attributes (device, source, plan), event properties, conversion labels.

Workflow

1. Environment

  1. Confirm the package: python -c "import retentioneering; print(retentioneering.__version__)". Expect 5.x; this skill's API map is version-verified for 5.0 — on a different major version, trust installed docstrings over the map.
  2. Locate the event data (CSV / Parquet / frames in existing code). Never modify inputs.
  3. Do not invent methods: anything not in references/api-map.md must be verified against the installed package before use.

2. Inspect the data BEFORE choosing methods

Run scripts/inspect_event_log.py <path> [--sep ...] (or replicate its checks inline for in-memory frames). It profiles columns, infers the user/event/timestamp mapping, checks timestamp parseability, duplicates, per-path ordering, path-length distribution, and emits artifacts/data-profile.json plus a ready-to-paste Eventstream(...) schema.

Report to the user before proceeding: inferred mapping, row/user/event-type counts, covered period, and any red flags (nulls in key columns, timestamp ties, suspected bots or ultra-long paths, order-only timestamps). Confirm the mapping if inference is ambiguous.

3. Frame the product question, then pick the SMALLEST recipe

Map the question to a recipe in references/analysis-recipes.md: navigation structure/loops → transition graph (R1/R6) · before/after an anchor → step matrix (R3) · ordered conversion flow → funnel (R1/R2) · what winners do differently → diff on a funnel-stage segment (R2) · heterogeneous users → clustering without target leakage (R5) · between two funnel levels → truncate micro-journey (R4) · intervention timing → time-to-outcome (R7) · cross-segment scan → segment overview (R8) · value of a fix → Markov what-if (R9, advanced).

Combine recipes only when each addition resolves a distinct uncertainty.

4. Execute reproducibly

  1. Prefer a rerunnable script (or a notebook executed top-to-bottom) over ad-hoc cells.
  2. Write artifacts to a dedicated output directory (artifacts/ by default).
  3. Log every filtering rule and its row/path impact; never silently drop data (integrity item B2).
  4. Use sample_paths(frac=, random_state=) for stable subsamples; stochastic steps get explicit seeds.
  5. Record lineage: save processed.recipe() and the package version into artifacts/run-metadata.json — any artifact must be regenerable from raw data via Eventstream.from_recipe(raw_df, recipe).

5. Validate before presenting

Work through references/gotchas-and-validation.md section B. Non-negotiables: every percentage names its denominator; population filters are disclosed with counts; survivorship and exposure confounds addressed; no outcome leakage into features; small cells flagged with n; caption numbers come from headless *_data twins; visuals agree with tables.

6. Interpret and deliver

Structure the final answer as:

  1. Observed — numbers with denominators and n.
  2. Interpretation — what it likely means.
  3. Alternative explanations — selection, structure, censoring.
  4. Product hypotheses — each with the metric an experiment would move.
  5. Suggested next analyses / A-B tests.
  6. Limitations.

Deliverables: analysis script or executed notebook; artifacts/data-profile.json; artifacts/metrics.csv (key tables); interactive HTML exports via widget.export_html(..., title=, analysis=) — write analysis= captions AFTER conclusions are final; artifacts/summary.md (mapping, filters, assumptions, versions, findings, limitations, next steps); artifacts/run-metadata.json (versions, parameters, seeds, recipe() lineage).

Files (retentioneering-tools)
  • references
    • analysis-recipes.md 8.1 KB
      # Analysis recipes
      
      Ten field-tested patterns, each distilled from real product investigations (e-commerce
      checkout, game telemetry at 1.6M events, catalog browsing, navigation-game paths).
      Each recipe: when to use → skeleton → how to read → pitfalls. Pick the SMALLEST recipe
      that answers the question; combine only when each addition resolves a distinct uncertainty.
      
      Skeletons assume `stream` is an `Eventstream` (see `api-map.md`).
      
      ---
      
      ## R1 · First picture of a product ("what is going on here?")
      
      ```python
      d = stream.describe(top_events=None)          # full frequency table, no truncation
      f = stream.funnel_data(steps=[...])           # your best guess of the core flow
      tg = stream.transition_graph(edge_weight="proba_out")
      tg.export_html("artifacts/overview_graph.html", title="...", analysis="...")
      ```
      
      Read: `step_conversion_rate` locates the worst cliff; the graph shows where traffic
      actually goes. Pitfalls: start the funnel at a meaningful entry (not the landing page —
      many users enter mid-flow); optional steps (e.g. a review screen only 10% pass) do not
      belong in `steps`.
      
      ## R2 · Converted vs dropped ("what do winners do differently?")
      
      The single highest-yield move in the toolkit.
      
      ```python
      lab = stream.add_segment("stage", funnel_events=["basket", "checkout", "purchase"])
      lab.transition_graph(diff=("stage", "purchase", "basket"))     # diff = purchase − basket
      lab.step_matrix(path_pattern=".*->basket->.*", diff=("stage", "purchase", "basket"))
      ```
      
      Read: positive cells = over-represented among converters. Pitfalls: `funnel_events` is
      closed/ordered — a path that hit `checkout` before ever hitting `basket` is labeled
      `basket`, target-only paths get `out_of_funnel`; check `get_segment_levels()` and group
      sizes before interpreting.
      
      ## R3 · Around an anchor event ("what happens right before/after X?")
      
      ```python
      (m,) = stream.step_matrix_data(max_steps=6, path_pattern=".*->basket->.*")
      m[[0, 1, 2]]        # column 0 = the anchor itself, 1..n = steps after
      ```
      
      Read: rising `path_end` share right after the anchor = the anchor is where paths die.
      Pitfalls: with `path_pattern` the headless return is a tuple of per-anchor blocks —
      unpack; with `diff` it is `(blocks, g1_blocks, g2_blocks)`.
      
      ## R4 · Micro-journey between two funnel levels
      
      ```python
      micro = stream.truncate_paths(start_anchor="basket", end_anchor="checkout")
      micro.transition_graph(edge_weight="proba_out")
      micro.get_metrics([{"metric": "length"}, {"metric": "duration"}]).describe()
      ```
      
      Read: what successful walkers actually traverse (detours, loops, help pages) and how
      long the crossing takes (median steps/minutes → timing for recovery nudges).
      Pitfalls: paths lacking either anchor are DROPPED — population = completers only. To
      compare with non-completers, split first (`filter_paths` on `has_event`), truncate each
      population by its own rules, analyze separately.
      
      ## R5 · Behavioral segmentation without target leakage
      
      ```python
      KEY_FAMILIES = ["listing", "product", "add_to_cart", "search", "promo"]  # NO outcome events!
      FEATURES = (
          [{"metric": "length"}, {"metric": "duration"}, {"metric": "active_days"}]
          + [{"metric": "event_count", "metric_args": {"event": e}} for e in KEY_FAMILIES]
      )
      res = stream.cluster_analysis_data(
          features=FEATURES, method_args={"n_clusters": "3-8"},
          overview_metrics=FEATURES + [
              {"metric": "has_event", "metric_args": {"event": "purchase"}, "agg": "mean"},
          ],   # outcome goes HERE, for validation only
      )
      labeled = stream.add_clusters(name="behavior", features=FEATURES,
                                    **res["best_params"])
      ```
      
      Read: conversion spread ACROSS clusters (from overview) is the finding; cluster profiles
      name the personas. Pitfalls: outcome events in `features` produce silhouette≈0.9
      "clusters" that merely restate the funnel — impressive and useless. Inspect the
      silhouette curve yourself: a best-K at the range boundary or a >90/10 split means the
      clustering is weak regardless of `best_params`; on degenerate data `best_params` may be
      absent entirely. Cluster labels are strings (`"cluster_0"`); interpret via top routes and
      sizes, and treat clusters as centroids, not rules.
      
      ## R6 · Loops and repeated behavior ("where do users churn in place?")
      
      Self-loops live on the transition-matrix diagonal; A→B→A patterns via `matches_pattern`:
      
      ```python
      tm = stream.transition_graph_data(edge_weight="proba_out")
      selfloops = {e: tm.loc[e, e] for e in tm.index}
      pogo = stream.get_metrics([{"metric": "matches_pattern",
                                  "metric_args": {"pattern": "listing->product->listing"}}])
      ```
      
      Pitfalls (both are result-killers): (1) count loops BEFORE `collapse_events(loops=)`
      — collapsing erases them; (2) naive "conversion of users with loop vs without" is
      confounded by exposure — longer paths have more loops AND more chances to convert;
      stratify by path-length quantiles before comparing.
      
      ## R7 · Time-to-outcome and intervention windows
      
      ```python
      tb = stream.get_metrics([{"metric": "time_between",
                                "metric_args": {"start_event": "basket",
                                                "end_event": "checkout"}}]).dropna()
      # completers' timing; for a survival view add censored paths (reached basket, no checkout)
      ```
      
      Read: median/quantiles of completion → after which delay organic completion is <5% —
      that is when a nudge fires. Pitfalls: `time_between` = first A to first B globally in the
      path, not "first B after A" — for strict semantics compute from `to_dataframe()`; account
      for right-censoring near the end of the log window.
      
      ## R8 · Compare segments on many metrics at once
      
      ```python
      stream.segment_overview(segment_col="device", metrics=[
          {"metric": "length", "agg": "median"},
          {"metric": "has_event", "metric_args": {"event": "purchase"}, "agg": "mean"},
      ])
      ```
      
      Read: scan for the metric with the largest cross-segment gap, then drill with R2/R3.
      Pitfalls: report `n` per segment value alongside every share — a 100% on 5 paths is
      noise; flag small cells explicitly (reviewers will ask).
      
      ## R9 · What-if on a Markov chain (advanced; custom math on top)
      
      Estimate the value of removing/rerouting a friction step (e.g., guest checkout):
      
      ```python
      counts = sub.transition_graph_data(edge_weight="count").astype(float).fillna(0)
      # build absorbing chain on counts; edit edges (reroute basket->login mass to basket->checkout);
      # recompute absorption; bootstrap paths for CIs
      ```
      
      Non-negotiables learned in the field: (1) build the chain on the RELEVANT SUB-POPULATION
      (paths containing the anchor, `truncate_paths` to first anchor→outcome) — a chain over
      the full log averages transition rows over unrelated users and badly distorts absorption;
      (2) validation gate — base-chain absorption must reproduce the observed conversion before
      any scenario is trusted; (3) plug-in absorption equals the training-set rate by
      construction, so the model's value is in scenario DELTAS, not level forecasts;
      (4) rerouted users converting like organic ones is an upper-bound assumption — present a
      sensitivity grid (25/50/100% of the empirical rate).
      
      ## R10 · Package results for stakeholders
      
      ```python
      w = lab.transition_graph(diff=("stage", "purchase", "basket"))
      w.export_html("artifacts/conv_vs_drop.html", title="...",
                    analysis="Findings in markdown; [basket] and [checkout] become clickable.")
      meta = {"recipe": processed.recipe(), "version": retentioneering.__version__,
              "filters": "...", "params": {...}}
      ```
      
      Rules that survived stakeholder review: write the `analysis=` text AFTER conclusions are
      final (a stale caption contradicting the report is a credibility killer); caption numbers
      must come from the headless twin, not from memory; ship `recipe()` + versions in
      `run-metadata.json` so any artifact is regenerable from raw data.
      
      ---
      
      ## Choosing quickly
      
      | Question shape | Recipe |
      |---|---|
      | "What's going on / where do we lose people?" | R1 → R2 |
      | "What happens around event X?" | R3 |
      | "What do successful users do between A and B?" | R4 |
      | "What kinds of users do we have?" | R5 |
      | "Why do users go in circles?" | R6 |
      | "When should we intervene?" | R7 |
      | "Which segment behaves differently?" | R8 |
      | "What is fixing X worth?" | R9 |
      | "How do I hand this to a PM?" | R10 |
      
    • api-map.md 11.7 KB
      # Retentioneering 5.0 — verified API map
      
      Every signature below was executed against retentioneering 5.0 (branch `v5-migration`,
      July 2026). If your installed version differs, verify before use:
      
      ```python
      import retentioneering; print(retentioneering.__version__)
      ```
      
      Authoritative per-method reference: docstrings in the installed package and
      https://retentioneering.com/docs. This map exists so you do not have to guess names,
      argument conventions, or return shapes.
      
      ## 1. Eventstream — the hub object
      
      ```python
      from retentioneering import Eventstream
      
      stream = Eventstream(df, schema={
          "path_cols":     ["user_id"],            # path identity; nesting allowed: ["user_id","session_id"]
          "event_col":     "event",
          "timestamp_col": "timestamp",            # REQUIRED; parseable datetimes (tz-aware OK)
          "segment_cols":  ["device", "country"],  # categorical labels usable in every diff/overview
          "custom_cols":   ["price"],              # carried along; visible to sql= modes
      })
      ```
      
      - Defaults match columns named `user_id` / `event` / `timestamp` — `Eventstream(df)` just works then.
      - Unknown schema keys raise `SchemaConfigError` listing valid keys (trust this error).
      - Rows with null path values are rejected loudly. Numeric path ids stay numeric
        (they are NOT cast to str) — join freely with your source frame.
      - `stream.df` is a READ-ONLY property (assignment raises). For a materialized copy use
        `stream.to_dataframe(exclude_start_end=True)`.
      - Event names may not contain `->` (raises `SchemaConfigError` — it is the path-pattern delimiter).
      - Every processor returns a NEW Eventstream (immutable chaining).
      
      First calls on any new dataset:
      
      ```python
      d = stream.describe(top_events=20)      # dict: schema, shape, date_range, event_frequency,
                                              #       path_stats, segments
      d["event_frequency"].attrs              # {'truncated': True, 'n_total_events': N} when cut;
                                              # pass top_events=None to disable truncation
      stream.get_event_counts()               # {event: count}
      stream.get_segment_levels()             # {segment_col: [values...]}
      ```
      
      ## 2. Data processors (verb-first, chainable)
      
      | Processor | Use for | Notes |
      |---|---|---|
      | `filter_events(keep=/drop=/func=/sql=)` | row-level filtering | exactly one mode; `sql=` is full DuckDB over alias `eventstream` (CTEs OK) |
      | `filter_paths(condition)` | keep whole paths by metric condition tree | leaves `{"op","metric","value","metric_args"}`; branches `and/or/not`; top-level list = AND; raises `EmptyEventstreamError` when nothing survives |
      | `add_segment(name, rules=/func=/sql=/funnel_events=/time_range=/metric_bins=)` | add a segment column | `funnel_events` labels each path by the furthest step reached IN ORDER (closed funnel); non-reachers get `"out_of_funnel"`. `metric_bins={"metric":..., "edges"|"quantiles":..., "segment_levels":[...]}` bins paths by a per-path metric — cut points are INTERIOR, so N points give N+1 bins |
      | `add_clusters(name, features, method_args={"n_clusters": k}, ...)` | materialize clusters as a segment column | labels are strings `"cluster_0"...`; deterministic (seeded) |
      | `add_start_end_events()` | explicit `path_start`/`path_end` rows | idempotent; widgets add them implicitly |
      | `rename_events(mapping)` | collapse taxonomies (e.g. `"PLP: *"` families by explicit dict) | unknown keys raise |
      | `collapse_events(loops=True | event_groups= | group_col= | bounds=, name=)` | merge a run of events into one; `name` labels it (literal, `{"col": …}`, or cases) | count loops BEFORE collapsing if loops are your subject |
      | `split_sessions(timeout="30m" | separator= | bounds={"start_event":…,"end_event":…})` | derive sessions | duration strings need units; column params use `session_col` naming |
      | `truncate_paths(start_anchor, end_anchor)` | window each path between two anchors | each anchor is an event name, a spec `{pattern, at, occurrence, offset}`, or a LIST of either (narrowest window wins). A list is how you get both "whichever comes first" and a keep-whole fallback: `end_anchor=["purchase", "path_end"]`. A bare name still DROPS paths missing the anchor |
      | `drop_events / drop_segment / edit_events / rename_segment_levels / sample_paths / to_daily_states / urls_to_events / add_events` | as named | `sample_paths(frac=, random_state=)` for stable subsamples |
      
      ## 3. Metrics registry — one config format everywhere
      
      Used by `get_metrics(list)`, `filter_paths(condition)`, `cluster_analysis*(features=/overview_metrics=)`,
      `segment_overview(metrics=)`.
      
      ```python
      stream.get_metrics([
          {"metric": "length"},
          {"metric": "duration"},                                            # seconds
          {"metric": "event_count",   "metric_args": {"event": "basket"}},   # SINGLE event → key 'event'
          {"metric": "has_event",     "metric_args": {"event": "purchase"}},
          {"metric": "has_any_event", "metric_args": {"events": ["a","b"]}}, # LIST → key 'events' (OR)
          {"metric": "has_all_events","metric_args": {"events": ["a","b"]}}, # LIST → key 'events' (AND)
          {"metric": "time_between",  "metric_args": {"start_event": "basket",
                                                      "end_event": "purchase"}},
          {"metric": "matches_pattern","metric_args": {"pattern": "basket->.*->purchase"}},
      ])   # -> DataFrame indexed by path id
      ```
      
      Argument-key convention (this exact split is version-verified):
      **single event → `event` (string); multiple events → `events` (list) and only on
      `has_any_event` / `has_all_events`.** Wrong keys raise `InvalidMetricConfigError`
      naming the requirement.
      
      Other metrics: `active_days`, `first_event_time`, `in_segment` (modes `any/all/event_share`),
      `in_segment_bulk` (same check, one column per segment level: omit `segment_levels` for every
      level of `segment_name`, omit both for every level of every segment column).
      `matches_pattern` is token-wise (no substring false-positives) and order-deterministic.
      One position may be a class of events — `[a|b]` any of, `[^a]` anything but, `.` any
      event — which replaces merging events with `rename_events` just to ask one question.
      `[^a]` and `.` never match `path_start`/`path_end`; a sentinel takes part only when named.
      Quantified with `*` a class becomes a restricted gap: `a->[^x]*->b` is "reached b from a
      without passing through x". A restricted gap needs an anchor on both sides — name
      `path_start`/`path_end` if that is what you mean.
      `time_between` measures first occurrence of A to first occurrence of B **globally in the
      path** (not "first B after A") — verify fit for your question.
      
      ## 4. Tools: widgets + headless twins
      
      Every widget has a headless `<name>_data(...)` twin returning plain data with the same
      data parameters. Common widget params: `diff=`, `path_col=`, `height=`, `sidebar_open=`,
      `state_file=`.
      
      | Widget | Headless returns | Key params |
      |---|---|---|
      | `transition_graph` | events×events DataFrame | `edge_weight` ∈ `proba_out, proba_in, count, unique_paths, share_of_total, avg_per_path, time_median, time_q95` |
      | `step_matrix` / `step_sankey` | no `path_pattern`: DataFrame; with `path_pattern`: tuple of per-anchor blocks; with pattern+diff: `(blocks, g1_blocks, g2_blocks)` | `max_steps`, `path_pattern=".*->X->.*"` — the anchor event sits at column **0**. `transition_graph` takes `path_pattern` too, but there it only SELECTS paths (no step axis to centre, nothing is cut) |
      | `funnel` | dict; each step has `unique_paths`, `conversion_rate` (share of ALL paths) **and `step_conversion_rate`** (step-to-step) | `steps=[...]` — closed/ordered semantics: a path counts at step N only after passing all previous |
      | `segment_overview` | DataFrame metrics×segment values | `metrics=[...]` with `agg` |
      | `cluster_analysis` | dict: `overview_df`, `silhouette` (`{"params": [...], "silhouette": [...], "best_index", "selected_index"}`), `cluster_labels`, `best_params` | `method_args={"n_clusters": …}` accepts int, list, or range string `"3-8"`; features = metric configs. `select={"n_clusters": 5}` interprets that grid point instead of the top score, keeping the whole grid — the top silhouette is a hint, not a verdict, and near-ties are common. `best_params` always describes the interpreted point and is shaped as `add_clusters` kwargs (`method`/`method_args`/`scaler`), so `add_clusters(name, features, **best_params)` reproduces it |
      
      **Headless-only, no widget:** `get_conversion_rate(start_anchor, end_anchor, within=None, path_col=None)`
      → DataFrame, one row per (start, end) pair: `paths_with_start` (the denominator), `converted`,
      `conversion_rate`, `base_rate` (share of ALL paths where the target occurs at all) and `lift`
      (= rate / base_rate; **< 1 means the start event makes the outcome LESS likely**). Report the
      denominator and the lift, never the rate alone. Both sides take event names or `truncate_paths`
      anchor specs (`path_start` / `path_end` are ordinary names — `end_anchor="path_end", within=1`
      is an exit rate); a LIST on either side FANS OUT into separate questions, one row per
      combination, unlike `truncate_paths` where a list describes one bound. `within` is an int
      (events) or a duration string (`"30m"`), measured from the start anchor, far edge inclusive;
      `None` = to the end of the path. The unit of observation is the PATH, not the occurrence — a
      path where the start happened three times counts once, so per-visit questions ("of N visits,
      how many were entrances") need a different tool. Prefer `path_col="session_id"` when the
      question is about a visit rather than a person.
      
      **diff semantics:** `diff=(segment_col, v1, v2)` (also `(path_ids1, path_ids2)`; `v2` may be
      `"<REST>"`). Returned diff block = **value1 − value2**. Segment values must match exactly
      (check `get_segment_levels()`).
      
      ## 5. Deliverables: standalone interactive HTML
      
      ```python
      w = stream.transition_graph(diff=("device", "mobile", "desktop"))
      w.export_html("artifacts/graph.html",
                    title="Mobile vs desktop",
                    analysis="Markdown text; [event_name] references become clickable node links.")
      ```
      
      - `export_html` is a method of the **widget**, not the stream.
      - Files are fully self-contained (~1.2 MB, no CDN) — safe to attach or email.
      - A failed recompute raises `WidgetExportError` (no silent empty exports).
      - Widgets construct headlessly in plain scripts — Jupyter is not required for export.
      
      ## 6. Reproducibility: lineage recipes
      
      Processor chains are recorded and replayable:
      
      ```python
      processed = stream.filter_events(drop={"event": ["noise"]}).rename_events({"a": "A"})
      rec = processed.recipe()                     # JSON-serializable list of ops
      replayed = Eventstream.from_recipe(raw_df, rec)   # re-applies the same chain
      ```
      
      Persist `rec` in run metadata so any artifact can be regenerated from raw data.
      
      ## 7. MCP server (agent-driven analysis)
      
      ```python
      import retentioneering.mcp as mcp
      mcp.serve()                    # data-agnostic: agent loads an eventstream on demand
      mcp.serve(stream, port=8765)   # or pre-loaded; raises OSError if port is taken
      ```
      
      Report-building tools (`add_transition_graph` / `add_step_matrix` /
      `add_segment_overview`) each register a tab; `get_conversion_rate` is the exception —
      it answers a pair question in numbers and registers nothing, so the agent must quote its
      figures in backticks (`check_analysis` requires an anchor link for every number that
      came from a tab). `playbook("conversion_rate")` carries the procedure.
      
      ## 8. Environment notes
      
      - Requires Python ≥ 3.10. Backend is an embedded engine (DuckDB family) — millions of rows
        run in seconds on a laptop; no network needed.
      - Interactive widgets from a *source checkout* need the JS bundle built (`make build`,
        Node required). pip-installed wheels ship the bundle.
      - Telemetry: the library reports anonymous usage (method names, never data);
        see the docs "Tracking" page for scope and opt-out.
      
    • gotchas-and-validation.md 5.2 KB
      # Gotchas and validation
      
      Two lists with different natures. **API gotchas**: current library behaviors that
      surprise; work around them as described. **Analysis integrity**: methodological traps
      that produced wrong-but-plausible conclusions in real investigations until a reviewer
      caught them; validate against each before shipping numbers.
      
      ## A. API gotchas (version-verified)
      
      | # | Behavior | Handle it |
      |---|---|---|
      | G1 | a bare `truncate_paths(start_anchor="A", end_anchor="B")` DROPS paths missing either anchor | pass a LIST to add a fallback: `end_anchor=["B", "path_end"]` cuts at B where there is one and keeps the rest whole. Comparing two groups, go further and bound BOTH by the same budget — `end_anchor=["B", {"pattern": "A", "offset": 10}]` — or the diff measures window length, not behaviour |
      | G2 | No order-only mode: `timestamp_col` is mandatory | for logs with click order but no clock: synthesize `base_date + order * 1s`; then durations/`time_median` are MEANINGLESS — never report them, only step counts |
      | G3 | `time_between` = first A to first B **globally**, not first-B-after-A | off-by-few on paths where B precedes A; compute strict semantics via `to_dataframe()` when it matters |
      | G4 | `funnel` is closed/ordered only | for presence-based ("did all of these happen, any order") use `has_all_events` via `get_metrics` |
      | G5 | Metric arg keys: single event → `event`, lists → `event_groups` (only on `has_any_event`/`has_all_events`) | copy from api-map, not from memory; errors are loud and name the requirement |
      | G6 | Cluster result may LACK `best_params` when silhouette is undefined for every candidate (degenerate/duplicate feature rows) | guard `res.get("best_params")`; treat as "no cluster structure" |
      | G7 | Cluster labels are strings `"cluster_0"...`; diff values must match segment levels exactly | check `get_segment_levels()` before writing `diff=(...)` |
      | G8 | Transition matrix may be integer-typed with NaN gaps | before matrix math: `.astype(float).fillna(0)` |
      | G9 | Graph widgets with >~100 nodes open as a dense hairball | teach the edge-weight threshold in the sidebar; or pre-aggregate events into families (`rename_events`) |
      | G10 | `stream.df` is read-only | mutate via processors; materialize with `to_dataframe()` |
      
      Fixed in 5.0 — do NOT carry these legacy workarounds forward: identifier quoting for
      SQL-reserved column names; nondeterministic `matches_pattern` ordering; `n_clusters`
      range strings; silent `describe()` truncation (now flagged in `.attrs`, disable with
      `top_events=None`); silent empty widget exports (now `WidgetExportError`); substring
      pattern matching; anchor off-by-one in step matrix; `diff` sign (now v1−v2).
      
      ## B. Analysis integrity checklist
      
      Run before presenting conclusions. Each item traces to a real wrong-conclusion incident.
      
      1. **Denominators.** Every percentage names its base. `funnel_data.conversion_rate` is
         share of ALL paths; step-to-step is `step_conversion_rate`. *(Incident: "2% conversion"
         nearly shipped where the true step rate was 32%.)*
      2. **Population.** Who exactly is in the analysis? Watch entry filters (`truncate_paths`
         drops non-completers, G1) and "engaged users only" thresholds. *(Incident: a ≥10-min
         activity cutoff silently excluded 43% — the weakest users, the ones the decision was
         about; on the full cohort the verdict got harsher, not softer.)*
      3. **Survivorship.** Late-stage aggregates describe survivors. Report "of all who
         started / of those who reached" side by side. Ratings/feedback exist only for
         finishers.
      4. **Exposure confound.** Longer paths contain more of everything (loops, feature
         touches) AND convert more. Any "users who did X convert better" claim needs
         stratification by path length or a matched design.
      5. **Target leakage.** No outcome events in clustering features (R5) or in
         sequence/pattern features computed over the full path — cut paths at the first
         outcome before mining predictive patterns.
      6. **Structural vs behavioral lift.** If the flow FORCES step A before outcome B, "users
         who did A convert ×100" is architecture, not behavior. Say which one you measured.
      7. **Small cells.** Shares without `n` are noise bait; flag cells with n below ~30 and
         add intervals (Wilson for proportions) when a decision hangs on them.
      8. **Right/left censoring.** Log windows cut both ends: "never returned" near the window
         edge and "first session" of users active before the window are both suspect.
      9. **Correlation discipline.** Observational path data supports "associated with", not
         "drives", unless there is an experiment. End reports with hypotheses + suggested A/B,
         not causal claims.
      10. **Numbers ↔ visuals.** Every caption number comes from the headless twin of the
          widget shown; write `analysis=` texts after conclusions are frozen.
      
      ## C. Self-checks worth automating
      
      ```python
      # conservation: filters explain themselves
      before = stream.describe()["shape"]; after = filtered.describe()["shape"]
      # funnel sanity: step counts are non-increasing
      # diff sanity: diff block equals g1 - g2 on a sample cell
      # determinism: rerun the pipeline; assert identical key outputs
      # reproducibility: Eventstream.from_recipe(raw_df, processed.recipe()) equals processed
      ```
      
  • scripts
    • inspect_event_log.py 7.6 KB
      #!/usr/bin/env python
      """Profile an event log and suggest a Retentioneering Eventstream schema.
      
      Usage:
          python inspect_event_log.py PATH [--sep SEP] [--user-col C] [--event-col C]
                                      [--ts-col C] [--sample-rows N] [--out DIR]
      
      Reads CSV/TSV/Parquet, infers user/event/timestamp columns when not given,
      runs data-quality checks, writes artifacts/data-profile.json, and prints a
      human report plus a ready-to-paste Eventstream(...) snippet.
      
      Only requires pandas. If retentioneering is importable, its version is recorded.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import os
      import sys
      
      import pandas as pd
      
      USER_HINTS = (
          "user_id",
          "user",
          "client_id",
          "customer_id",
          "uid",
          "session_id",
          "hashed",
          "player",
          "visitor",
      )
      EVENT_HINTS = ("event_name", "event", "action", "event_type_name", "page", "screen")
      TS_HINTS = ("timestamp", "event_time", "time", "datetime", "ts", "date")
      SEGMENT_HINTS = (
          "device",
          "platform",
          "country",
          "source",
          "utm",
          "channel",
          "plan",
          "os",
          "browser",
          "campaign",
          "medium",
          "group_name",
          "variant",
      )
      
      
      def sniff_sep(path: str) -> str:
          with open(path, "r", encoding="utf-8", errors="replace") as fh:
              head = fh.readline()
          for sep in ("\t", ";", ",", "|"):
              if head.count(sep) >= 2:
                  return sep
          return ","
      
      
      def pick(columns, hints, taken):
          lowered = {c.lower(): c for c in columns}
          for h in hints:
              for lc, orig in lowered.items():
                  if h == lc and orig not in taken:
                      return orig
          for h in hints:
              for lc, orig in lowered.items():
                  if h in lc and orig not in taken:
                      return orig
          return None
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description=__doc__)
          ap.add_argument("path")
          ap.add_argument("--sep", default=None)
          ap.add_argument("--user-col", default=None)
          ap.add_argument("--event-col", default=None)
          ap.add_argument("--ts-col", default=None)
          ap.add_argument(
              "--sample-rows",
              type=int,
              default=None,
              help="read only the first N rows (for very large files)",
          )
          ap.add_argument("--out", default="artifacts")
          a = ap.parse_args()
      
          if a.path.endswith((".parquet", ".pq")):
              df = pd.read_parquet(a.path)
              sep = None
          else:
              sep = a.sep or sniff_sep(a.path)
              df = pd.read_csv(a.path, sep=sep, nrows=a.sample_rows, low_memory=False)
      
          cols = list(df.columns)
          taken: set = set()
          user_col = a.user_col or pick(cols, USER_HINTS, taken)
          taken.add(user_col)
          event_col = a.event_col or pick(cols, EVENT_HINTS, taken)
          taken.add(event_col)
          ts_col = a.ts_col or pick(cols, TS_HINTS, taken)
          taken.add(ts_col)
          segment_cols = [
              c
              for c in cols
              if c not in taken
              and any(h in c.lower() for h in SEGMENT_HINTS)
              and df[c].nunique(dropna=True) <= max(50, len(df) // 100)
          ]
      
          problems: list[str] = []
          if not user_col or not event_col:
              problems.append(
                  "could not infer user/event columns — pass --user-col/--event-col"
              )
      
          profile: dict = {
              "path": os.path.abspath(a.path),
              "sep": sep,
              "rows_read": int(len(df)),
              "sampled": a.sample_rows is not None,
              "columns": cols,
              "inferred": {
                  "user_col": user_col,
                  "event_col": event_col,
                  "ts_col": ts_col,
                  "segment_candidates": segment_cols,
              },
          }
      
          if user_col and event_col:
              nulls_user = int(df[user_col].isna().sum())
              nulls_event = int(df[event_col].isna().sum())
              profile["n_paths"] = int(df[user_col].nunique(dropna=True))
              profile["n_event_types"] = int(df[event_col].nunique(dropna=True))
              profile["nulls"] = {"user": nulls_user, "event": nulls_event}
              if nulls_user:
                  problems.append(
                      f"{nulls_user} rows with null {user_col} — Eventstream will "
                      "reject them; decide drop/repair explicitly"
                  )
              top = df[event_col].value_counts()
              profile["top_events"] = {str(k): int(v) for k, v in top.head(25).items()}
              share = float(top.iloc[0]) / max(len(df), 1)
              if share > 0.30:
                  problems.append(
                      f"event '{top.index[0]}' is {share:.0%} of all rows — likely "
                      "noise (heartbeat/dialogue); consider drop_events before path tools"
                  )
              plen = df.groupby(user_col, dropna=True).size()
              profile["path_length"] = {
                  q: float(plen.quantile(x))
                  for q, x in [("p50", 0.5), ("p90", 0.9), ("p99", 0.99)]
              } | {"max": int(plen.max()), "n_len1": int((plen == 1).sum())}
              if plen.max() > 50 * max(plen.median(), 1):
                  problems.append(
                      f"max path length {plen.max()} vs median {plen.median():.0f} "
                      "— check bots/shared accounts"
                  )
      
          ts_ok = False
          if ts_col:
              parsed = pd.to_datetime(df[ts_col], errors="coerce", utc=True, format="mixed")
              bad = int(parsed.isna().sum())
              ts_ok = bad == 0
              profile["timestamp"] = {
                  "unparseable": bad,
                  "min": str(parsed.min()),
                  "max": str(parsed.max()),
              }
              if bad:
                  problems.append(f"{bad} unparseable timestamps in {ts_col}")
              if user_col:
                  ties = int(df.assign(_p=parsed).duplicated([user_col, "_p"]).sum())
                  profile["timestamp"]["within_path_ties"] = ties
                  if ties:
                      problems.append(
                          f"{ties} same-timestamp ties within paths — ensure a "
                          "stable secondary order (row order is preserved)"
                      )
              dup = int(df.duplicated([c for c in (user_col, event_col, ts_col) if c]).sum())
              profile["exact_duplicates"] = dup
              if dup:
                  problems.append(
                      f"{dup} exact duplicate (user,event,ts) rows — dedupe or justify"
                  )
          else:
              problems.append(
                  "no timestamp column found — if only an order column exists, "
                  "synthesize base_date + order*1s and NEVER report durations (gotcha G2)"
              )
      
          try:
              import retentioneering
      
              profile["retentioneering_version"] = retentioneering.__version__
          except Exception:
              profile["retentioneering_version"] = None
              problems.append("retentioneering not importable in this environment")
      
          profile["problems"] = problems
      
          os.makedirs(a.out, exist_ok=True)
          out_path = os.path.join(a.out, "data-profile.json")
          with open(out_path, "w", encoding="utf-8") as fh:
              json.dump(profile, fh, indent=2, ensure_ascii=False, default=str)
      
          print(
              f"rows={profile['rows_read']:,}  paths={profile.get('n_paths', '?'):,}  "
              f"event_types={profile.get('n_event_types', '?')}"
          )
          if ts_col and "timestamp" in profile:
              print(f"period: {profile['timestamp']['min']} .. {profile['timestamp']['max']}")
          print(
              f"inferred: user={user_col}  event={event_col}  ts={ts_col}  "
              f"segments={segment_cols}"
          )
          for p in problems:
              print(f"  ⚠ {p}")
          print(f"\nprofile written: {out_path}\n")
          print("suggested schema:\n")
          seg = ", ".join(f'"{c}"' for c in segment_cols)
          print(f"""from retentioneering import Eventstream
      stream = Eventstream(df, schema={{
          "path_cols": ["{user_col}"],
          "event_col": "{event_col}",
          "timestamp_col": "{ts_col}",
          "segment_cols": [{seg}],
      }})
      stream.describe()""")
          return 1 if (not user_col or not event_col or not ts_ok) else 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • SKILL.md 6.1 KB
    ---
    name: retentioneering-product-analytics
    description: >
      Analyze event logs, clickstreams, user paths, product funnels, retention,
      behavioral segments, transition graphs, step matrices, sequence patterns,
      and customer journeys using Retentioneering. Use when the user provides
      CSV, Parquet, pandas, or database event data containing user, event, and
      timestamp columns, or asks why users convert, churn, loop, abandon a flow,
      or follow particular product paths. Do not use for qualitative
      journey-mapping workshops or aggregate website traffic without user-level
      event sequences.
    license: Apache-2.0
    compatibility: >
      Requires Python >= 3.10 and Retentioneering 5.x. Designed for local CSV,
      Parquet, and pandas event logs. Network access is not required for local
      analysis.
    metadata:
      author: retentioneering
      version: "1.0.0"
      package: retentioneering
      category: data-analysis
      keywords: >-
        clickstream, event log, user paths, customer journey, product analytics,
        funnel analysis, retention, churn, behavioral segmentation, transition
        graph, step matrix, sankey, sequence mining, markov chain, pandas, duckdb
      homepage: https://retentioneering.com
      documentation: https://retentioneering.com/docs
      repository: https://github.com/retentioneering/retentioneering-tools
    ---
    
    # Retentioneering product analytics
    
    ## Objective
    
    Turn event-level behavioral data into a reproducible answer to a **product question** —
    why users convert, churn, loop, or abandon — using user trajectories, transitions,
    funnels, and behavioral segments.
    
    Do not merely generate visualizations. Connect each output to the question, separate
    observation from interpretation, and never present path correlations as causal effects.
    
    ## Bundled references (read on demand, not upfront)
    
    | File | Read it when |
    |---|---|
    | `references/api-map.md` | before writing any Retentioneering call — verified signatures, argument conventions, return shapes for 5.x |
    | `references/analysis-recipes.md` | after the question is clear — 10 field-tested patterns (R1–R10) with skeletons and pitfalls |
    | `references/gotchas-and-validation.md` | before executing (API gotchas G1–G10) and before presenting (integrity checklist B1–B10) |
    | `scripts/inspect_event_log.py` | step 2 — automated data profiling and schema suggestion |
    
    ## Required event-log semantics
    
    Minimum: a path identifier (user or session), an event name, a timestamp (or a reliable
    order column — see gotcha G2 for order-only data). Useful extras: session id, segment
    attributes (device, source, plan), event properties, conversion labels.
    
    ## Workflow
    
    ### 1. Environment
    
    1. Confirm the package: `python -c "import retentioneering; print(retentioneering.__version__)"`.
       Expect 5.x; this skill's API map is version-verified for 5.0 — on a different major
       version, trust installed docstrings over the map.
    2. Locate the event data (CSV / Parquet / frames in existing code). Never modify inputs.
    3. Do not invent methods: anything not in `references/api-map.md` must be verified
       against the installed package before use.
    
    ### 2. Inspect the data BEFORE choosing methods
    
    Run `scripts/inspect_event_log.py <path> [--sep ...]` (or replicate its checks inline for
    in-memory frames). It profiles columns, infers the user/event/timestamp mapping, checks
    timestamp parseability, duplicates, per-path ordering, path-length distribution, and
    emits `artifacts/data-profile.json` plus a ready-to-paste `Eventstream(...)` schema.
    
    Report to the user before proceeding: inferred mapping, row/user/event-type counts,
    covered period, and any red flags (nulls in key columns, timestamp ties, suspected bots
    or ultra-long paths, order-only timestamps). Confirm the mapping if inference is
    ambiguous.
    
    ### 3. Frame the product question, then pick the SMALLEST recipe
    
    Map the question to a recipe in `references/analysis-recipes.md`:
    navigation structure/loops → transition graph (R1/R6) · before/after an anchor →
    step matrix (R3) · ordered conversion flow → funnel (R1/R2) · what winners do
    differently → diff on a funnel-stage segment (R2) · heterogeneous users → clustering
    without target leakage (R5) · between two funnel levels → truncate micro-journey (R4) ·
    intervention timing → time-to-outcome (R7) · cross-segment scan → segment overview (R8) ·
    value of a fix → Markov what-if (R9, advanced).
    
    Combine recipes only when each addition resolves a distinct uncertainty.
    
    ### 4. Execute reproducibly
    
    1. Prefer a rerunnable script (or a notebook executed top-to-bottom) over ad-hoc cells.
    2. Write artifacts to a dedicated output directory (`artifacts/` by default).
    3. Log every filtering rule and its row/path impact; never silently drop data
       (integrity item B2).
    4. Use `sample_paths(frac=, random_state=)` for stable subsamples; stochastic steps get
       explicit seeds.
    5. Record lineage: save `processed.recipe()` and the package version into
       `artifacts/run-metadata.json` — any artifact must be regenerable from raw data via
       `Eventstream.from_recipe(raw_df, recipe)`.
    
    ### 5. Validate before presenting
    
    Work through `references/gotchas-and-validation.md` section B. Non-negotiables:
    every percentage names its denominator; population filters are disclosed with counts;
    survivorship and exposure confounds addressed; no outcome leakage into features;
    small cells flagged with n; caption numbers come from headless `*_data` twins;
    visuals agree with tables.
    
    ### 6. Interpret and deliver
    
    Structure the final answer as:
    
    1. **Observed** — numbers with denominators and n.
    2. **Interpretation** — what it likely means.
    3. **Alternative explanations** — selection, structure, censoring.
    4. **Product hypotheses** — each with the metric an experiment would move.
    5. **Suggested next analyses / A-B tests.**
    6. **Limitations.**
    
    Deliverables: analysis script or executed notebook; `artifacts/data-profile.json`;
    `artifacts/metrics.csv` (key tables); interactive HTML exports via
    `widget.export_html(..., title=, analysis=)` — write `analysis=` captions AFTER
    conclusions are final; `artifacts/summary.md` (mapping, filters, assumptions, versions,
    findings, limitations, next steps); `artifacts/run-metadata.json` (versions, parameters,
    seeds, `recipe()` lineage).
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related