Claude Skill

exploring-data

Exploratory data analysis. Use when users upload .csv/.xlsx/.json/.parquet files or request "explore data", "analyze dataset", "EDA", "profile data". Small files get ydata-profiling HTML/JSON reports; large files (over 200MB or 5M rows) get fixed-memory DuckDB/sketch profiling. A

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

Full trust report

Download oaustegard-claude-skills-plugins_data-and-visualization_skills_exploring-data-e39c726.zip · 22 KB
Part of oaustegard/claude-skills — 39 skills

Install

skills CLI npx skills add https://github.com/oaustegard/claude-skills/tree/main/plugins/data-and-visualization/skills/exploring-data
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install oaustegard-claude-skills@llmmart
Git git clone https://github.com/oaustegard/claude-skills.git

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

README

exploring-data

Exploratory data analysis using ydata-profiling. Use when users upload .csv/.xlsx/.json/.parquet files or request "explore data", "analyze dataset", "EDA", "profile data". Generates interactive HTML or JSON reports with statistics, visualizations, correlations, and quality alerts.

Skill manifest

Exploring Data

0. Route by size FIRST

ls -la <filepath>   # or: wc -l for row estimate
  • < 200MB and < ~5M rows → ydata-profiling path (section A). Exact stats, interactive HTML.
  • Larger → large-file path (section B). ydata-profiling loads everything into pandas and will crawl or OOM; the DuckDB/sketch path runs in fixed memory at any size.
  • Task-specific ops (any size): duplicates, join feasibility, drift → section C.

A. Standard path (ydata-profiling)

1. Check if installed (instant)

bash /mnt/skills/user/exploring-data/scripts/check_install.sh

Returns: installed or not_installed

2. Install if needed (one-time, ~19s)

if [ "$(bash /mnt/skills/user/exploring-data/scripts/check_install.sh)" = "not_installed" ]; then
    bash /mnt/skills/user/exploring-data/scripts/install_ydata.sh
fi

3. Run analysis (always generates JSON + HTML by default)

bash /mnt/skills/user/exploring-data/scripts/analyze.sh <filepath> [minimal|full] [html|json]

Defaults: minimal + html (also generates JSON)

Output:

  • eda_report.html - Interactive report for user
  • eda_report.json - Machine-readable for Claude analysis

4. If Claude needs to analyze (user asks "what do you think?" etc.)

python /mnt/skills/user/exploring-data/scripts/summarize_insights.py /mnt/user-data/outputs/eda_report.json

Claude should read the stdout markdown summary, NOT the full JSON report.

5. Present findings visually (don't just hand over the ydata HTML)

The ydata report is exhaustive but dense; a link to it is a weak deliverable. Turn the JSON into a compact dashboard of the findings that matter:

python3 /mnt/skills/user/exploring-data/scripts/visualize_findings.py \
    /mnt/user-data/outputs/eda_report.json
# → /mnt/user-data/outputs/eda_findings.html

Emits a single self-contained HTML file (Chart.js from cdnjs, dark-mode aware): missingness by column (tiered good/bad), the most skewed or zero-inflated numeric distributions as small-multiple histograms, and the largest categorical breakdowns. --top N caps charts per category (default 6). Also reads profile_large.py --json output, so the large-file path gets the same treatment.

Present BOTH files: eda_findings.html for the headline read, eda_report.html for the full drill-down. In a chat surface that renders inline visuals, prefer rendering the two or three findings that actually answer the user's question as inline charts over linking a file — a link the user has to open is the weakest form of "showing" data.

Modes

Minimal (default, 5-10s): overview, variable analysis, correlations, missing values, alerts Full (10-20s): minimal + scatter matrices, sample data, character analysis

Full-mode triggers: "comprehensive analysis", "detailed EDA", "full profiling", "deep analysis". Otherwise minimal.

Time series

If the data has a datetime index/column and the user cares about temporal behavior (gaps, trends, seasonality, autocorrelation), pass tsmode=True to ProfileReport — run the venv python directly instead of analyze.sh:

ProfileReport(df, tsmode=True, sortby="<datetime_col>", title=...)

This adds gap detection, stationarity and seasonality checks that the default report omits.

Small-file drift

Comparing two versions of a dataset that BOTH fit in memory: use ydata's native compare — ProfileReport(df_a).compare(ProfileReport(df_b)).to_file(...). For files too big to load, or comparing against a months-old file you no longer have, use the sketch snapshot/drift ops in section C.

B. Large-file path (DuckDB, fixed memory)

1. Install deps (idempotent, ~10s first time)

bash /mnt/skills/user/exploring-data/scripts/install_large.sh

2. Profile

python3 /mnt/skills/user/exploring-data/scripts/profile_large.py <file> [--json out.json]

Streams the file through DuckDB: per-column null%, approximate distinct counts (HLL), min/max/mean, approximate quantiles (t-digest) for numerics, top-5 values for strings, plus quality flags (mostly-null, constant, id-like columns). Markdown lands on stdout — read it directly, no summarize step needed. Handles csv/tsv/parquet/json/ndjson. 1M rows profiles in seconds; memory is flat regardless of file size.

For ad-hoc follow-up queries on the same large file, use DuckDB SQL directly (duckdb.connect().execute("SELECT ... FROM read_csv_auto('...')")) rather than loading pandas.

C. Sketch ops (any file size, fixed memory)

All via scripts/sketch_ops.py (deps from install_large.sh). These answer questions profilers don't:

Near-duplicate rows

python3 sketch_ops.py dups <file> [--threshold 0.9] [--cols a,b,c] [--unweighted]

Exact duplicates counted by hash; near-duplicates via MinHash LSH over row tokens. --threshold is a weighted Jaccard cutoff: a token occurring c times in a row counts c times, so new york new york and york new score 0.5 rather than 1.0. Pass --unweighted for set semantics, where repeats are discarded. Use --cols to restrict to the columns that define identity.

Key overlap / join feasibility

python3 sketch_ops.py overlap <fileA> <fileB> --key <col> [--key-b <col>]

Theta sketches per key column → estimated intersection, Jaccard, and "% of A's keys in B" both ways — answers "will this join hold?" without loading either file.

Drift vs stored baseline

python3 sketch_ops.py snapshot <file> --out baseline.sketch.json   # ~20KB
python3 sketch_ops.py drift <newfile> --baseline baseline.sketch.json

Snapshot serializes HLL (all columns) + KLL quantile sketches (numeric columns) to a small JSON. Drift reports schema changes, >10% shifts in distinct counts, and IQR-relative quantile movement. The snapshot is a few KB — store it (repo, memory) and diff next month's delivery against it without keeping the original file.

Note: snapshot/dups stream rows through Python (~1M rows in a few seconds); profile_large is pure DuckDB and faster. For a quick look at a big file, profile first, sketch ops only when the question calls for them.

Files (claude-skills)
  • references
    • USAGE.md 1.8 KB
      # Exploring Data - Usage Documentation
      
      ## What This Skill Does
      
      Generates comprehensive exploratory data analysis reports using ydata-profiling, a battle-tested library with 12.5k+ GitHub stars.
      
      ## Features
      
      **Minimal Mode (default, 5-10s):**
      - Dataset overview (rows, columns, types, memory)
      - Variable analysis (distributions, statistics)
      - Missing value analysis
      - Duplicate detection
      - Correlation matrices
      - Data quality alerts
      
      **Full Mode (10-20s):**
      - Everything in minimal +
      - Scatter plot matrices
      - Sample data display
      - Character/script analysis (for text)
      - Auto-correlation
      - More sophisticated visualizations
      
      ## Output Formats
      
      **HTML (default):**
      - Interactive, self-contained report (500KB-2MB)
      - Tabbed navigation
      - Embedded visualizations
      - Downloadable via browser
      
      **JSON:**
      - Machine-readable format
      - For programmatic access
      - Can be parsed by other tools
      
      ## Installation
      
      **First use only (~19s):**
      ```bash
      bash /mnt/skills/user/exploring-data/scripts/install_ydata.sh
      ```
      
      Creates isolated venv at `/home/claude/.venvs/exploring-data` with:
      - ydata-profiling 4.17.0
      - ~50 dependencies
      - Total: 559MB
      
      **Subsequent uses:** Instant (venv already exists)
      
      ## Performance
      
      | Dataset Size | Minimal Mode | Full Mode |
      |-------------|-------------|-----------|
      | <10k rows | 2-5s | 5-10s |
      | 10-50k rows | 5-10s | 10-15s |
      | 50-100k rows | 10-15s | 15-25s |
      
      ## Troubleshooting
      
      **If installation fails:**
      - Check network access to pypi.org
      - Ensure uv is available: `uv --version`
      - Try manual install:
        ```bash
        uv venv /home/claude/.venvs/exploring-data
        uv pip install ydata-profiling setuptools --python /home/claude/.venvs/exploring-data
        ```
      
      **If analysis fails:**
      - Check file format is supported (.csv, .xlsx, .json, .parquet, .tsv)
      - Verify file exists at specified path
      - Check file is not corrupted
      
  • scripts
    • analyze.sh 2.2 KB
      #!/bin/bash
      # Run ydata-profiling analysis
      # Usage: analyze.sh <datafile> [minimal|full] [html|json|both]
      
      set -e
      
      DATAFILE="$1"
      MODE="${2:-minimal}"  # default: minimal
      FORMAT="${3:-html}"   # default: html
      
      VENV_PYTHON="/home/claude/.venvs/exploring-data/bin/python"
      OUTPUT_DIR="/mnt/user-data/outputs"
      
      if [ ! -f "$VENV_PYTHON" ]; then
          echo "Error: ydata-profiling not installed"
          echo "Run: bash /mnt/skills/user/exploring-data/scripts/install_ydata.sh"
          exit 1
      fi
      
      if [ ! -f "$DATAFILE" ]; then
          echo "Error: File not found: $DATAFILE"
          exit 1
      fi
      
      # Set minimal flag
      if [ "$MODE" = "minimal" ]; then
          MINIMAL_FLAG="True"
      else
          MINIMAL_FLAG="False"
      fi
      
      # Determine what to generate
      GENERATE_HTML="false"
      GENERATE_JSON="false"
      
      if [ "$FORMAT" = "json" ]; then
          GENERATE_JSON="true"
      elif [ "$FORMAT" = "both" ]; then
          GENERATE_HTML="true"
          GENERATE_JSON="true"
      else
          # Default: HTML + always generate JSON for potential Claude analysis
          GENERATE_HTML="true"
          GENERATE_JSON="true"
      fi
      
      # Generate report
      "$VENV_PYTHON" << PYEOF
      import sys
      from pathlib import Path
      from ydata_profiling import ProfileReport
      import pandas as pd
      
      filepath = Path("$DATAFILE")
      
      # Load data
      if filepath.suffix == '.csv':
          df = pd.read_csv(filepath)
      elif filepath.suffix == '.xlsx':
          df = pd.read_excel(filepath)
      elif filepath.suffix == '.json':
          df = pd.read_json(filepath)
      elif filepath.suffix == '.parquet':
          df = pd.read_parquet(filepath)
      elif filepath.suffix == '.tsv':
          df = pd.read_csv(filepath, sep='\t')
      else:
          print(f"Unsupported format: {filepath.suffix}")
          sys.exit(1)
      
      # Generate profile
      minimal = $MINIMAL_FLAG
      profile = ProfileReport(df, minimal=minimal, title=f"EDA: {filepath.name}")
      
      # Output files
      output_dir = Path("$OUTPUT_DIR")
      html_path = output_dir / "eda_report.html"
      json_path = output_dir / "eda_report.json"
      
      # Generate requested outputs
      if "$GENERATE_HTML" == "true":
          profile.to_file(html_path)
          print(f"✓ HTML report: {html_path}")
      
      if "$GENERATE_JSON" == "true":
          profile.to_file(json_path)
          print(f"✓ JSON report: {json_path}")
      
      # Print summary
      print(f"\nDataset: {len(df):,} rows × {len(df.columns)} columns")
      print(f"Mode: {'Minimal' if minimal else 'Full'} analysis")
      PYEOF
      
    • check_install.sh 184 B
      #!/bin/bash
      # Quick check if ydata-profiling is installed
      VENV_PYTHON="/home/claude/.venvs/exploring-data/bin/python"
      [ -f "$VENV_PYTHON" ] && echo "installed" || echo "not_installed"
      
    • install_large.sh 335 B
      #!/bin/bash
      # Install large-file/sketch deps (system-safe self-contained wheels, ~10s)
      set -e
      python3 - <<'PY' 2>/dev/null && { echo installed; exit 0; }
      import duckdb, datasketches, datasketch
      PY
      echo "Installing duckdb + datasketches + datasketch..."
      pip install --break-system-packages -q duckdb datasketches datasketch
      echo "done"
      
    • install_ydata.sh 717 B
      #!/bin/bash
      # Install ydata-profiling via uv (runs once, ~19s)
      set -e
      
      VENV_PATH="/home/claude/.venvs/exploring-data"
      
      echo "⚙️  Installing ydata-profiling (~19 seconds, one-time only)..."
      
      # Create venv
      uv venv "$VENV_PATH" 2>&1 | grep -v "Using Python"
      
      # Install packages.
      #  - setuptools<81: ydata-profiling 4.18.x still imports pkg_resources, which
      #    was removed from setuptools>=81. Unpinned, uv resolves the latest and the
      #    first ProfileReport import dies with ModuleNotFoundError: pkg_resources.
      #  - pyarrow: analyze.sh reads .parquet via pandas, which needs an engine.
      uv pip install ydata-profiling "setuptools<81" pyarrow --python "$VENV_PATH" 2>&1 | tail -3
      
      echo "✓ Installation complete!"
      
    • profile_large.py 4.2 KB
      #!/usr/bin/env python3
      """Out-of-core profiling for files too large for ydata-profiling.
      
      Uses DuckDB streaming scans: approx_count_distinct (HLL) and approx_quantile
      (t-digest) run in fixed memory regardless of file size.
      
      Usage: profile_large.py <datafile> [--json /path/out.json]
      Output: markdown summary to stdout (Claude reads this directly).
      """
      import json
      import sys
      from pathlib import Path
      
      try:
          import duckdb
      except ImportError:
          sys.exit("duckdb not installed — run install_large.sh first")
      
      
      def reader_sql(path: Path) -> str:
          s = path.suffix.lower()
          p = str(path).replace("'", "''")
          if s in (".csv", ".tsv", ".txt"):
              return f"read_csv_auto('{p}', sample_size=-1)"
          if s == ".parquet":
              return f"read_parquet('{p}')"
          if s in (".json", ".ndjson", ".jsonl"):
              return f"read_json_auto('{p}')"
          sys.exit(f"Unsupported format: {s}")
      
      
      def main():
          if len(sys.argv) < 2:
              sys.exit(__doc__)
          path = Path(sys.argv[1])
          if not path.is_file():
              sys.exit(f"File not found: {path}")
          json_out = None
          if "--json" in sys.argv:
              json_out = Path(sys.argv[sys.argv.index("--json") + 1])
      
          con = duckdb.connect()
          src = reader_sql(path)
          cols = con.execute(f"DESCRIBE SELECT * FROM {src}").fetchall()
          n_rows = con.execute(f"SELECT count(*) FROM {src}").fetchone()[0]
      
          numeric_types = ("TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT",
                           "FLOAT", "DOUBLE", "DECIMAL", "UTINYINT", "USMALLINT",
                           "UINTEGER", "UBIGINT")
          report = {"file": str(path), "rows": n_rows,
                    "size_bytes": path.stat().st_size, "columns": []}
      
          for name, dtype, *_ in cols:
              q = f'"{name}"'
              base = con.execute(
                  f"SELECT count({q}), approx_count_distinct({q}) FROM {src}"
              ).fetchone()
              non_null, distinct = base
              col = {"name": name, "dtype": dtype,
                     "null_frac": round(1 - non_null / n_rows, 4) if n_rows else 0,
                     "approx_distinct": distinct}
              if any(dtype.upper().startswith(t) for t in numeric_types):
                  stats = con.execute(
                      f"SELECT min({q}), max({q}), avg({q}), "
                      f"approx_quantile({q}, 0.25), approx_quantile({q}, 0.5), "
                      f"approx_quantile({q}, 0.75) FROM {src}"
                  ).fetchone()
                  col.update(dict(zip(
                      ("min", "max", "mean", "q25", "median", "q75"),
                      [round(v, 4) if isinstance(v, float) else v for v in stats])))
              elif dtype.upper().startswith(("VARCHAR", "DATE", "TIME")):
                  top = con.execute(
                      f"SELECT {q}, count(*) c FROM {src} WHERE {q} IS NOT NULL "
                      f"GROUP BY {q} ORDER BY c DESC LIMIT 5"
                  ).fetchall()
                  col["top_values"] = [[str(v)[:60], c] for v, c in top]
              report["columns"].append(col)
      
          # markdown to stdout
          mb = report["size_bytes"] / 1e6
          print(f"# Profile: {path.name}\n")
          print(f"{n_rows:,} rows x {len(cols)} columns, {mb:,.1f} MB on disk\n")
          print("| column | type | null% | ~distinct | detail |")
          print("|---|---|---|---|---|")
          for c in report["columns"]:
              if "median" in c:
                  d = (f"min {c['min']} / q25 {c['q25']} / med {c['median']} / "
                       f"q75 {c['q75']} / max {c['max']}")
              elif "top_values" in c:
                  d = "; ".join(f"{v} ({n:,})" for v, n in c["top_values"][:3])
              else:
                  d = ""
              print(f"| {c['name']} | {c['dtype']} | {c['null_frac']*100:.1f} "
                    f"| {c['approx_distinct']:,} | {d} |")
      
          # quality flags
          flags = []
          for c in report["columns"]:
              if c["null_frac"] > 0.5:
                  flags.append(f"{c['name']}: {c['null_frac']*100:.0f}% null")
              if c["approx_distinct"] <= 1 and n_rows > 1:
                  flags.append(f"{c['name']}: constant")
              if c["approx_distinct"] >= 0.95 * n_rows and "top_values" in c:
                  flags.append(f"{c['name']}: near-unique string (id-like)")
          if flags:
              print("\n**Flags:** " + "; ".join(flags))
      
          if json_out:
              json_out.write_text(json.dumps(report, indent=1, default=str))
              print(f"\nJSON: {json_out}")
      
      
      if __name__ == "__main__":
          main()
      
    • sketch_ops.py 8.1 KB
      #!/usr/bin/env python3
      """Sketch-based exploration ops that profilers miss. Fixed memory, any file size.
      
      Subcommands:
        dups <file> [--threshold 0.9] [--cols a,b] [--unweighted]
                                                     near-duplicate row detection (MinHash LSH)
        overlap <fileA> <fileB> --key <col> [--key-b <col>]   key overlap / join feasibility (theta sketch)
        snapshot <file> --out <sketches.json>        persist HLL+KLL sketches per column
        drift <file> --baseline <sketches.json>      compare current file against a snapshot
      
      Files stream through DuckDB in batches; sketches are the only state held.
      """
      import base64
      import json
      import sys
      from collections import Counter
      from pathlib import Path
      
      try:
          import duckdb
          from datasketch import MinHash, MinHashLSH
          from datasketches import (
              hll_sketch,
              kll_floats_sketch,
              theta_intersection,
              theta_union,
              update_theta_sketch,
          )
      except ImportError as e:
          sys.exit(f"Missing dep ({e.name}) — run install_large.sh first")
      
      BATCH = 50_000
      
      
      def reader_sql(path: Path) -> str:
          s = path.suffix.lower()
          p = str(path).replace("'", "''")
          return {
              ".csv": f"read_csv_auto('{p}', sample_size=-1)",
              ".tsv": f"read_csv_auto('{p}', sample_size=-1)",
              ".parquet": f"read_parquet('{p}')",
              ".json": f"read_json_auto('{p}')",
              ".ndjson": f"read_json_auto('{p}')",
              ".jsonl": f"read_json_auto('{p}')",
          }.get(s) or sys.exit(f"Unsupported format: {s}")
      
      
      def batches(con, sql):
          cur = con.execute(sql)
          while rows := cur.fetchmany(BATCH):
              yield rows
      
      
      def row_minhash(tokens, weighted=True, num_perm=128):
          """MinHash over a row's tokens.
      
          Weighted (the default) inserts c distinct elements (t, 0) ... (t, c-1) for
          a token t occurring c times, so the signature estimates the weighted
          Jaccard index sum(min(a_i, b_i)) / sum(max(a_i, b_i)). Unweighted collapses
          the tokens to a set: `new york new york` and `york new` then score 1.0
          against each other, because MinHash.update keeps a per-permutation minimum
          and hashing a token twice changes nothing. Rows whose tokens are all
          distinct produce the same signature either way.
          """
          m = MinHash(num_perm=num_perm)
          if weighted:
              for tok, c in Counter(tokens).items():
                  for j in range(c):
                      m.update(f"{tok}\x00{j}".encode())
          else:
              for tok in tokens:
                  m.update(tok.encode())
          return m
      
      
      def cmd_dups(argv):
          path = Path(argv[0])
          thresh = float(argv[argv.index("--threshold") + 1]) if "--threshold" in argv else 0.9
          con = duckdb.connect()
          src = reader_sql(path)
          cols = ('"' + '", "'.join(argv[argv.index("--cols") + 1].split(",")) + '"'
                  ) if "--cols" in argv else "*"
          weighted = "--unweighted" not in argv
          lsh = MinHashLSH(threshold=thresh, num_perm=128)
          exact_seen, exact_dups, clusters, i = set(), 0, [], 0
          for rows in batches(con, f"SELECT {cols} FROM {src}"):
              for row in rows:
                  i += 1
                  key = tuple(str(v) for v in row)
                  if key in exact_seen:
                      exact_dups += 1
                      continue
                  exact_seen.add(key)
                  m = row_minhash(" ".join(key).lower().split(), weighted=weighted)
                  near = lsh.query(m)
                  if near:
                      clusters.append((near[0], i))
                  lsh.insert(f"row{i}", m)
          print(f"# Near-duplicate scan: {path.name}")
          metric = "weighted Jaccard" if weighted else "Jaccard"
          print(f"{i:,} rows; {exact_dups:,} exact duplicates; "
                f"{len(clusters):,} near-duplicate pairs at {metric}>={thresh}")
          for anchor, dup in clusters[:10]:
              print(f"  {anchor} ~ row{dup}")
          if len(clusters) > 10:
              print(f"  ... {len(clusters) - 10} more")
      
      
      def _theta_of(path, key):
          con = duckdb.connect()
          sk = update_theta_sketch()
          n = 0
          for rows in batches(con, f'SELECT "{key}" FROM {reader_sql(path)} '
                                   f'WHERE "{key}" IS NOT NULL'):
              for (v,) in rows:
                  sk.update(str(v))
                  n += 1
          return sk, n
      
      
      def cmd_overlap(argv):
          a, b = Path(argv[0]), Path(argv[1])
          key = argv[argv.index("--key") + 1]
          key_b = argv[argv.index("--key-b") + 1] if "--key-b" in argv else key
          sa, na = _theta_of(a, key)
          sb, nb = _theta_of(b, key_b)
          inter = theta_intersection()
          inter.update(sa)
          inter.update(sb)
          uni = theta_union()
          uni.update(sa)
          uni.update(sb)
          i, u = inter.get_result().get_estimate(), uni.get_result().get_estimate()
          ea, eb = sa.get_estimate(), sb.get_estimate()
          print(f"# Key overlap: {a.name}.{key} vs {b.name}.{key_b}")
          print(f"A: {na:,} values, ~{ea:,.0f} distinct")
          print(f"B: {nb:,} values, ~{eb:,.0f} distinct")
          print(f"Intersection ~{i:,.0f} | Jaccard ~{i/u:.3f}" if u else "empty")
          if ea:
              print(f"~{i/ea*100:.1f}% of A's keys appear in B; "
                    f"~{i/eb*100:.1f}% of B's in A" if eb else "")
      
      
      def _snapshot(path):
          con = duckdb.connect()
          src = reader_sql(path)
          cols = con.execute(f"DESCRIBE SELECT * FROM {src}").fetchall()
          numeric = ("TINYINT", "SMALLINT", "INTEGER", "BIGINT", "FLOAT", "DOUBLE",
                     "DECIMAL", "HUGEINT")
          out = {"file": path.name, "rows": 0, "cols": {}}
          hll = {n: hll_sketch(12) for n, *_ in cols}
          kll = {n: kll_floats_sketch(200) for n, t, *_ in cols
                 if any(t.upper().startswith(x) for x in numeric)}
          names = [n for n, *_ in cols]
          for rows in batches(con, f"SELECT * FROM {src}"):
              out["rows"] += len(rows)
              for row in rows:
                  for n, v in zip(names, row):
                      if v is None:
                          continue
                      hll[n].update(str(v))
                      if n in kll:
                          kll[n].update(float(v))
          for n in names:
              out["cols"][n] = {
                  "hll": base64.b64encode(hll[n].serialize_compact()).decode()}
              if n in kll and not kll[n].is_empty():
                  out["cols"][n]["kll"] = base64.b64encode(
                      kll[n].serialize()).decode()
          return out
      
      
      def cmd_snapshot(argv):
          path = Path(argv[0])
          out = Path(argv[argv.index("--out") + 1])
          snap = _snapshot(path)
          out.write_text(json.dumps(snap))
          print(f"Snapshot: {snap['rows']:,} rows, {len(snap['cols'])} columns "
                f"-> {out} ({out.stat().st_size/1024:.1f} KB)")
      
      
      def cmd_drift(argv):
          path = Path(argv[0])
          base = json.loads(Path(argv[argv.index("--baseline") + 1]).read_text())
          cur = _snapshot(path)
          print(f"# Drift: {path.name} vs baseline {base['file']}")
          print(f"Rows: {base['rows']:,} -> {cur['rows']:,}")
          gone = set(base["cols"]) - set(cur["cols"])
          new = set(cur["cols"]) - set(base["cols"])
          if gone:
              print(f"Columns dropped: {sorted(gone)}")
          if new:
              print(f"Columns added: {sorted(new)}")
          for n in sorted(set(base["cols"]) & set(cur["cols"])):
              b, c = base["cols"][n], cur["cols"][n]
              hb = hll_sketch.deserialize(base64.b64decode(b["hll"]))
              hc = hll_sketch.deserialize(base64.b64decode(c["hll"]))
              eb, ec = hb.get_estimate(), hc.get_estimate()
              notes = []
              if eb and abs(ec - eb) / eb > 0.10:
                  notes.append(f"distinct ~{eb:,.0f} -> ~{ec:,.0f}")
              if "kll" in b and "kll" in c:
                  kb = kll_floats_sketch.deserialize(base64.b64decode(b["kll"]))
                  kc = kll_floats_sketch.deserialize(base64.b64decode(c["kll"]))
                  qb = [kb.get_quantile(q) for q in (0.25, 0.5, 0.75)]
                  qc = [kc.get_quantile(q) for q in (0.25, 0.5, 0.75)]
                  span = (qb[2] - qb[0]) or 1.0
                  if any(abs(x - y) / abs(span) > 0.10 for x, y in zip(qb, qc)):
                      notes.append(f"quantiles {[round(v,2) for v in qb]} -> "
                                   f"{[round(v,2) for v in qc]}")
              if notes:
                  print(f"  {n}: " + "; ".join(notes))
          print("(threshold: >10% shift in distinct count or IQR-relative quantiles)")
      
      
      if __name__ == "__main__":
          cmds = {"dups": cmd_dups, "overlap": cmd_overlap,
                  "snapshot": cmd_snapshot, "drift": cmd_drift}
          if len(sys.argv) < 3 or sys.argv[1] not in cmds:
              sys.exit(__doc__)
          cmds[sys.argv[1]](sys.argv[2:])
      
    • summarize_insights.py 12.3 KB
      #!/usr/bin/env python3
      """
      Extract key insights from ydata-profiling JSON for Claude to analyze.
      Focuses on patterns, relationships, and quality issues rather than raw data.
      """
      import json
      import sys
      from pathlib import Path
      
      
      def extract_insights(profile_json_path):
          """Extract high-level insights optimized for LLM analysis."""
          with open(profile_json_path, 'r') as f:
              data = json.load(f)
          
          insights = {
              "dataset_overview": {},
              "variable_summary": {
                  "numeric": [],
                  "categorical": [],
                  "temporal": [],
                  "text": []
              },
              "data_quality": {
                  "issues": [],
                  "warnings": []
              },
              "patterns": {
                  "correlations": [],
                  "distributions": [],
                  "relationships": []
              },
              "recommendations": []
          }
          
          # Dataset overview
          table = data.get('table', {})
          insights['dataset_overview'] = {
              "n_rows": table.get('n', 0),
              "n_cols": table.get('n_var', 0),
              "memory_size": table.get('memory_size', 0),
              "n_duplicates": table.get('n_duplicates', 0),
              "missing_cells": table.get('n_cells_missing', 0),
              "missing_pct": round(table.get('p_cells_missing', 0) * 100, 2)
          }
          
          variables = data.get('variables', {})
          correlations = data.get('correlations', {})
          
          # Analyze each variable
          for var_name, var in variables.items():
              vtype = var.get('type')
              n_missing = var.get('n_missing', 0)
              n_total = insights['dataset_overview']['n_rows']
              missing_pct = round((n_missing / n_total * 100), 2) if n_total > 0 else 0
              
              var_summary = {
                  "name": var_name,
                  "missing_pct": missing_pct,
                  "n_unique": var.get('n_distinct', var.get('n_unique', 0))
              }
              
              if vtype == 'Numeric':
                  # Skip if all missing
                  if n_missing == n_total:
                      continue
                      
                  var_summary.update({
                      "mean": var.get('mean'),
                      "std": var.get('std'),
                      "min": var.get('min'),
                      "max": var.get('max'),
                      "skewness": var.get('skewness'),
                      "n_zeros": var.get('n_zeros', 0),
                      "n_infinite": var.get('n_infinite', 0)
                  })
                  
                  # Detect patterns
                  if var.get('n_zeros', 0) / n_total > 0.5:
                      var_summary['note'] = "majority_zeros"
                  elif var.get('skewness', 0) and abs(var.get('skewness', 0)) > 2:
                      var_summary['note'] = "highly_skewed"
                  elif var.get('std', 1) == 0:
                      var_summary['note'] = "constant"
                      
                  insights['variable_summary']['numeric'].append(var_summary)
                  
              elif vtype == 'Text':
                  if n_missing == n_total:
                      continue
                      
                  cardinality_ratio = var_summary['n_unique'] / n_total if n_total > 0 else 0
                  var_summary['cardinality_ratio'] = round(cardinality_ratio, 3)
                  
                  # Classify
                  if var_summary['n_unique'] == 1:
                      var_summary['note'] = "constant"
                  elif cardinality_ratio > 0.95:
                      var_summary['note'] = "unique_identifier"
                  elif cardinality_ratio < 0.5:
                      var_summary['note'] = "categorical"
                  else:
                      var_summary['note'] = "high_cardinality"
                      
                  insights['variable_summary']['categorical'].append(var_summary)
                  
              elif vtype == 'DateTime':
                  var_summary.update({
                      "min": var.get('min'),
                      "max": var.get('max')
                  })
                  insights['variable_summary']['temporal'].append(var_summary)
          
          # Data quality issues
          if insights['dataset_overview']['missing_pct'] > 10:
              insights['data_quality']['issues'].append({
                  "type": "high_missingness",
                  "severity": "high" if insights['dataset_overview']['missing_pct'] > 30 else "medium",
                  "description": f"{insights['dataset_overview']['missing_pct']}% of cells are missing"
              })
          
          if insights['dataset_overview']['n_duplicates'] > 0:
              dup_pct = round((insights['dataset_overview']['n_duplicates'] / insights['dataset_overview']['n_rows'] * 100), 2)
              insights['data_quality']['issues'].append({
                  "type": "duplicates",
                  "severity": "medium" if dup_pct > 1 else "low",
                  "description": f"{insights['dataset_overview']['n_duplicates']} duplicate rows ({dup_pct}%)"
              })
          
          # Check for constant/useless columns
          constant_vars = [v['name'] for v in insights['variable_summary']['numeric'] if v.get('note') == 'constant']
          constant_vars += [v['name'] for v in insights['variable_summary']['categorical'] if v.get('note') == 'constant']
          if constant_vars:
              insights['data_quality']['warnings'].append({
                  "type": "constant_columns",
                  "count": len(constant_vars),
                  "columns": constant_vars[:5]  # Show first 5
              })
          
          # Identify potential ID columns
          id_cols = [v['name'] for v in insights['variable_summary']['categorical'] if v.get('note') == 'unique_identifier']
          if id_cols:
              insights['data_quality']['warnings'].append({
                  "type": "identifier_columns",
                  "count": len(id_cols),
                  "columns": id_cols[:5]
              })
          
          # Extract correlations (if available)
          if correlations and 'pearson' in correlations:
              pearson = correlations['pearson']
              # Find high correlations
              high_corrs = []
              processed_pairs = set()
              
              for var1, corr_dict in pearson.items():
                  if isinstance(corr_dict, dict):
                      for var2, corr_val in corr_dict.items():
                          if var1 != var2 and isinstance(corr_val, (int, float)):
                              pair = tuple(sorted([var1, var2]))
                              if pair not in processed_pairs and abs(corr_val) > 0.7:
                                  high_corrs.append({
                                      "var1": var1,
                                      "var2": var2,
                                      "correlation": round(corr_val, 3)
                                  })
                                  processed_pairs.add(pair)
              
              if high_corrs:
                  insights['patterns']['correlations'] = sorted(high_corrs, key=lambda x: abs(x['correlation']), reverse=True)[:10]
          
          # Distribution patterns
          for var in insights['variable_summary']['numeric']:
              if var.get('note') == 'highly_skewed':
                  insights['patterns']['distributions'].append({
                      "variable": var['name'],
                      "pattern": "skewed",
                      "skewness": round(var.get('skewness', 0), 2)
                  })
              elif var.get('note') == 'majority_zeros':
                  insights['patterns']['distributions'].append({
                      "variable": var['name'],
                      "pattern": "zero-inflated",
                      "zero_pct": round((var['n_zeros'] / insights['dataset_overview']['n_rows'] * 100), 1)
                  })
          
          # Generate recommendations
          if insights['dataset_overview']['missing_pct'] > 20:
              insights['recommendations'].append("Consider imputation strategies or investigate the cause of missing data")
          
          if len(insights['patterns']['correlations']) > 5:
              insights['recommendations'].append("High correlations detected - consider dimensionality reduction or feature selection")
          
          if len([v for v in insights['variable_summary']['categorical'] if v.get('note') == 'high_cardinality']) > 0:
              insights['recommendations'].append("High-cardinality categorical variables may need encoding or grouping")
          
          numeric_count = len(insights['variable_summary']['numeric'])
          categorical_count = len([v for v in insights['variable_summary']['categorical'] if v.get('note') == 'categorical'])
          
          if numeric_count > 0 and categorical_count > 0:
              insights['recommendations'].append(f"Dataset has {numeric_count} numeric and {categorical_count} categorical variables suitable for analysis")
          
          return insights
      
      def format_for_claude(insights):
          """Format insights in a Claude-friendly markdown format."""
          lines = []
          
          lines.append("# EDA Insights Summary")
          lines.append("")
          
          # Dataset overview
          overview = insights['dataset_overview']
          lines.append("## Dataset Overview")
          lines.append(f"- **Shape**: {overview['n_rows']:,} rows × {overview['n_cols']} columns")
          lines.append(f"- **Memory**: {overview['memory_size']:,} bytes")
          lines.append(f"- **Missing data**: {overview['missing_pct']}% of cells")
          if overview['n_duplicates'] > 0:
              lines.append(f"- **Duplicates**: {overview['n_duplicates']:,} rows")
          lines.append("")
          
          # Variable summary
          lines.append("## Variable Summary")
          
          numeric_vars = insights['variable_summary']['numeric']
          if numeric_vars:
              lines.append(f"\n**Numeric Variables** ({len(numeric_vars)}):")
              for var in numeric_vars[:10]:  # Show top 10
                  range_str = f"[{var['min']:.2f}, {var['max']:.2f}]" if var['min'] is not None else "N/A"
                  note = f" ({var['note']})" if var.get('note') else ""
                  lines.append(f"- **{var['name']}**: range {range_str}, μ={var.get('mean', 'N/A'):.2f}, σ={var.get('std', 'N/A'):.2f}{note}")
          
          categorical_vars = [v for v in insights['variable_summary']['categorical'] if v.get('note') == 'categorical']
          if categorical_vars:
              lines.append(f"\n**Categorical Variables** ({len(categorical_vars)}):")
              for var in categorical_vars[:10]:
                  lines.append(f"- **{var['name']}**: {var['n_unique']} unique values")
          
          # Data quality
          if insights['data_quality']['issues']:
              lines.append("\n## Data Quality Issues")
              for issue in insights['data_quality']['issues']:
                  severity = issue['severity'].upper()
                  lines.append(f"- **[{severity}]** {issue['type']}: {issue['description']}")
          
          if insights['data_quality']['warnings']:
              lines.append("\n## Warnings")
              for warning in insights['data_quality']['warnings']:
                  cols_display = ", ".join(warning['columns']) if len(warning['columns']) <= 5 else f"{', '.join(warning['columns'][:5])}... ({warning['count']} total)"
                  lines.append(f"- **{warning['type']}**: {cols_display}")
          
          # Patterns
          if insights['patterns']['correlations']:
              lines.append("\n## Strong Correlations (|r| > 0.7)")
              for corr in insights['patterns']['correlations'][:5]:
                  lines.append(f"- **{corr['var1']}** ↔ **{corr['var2']}**: r={corr['correlation']:.3f}")
          
          if insights['patterns']['distributions']:
              lines.append("\n## Distribution Patterns")
              for dist in insights['patterns']['distributions'][:5]:
                  if dist['pattern'] == 'skewed':
                      lines.append(f"- **{dist['variable']}**: Highly skewed (skewness={dist['skewness']})")
                  elif dist['pattern'] == 'zero-inflated':
                      lines.append(f"- **{dist['variable']}**: Zero-inflated ({dist['zero_pct']}% zeros)")
          
          # Recommendations
          if insights['recommendations']:
              lines.append("\n## Recommendations")
              for rec in insights['recommendations']:
                  lines.append(f"- {rec}")
          
          return "\n".join(lines)
      
      if __name__ == "__main__":
          if len(sys.argv) != 2:
              print("Usage: python summarize_insights.py <eda_report.json>")
              sys.exit(1)
          
          json_path = Path(sys.argv[1])
          if not json_path.exists():
              print(f"Error: File not found: {json_path}")
              sys.exit(1)
          
          # Extract insights
          insights = extract_insights(json_path)
          
          # Format for Claude
          markdown = format_for_claude(insights)
          
          # Save markdown summary
          summary_path = json_path.parent / "eda_insights_summary.md"
          with open(summary_path, 'w') as f:
              f.write(markdown)
          
          # Also save JSON for programmatic access
          json_summary_path = json_path.parent / "eda_insights_summary.json"
          with open(json_summary_path, 'w') as f:
              json.dump(insights, f, indent=2)
          
          # Print to stdout for immediate use
          print(markdown)
          print(f"\n---\n✓ Summary saved to: {summary_path}")
          print(f"✓ JSON saved to: {json_summary_path}")
      
    • visualize_findings.py 12.1 KB
      #!/usr/bin/env python3
      """Turn an EDA profile into a clean, self-contained visual dashboard.
      
      The ydata HTML report is exhaustive but visually noisy; the DuckDB large-file
      path emits only text/JSON. This script reads either JSON shape and renders a
      single standalone HTML file with a handful of Chart.js charts that carry the
      findings worth seeing: missingness by column, the most skewed / zero-inflated
      distributions, and the largest categorical breakdowns.
      
      Usage:
        python3 visualize_findings.py <report.json> [--out findings.html] [--top N]
      
      Accepts:
        - ydata-profiling JSON (has top-level "variables" + "table")
        - profile_large.py --json output (has top-level "columns")
      
      Output: one HTML file, no external data, Chart.js from cdnjs. Open directly or
      hand to the user. Charts are flat, dark-mode aware, and each has an aria-label.
      """
      import argparse
      import html
      import json
      import sys
      from pathlib import Path
      
      CDN = "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"
      # Cove categorical order (light-mode hexes); charts pick from these by slot.
      SERIES = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100",
                "#e87ba4", "#008300", "#4a3aa7", "#e34948"]
      GOOD, BAD, NEUTRAL = "#1baf7a", "#e34948", "#378add"
      
      
      def _pct(x):
          try:
              return round(float(x) * 100, 1)
          except (TypeError, ValueError):
              return None
      
      
      def load(path):
          d = json.loads(Path(path).read_text())
          if "variables" in d and "table" in d:
              return _from_ydata(d)
          if "columns" in d:
              return _from_large(d)
          sys.exit("Unrecognized JSON: expected ydata ('variables') or profile_large ('columns') shape.")
      
      
      def _from_ydata(d):
          t = d["table"]
          n = t.get("n")
          vars_ = d["variables"]
          cols = []
          for name, v in vars_.items():
              typ = v.get("type")
              rec = {
                  "name": name,
                  "type": typ,
                  "p_missing": _pct(v.get("p_missing")),
                  "n_distinct": v.get("n_distinct"),
              }
              if typ == "Numeric":
                  rec["skewness"] = v.get("skewness")
                  rec["p_zeros"] = _pct(v.get("p_zeros"))
                  h = v.get("histogram") or {}
                  counts = h.get("counts")
                  edges = h.get("bin_edges")
                  if counts and edges and len(edges) == len(counts) + 1:
                      rec["hist"] = {
                          "counts": counts,
                          "labels": [f"{edges[i]:.2g}" for i in range(len(counts))],
                      }
              elif typ in ("Categorical", "Text", "Boolean"):
                  vc = v.get("value_counts_without_nan") or {}
                  rec["value_counts"] = list(vc.items())[:12]
              cols.append(rec)
          return {
              "title": d.get("analysis", {}).get("title", "Dataset"),
              "n_rows": n,
              "n_cols": t.get("n_var"),
              "p_cells_missing": _pct(t.get("p_cells_missing")),
              "alerts": d.get("alerts", []),
              "columns": cols,
          }
      
      
      def _from_large(d):
          cols = []
          for c in d["columns"]:
              rec = {
                  "name": c.get("name"),
                  "type": c.get("type", ""),
                  "p_missing": _pct(c.get("null_frac")) if c.get("null_frac") is not None
                  else c.get("null_pct"),
                  "n_distinct": c.get("approx_distinct") or c.get("n_distinct"),
              }
              top = c.get("top_values") or c.get("top")
              if top:
                  rec["value_counts"] = [(str(k), val) for k, val in
                                         (top.items() if isinstance(top, dict) else top)][:12]
              cols.append(rec)
          return {
              "title": d.get("file", "Dataset"),
              "n_rows": d.get("n_rows"),
              "n_cols": len(cols),
              "p_cells_missing": None,
              "alerts": d.get("flags", []),
              "columns": cols,
          }
      
      
      def build_charts(data, top):
          """Return list of chart dicts: {id, kind, aria, cfg, note}."""
          charts = []
          cols = data["columns"]
      
          # 1) Missingness by column (only columns that have any). Sorted desc, capped.
          miss = [(c["name"], c["p_missing"]) for c in cols
                  if c.get("p_missing")]
          miss.sort(key=lambda x: -x[1])
          if miss:
              miss = miss[:max(top, 12)]
              charts.append({
                  "id": "miss",
                  "aria": "Missing data percentage by column.",
                  "note": "Columns with any missing values, worst first. Red ≥ 50%.",
                  "cfg": {
                      "type": "bar",
                      "data": {"labels": [m[0] for m in miss],
                               "datasets": [{"data": [m[1] for m in miss],
                                             "backgroundColor": [BAD if m[1] >= 50 else GOOD for m in miss],
                                             "borderRadius": 4, "borderSkipped": False}]},
                      "options": _hbar_opts("% missing", 0, 100, pct=True,
                                            height=max(200, 26 * len(miss) + 60)),
                  },
              })
      
          # 2) Most skewed / zero-inflated numeric distributions — small-multiple histograms.
          nums = [c for c in cols if c.get("hist")]
          def _severity(c):
              return max(abs(c.get("skewness") or 0), (c.get("p_zeros") or 0) / 10)
          nums.sort(key=_severity, reverse=True)
          for i, c in enumerate(nums[:min(top, 6)]):
              tags = []
              if c.get("skewness") is not None and abs(c["skewness"]) >= 2:
                  tags.append(f"skew {c['skewness']:.1f}")
              if c.get("p_zeros") and c["p_zeros"] >= 40:
                  tags.append(f"{c['p_zeros']:.0f}% zeros")
              charts.append({
                  "id": f"hist{i}",
                  "aria": f"Distribution histogram of {c['name']}.",
                  "note": f"{c['name']}" + (f" — {', '.join(tags)}" if tags else ""),
                  "half": True,
                  "cfg": {
                      "type": "bar",
                      "data": {"labels": c["hist"]["labels"],
                               "datasets": [{"data": c["hist"]["counts"],
                                             "backgroundColor": NEUTRAL,
                                             "borderRadius": 2, "borderSkipped": False,
                                             "barPercentage": 1.0, "categoryPercentage": 1.0}]},
                      "options": _vbar_opts(height=220),
                  },
              })
      
          # 3) Largest categorical breakdowns (excluding near-unique id-like columns).
          cats = [c for c in cols if c.get("value_counts")
                  and c.get("n_distinct") and 1 < c["n_distinct"] <= 15]
          cats.sort(key=lambda c: c["n_distinct"])
          for i, c in enumerate(cats[:min(top, 4)]):
              vc = c["value_counts"]
              charts.append({
                  "id": f"cat{i}",
                  "aria": f"Value counts for {c['name']}.",
                  "note": f"{c['name']} — {c['n_distinct']} distinct",
                  "half": True,
                  "cfg": {
                      "type": "bar",
                      "data": {"labels": [str(k) for k, _ in vc],
                               "datasets": [{"data": [val for _, val in vc],
                                             "backgroundColor": [SERIES[j % len(SERIES)] for j in range(len(vc))],
                                             "borderRadius": 4, "borderSkipped": False}]},
                      "options": _hbar_opts("count", height=max(160, 24 * len(vc) + 50)),
                  },
              })
          return charts
      
      
      def _hbar_opts(x_title, xmin=None, xmax=None, pct=False, height=300):
          x = {"title": {"display": True, "text": x_title, "color": "#898781",
                         "font": {"size": 11}},
               "ticks": {"color": "#898781"}, "grid": {"color": "#e1e0d9"}}
          if xmin is not None:
              x["min"] = xmin
          if xmax is not None:
              x["max"] = xmax
          if pct:
              x["ticks"]["callback"] = "__PCT__"
          return {"indexAxis": "y", "responsive": True, "maintainAspectRatio": False,
                  "plugins": {"legend": {"display": False}},
                  "scales": {"x": x,
                             "y": {"ticks": {"color": "#52514e", "font": {"size": 12}},
                                   "grid": {"display": False}}},
                  "__height__": height}
      
      
      def _vbar_opts(height=220):
          return {"responsive": True, "maintainAspectRatio": False,
                  "plugins": {"legend": {"display": False}},
                  "scales": {"x": {"ticks": {"color": "#898781", "maxTicksLimit": 6,
                                             "maxRotation": 0, "font": {"size": 10}},
                                   "grid": {"display": False}},
                             "y": {"ticks": {"color": "#898781"}, "grid": {"color": "#e1e0d9"},
                                   "type": "logarithmic"}},
                  "__height__": height}
      
      
      def render(data, charts):
          def js(cfg):
              h = cfg["options"].pop("__height__", 300)
              s = json.dumps(cfg)
              s = s.replace('"__PCT__"', "(v)=>v+'%'")
              return s, h
      
          cards = []
          specs = []
          for c in charts:
              cfg_json, h = js(c["cfg"])
              cls = "card half" if c.get("half") else "card"
              cards.append(f"""<div class="{cls}">
      <div class="note">{html.escape(c['note'])}</div>
      <div class="wrap" style="height:{h}px"><canvas id="{c['id']}" role="img" aria-label="{html.escape(c['aria'])}"></canvas></div>
      </div>""")
              specs.append(f'["{c["id"]}",{cfg_json}]')
          init = ("<script>function __draw(){if(typeof Chart===\"undefined\"){"
                  "document.querySelectorAll('.wrap').forEach(w=>w.innerHTML="
                  "'<div style=\\'color:var(--mut);font-size:13px;padding:20px 0\\'>chart library did not load</div>');return;}"
                  "[" + ",".join(specs) + "].forEach(([id,cfg])=>"
                  "new Chart(document.getElementById(id),cfg));}"
                  "if(window.Chart)__draw();else window.addEventListener('load',__draw);</script>")
      
          alerts = "".join(f"<li>{html.escape(str(a))}</li>" for a in data["alerts"][:12])
          miss_line = (f' · {data["p_cells_missing"]}% cells missing'
                       if data.get("p_cells_missing") else "")
          rows = f'{data["n_rows"]:,}' if data.get("n_rows") else "?"
      
          return f"""<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <title>Findings — {html.escape(str(data['title']))}</title>
      <script src="{CDN}"></script>
      <style>
      :root{{--fg:#0b0b0b;--fg2:#52514e;--mut:#898781;--bg:#fcfcfb;--card:#fff;--bd:#e1e0d9}}
      @media(prefers-color-scheme:dark){{:root{{--fg:#fff;--fg2:#c3c2b7;--mut:#898781;--bg:#161615;--card:#1e1e1c;--bd:#2c2c2a}}}}
      *{{box-sizing:border-box}}
      body{{margin:0;padding:28px;background:var(--bg);color:var(--fg);
      font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;line-height:1.5}}
      .head{{max-width:1080px;margin:0 auto 20px}}
      h1{{font-size:22px;font-weight:500;margin:0 0 4px}}
      .sub{{color:var(--mut);font-size:13px}}
      .grid{{max-width:1080px;margin:0 auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}}
      .card{{grid-column:span 2;background:var(--card);border:.5px solid var(--bd);border-radius:12px;padding:14px 16px}}
      .card.half{{grid-column:span 1}}
      .note{{font-size:13px;color:var(--fg2);margin-bottom:8px}}
      .wrap{{position:relative;width:100%}}
      .alerts{{max-width:1080px;margin:20px auto 0;font-size:13px;color:var(--fg2)}}
      .alerts h2{{font-size:14px;font-weight:500;color:var(--fg)}}
      .alerts ul{{margin:6px 0 0;padding-left:18px}}
      @media(max-width:640px){{.card.half{{grid-column:span 2}}}}
      </style></head><body>
      <div class="head"><h1>{html.escape(str(data['title']))}</h1>
      <div class="sub">{rows} rows · {data.get('n_cols','?')} columns{miss_line}</div></div>
      <div class="grid">
      {''.join(cards)}
      </div>
      {f'<div class="alerts"><h2>Quality alerts</h2><ul>{alerts}</ul></div>' if alerts else ''}
      {init}
      </body></html>"""
      
      
      def main():
          ap = argparse.ArgumentParser()
          ap.add_argument("report", help="eda_report.json or profile_large --json output")
          ap.add_argument("--out", default="/mnt/user-data/outputs/eda_findings.html")
          ap.add_argument("--top", type=int, default=6,
                          help="max charts per category (default 6)")
          a = ap.parse_args()
          data = load(a.report)
          charts = build_charts(data, a.top)
          if not charts:
              sys.exit("No chartable findings in report.")
          Path(a.out).write_text(render(data, charts))
          print(f"\u2713 Findings dashboard: {a.out}")
          print(f"  {len(charts)} charts from {data.get('n_cols','?')} columns, "
                f"{data.get('n_rows','?'):,} rows" if data.get('n_rows')
                else f"  {len(charts)} charts")
      
      
      if __name__ == "__main__":
          main()
      
  • tests
    • test_sketch_ops.py 3 KB
      """Tests for sketch_ops MinHash duplicate detection.
      
      Coverage:
      - row_minhash weighted vs unweighted on rows that share a token set but
        differ in token counts (the case #787 reported)
      - rows whose tokens are all distinct: both metrics agree
      - cmd_dups end to end over a CSV, both metrics, including the printed label
      """
      
      import contextlib
      import io
      import os
      import sys
      import tempfile
      import unittest
      from pathlib import Path
      
      sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
      
      from sketch_ops import cmd_dups, row_minhash
      
      # Same token set {new, york}; counts 2:2 vs 1:1.
      # Weighted Jaccard = sum(min)/sum(max) = (1+1)/(2+2) = 0.5.
      REPEATED = ["new", "york", "new", "york"]
      DISTINCT = ["york", "new"]
      
      
      class TestRowMinhash(unittest.TestCase):
      
          def test_unweighted_collapses_repeats(self):
              a = row_minhash(REPEATED, weighted=False)
              b = row_minhash(DISTINCT, weighted=False)
              self.assertEqual(a.jaccard(b), 1.0)
      
          def test_weighted_separates_by_count(self):
              a = row_minhash(REPEATED, weighted=True)
              b = row_minhash(DISTINCT, weighted=True)
              est = a.jaccard(b)
              self.assertLess(est, 0.9)
              # 128 permutations; ±0.15 is well outside the sampling error here.
              self.assertAlmostEqual(est, 0.5, delta=0.15)
      
          def test_distinct_tokens_unaffected(self):
              toks = ["alpha", "beta", "gamma", "delta"]
              for weighted in (True, False):
                  a = row_minhash(toks, weighted=weighted)
                  b = row_minhash(list(reversed(toks)), weighted=weighted)
                  self.assertEqual(a.jaccard(b), 1.0, f"weighted={weighted}")
      
          def test_weighted_default(self):
              self.assertEqual(
                  row_minhash(REPEATED).digest().tolist(),
                  row_minhash(REPEATED, weighted=True).digest().tolist(),
              )
      
      
      class TestCmdDups(unittest.TestCase):
          """The two rows are not exact duplicates, so only the sketch separates them."""
      
          def setUp(self):
              self.tmp = tempfile.TemporaryDirectory()
              self.csv = Path(self.tmp.name) / "counts.csv"
              self.csv.write_text("city\nnew york new york\nyork new\n")
      
          def tearDown(self):
              self.tmp.cleanup()
      
          def _run(self, argv):
              buf = io.StringIO()
              with contextlib.redirect_stdout(buf):
                  cmd_dups(argv)
              return buf.getvalue()
      
          def test_weighted_rejects_the_pair(self):
              out = self._run([str(self.csv), "--threshold", "0.9"])
              self.assertIn("0 exact duplicates", out)
              self.assertIn("0 near-duplicate pairs at weighted Jaccard>=0.9", out)
      
          def test_unweighted_matches_the_pair(self):
              out = self._run([str(self.csv), "--threshold", "0.9", "--unweighted"])
              self.assertIn("1 near-duplicate pairs at Jaccard>=0.9", out)
      
          def test_exact_duplicates_still_counted(self):
              csv = Path(self.tmp.name) / "exact.csv"
              csv.write_text("city\nnew york new york\nnew york new york\n")
              out = self._run([str(csv)])
              self.assertIn("1 exact duplicates", out)
      
      
      if __name__ == "__main__":
          unittest.main()
      
  • CHANGELOG.md 2.7 KB
    # exploring-data - Changelog
    
    All notable changes to the `exploring-data` skill are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
    
    ## [0.2.0] - 2026-09-06
    
    ### Fixed
    
    - `scripts/sketch_ops.py dups`: build each row's MinHash from token *counts*, not the token set. `MinHash.update` keeps a per-permutation minimum, so hashing a token twice changed nothing and `new york new york` scored Jaccard 1.0 against `york new`. A token occurring c times now contributes c distinct elements `(t, 0) … (t, c-1)`, which makes the signature estimate the weighted Jaccard index `sum(min(a_i, b_i)) / sum(max(a_i, b_i))`. Rows whose tokens are all distinct are unaffected.
    
    ### Added
    
    - `dups --unweighted`: keeps the pre-0.2.0 set semantics for callers that want them.
    - `tests/test_sketch_ops.py`: covers both metrics, including a pair that differs only in token counts.
    
    ### Changed
    
    - The `dups` summary line now names the metric it used — `weighted Jaccard>=` by default, `Jaccard>=` under `--unweighted`.
    
    ## [0.1.2] - 2026-08-25
    
    ### Fixed
    
    - repair broken frontmatter, mark obsolete skills, close registry gaps (#746)
    
    ### Other
    
    - creating-skill: use Anthropic's quick_validate.py instead of a hand-rolled check (#775)
    - Deprecate mapping-codebases; adopt ruff 0.16.0 baseline (#747)
    
    ## [0.1.1] - 2026-07-20
    
    ### Fixed
    
    - `install_ydata.sh`: pin `setuptools<81`. ydata-profiling 4.18.x imports `pkg_resources`, removed in setuptools>=81; unpinned installs died on the first `ProfileReport` import with `ModuleNotFoundError: pkg_resources`.
    - `install_ydata.sh`: add `pyarrow`. `analyze.sh` reads `.parquet` via pandas, which needs a parquet engine that wasn't installed.
    
    ### Added
    
    - `scripts/visualize_findings.py`: render an EDA profile as a compact, self-contained Chart.js dashboard (missingness tiers, most-skewed/zero-inflated distributions, largest categorical breakdowns) instead of only linking the dense ydata HTML. Reads both ydata JSON and `profile_large.py --json` output. Stdlib-only.
    - SKILL.md step A.5: present findings visually; prefer inline charts over a file link on surfaces that render visuals.
    
    ### Added
    
    - add mapping-features skill for behavioral web app documentation (#432)
    - add line numbers, markdown ToC, and other files listing
    - add code maps and CLAUDE.md integration guidance
    - Delete VERSION files, complete migration to frontmatter
    - Migrate all 27 skills from VERSION files to frontmatter
    
    ### Fixed
    
    - limit markdown ToC to h1/h2 headings only
    
    ### Other
    
    - exploring-data v0.1.0: large-file profiling (DuckDB) + sketch ops (dups, overlap, drift) (#737)
    - exploring-data: use absolute path for check_install.sh in Step 2 (#710)
    - Remove _MAP.md files, direct agents to tree-sitting for code navigation (#545)
  • README.md 300 B
    # exploring-data
    
    Exploratory data analysis using ydata-profiling. Use when users upload .csv/.xlsx/.json/.parquet files or request "explore data", "analyze dataset", "EDA", "profile data". Generates interactive HTML or JSON reports with statistics, visualizations, correlations, and quality alerts.
    
  • SKILL.md 6.5 KB
    ---
    name: exploring-data
    description: Exploratory data analysis. Use when users upload .csv/.xlsx/.json/.parquet files or request "explore data", "analyze dataset", "EDA", "profile data". Small files get ydata-profiling HTML/JSON reports; large files (over 200MB or 5M rows) get fixed-memory DuckDB/sketch profiling. Also covers near-duplicate row detection, cross-file key overlap ("can these join?"), dataset drift vs a stored baseline, and time-series profiling.
    metadata:
      version: 0.2.0
    ---
    
    # Exploring Data
    
    ## 0. Route by size FIRST
    
    ```bash
    ls -la <filepath>   # or: wc -l for row estimate
    ```
    
    - **< 200MB and < ~5M rows** → ydata-profiling path (section A). Exact stats, interactive HTML.
    - **Larger** → large-file path (section B). ydata-profiling loads everything into pandas and will crawl or OOM; the DuckDB/sketch path runs in fixed memory at any size.
    - **Task-specific ops** (any size): duplicates, join feasibility, drift → section C.
    
    ## A. Standard path (ydata-profiling)
    
    ### 1. Check if installed (instant)
    ```bash
    bash /mnt/skills/user/exploring-data/scripts/check_install.sh
    ```
    Returns: `installed` or `not_installed`
    
    ### 2. Install if needed (one-time, ~19s)
    ```bash
    if [ "$(bash /mnt/skills/user/exploring-data/scripts/check_install.sh)" = "not_installed" ]; then
        bash /mnt/skills/user/exploring-data/scripts/install_ydata.sh
    fi
    ```
    
    ### 3. Run analysis (always generates JSON + HTML by default)
    ```bash
    bash /mnt/skills/user/exploring-data/scripts/analyze.sh <filepath> [minimal|full] [html|json]
    ```
    
    **Defaults:** minimal + html (also generates JSON)
    
    **Output:**
    - `eda_report.html` - Interactive report for user
    - `eda_report.json` - Machine-readable for Claude analysis
    
    ### 4. If Claude needs to analyze (user asks "what do you think?" etc.)
    ```bash
    python /mnt/skills/user/exploring-data/scripts/summarize_insights.py /mnt/user-data/outputs/eda_report.json
    ```
    
    Claude should read the stdout markdown summary, NOT the full JSON report.
    
    ### 5. Present findings visually (don't just hand over the ydata HTML)
    
    The ydata report is exhaustive but dense; a link to it is a weak deliverable.
    Turn the JSON into a compact dashboard of the findings that matter:
    ```bash
    python3 /mnt/skills/user/exploring-data/scripts/visualize_findings.py \
        /mnt/user-data/outputs/eda_report.json
    # → /mnt/user-data/outputs/eda_findings.html
    ```
    Emits a single self-contained HTML file (Chart.js from cdnjs, dark-mode aware):
    missingness by column (tiered good/bad), the most skewed or zero-inflated
    numeric distributions as small-multiple histograms, and the largest categorical
    breakdowns. `--top N` caps charts per category (default 6). Also reads
    `profile_large.py --json` output, so the large-file path gets the same treatment.
    
    Present BOTH files: `eda_findings.html` for the headline read, `eda_report.html`
    for the full drill-down. In a chat surface that renders inline visuals, prefer
    rendering the two or three findings that actually answer the user's question as
    inline charts over linking a file — a link the user has to open is the weakest
    form of "showing" data.
    
    ### Modes
    
    **Minimal (default, 5-10s):** overview, variable analysis, correlations, missing values, alerts
    **Full (10-20s):** minimal + scatter matrices, sample data, character analysis
    
    Full-mode triggers: "comprehensive analysis", "detailed EDA", "full profiling", "deep analysis". Otherwise minimal.
    
    ### Time series
    If the data has a datetime index/column and the user cares about temporal behavior
    (gaps, trends, seasonality, autocorrelation), pass `tsmode=True` to ProfileReport —
    run the venv python directly instead of analyze.sh:
    ```python
    ProfileReport(df, tsmode=True, sortby="<datetime_col>", title=...)
    ```
    This adds gap detection, stationarity and seasonality checks that the default
    report omits.
    
    ### Small-file drift
    Comparing two versions of a dataset that BOTH fit in memory: use ydata's native
    compare — `ProfileReport(df_a).compare(ProfileReport(df_b)).to_file(...)`.
    For files too big to load, or comparing against a months-old file you no longer
    have, use the sketch snapshot/drift ops in section C.
    
    ## B. Large-file path (DuckDB, fixed memory)
    
    ### 1. Install deps (idempotent, ~10s first time)
    ```bash
    bash /mnt/skills/user/exploring-data/scripts/install_large.sh
    ```
    
    ### 2. Profile
    ```bash
    python3 /mnt/skills/user/exploring-data/scripts/profile_large.py <file> [--json out.json]
    ```
    
    Streams the file through DuckDB: per-column null%, approximate distinct counts
    (HLL), min/max/mean, approximate quantiles (t-digest) for numerics, top-5
    values for strings, plus quality flags (mostly-null, constant, id-like
    columns). Markdown lands on stdout — read it directly, no summarize step
    needed. Handles csv/tsv/parquet/json/ndjson. 1M rows profiles in seconds;
    memory is flat regardless of file size.
    
    For ad-hoc follow-up queries on the same large file, use DuckDB SQL directly
    (`duckdb.connect().execute("SELECT ... FROM read_csv_auto('...')")`) rather
    than loading pandas.
    
    ## C. Sketch ops (any file size, fixed memory)
    
    All via `scripts/sketch_ops.py` (deps from install_large.sh). These answer
    questions profilers don't:
    
    ### Near-duplicate rows
    ```bash
    python3 sketch_ops.py dups <file> [--threshold 0.9] [--cols a,b,c] [--unweighted]
    ```
    Exact duplicates counted by hash; near-duplicates via MinHash LSH over row
    tokens. `--threshold` is a **weighted** Jaccard cutoff: a token occurring c
    times in a row counts c times, so `new york new york` and `york new` score 0.5
    rather than 1.0. Pass `--unweighted` for set semantics, where repeats are
    discarded. Use `--cols` to restrict to the columns that define identity.
    
    ### Key overlap / join feasibility
    ```bash
    python3 sketch_ops.py overlap <fileA> <fileB> --key <col> [--key-b <col>]
    ```
    Theta sketches per key column → estimated intersection, Jaccard, and "% of A's
    keys in B" both ways — answers "will this join hold?" without loading either
    file.
    
    ### Drift vs stored baseline
    ```bash
    python3 sketch_ops.py snapshot <file> --out baseline.sketch.json   # ~20KB
    python3 sketch_ops.py drift <newfile> --baseline baseline.sketch.json
    ```
    Snapshot serializes HLL (all columns) + KLL quantile sketches (numeric
    columns) to a small JSON. Drift reports schema changes, >10% shifts in
    distinct counts, and IQR-relative quantile movement. The snapshot is a few KB
    — store it (repo, memory) and diff next month's delivery against it without
    keeping the original file.
    
    Note: snapshot/dups stream rows through Python (~1M rows in a few seconds);
    profile_large is pure DuckDB and faster. For a quick look at a big file,
    profile first, sketch ops only when the question calls for them.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related