Spaces:
Sleeping
Sleeping
Deploy DataPilot AI production Docker Space
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +25 -0
- .env.example +18 -0
- .github/CODEOWNERS +5 -0
- .github/ISSUE_TEMPLATE/bug_report.yml +20 -0
- .github/ISSUE_TEMPLATE/feature_request.yml +16 -0
- .github/dependabot.yml +13 -0
- .github/pull_request_template.md +15 -0
- .github/workflows/ci.yml +121 -0
- .github/workflows/release.yml +23 -0
- .gitignore +24 -0
- .pre-commit-config.yaml +22 -0
- .streamlit/config.toml +11 -0
- CHANGELOG.md +18 -0
- CONTRIBUTING.md +13 -0
- Dockerfile +32 -0
- Dockerfile.api +16 -0
- Dockerfile.worker +13 -0
- LICENSE +22 -0
- Makefile +20 -0
- README.md +354 -4
- SECURITY.md +11 -0
- api/__init__.py +1 -0
- api/main.py +186 -0
- app.py +451 -0
- artifacts/run_f13555d6077f/MODEL_CARD.md +41 -0
- artifacts/run_f13555d6077f/analysis_report.html +23 -0
- artifacts/run_f13555d6077f/metrics.json +228 -0
- artifacts/run_f13555d6077f/reproduction.json +8 -0
- datapilot/__init__.py +3 -0
- datapilot/analyst.py +200 -0
- datapilot/config.py +44 -0
- datapilot/data.py +102 -0
- datapilot/evaluation.py +58 -0
- datapilot/insights.py +105 -0
- datapilot/jobs.py +78 -0
- datapilot/modeling.py +389 -0
- datapilot/observability.py +30 -0
- datapilot/persistence.py +91 -0
- datapilot/quality.py +243 -0
- datapilot/reports.py +140 -0
- datapilot/safety.py +60 -0
- datapilot/schemas.py +114 -0
- datapilot/tuning.py +67 -0
- datapilot/workflow.py +341 -0
- docker-compose.yml +63 -0
- docs/API_EXAMPLES.md +37 -0
- docs/ARCHITECTURE.md +56 -0
- docs/BENCHMARKS.md +15 -0
- docs/DATA_PRIVACY.md +10 -0
- docs/DEPLOYMENT.md +37 -0
.dockerignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.github
|
| 3 |
+
.venv
|
| 4 |
+
venv
|
| 5 |
+
__pycache__
|
| 6 |
+
*.py[cod]
|
| 7 |
+
.pytest_cache
|
| 8 |
+
.ruff_cache
|
| 9 |
+
.coverage
|
| 10 |
+
htmlcov
|
| 11 |
+
artifacts
|
| 12 |
+
tests
|
| 13 |
+
docs
|
| 14 |
+
scripts
|
| 15 |
+
worker
|
| 16 |
+
api
|
| 17 |
+
*.db
|
| 18 |
+
*.sqlite
|
| 19 |
+
*.sqlite3
|
| 20 |
+
.env
|
| 21 |
+
.streamlit/secrets.toml
|
| 22 |
+
Dockerfile.api
|
| 23 |
+
Dockerfile.worker
|
| 24 |
+
docker-compose.yml
|
| 25 |
+
render.yaml
|
.env.example
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ENVIRONMENT=development
|
| 2 |
+
ARTIFACT_ROOT=artifacts
|
| 3 |
+
DATABASE_URL=sqlite:///artifacts/datapilot.db
|
| 4 |
+
MAX_UPLOAD_MB=25
|
| 5 |
+
MAX_ROWS=100000
|
| 6 |
+
MAX_COLUMNS=250
|
| 7 |
+
MAX_CATEGORIES_PER_FEATURE=100
|
| 8 |
+
MAX_ENCODED_FEATURES=5000
|
| 9 |
+
API_KEY=
|
| 10 |
+
REQUESTS_PER_MINUTE=30
|
| 11 |
+
MAX_CRITIC_RETRIES=1
|
| 12 |
+
ENABLE_MLFLOW=false
|
| 13 |
+
MLFLOW_TRACKING_URI=file:./artifacts/mlruns
|
| 14 |
+
# Optional: the deterministic application works without an LLM key.
|
| 15 |
+
GEMINI_API_KEY=
|
| 16 |
+
GEMINI_MODEL=gemini-2.5-flash
|
| 17 |
+
CORS_ORIGINS=http://localhost:8501
|
| 18 |
+
|
.github/CODEOWNERS
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
* @dineshbarri
|
| 2 |
+
/.github/ @dineshbarri
|
| 3 |
+
/datapilot/ @dineshbarri
|
| 4 |
+
/api/ @dineshbarri
|
| 5 |
+
/docs/ @dineshbarri
|
.github/ISSUE_TEMPLATE/bug_report.yml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Bug report
|
| 2 |
+
description: Report a reproducible DataPilot defect
|
| 3 |
+
title: "[Bug]: "
|
| 4 |
+
labels: [bug]
|
| 5 |
+
body:
|
| 6 |
+
- type: textarea
|
| 7 |
+
id: description
|
| 8 |
+
attributes: {label: Description, description: What happened?}
|
| 9 |
+
validations: {required: true}
|
| 10 |
+
- type: textarea
|
| 11 |
+
id: reproduce
|
| 12 |
+
attributes: {label: Reproduction, description: Provide minimal safe reproduction steps.}
|
| 13 |
+
validations: {required: true}
|
| 14 |
+
- type: input
|
| 15 |
+
id: version
|
| 16 |
+
attributes: {label: Version or commit}
|
| 17 |
+
validations: {required: true}
|
| 18 |
+
- type: textarea
|
| 19 |
+
id: logs
|
| 20 |
+
attributes: {label: Sanitized logs, description: Remove API keys and private data.}
|
.github/ISSUE_TEMPLATE/feature_request.yml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Feature request
|
| 2 |
+
description: Propose a product, ML, platform, or evaluation improvement
|
| 3 |
+
title: "[Feature]: "
|
| 4 |
+
labels: [enhancement]
|
| 5 |
+
body:
|
| 6 |
+
- type: textarea
|
| 7 |
+
id: problem
|
| 8 |
+
attributes: {label: Problem, description: Which user or engineering problem should this solve?}
|
| 9 |
+
validations: {required: true}
|
| 10 |
+
- type: textarea
|
| 11 |
+
id: proposal
|
| 12 |
+
attributes: {label: Proposed approach}
|
| 13 |
+
validations: {required: true}
|
| 14 |
+
- type: textarea
|
| 15 |
+
id: risks
|
| 16 |
+
attributes: {label: Privacy, security, and ML risks}
|
.github/dependabot.yml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: 2
|
| 2 |
+
updates:
|
| 3 |
+
- package-ecosystem: pip
|
| 4 |
+
directory: /
|
| 5 |
+
schedule: {interval: weekly}
|
| 6 |
+
groups:
|
| 7 |
+
python-dependencies: {patterns: ["*"]}
|
| 8 |
+
- package-ecosystem: docker
|
| 9 |
+
directory: /
|
| 10 |
+
schedule: {interval: weekly}
|
| 11 |
+
- package-ecosystem: github-actions
|
| 12 |
+
directory: /
|
| 13 |
+
schedule: {interval: weekly}
|
.github/pull_request_template.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Summary
|
| 2 |
+
|
| 3 |
+
## Why this change
|
| 4 |
+
|
| 5 |
+
## Validation
|
| 6 |
+
|
| 7 |
+
- [ ] Tests added or updated
|
| 8 |
+
- [ ] `ruff check .` passes
|
| 9 |
+
- [ ] `pytest --cov` passes
|
| 10 |
+
- [ ] Security/privacy impact reviewed
|
| 11 |
+
- [ ] Documentation updated
|
| 12 |
+
|
| 13 |
+
## ML/AI impact
|
| 14 |
+
|
| 15 |
+
Describe changes to data handling, evaluation methodology, prompts, models, or metrics.
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
pull_request:
|
| 7 |
+
workflow_dispatch:
|
| 8 |
+
|
| 9 |
+
permissions:
|
| 10 |
+
contents: read
|
| 11 |
+
security-events: write
|
| 12 |
+
|
| 13 |
+
concurrency:
|
| 14 |
+
group: ci-${{ github.ref }}
|
| 15 |
+
cancel-in-progress: true
|
| 16 |
+
|
| 17 |
+
jobs:
|
| 18 |
+
test:
|
| 19 |
+
name: Python ${{ matrix.python-version }}
|
| 20 |
+
runs-on: ubuntu-latest
|
| 21 |
+
timeout-minutes: 25
|
| 22 |
+
strategy:
|
| 23 |
+
fail-fast: false
|
| 24 |
+
matrix:
|
| 25 |
+
python-version: ["3.11", "3.12", "3.13"]
|
| 26 |
+
steps:
|
| 27 |
+
- uses: actions/checkout@v4
|
| 28 |
+
- uses: actions/setup-python@v5
|
| 29 |
+
with:
|
| 30 |
+
python-version: ${{ matrix.python-version }}
|
| 31 |
+
cache: pip
|
| 32 |
+
- run: python -m pip install --upgrade pip && pip install -e ".[dev]"
|
| 33 |
+
- run: ruff check .
|
| 34 |
+
- run: ruff format --check .
|
| 35 |
+
- run: pytest --cov=datapilot --cov=api --cov-report=term-missing --cov-report=xml --cov-fail-under=75
|
| 36 |
+
- run: python -m compileall -q datapilot api worker app.py streamlit_app.py
|
| 37 |
+
- name: Generate example model card
|
| 38 |
+
if: matrix.python-version == '3.12'
|
| 39 |
+
run: python scripts/generate_example_model_card.py
|
| 40 |
+
- uses: actions/upload-artifact@v4
|
| 41 |
+
if: matrix.python-version == '3.12'
|
| 42 |
+
with:
|
| 43 |
+
name: evaluation-evidence
|
| 44 |
+
path: |
|
| 45 |
+
coverage.xml
|
| 46 |
+
build/example-model-card.md
|
| 47 |
+
|
| 48 |
+
package:
|
| 49 |
+
runs-on: ubuntu-latest
|
| 50 |
+
steps:
|
| 51 |
+
- uses: actions/checkout@v4
|
| 52 |
+
- uses: actions/setup-python@v5
|
| 53 |
+
with:
|
| 54 |
+
python-version: "3.12"
|
| 55 |
+
- run: pip install build twine
|
| 56 |
+
- run: python -m build
|
| 57 |
+
- run: twine check dist/*
|
| 58 |
+
- uses: actions/upload-artifact@v4
|
| 59 |
+
with:
|
| 60 |
+
name: python-package
|
| 61 |
+
path: dist/
|
| 62 |
+
|
| 63 |
+
dependency-audit:
|
| 64 |
+
runs-on: ubuntu-latest
|
| 65 |
+
steps:
|
| 66 |
+
- uses: actions/checkout@v4
|
| 67 |
+
- uses: actions/setup-python@v5
|
| 68 |
+
with:
|
| 69 |
+
python-version: "3.12"
|
| 70 |
+
cache: pip
|
| 71 |
+
- run: python -m pip install --upgrade pip pip-audit
|
| 72 |
+
- run: pip-audit --requirement requirements.txt
|
| 73 |
+
|
| 74 |
+
containers:
|
| 75 |
+
runs-on: ubuntu-latest
|
| 76 |
+
strategy:
|
| 77 |
+
fail-fast: false
|
| 78 |
+
matrix:
|
| 79 |
+
include:
|
| 80 |
+
- file: Dockerfile
|
| 81 |
+
image: datapilot-ui
|
| 82 |
+
- file: Dockerfile.api
|
| 83 |
+
image: datapilot-api
|
| 84 |
+
- file: Dockerfile.worker
|
| 85 |
+
image: datapilot-worker
|
| 86 |
+
steps:
|
| 87 |
+
- uses: actions/checkout@v4
|
| 88 |
+
- uses: docker/setup-buildx-action@v3
|
| 89 |
+
- uses: docker/build-push-action@v6
|
| 90 |
+
with:
|
| 91 |
+
context: .
|
| 92 |
+
file: ${{ matrix.file }}
|
| 93 |
+
push: false
|
| 94 |
+
load: true
|
| 95 |
+
tags: ${{ matrix.image }}:ci
|
| 96 |
+
- uses: aquasecurity/trivy-action@v0.36.0
|
| 97 |
+
with:
|
| 98 |
+
image-ref: ${{ matrix.image }}:ci
|
| 99 |
+
format: sarif
|
| 100 |
+
output: "trivy-${{ matrix.image }}.sarif"
|
| 101 |
+
severity: HIGH,CRITICAL
|
| 102 |
+
ignore-unfixed: true
|
| 103 |
+
- uses: github/codeql-action/upload-sarif@v4
|
| 104 |
+
if: github.actor != 'dependabot[bot]'
|
| 105 |
+
with:
|
| 106 |
+
sarif_file: "trivy-${{ matrix.image }}.sarif"
|
| 107 |
+
- uses: actions/upload-artifact@v4
|
| 108 |
+
if: always()
|
| 109 |
+
with:
|
| 110 |
+
name: "trivy-${{ matrix.image }}"
|
| 111 |
+
path: "trivy-${{ matrix.image }}.sarif"
|
| 112 |
+
|
| 113 |
+
secrets:
|
| 114 |
+
runs-on: ubuntu-latest
|
| 115 |
+
steps:
|
| 116 |
+
- uses: actions/checkout@v4
|
| 117 |
+
with:
|
| 118 |
+
fetch-depth: 0
|
| 119 |
+
- uses: gitleaks/gitleaks-action@v2
|
| 120 |
+
env:
|
| 121 |
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
.github/workflows/release.yml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Release
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
tags: ["v*"]
|
| 6 |
+
|
| 7 |
+
permissions:
|
| 8 |
+
contents: write
|
| 9 |
+
|
| 10 |
+
jobs:
|
| 11 |
+
release:
|
| 12 |
+
runs-on: ubuntu-latest
|
| 13 |
+
steps:
|
| 14 |
+
- uses: actions/checkout@v4
|
| 15 |
+
- uses: actions/setup-python@v5
|
| 16 |
+
with: {python-version: "3.12"}
|
| 17 |
+
- run: pip install build
|
| 18 |
+
- run: python -m build
|
| 19 |
+
- run: git archive --format=zip --output=dist/DataPilot-AI-${GITHUB_REF_NAME}.zip HEAD
|
| 20 |
+
- uses: softprops/action-gh-release@v2
|
| 21 |
+
with:
|
| 22 |
+
generate_release_notes: true
|
| 23 |
+
files: dist/*
|
.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
.ruff_cache/
|
| 5 |
+
.coverage
|
| 6 |
+
htmlcov/
|
| 7 |
+
build/
|
| 8 |
+
dist/
|
| 9 |
+
.venv/
|
| 10 |
+
venv/
|
| 11 |
+
.env
|
| 12 |
+
artifacts/
|
| 13 |
+
mlruns/
|
| 14 |
+
*.joblib
|
| 15 |
+
*.parquet
|
| 16 |
+
.DS_Store
|
| 17 |
+
.idea/
|
| 18 |
+
.vscode/
|
| 19 |
+
|
| 20 |
+
.streamlit/secrets.toml
|
| 21 |
+
*.db
|
| 22 |
+
*.sqlite
|
| 23 |
+
*.sqlite3
|
| 24 |
+
|
.pre-commit-config.yaml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
repos:
|
| 2 |
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
| 3 |
+
rev: v0.12.8
|
| 4 |
+
hooks:
|
| 5 |
+
- id: ruff-check
|
| 6 |
+
args: [--fix]
|
| 7 |
+
- id: ruff-format
|
| 8 |
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
| 9 |
+
rev: v5.0.0
|
| 10 |
+
hooks:
|
| 11 |
+
- id: trailing-whitespace
|
| 12 |
+
- id: end-of-file-fixer
|
| 13 |
+
- id: check-yaml
|
| 14 |
+
- id: check-toml
|
| 15 |
+
- id: check-json
|
| 16 |
+
- id: check-added-large-files
|
| 17 |
+
args: [--maxkb=5000]
|
| 18 |
+
- id: detect-private-key
|
| 19 |
+
- repo: https://github.com/gitleaks/gitleaks
|
| 20 |
+
rev: v8.28.0
|
| 21 |
+
hooks:
|
| 22 |
+
- id: gitleaks
|
.streamlit/config.toml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[theme]
|
| 2 |
+
primaryColor = "#6045D8"
|
| 3 |
+
backgroundColor = "#FBFAFF"
|
| 4 |
+
secondaryBackgroundColor = "#F4F1FF"
|
| 5 |
+
textColor = "#172033"
|
| 6 |
+
font = "sans serif"
|
| 7 |
+
|
| 8 |
+
[server]
|
| 9 |
+
headless = true
|
| 10 |
+
maxUploadSize = 25
|
| 11 |
+
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
All notable changes follow [Keep a Changelog](https://keepachangelog.com/) and semantic versioning.
|
| 4 |
+
|
| 5 |
+
## [2.0.0] - 2026-08-10
|
| 6 |
+
|
| 7 |
+
### Added
|
| 8 |
+
|
| 9 |
+
- Training-only cross-validation selection and one-time untouched test evaluation.
|
| 10 |
+
- Sparse, cardinality-bounded categorical encoding and encoded-width guardrails.
|
| 11 |
+
- Structured candidate failure records.
|
| 12 |
+
- Asynchronous API job contract, cancellation, authentication, rate limiting, and correlation IDs.
|
| 13 |
+
- CI, Dependabot, pre-commit, Docker context controls, templates, threat model, privacy, and evaluation docs.
|
| 14 |
+
|
| 15 |
+
### Changed
|
| 16 |
+
|
| 17 |
+
- Consolidated Streamlit deployment onto the `app.py` implementation.
|
| 18 |
+
- Sanitized API and job errors returned to clients.
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing
|
| 2 |
+
|
| 3 |
+
1. Create a feature branch.
|
| 4 |
+
2. Install `pip install -e ".[dev]"`.
|
| 5 |
+
3. Run `ruff check .`.
|
| 6 |
+
4. Run `pytest --cov=datapilot --cov=api`.
|
| 7 |
+
5. Keep computed metrics deterministic and source-backed.
|
| 8 |
+
6. Add a test for every new quality rule, workflow route or API behavior.
|
| 9 |
+
7. Never commit datasets containing personal data, credentials or generated artifacts.
|
| 10 |
+
|
| 11 |
+
Pull requests should explain the user problem, architecture impact, validation performed and
|
| 12 |
+
any new security or model-risk considerations.
|
| 13 |
+
|
Dockerfile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim AS runtime
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PIP_NO_CACHE_DIR=1 \
|
| 6 |
+
STREAMLIT_SERVER_HEADLESS=true \
|
| 7 |
+
STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
RUN addgroup --system datapilot \
|
| 12 |
+
&& adduser --system --ingroup datapilot --home /home/datapilot datapilot
|
| 13 |
+
|
| 14 |
+
COPY requirements.txt ./requirements.txt
|
| 15 |
+
RUN pip install --no-cache-dir --upgrade pip \
|
| 16 |
+
&& pip install --no-cache-dir -r requirements.txt
|
| 17 |
+
|
| 18 |
+
COPY app.py streamlit_app.py ./
|
| 19 |
+
COPY datapilot ./datapilot
|
| 20 |
+
COPY .streamlit ./.streamlit
|
| 21 |
+
|
| 22 |
+
RUN mkdir -p /app/artifacts /home/datapilot/.streamlit \
|
| 23 |
+
&& chown -R datapilot:datapilot /app /home/datapilot
|
| 24 |
+
|
| 25 |
+
USER datapilot
|
| 26 |
+
|
| 27 |
+
EXPOSE 7860
|
| 28 |
+
|
| 29 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
| 30 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/_stcore/health', timeout=3)"
|
| 31 |
+
|
| 32 |
+
CMD ["streamlit", "run", "streamlit_app.py", "--server.address=0.0.0.0", "--server.port=7860", "--server.headless=true", "--browser.gatherUsageStats=false"]
|
Dockerfile.api
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
RUN addgroup --system datapilot && adduser --system --ingroup datapilot datapilot
|
| 6 |
+
COPY requirements.txt .
|
| 7 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
+
COPY datapilot ./datapilot
|
| 9 |
+
COPY api ./api
|
| 10 |
+
RUN mkdir -p /app/artifacts && chown -R datapilot:datapilot /app
|
| 11 |
+
USER datapilot
|
| 12 |
+
EXPOSE 8000
|
| 13 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
| 14 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
| 15 |
+
CMD ["uvicorn", "api.main:app", "--host=0.0.0.0", "--port=8000"]
|
| 16 |
+
|
Dockerfile.worker
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
RUN addgroup --system datapilot && adduser --system --ingroup datapilot datapilot
|
| 6 |
+
RUN pip install --no-cache-dir fastapi uvicorn pydantic
|
| 7 |
+
COPY datapilot/safety.py ./datapilot/safety.py
|
| 8 |
+
COPY datapilot/__init__.py ./datapilot/__init__.py
|
| 9 |
+
COPY worker ./worker
|
| 10 |
+
USER datapilot
|
| 11 |
+
EXPOSE 8010
|
| 12 |
+
CMD ["uvicorn", "worker.main:app", "--host=0.0.0.0", "--port=8010"]
|
| 13 |
+
|
LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Dinesh Barri
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
| 22 |
+
|
Makefile
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.PHONY: install test lint format coverage build run api security
|
| 2 |
+
|
| 3 |
+
install:
|
| 4 |
+
python -m pip install -e ".[dev]"
|
| 5 |
+
test:
|
| 6 |
+
python -m pytest
|
| 7 |
+
lint:
|
| 8 |
+
python -m ruff check .
|
| 9 |
+
format:
|
| 10 |
+
python -m ruff format .
|
| 11 |
+
coverage:
|
| 12 |
+
python -m pytest --cov=datapilot --cov=api --cov-fail-under=75
|
| 13 |
+
build:
|
| 14 |
+
python -m build
|
| 15 |
+
run:
|
| 16 |
+
python -m streamlit run app.py
|
| 17 |
+
api:
|
| 18 |
+
python -m uvicorn api.main:app --reload
|
| 19 |
+
security:
|
| 20 |
+
python -m pip_audit
|
README.md
CHANGED
|
@@ -1,11 +1,361 @@
|
|
| 1 |
---
|
| 2 |
title: DataPilot AI Agent
|
| 3 |
-
emoji: 📊
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
-
|
|
|
|
| 8 |
license: mit
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: DataPilot AI Agent
|
| 3 |
+
emoji: "📊"
|
| 4 |
+
colorFrom: indigo
|
| 5 |
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: true
|
| 9 |
license: mit
|
| 10 |
+
short_description: Evidence-grounded autonomous data science and ML copilot
|
| 11 |
---
|
| 12 |
|
| 13 |
+
<div align="center">
|
| 14 |
+
|
| 15 |
+
<img src="https://capsule-render.vercel.app/api?type=rect&color=0:21154F,48:6045D8,100:19A98E&height=190§ion=header&text=DataPilot%20AI&fontSize=48&fontColor=ffffff&animation=fadeIn&desc=Autonomous%20Data%20Science%20%C2%B7%20Evidence-Grounded%20ML%20%C2%B7%20Explainable%20Decisions&descSize=17&descAlignY=72" width="100%" alt="DataPilot AI banner">
|
| 16 |
+
|
| 17 |
+
<p>
|
| 18 |
+
<img src="https://readme-typing-svg.herokuapp.com/?font=Fira+Code&size=16&duration=2600&pause=850&color=6D4AFF¢er=true&vCenter=true&width=780&lines=CSV+or+Parquet+%E2%86%92+Quality+Audit+%E2%86%92+Validated+Model;8+Specialized+Agents+%7C+Stateful+LangGraph+Orchestration;Leakage-Safe+Pipelines+%7C+Critic+Retry+Loop+%7C+SHAP-Ready;Streamlit+%7C+FastAPI+%7C+DuckDB+%7C+scikit-learn" alt="DataPilot AI capabilities">
|
| 19 |
+
</p>
|
| 20 |
+
|
| 21 |
+
[](https://www.python.org/)
|
| 22 |
+
[](https://langchain-ai.github.io/langgraph/)
|
| 23 |
+
[](https://datapilot-ai-agent.streamlit.app/)
|
| 24 |
+
[](https://fastapi.tiangolo.com/)
|
| 25 |
+
[](https://github.com/dineshbarri/DataPilot-AI/actions/workflows/ci.yml)
|
| 26 |
+
[](LICENSE)
|
| 27 |
+
|
| 28 |
+
**[API Docs](#fastapi)** ·
|
| 29 |
+
**[Architecture](docs/ARCHITECTURE.md)** ·
|
| 30 |
+
**[Security](docs/SECURITY.md)** ·
|
| 31 |
+
**[Model Governance](docs/MODEL_GOVERNANCE.md)** ·
|
| 32 |
+
**[Benchmarks](docs/BENCHMARKS.md)** ·
|
| 33 |
+
**[API Examples](docs/API_EXAMPLES.md)**
|
| 34 |
+
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## Why DataPilot AI?
|
| 40 |
+
|
| 41 |
+
Most “AI data scientist” demos upload a CSV, run preprocessing on the entire dataset, compare
|
| 42 |
+
a few models, and ask an LLM to write an impressive-sounding summary. That is fast—but it can
|
| 43 |
+
leak test information, exaggerate confidence, and produce insights with no numerical provenance.
|
| 44 |
+
|
| 45 |
+
**DataPilot AI treats trust as a feature.** It coordinates a stateful agent team that audits the
|
| 46 |
+
data, plans the experiment, builds leakage-safe pipelines, compares models, challenges the
|
| 47 |
+
winner, explains predictive signals, and exports reproducible artifacts. Every displayed metric
|
| 48 |
+
comes from deterministic computation. The optional LLM may improve wording; it cannot create
|
| 49 |
+
new numbers or execute code.
|
| 50 |
+
|
| 51 |
+
### Recruiter five-minute test
|
| 52 |
+
|
| 53 |
+
1. Open the app.
|
| 54 |
+
2. Keep **Iris classification** selected.
|
| 55 |
+
3. Click **Run autonomous analysis**.
|
| 56 |
+
4. Inspect the model comparison, critic decision, feature importance and full agent trace.
|
| 57 |
+
5. Download the fitted pipeline, model card and standalone report.
|
| 58 |
+
|
| 59 |
+
No account, upload or API key is required.
|
| 60 |
+
|
| 61 |
+
---
|
| 62 |
+
|
| 63 |
+
## Product capabilities
|
| 64 |
+
|
| 65 |
+
| Stage | What DataPilot does | Evidence produced |
|
| 66 |
+
|---|---|---|
|
| 67 |
+
| Data intake | Accepts bounded CSV, TSV, Excel, JSON, Parquet or packaged demos | row/column limits and validated schema |
|
| 68 |
+
| Data quality | Detects missingness, duplicates, target gaps, imbalance, outliers and leakage-like fields | evidence registry with IDs, source and method |
|
| 69 |
+
| EDA | Uses DuckDB and pandas for compact profiles, cardinality and correlations | dataset profile and statistical summary |
|
| 70 |
+
| Planning | Infers classification/regression, primary metric, validation strategy and risk controls | typed Pydantic analysis plan |
|
| 71 |
+
| Feature engineering | Builds numeric and categorical transformers inside the model pipeline | transformation plan and fitted pipeline |
|
| 72 |
+
| Modeling | Selects linear, forest, extra-trees and optional XGBoost models by training-only CV | CV mean/stability and one-time untouched test results |
|
| 73 |
+
| Evaluation | Applies thresholds and validation-consistency checks | critic approval, rejection reasons and retry count |
|
| 74 |
+
| Explainability | Uses SHAP only when the optional dependency and fitted estimator are compatible; otherwise permutation importance | ranked predictive signals and caveats |
|
| 75 |
+
| Reporting | Produces a dashboard, evidence-backed narrative and portable artifacts | HTML report, model card, JSON, joblib pipeline |
|
| 76 |
+
| Follow-up | Answers questions from persisted run evidence | bounded, non-hallucinatory responses |
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## Real agentic orchestration
|
| 81 |
+
|
| 82 |
+
The “agents” are typed LangGraph nodes combining deterministic Python computation with optional,
|
| 83 |
+
evidence-bounded LLM narration. The critic controls a conditional edge:
|
| 84 |
+
weak or unstable analysis returns to the modeling node before explanation is allowed.
|
| 85 |
+
|
| 86 |
+
```mermaid
|
| 87 |
+
flowchart LR
|
| 88 |
+
A["CSV / Parquet / Demo"] --> B["Data Quality Agent"]
|
| 89 |
+
B --> C["EDA Agent"]
|
| 90 |
+
C --> D["Statistical Agent"]
|
| 91 |
+
D --> E["Planning Agent"]
|
| 92 |
+
E --> F["Feature Engineering Agent"]
|
| 93 |
+
F --> G["Modeling Agent"]
|
| 94 |
+
G --> H{"Evaluation / Critic"}
|
| 95 |
+
H -->|"Reject + retry"| G
|
| 96 |
+
H -->|"Approve"| I["Explainability Agent"]
|
| 97 |
+
I --> J["Executive Insights Agent"]
|
| 98 |
+
J --> K["Report · Model Card · Pipeline · Evidence"]
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
The Streamlit **Agent trace** tab shows every completed node, its duration and decision.
|
| 102 |
+
|
| 103 |
+
---
|
| 104 |
+
|
| 105 |
+
## Leakage-safe ML design
|
| 106 |
+
|
| 107 |
+
```python
|
| 108 |
+
pipeline = Pipeline(
|
| 109 |
+
[
|
| 110 |
+
("preprocessor", ColumnTransformer(...)),
|
| 111 |
+
("model", candidate_model),
|
| 112 |
+
]
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
# Imputers, encoders and scalers learn only from training folds.
|
| 116 |
+
pipeline.fit(x_train, y_train)
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
- Split occurs before learned preprocessing.
|
| 120 |
+
- Cross-validation refits the complete pipeline in every fold.
|
| 121 |
+
- Classification defaults to balanced accuracy and stratification where possible.
|
| 122 |
+
- Candidates are selected only by training-partition CV; the selected model touches the test set once.
|
| 123 |
+
- Target-like names and identifier cardinality are flagged for human review.
|
| 124 |
+
- Predictive importance is never described as causality.
|
| 125 |
+
|
| 126 |
+
---
|
| 127 |
+
|
| 128 |
+
## Dashboard experience
|
| 129 |
+
|
| 130 |
+
The Streamlit application is summary-first and useful before any upload:
|
| 131 |
+
|
| 132 |
+
- **Built-in demos:** Iris, Breast Cancer and Diabetes Progression.
|
| 133 |
+
- **Executive overview:** findings, recommendations and quality risks.
|
| 134 |
+
- **Model laboratory:** candidate comparison, CV stability and critic gate.
|
| 135 |
+
- **Explainability:** interactive Plotly feature-importance view.
|
| 136 |
+
- **Agent trace:** visible orchestration and retry behavior.
|
| 137 |
+
- **Artifacts and Q&A:** downloads, evidence registry and run-specific questions.
|
| 138 |
+
|
| 139 |
+

|
| 140 |
+
|
| 141 |
+
The public demo URL is intentionally not claimed until a monitored deployment exists. Add a
|
| 142 |
+
real browser capture under `assets/` together with the deployment URL after release validation.
|
| 143 |
+
|
| 144 |
+
---
|
| 145 |
+
|
| 146 |
+
## Technology stack
|
| 147 |
+
|
| 148 |
+
| Layer | Technology |
|
| 149 |
+
|---|---|
|
| 150 |
+
| Agent orchestration | LangGraph typed state and conditional routing |
|
| 151 |
+
| User interface | Streamlit and Plotly |
|
| 152 |
+
| API | FastAPI and Pydantic |
|
| 153 |
+
| Analytical engine | DuckDB, pandas and NumPy |
|
| 154 |
+
| ML | scikit-learn; optional XGBoost and Optuna |
|
| 155 |
+
| Explainability | optional SHAP with permutation fallback |
|
| 156 |
+
| Persistence | SQLAlchemy; SQLite locally, PostgreSQL in production |
|
| 157 |
+
| Experiment tracking | structured agent trace; optional MLflow/OpenTelemetry |
|
| 158 |
+
| Artifacts | local storage interface, ready for S3/MinIO replacement |
|
| 159 |
+
| Delivery | Docker Compose, Render Blueprint and Streamlit Cloud |
|
| 160 |
+
| Quality | Pytest, Ruff, coverage, compile checks and Docker builds in GitHub Actions |
|
| 161 |
+
|
| 162 |
+
---
|
| 163 |
+
|
| 164 |
+
## Repository structure
|
| 165 |
+
|
| 166 |
+
```text
|
| 167 |
+
DataPilot-AI/
|
| 168 |
+
├── datapilot/
|
| 169 |
+
│ ├── config.py # typed environment configuration and limits
|
| 170 |
+
│ ├── data.py # safe readers, DuckDB overview and sample datasets
|
| 171 |
+
│ ├── quality.py # quality, leakage, imbalance, outlier and drift checks
|
| 172 |
+
│ ├── modeling.py # leakage-safe pipelines and model comparison
|
| 173 |
+
│ ├── workflow.py # LangGraph agent graph and critic loop
|
| 174 |
+
│ ├── insights.py # deterministic + optional evidence-bounded narrative
|
| 175 |
+
│ ├── reports.py # HTML report, model card, pipeline and JSON export
|
| 176 |
+
│ ├── persistence.py # SQL run store and artifact interface
|
| 177 |
+
│ ├── observability.py # optional MLflow integration
|
| 178 |
+
│ └── safety.py # expression-only AST security policy
|
| 179 |
+
├── api/main.py # versioned FastAPI application
|
| 180 |
+
├── worker/main.py # optional isolated calculation worker
|
| 181 |
+
├── tests/ # quality, safety, API and end-to-end workflow tests
|
| 182 |
+
├── docs/ # architecture, deployment, security and governance
|
| 183 |
+
├── app.py # canonical premium Streamlit implementation
|
| 184 |
+
├── streamlit_app.py # Streamlit Cloud compatibility shim
|
| 185 |
+
├── Dockerfile* # non-root UI, API and worker images
|
| 186 |
+
├── docker-compose.yml # constrained local multi-service stack
|
| 187 |
+
├── render.yaml # Render API deployment blueprint
|
| 188 |
+
└── .github/workflows/ci.yml # lint, tests, coverage, compile and image builds
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
---
|
| 192 |
+
|
| 193 |
+
## Quick start
|
| 194 |
+
|
| 195 |
+
### Local Streamlit demo
|
| 196 |
+
|
| 197 |
+
```bash
|
| 198 |
+
git clone https://github.com/dineshbarri/DataPilot-AI.git
|
| 199 |
+
cd DataPilot-AI
|
| 200 |
+
|
| 201 |
+
python -m venv .venv
|
| 202 |
+
# Windows
|
| 203 |
+
.venv\Scripts\activate
|
| 204 |
+
# macOS/Linux
|
| 205 |
+
source .venv/bin/activate
|
| 206 |
+
|
| 207 |
+
pip install -e ".[dev]"
|
| 208 |
+
streamlit run app.py
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
For the validated Python 3.12 reference environment, use `pip install -r requirements.lock`.
|
| 212 |
+
The lock snapshot is refreshed after dependency updates and tested across supported interpreters.
|
| 213 |
+
|
| 214 |
+
Open <http://localhost:8501>.
|
| 215 |
+
|
| 216 |
+
### Full AI/AutoML/observability extras
|
| 217 |
+
|
| 218 |
+
```bash
|
| 219 |
+
pip install -e ".[all,dev]"
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
The default installation intentionally stays deployable on modest public-demo infrastructure.
|
| 223 |
+
|
| 224 |
+
### FastAPI
|
| 225 |
+
|
| 226 |
+
```bash
|
| 227 |
+
uvicorn api.main:app --reload --port 8000
|
| 228 |
+
```
|
| 229 |
+
|
| 230 |
+
Open <http://localhost:8000/docs>.
|
| 231 |
+
|
| 232 |
+
Example:
|
| 233 |
+
|
| 234 |
+
```bash
|
| 235 |
+
curl -X POST http://localhost:8000/v1/analyze/sample \
|
| 236 |
+
-H "Content-Type: application/json" \
|
| 237 |
+
-d "{\"sample\":\"iris\"}"
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
The API returns `202 Accepted` with a job ID. Poll `GET /v1/jobs/{job_id}` and cancel queued
|
| 241 |
+
work with `DELETE /v1/jobs/{job_id}`. Set `API_KEY` in deployed environments and send it as
|
| 242 |
+
`X-API-Key`. The bundled in-process queue is for development; use the production topology in
|
| 243 |
+
`docs/ARCHITECTURE.md` for durable execution.
|
| 244 |
+
|
| 245 |
+
### Docker
|
| 246 |
+
|
| 247 |
+
```bash
|
| 248 |
+
docker compose up --build
|
| 249 |
+
```
|
| 250 |
+
|
| 251 |
+
The optional worker runs with no network, a read-only filesystem, dropped capabilities,
|
| 252 |
+
memory/CPU/PID limits and expression-only AST validation.
|
| 253 |
+
|
| 254 |
+
---
|
| 255 |
+
|
| 256 |
+
## Configuration
|
| 257 |
+
|
| 258 |
+
Copy `.env.example` to `.env`.
|
| 259 |
+
|
| 260 |
+
| Variable | Default | Purpose |
|
| 261 |
+
|---|---|---|
|
| 262 |
+
| `DATABASE_URL` | SQLite | Set a PostgreSQL URL for persistent production runs |
|
| 263 |
+
| `ARTIFACT_ROOT` | `artifacts` | Root for reports, pipelines and model cards |
|
| 264 |
+
| `MAX_UPLOAD_MB` | `25` | Public upload protection |
|
| 265 |
+
| `MAX_ROWS` | `100000` | Maximum rows per analysis |
|
| 266 |
+
| `MAX_COLUMNS` | `250` | Maximum feature width |
|
| 267 |
+
| `MAX_CATEGORIES_PER_FEATURE` | `100` | Bound categorical expansion |
|
| 268 |
+
| `MAX_ENCODED_FEATURES` | `5000` | Refuse unsafe estimated encoded width |
|
| 269 |
+
| `API_KEY` | empty | Optional API authentication; required for public deployment |
|
| 270 |
+
| `REQUESTS_PER_MINUTE` | `30` | Per-client API rate limit |
|
| 271 |
+
| `MAX_CRITIC_RETRIES` | `1` | Conditional modeling retry budget |
|
| 272 |
+
| `ENABLE_MLFLOW` | `false` | Enable optional experiment tracking |
|
| 273 |
+
| `GEMINI_API_KEY` | empty | Optional narrative refinement only |
|
| 274 |
+
|
| 275 |
+
No credential is embedded in the repository.
|
| 276 |
+
|
| 277 |
+
---
|
| 278 |
+
|
| 279 |
+
## Testing and engineering quality
|
| 280 |
+
|
| 281 |
+
```bash
|
| 282 |
+
pip install -e ".[dev]"
|
| 283 |
+
ruff check .
|
| 284 |
+
pytest --cov=datapilot --cov=api --cov-fail-under=75
|
| 285 |
+
python -m compileall datapilot api worker app.py streamlit_app.py
|
| 286 |
+
```
|
| 287 |
+
|
| 288 |
+
CI runs Python 3.11, 3.12 and 3.13, enforces coverage, builds the package and all three images,
|
| 289 |
+
audits dependencies, scans containers with Trivy, and performs secret detection.
|
| 290 |
+
|
| 291 |
+
---
|
| 292 |
+
|
| 293 |
+
## Deployment
|
| 294 |
+
|
| 295 |
+
### Streamlit Community Cloud
|
| 296 |
+
|
| 297 |
+
- Entrypoint: `streamlit_app.py`
|
| 298 |
+
- Python: 3.12
|
| 299 |
+
- Secrets: none required; `GEMINI_API_KEY` is optional
|
| 300 |
+
- Default recruiter path: bundled demo dataset
|
| 301 |
+
|
| 302 |
+
### Render / Railway / Fly.io
|
| 303 |
+
|
| 304 |
+
Deploy `Dockerfile.api`, attach PostgreSQL, and configure durable object storage if artifacts
|
| 305 |
+
must survive container replacement. See [the deployment guide](docs/DEPLOYMENT.md).
|
| 306 |
+
|
| 307 |
+
### Production recommendation
|
| 308 |
+
|
| 309 |
+
The included in-process job manager makes local requests non-blocking. For durable production
|
| 310 |
+
jobs, replace it with a queue and separate workers, PostgreSQL state, S3-compatible artifacts,
|
| 311 |
+
and short-lived sandboxed workers for any future code-execution capability.
|
| 312 |
+
|
| 313 |
+
---
|
| 314 |
+
|
| 315 |
+
## Responsible-use boundaries
|
| 316 |
+
|
| 317 |
+
DataPilot is an exploratory decision-support system, not an automatic production approval
|
| 318 |
+
authority. Before consequential use, complete:
|
| 319 |
+
|
| 320 |
+
- target and leakage review
|
| 321 |
+
- out-of-time and segment evaluation
|
| 322 |
+
- privacy and retention assessment
|
| 323 |
+
- fairness and disparate-impact evaluation
|
| 324 |
+
- domain and legal approval
|
| 325 |
+
- monitoring, rollback and retraining ownership
|
| 326 |
+
|
| 327 |
+
See [Model Governance](docs/MODEL_GOVERNANCE.md) and [Security](docs/SECURITY.md).
|
| 328 |
+
|
| 329 |
+
---
|
| 330 |
+
|
| 331 |
+
## Roadmap
|
| 332 |
+
|
| 333 |
+
- [ ] Background job queue and live progress streaming
|
| 334 |
+
- [ ] S3/MinIO artifact adapter with signed downloads
|
| 335 |
+
- [ ] Native PostgreSQL checkpoints for resumable LangGraph runs
|
| 336 |
+
- [ ] Optuna study dashboard and experiment comparison
|
| 337 |
+
- [ ] Time-series and clustering task families
|
| 338 |
+
- [ ] Fairness and segment-performance report
|
| 339 |
+
- [ ] Data-contract and schema-drift registry
|
| 340 |
+
- [ ] Authenticated multi-tenant workspace
|
| 341 |
+
|
| 342 |
+
---
|
| 343 |
+
|
| 344 |
+
## Creator
|
| 345 |
+
|
| 346 |
+
### Dinesh Barri
|
| 347 |
+
|
| 348 |
+
AI Engineer building agentic systems, data products, RAG applications and production-oriented
|
| 349 |
+
machine-learning workflows.
|
| 350 |
+
|
| 351 |
+
[](https://github.com/dineshbarri)
|
| 352 |
+
[](https://www.linkedin.com/in/dinesh-barri-7654b010b)
|
| 353 |
+
|
| 354 |
+
---
|
| 355 |
+
|
| 356 |
+
## License
|
| 357 |
+
|
| 358 |
+
Released under the [MIT License](LICENSE).
|
| 359 |
+
|
| 360 |
+
If this project helps you, please star the repository and share the live demo.
|
| 361 |
+
|
SECURITY.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security policy
|
| 2 |
+
|
| 3 |
+
Supported security fixes target the latest release and the `main` branch.
|
| 4 |
+
|
| 5 |
+
Do not open public issues for vulnerabilities or include datasets, credentials, API keys, or
|
| 6 |
+
database URLs in reports. Report vulnerabilities privately through GitHub Security Advisories:
|
| 7 |
+
|
| 8 |
+
<https://github.com/dineshbarri/DataPilot-AI/security/advisories/new>
|
| 9 |
+
|
| 10 |
+
Include affected version, reproduction steps, impact, and a suggested mitigation when possible.
|
| 11 |
+
See [docs/SECURITY.md](docs/SECURITY.md) and [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md).
|
api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI service for DataPilot AI."""
|
api/main.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import time
|
| 5 |
+
from collections import defaultdict, deque
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from uuid import uuid4
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from fastapi.responses import FileResponse
|
| 12 |
+
from pydantic import BaseModel
|
| 13 |
+
|
| 14 |
+
from datapilot.config import get_settings
|
| 15 |
+
from datapilot.data import load_sample, read_dataset
|
| 16 |
+
from datapilot.insights import answer_follow_up
|
| 17 |
+
from datapilot.jobs import JobManager
|
| 18 |
+
from datapilot.persistence import RunStore
|
| 19 |
+
from datapilot.workflow import run_analysis
|
| 20 |
+
|
| 21 |
+
settings = get_settings()
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
jobs = JobManager(workers=2)
|
| 24 |
+
request_windows: dict[str, deque[float]] = defaultdict(deque)
|
| 25 |
+
app = FastAPI(
|
| 26 |
+
title="DataPilot AI API",
|
| 27 |
+
version="1.0.0",
|
| 28 |
+
description="Evidence-grounded autonomous data science with LangGraph.",
|
| 29 |
+
)
|
| 30 |
+
app.add_middleware(
|
| 31 |
+
CORSMiddleware,
|
| 32 |
+
allow_origins=[item.strip() for item in settings.cors_origins.split(",")],
|
| 33 |
+
allow_methods=["GET", "POST"],
|
| 34 |
+
allow_headers=["*"],
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@app.middleware("http")
|
| 39 |
+
async def security_middleware(request: Request, call_next):
|
| 40 |
+
correlation_id = request.headers.get("X-Correlation-ID") or uuid4().hex
|
| 41 |
+
request.state.correlation_id = correlation_id
|
| 42 |
+
if request.url.path.startswith("/v1/"):
|
| 43 |
+
if settings.api_key and request.headers.get("X-API-Key") != settings.api_key:
|
| 44 |
+
return _error_response(401, "Authentication required.", correlation_id)
|
| 45 |
+
client = request.client.host if request.client else "unknown"
|
| 46 |
+
now = time.monotonic()
|
| 47 |
+
window = request_windows[client]
|
| 48 |
+
while window and now - window[0] > 60:
|
| 49 |
+
window.popleft()
|
| 50 |
+
if len(window) >= settings.requests_per_minute:
|
| 51 |
+
return _error_response(429, "Rate limit exceeded.", correlation_id)
|
| 52 |
+
window.append(now)
|
| 53 |
+
response = await call_next(request)
|
| 54 |
+
response.headers["X-Correlation-ID"] = correlation_id
|
| 55 |
+
return response
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _error_response(status_code: int, message: str, correlation_id: str):
|
| 59 |
+
from fastapi.responses import JSONResponse
|
| 60 |
+
|
| 61 |
+
return JSONResponse(
|
| 62 |
+
status_code=status_code,
|
| 63 |
+
content={"detail": message, "correlation_id": correlation_id},
|
| 64 |
+
headers={"X-Correlation-ID": correlation_id},
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _safe_failure(exc: Exception, correlation_id: str) -> HTTPException:
|
| 69 |
+
logger.exception(
|
| 70 |
+
"Analysis failure correlation_id=%s category=%s", correlation_id, type(exc).__name__
|
| 71 |
+
)
|
| 72 |
+
return HTTPException(
|
| 73 |
+
status_code=500,
|
| 74 |
+
detail={
|
| 75 |
+
"message": "Analysis failed. Use the correlation ID when contacting support.",
|
| 76 |
+
"correlation_id": correlation_id,
|
| 77 |
+
},
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class SampleRequest(BaseModel):
|
| 82 |
+
sample: str
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class ChatRequest(BaseModel):
|
| 86 |
+
question: str
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@app.get("/")
|
| 90 |
+
def root() -> dict[str, str]:
|
| 91 |
+
return {"name": settings.app_name, "status": "ready", "docs": "/docs"}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@app.get("/health")
|
| 95 |
+
def health() -> dict[str, object]:
|
| 96 |
+
return {
|
| 97 |
+
"status": "healthy",
|
| 98 |
+
"environment": settings.environment,
|
| 99 |
+
"limits": {
|
| 100 |
+
"max_upload_mb": settings.max_upload_mb,
|
| 101 |
+
"max_rows": settings.max_rows,
|
| 102 |
+
"max_columns": settings.max_columns,
|
| 103 |
+
},
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@app.post("/v1/analyze/sample", status_code=202)
|
| 108 |
+
def analyze_sample(request: SampleRequest, http_request: Request):
|
| 109 |
+
try:
|
| 110 |
+
frame, target, dataset_name = load_sample(request.sample)
|
| 111 |
+
job = jobs.submit(lambda: run_analysis(frame, target, dataset_name, settings))
|
| 112 |
+
return {"job_id": job.job_id, "status": job.status, "status_url": f"/v1/jobs/{job.job_id}"}
|
| 113 |
+
except ValueError as exc:
|
| 114 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 115 |
+
except Exception as exc:
|
| 116 |
+
raise _safe_failure(exc, http_request.state.correlation_id) from exc
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@app.post("/v1/analyze/upload")
|
| 120 |
+
async def analyze_upload(
|
| 121 |
+
request: Request,
|
| 122 |
+
file: UploadFile = File(...),
|
| 123 |
+
target: str = Form(...),
|
| 124 |
+
):
|
| 125 |
+
try:
|
| 126 |
+
content = await file.read(settings.max_upload_mb * 1024 * 1024 + 1)
|
| 127 |
+
frame = read_dataset(content, file.filename or "dataset.csv", settings)
|
| 128 |
+
job = jobs.submit(
|
| 129 |
+
lambda: run_analysis(frame, target, file.filename or "uploaded_dataset", settings)
|
| 130 |
+
)
|
| 131 |
+
return {"job_id": job.job_id, "status": job.status, "status_url": f"/v1/jobs/{job.job_id}"}
|
| 132 |
+
except ValueError as exc:
|
| 133 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 134 |
+
except Exception as exc:
|
| 135 |
+
raise _safe_failure(exc, request.state.correlation_id) from exc
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@app.get("/v1/jobs/{job_id}")
|
| 139 |
+
def get_job(job_id: str):
|
| 140 |
+
job = jobs.get(job_id)
|
| 141 |
+
if job is None:
|
| 142 |
+
raise HTTPException(status_code=404, detail="Job not found.")
|
| 143 |
+
return job.public()
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@app.delete("/v1/jobs/{job_id}")
|
| 147 |
+
def cancel_job(job_id: str):
|
| 148 |
+
if jobs.get(job_id) is None:
|
| 149 |
+
raise HTTPException(status_code=404, detail="Job not found.")
|
| 150 |
+
return {"job_id": job_id, "cancelled": jobs.cancel(job_id)}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@app.get("/v1/runs")
|
| 154 |
+
def recent_runs():
|
| 155 |
+
return RunStore(settings).list_recent()
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@app.get("/v1/runs/{run_id}")
|
| 159 |
+
def get_run(run_id: str):
|
| 160 |
+
run = RunStore(settings).get(run_id)
|
| 161 |
+
if run is None:
|
| 162 |
+
raise HTTPException(status_code=404, detail="Run not found.")
|
| 163 |
+
return run
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@app.post("/v1/runs/{run_id}/chat")
|
| 167 |
+
def chat_with_run(run_id: str, request: ChatRequest):
|
| 168 |
+
run = RunStore(settings).get(run_id)
|
| 169 |
+
if run is None:
|
| 170 |
+
raise HTTPException(status_code=404, detail="Run not found.")
|
| 171 |
+
return {"answer": answer_follow_up(run, request.question)}
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@app.get("/v1/runs/{run_id}/artifacts/{artifact_name}")
|
| 175 |
+
def download_artifact(run_id: str, artifact_name: str):
|
| 176 |
+
run = RunStore(settings).get(run_id)
|
| 177 |
+
if run is None:
|
| 178 |
+
raise HTTPException(status_code=404, detail="Run not found.")
|
| 179 |
+
path_string = run.get("artifacts", {}).get(artifact_name)
|
| 180 |
+
if not path_string:
|
| 181 |
+
raise HTTPException(status_code=404, detail="Artifact not found.")
|
| 182 |
+
path = Path(path_string).resolve()
|
| 183 |
+
artifact_root = settings.artifact_root.resolve()
|
| 184 |
+
if artifact_root not in path.parents or not path.is_file():
|
| 185 |
+
raise HTTPException(status_code=404, detail="Artifact not found.")
|
| 186 |
+
return FileResponse(path, filename=path.name)
|
app.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import os
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import plotly.express as px
|
| 9 |
+
import streamlit as st
|
| 10 |
+
|
| 11 |
+
from datapilot.analyst import dataframe_csv, gemini_dataset_summary, inspect_dataset
|
| 12 |
+
from datapilot.config import get_settings
|
| 13 |
+
from datapilot.data import SAMPLE_DATASETS, load_sample
|
| 14 |
+
from datapilot.workflow import run_analysis
|
| 15 |
+
|
| 16 |
+
st.set_page_config(
|
| 17 |
+
page_title="DataPilot · Autonomous Data Analyst",
|
| 18 |
+
page_icon="✦",
|
| 19 |
+
layout="wide",
|
| 20 |
+
initial_sidebar_state="expanded",
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
st.markdown(
|
| 24 |
+
"""
|
| 25 |
+
<style>
|
| 26 |
+
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@600;700;800&display=swap');
|
| 27 |
+
:root{--navy:#07111f;--panel:#0e1b2c;--line:#203149;--cyan:#49d7c5;--blue:#6d8dff;--text:#edf4ff;--muted:#91a1b7}
|
| 28 |
+
.stApp{background:radial-gradient(circle at 75% -10%,#17355c 0,transparent 35%),#07111f;color:var(--text)}
|
| 29 |
+
html,body,[class*="css"]{font-family:"DM Sans",sans-serif}
|
| 30 |
+
h1,h2,h3{font-family:"Manrope",sans-serif;letter-spacing:-.03em}
|
| 31 |
+
header[data-testid="stHeader"]{background:transparent}
|
| 32 |
+
div[data-testid="stSidebar"]{background:#091522;border-right:1px solid var(--line)}
|
| 33 |
+
.block-container{max-width:1480px;padding-top:1.1rem;padding-bottom:4rem}
|
| 34 |
+
.brand{display:flex;gap:.75rem;align-items:center;font:800 1.2rem Manrope;color:white;margin:.2rem 0 1.3rem}
|
| 35 |
+
.brand-mark{display:grid;place-items:center;width:34px;height:34px;border-radius:10px;background:linear-gradient(135deg,var(--cyan),var(--blue));color:#07111f}
|
| 36 |
+
.hero{border:1px solid #29405d;background:linear-gradient(125deg,rgba(17,35,57,.96),rgba(9,22,38,.88));border-radius:24px;padding:2rem 2.2rem;margin-bottom:1rem;overflow:hidden;position:relative}
|
| 37 |
+
.hero:after{content:"";position:absolute;width:340px;height:340px;border-radius:50%;right:-100px;top:-190px;background:rgba(73,215,197,.10)}
|
| 38 |
+
.eyebrow{color:var(--cyan);font-size:.73rem;font-weight:700;letter-spacing:.18em;text-transform:uppercase}
|
| 39 |
+
.hero h1{font-size:clamp(2.1rem,4vw,4rem);line-height:1.02;margin:.45rem 0 .7rem;color:white}
|
| 40 |
+
.hero p{max-width:790px;color:#aebdd0;font-size:1.02rem;line-height:1.65;margin:0}
|
| 41 |
+
.stepbar{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:1.4rem}.step{border:1px solid #2d4664;border-radius:999px;padding:.4rem .72rem;color:#9eafc4;font-size:.75rem}.step.on{color:#07111f;background:var(--cyan);border-color:var(--cyan);font-weight:700}
|
| 42 |
+
.panel{background:rgba(14,27,44,.92);border:1px solid var(--line);border-radius:18px;padding:1.15rem 1.25rem;height:100%}
|
| 43 |
+
.kicker{color:var(--cyan);font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.12em}.muted{color:var(--muted);font-size:.87rem;line-height:1.55}
|
| 44 |
+
.signature{background:linear-gradient(145deg,#11263b,#0b1828);border:1px solid #29435f;border-radius:17px;padding:1rem;margin-top:1rem}.signature strong{color:white}.signature a{color:var(--cyan);text-decoration:none;font-size:.83rem}
|
| 45 |
+
div[data-testid="stMetric"]{background:#0d1b2c;border:1px solid var(--line);padding:15px 17px;border-radius:15px}div[data-testid="stMetric"] label{color:#91a1b7}div[data-testid="stMetricValue"]{color:white}
|
| 46 |
+
.stButton>button,.stDownloadButton>button{border:0;border-radius:11px;background:linear-gradient(135deg,#49d7c5,#6d8dff);color:#07111f;font-weight:800}
|
| 47 |
+
.stButton>button:hover,.stDownloadButton>button:hover{color:#07111f;filter:brightness(1.08)}
|
| 48 |
+
div[data-testid="stFileUploaderDropzone"]{background:#0c1a2b;border:1.5px dashed #3b617c;border-radius:16px;padding:1.3rem}
|
| 49 |
+
div[data-baseweb="tab-list"]{gap:.3rem;background:#0b1828;border:1px solid var(--line);border-radius:13px;padding:.3rem}
|
| 50 |
+
button[data-baseweb="tab"]{border-radius:9px;color:#9caec3}button[data-baseweb="tab"][aria-selected="true"]{background:#172b41;color:white}
|
| 51 |
+
.stDataFrame{border:1px solid var(--line);border-radius:13px;overflow:hidden}
|
| 52 |
+
[data-testid="stAlert"]{border-radius:13px}
|
| 53 |
+
</style>
|
| 54 |
+
""",
|
| 55 |
+
unsafe_allow_html=True,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
settings = get_settings()
|
| 59 |
+
for key, default in {
|
| 60 |
+
"frame": None,
|
| 61 |
+
"dataset_name": "",
|
| 62 |
+
"profile": None,
|
| 63 |
+
"result": None,
|
| 64 |
+
"ai_summary": "",
|
| 65 |
+
"chat": [],
|
| 66 |
+
"target": None,
|
| 67 |
+
}.items():
|
| 68 |
+
if key not in st.session_state:
|
| 69 |
+
st.session_state[key] = default
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def read_upload(uploaded) -> pd.DataFrame:
|
| 73 |
+
suffix = Path(uploaded.name).suffix.lower()
|
| 74 |
+
raw = uploaded.getvalue()
|
| 75 |
+
if len(raw) > settings.max_upload_mb * 1_048_576:
|
| 76 |
+
raise ValueError(f"File exceeds the {settings.max_upload_mb} MB limit.")
|
| 77 |
+
stream = io.BytesIO(raw)
|
| 78 |
+
if suffix in {".csv", ".tsv", ".txt"}:
|
| 79 |
+
return pd.read_csv(stream, sep="\t" if suffix == ".tsv" else None, engine="python")
|
| 80 |
+
if suffix in {".xlsx", ".xls"}:
|
| 81 |
+
return pd.read_excel(stream)
|
| 82 |
+
if suffix == ".parquet":
|
| 83 |
+
return pd.read_parquet(stream)
|
| 84 |
+
if suffix == ".json":
|
| 85 |
+
try:
|
| 86 |
+
return pd.read_json(stream)
|
| 87 |
+
except ValueError:
|
| 88 |
+
stream.seek(0)
|
| 89 |
+
return pd.read_json(stream, lines=True)
|
| 90 |
+
raise ValueError("Use CSV, TSV, Excel, JSON, or Parquet.")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
with st.sidebar:
|
| 94 |
+
st.markdown(
|
| 95 |
+
'<div class="brand"><span class="brand-mark">✦</span>DataPilot</div>',
|
| 96 |
+
unsafe_allow_html=True,
|
| 97 |
+
)
|
| 98 |
+
st.caption("AUTONOMOUS ANALYSIS WORKSPACE")
|
| 99 |
+
st.markdown("##### Gemini intelligence")
|
| 100 |
+
default_key = os.getenv("GEMINI_API_KEY", os.getenv("GOOGLE_API_KEY", ""))
|
| 101 |
+
api_key = st.text_input(
|
| 102 |
+
"Gemini API key",
|
| 103 |
+
value=default_key,
|
| 104 |
+
type="password",
|
| 105 |
+
help="Masked in the interface and not intentionally written to files or logs.",
|
| 106 |
+
)
|
| 107 |
+
model = st.selectbox("Model", ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash"])
|
| 108 |
+
st.caption("● AI ready" if api_key else "○ Local analysis mode")
|
| 109 |
+
st.divider()
|
| 110 |
+
st.markdown("##### Privacy controls")
|
| 111 |
+
metadata_only = st.toggle(
|
| 112 |
+
"Metadata-first AI",
|
| 113 |
+
value=True,
|
| 114 |
+
help="Send schema, aggregate statistics, and three redacted examples—not the full dataset.",
|
| 115 |
+
)
|
| 116 |
+
excluded = st.multiselect(
|
| 117 |
+
"Exclude columns from AI",
|
| 118 |
+
list(st.session_state.frame.columns) if st.session_state.frame is not None else [],
|
| 119 |
+
)
|
| 120 |
+
st.divider()
|
| 121 |
+
if st.button("Reset workspace", width="stretch"):
|
| 122 |
+
for key in ("frame", "profile", "result", "ai_summary", "chat", "target"):
|
| 123 |
+
st.session_state[key] = (
|
| 124 |
+
None
|
| 125 |
+
if key in {"frame", "profile", "result", "target"}
|
| 126 |
+
else ([] if key == "chat" else "")
|
| 127 |
+
)
|
| 128 |
+
st.rerun()
|
| 129 |
+
st.markdown(
|
| 130 |
+
"""
|
| 131 |
+
<div class="signature">
|
| 132 |
+
<div class="kicker">Built & designed by</div>
|
| 133 |
+
<strong>Dinesh Barri</strong><br>
|
| 134 |
+
<span class="muted">AI Engineer · Data Scientist</span><br><br>
|
| 135 |
+
<a href="https://github.com/dineshbarri">GitHub ↗</a>
|
| 136 |
+
<a href="https://www.linkedin.com/in/dinesh-barri-7654b010b">LinkedIn ↗</a>
|
| 137 |
+
</div>""",
|
| 138 |
+
unsafe_allow_html=True,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
loaded = st.session_state.frame is not None
|
| 142 |
+
st.markdown(
|
| 143 |
+
f"""
|
| 144 |
+
<section class="hero">
|
| 145 |
+
<div class="eyebrow">Evidence-first autonomous data science</div>
|
| 146 |
+
<h1>Your data. Explained.<br>Decisions, accelerated.</h1>
|
| 147 |
+
<p>Upload a dataset and DataPilot immediately inspects its structure, surfaces quality risks,
|
| 148 |
+
recommends analytical targets, creates interactive evidence, and prepares a leakage-safe
|
| 149 |
+
machine-learning study—with Gemini available for grounded interpretation.</p>
|
| 150 |
+
<div class="stepbar">
|
| 151 |
+
<span class="step {"on" if loaded else ""}">01 · Connect</span>
|
| 152 |
+
<span class="step {"on" if loaded else ""}">02 · Inspect</span>
|
| 153 |
+
<span class="step {"on" if st.session_state.ai_summary else ""}">03 · Interpret</span>
|
| 154 |
+
<span class="step {"on" if st.session_state.result else ""}">04 · Model</span>
|
| 155 |
+
<span class="step {"on" if st.session_state.result else ""}">05 · Deliver</span>
|
| 156 |
+
</div>
|
| 157 |
+
</section>""",
|
| 158 |
+
unsafe_allow_html=True,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
if not loaded:
|
| 162 |
+
left, right = st.columns([1.35, 0.65], gap="large")
|
| 163 |
+
with left:
|
| 164 |
+
st.markdown('<div class="kicker">Start a new analysis</div>', unsafe_allow_html=True)
|
| 165 |
+
st.subheader("Drop in your dataset")
|
| 166 |
+
uploaded = st.file_uploader(
|
| 167 |
+
"Upload dataset",
|
| 168 |
+
type=["csv", "tsv", "txt", "xlsx", "xls", "json", "parquet"],
|
| 169 |
+
label_visibility="collapsed",
|
| 170 |
+
)
|
| 171 |
+
st.caption("CSV · TSV · Excel · JSON · Parquet | Raw data remains in this session.")
|
| 172 |
+
if uploaded:
|
| 173 |
+
try:
|
| 174 |
+
with st.status("DataPilot is inspecting your dataset…", expanded=True) as status:
|
| 175 |
+
st.write("Validating file structure")
|
| 176 |
+
frame = read_upload(uploaded)
|
| 177 |
+
st.write("Profiling columns, missingness, cardinality, and target candidates")
|
| 178 |
+
profile = inspect_dataset(frame)
|
| 179 |
+
st.session_state.frame = frame
|
| 180 |
+
st.session_state.profile = profile
|
| 181 |
+
st.session_state.dataset_name = uploaded.name
|
| 182 |
+
status.update(label="Dataset ready", state="complete")
|
| 183 |
+
st.rerun()
|
| 184 |
+
except Exception as exc:
|
| 185 |
+
st.error(f"Upload could not be processed: {exc}")
|
| 186 |
+
with right:
|
| 187 |
+
st.markdown(
|
| 188 |
+
'<div class="panel"><div class="kicker">Try it instantly</div><h3>Explore a trusted demo</h3><p class="muted">Load a complete classification or regression dataset and see the full analyst workflow.</p></div>',
|
| 189 |
+
unsafe_allow_html=True,
|
| 190 |
+
)
|
| 191 |
+
demo = st.selectbox("Demo dataset", list(SAMPLE_DATASETS))
|
| 192 |
+
if st.button("Load demo workspace", width="stretch"):
|
| 193 |
+
frame, target, name = load_sample(SAMPLE_DATASETS[demo])
|
| 194 |
+
st.session_state.frame, st.session_state.target = frame, target
|
| 195 |
+
st.session_state.dataset_name = name
|
| 196 |
+
st.session_state.profile = inspect_dataset(frame)
|
| 197 |
+
st.rerun()
|
| 198 |
+
st.stop()
|
| 199 |
+
|
| 200 |
+
frame: pd.DataFrame = st.session_state.frame
|
| 201 |
+
profile = st.session_state.profile or inspect_dataset(frame)
|
| 202 |
+
brief = profile["brief"]
|
| 203 |
+
|
| 204 |
+
metrics = st.columns(6)
|
| 205 |
+
metrics[0].metric("Rows", f"{brief.rows:,}")
|
| 206 |
+
metrics[1].metric("Columns", f"{brief.columns:,}")
|
| 207 |
+
metrics[2].metric("Numeric", brief.numeric)
|
| 208 |
+
metrics[3].metric("Categorical", brief.categorical)
|
| 209 |
+
metrics[4].metric("Missing cells", f"{brief.missing_cells:,}")
|
| 210 |
+
metrics[5].metric("Quality score", f"{profile['quality_score']}/100")
|
| 211 |
+
|
| 212 |
+
overview, quality, explore, ai_tab, model_tab, deliver = st.tabs(
|
| 213 |
+
["Overview", "Data quality", "Explore", "AI insights", "Model lab", "Deliver"]
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
with overview:
|
| 217 |
+
st.subheader(st.session_state.dataset_name)
|
| 218 |
+
st.caption(f"Dataset fingerprint {brief.fingerprint} · {brief.memory_mb:.2f} MB in memory")
|
| 219 |
+
first, last, sample = st.tabs(["First 5 rows", "Last 5 rows", "Random sample"])
|
| 220 |
+
first.dataframe(frame.head(), width="stretch", hide_index=True)
|
| 221 |
+
last.dataframe(frame.tail(), width="stretch", hide_index=True)
|
| 222 |
+
sample.dataframe(
|
| 223 |
+
frame.sample(min(5, len(frame)), random_state=42), width="stretch", hide_index=True
|
| 224 |
+
)
|
| 225 |
+
st.markdown("#### Data dictionary")
|
| 226 |
+
st.dataframe(
|
| 227 |
+
profile["dictionary"].drop(columns=["issue_count"]), width="stretch", hide_index=True
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
with quality:
|
| 231 |
+
a, b = st.columns([0.75, 1.25])
|
| 232 |
+
with a:
|
| 233 |
+
st.markdown("#### Quality signals")
|
| 234 |
+
st.metric("Duplicate rows", f"{brief.duplicate_rows:,}")
|
| 235 |
+
st.metric("Completeness", f"{100 - brief.missing_cells / max(1, frame.size) * 100:.1f}%")
|
| 236 |
+
flagged = profile["dictionary"].query("issue_count > 0")
|
| 237 |
+
st.metric("Flagged columns", len(flagged))
|
| 238 |
+
st.info("DataPilot reports evidence first. No rows or values are changed without approval.")
|
| 239 |
+
with b:
|
| 240 |
+
missing = profile["missing"][profile["missing"] > 0].sort_values()
|
| 241 |
+
if len(missing):
|
| 242 |
+
fig = px.bar(
|
| 243 |
+
x=missing.values,
|
| 244 |
+
y=missing.index,
|
| 245 |
+
orientation="h",
|
| 246 |
+
labels={"x": "Missing values", "y": "Column"},
|
| 247 |
+
title="Missing values by column",
|
| 248 |
+
color=missing.values,
|
| 249 |
+
color_continuous_scale=["#49d7c5", "#6d8dff"],
|
| 250 |
+
)
|
| 251 |
+
fig.update_layout(
|
| 252 |
+
template="plotly_dark",
|
| 253 |
+
paper_bgcolor="#0e1b2c",
|
| 254 |
+
plot_bgcolor="#0e1b2c",
|
| 255 |
+
coloraxis_showscale=False,
|
| 256 |
+
)
|
| 257 |
+
st.plotly_chart(fig, width="stretch")
|
| 258 |
+
else:
|
| 259 |
+
st.success("No missing values detected.")
|
| 260 |
+
if len(flagged):
|
| 261 |
+
st.dataframe(flagged.drop(columns=["issue_count"]), width="stretch", hide_index=True)
|
| 262 |
+
|
| 263 |
+
with explore:
|
| 264 |
+
numeric = profile["numeric"]
|
| 265 |
+
if numeric:
|
| 266 |
+
selected = st.selectbox("Explore a numerical feature", numeric)
|
| 267 |
+
c1, c2 = st.columns(2)
|
| 268 |
+
fig = px.histogram(
|
| 269 |
+
frame,
|
| 270 |
+
x=selected,
|
| 271 |
+
marginal="box",
|
| 272 |
+
title=f"Distribution of {selected}",
|
| 273 |
+
color_discrete_sequence=["#49d7c5"],
|
| 274 |
+
)
|
| 275 |
+
fig.update_layout(template="plotly_dark", paper_bgcolor="#0e1b2c", plot_bgcolor="#0e1b2c")
|
| 276 |
+
c1.plotly_chart(fig, width="stretch")
|
| 277 |
+
if not profile["correlation"].empty:
|
| 278 |
+
heat = px.imshow(
|
| 279 |
+
profile["correlation"],
|
| 280 |
+
text_auto=".2f",
|
| 281 |
+
aspect="auto",
|
| 282 |
+
color_continuous_scale=["#1a2940", "#49d7c5", "#f4b860"],
|
| 283 |
+
title="Numeric correlation map",
|
| 284 |
+
)
|
| 285 |
+
heat.update_layout(template="plotly_dark", paper_bgcolor="#0e1b2c")
|
| 286 |
+
c2.plotly_chart(heat, width="stretch")
|
| 287 |
+
else:
|
| 288 |
+
c2.info("Add another numerical column to calculate correlations.")
|
| 289 |
+
st.dataframe(frame[numeric].describe().T, width="stretch")
|
| 290 |
+
else:
|
| 291 |
+
st.info("This dataset has no numerical columns. Use the categorical overview below.")
|
| 292 |
+
categories = profile["categorical"]
|
| 293 |
+
if categories:
|
| 294 |
+
selected_cat = st.selectbox("Explore a categorical feature", categories)
|
| 295 |
+
counts = frame[selected_cat].astype(str).value_counts().head(20).reset_index()
|
| 296 |
+
fig = px.bar(
|
| 297 |
+
counts,
|
| 298 |
+
x="count",
|
| 299 |
+
y=selected_cat,
|
| 300 |
+
orientation="h",
|
| 301 |
+
title=f"Top values · {selected_cat}",
|
| 302 |
+
color="count",
|
| 303 |
+
color_continuous_scale=["#49d7c5", "#6d8dff"],
|
| 304 |
+
)
|
| 305 |
+
fig.update_layout(
|
| 306 |
+
template="plotly_dark",
|
| 307 |
+
paper_bgcolor="#0e1b2c",
|
| 308 |
+
plot_bgcolor="#0e1b2c",
|
| 309 |
+
coloraxis_showscale=False,
|
| 310 |
+
)
|
| 311 |
+
st.plotly_chart(fig, width="stretch")
|
| 312 |
+
|
| 313 |
+
with ai_tab:
|
| 314 |
+
st.markdown("#### Ask Gemini to interpret the computed evidence")
|
| 315 |
+
st.caption(
|
| 316 |
+
"AI interpretation based on dataset metadata and limited redacted samples. Verify against source documentation."
|
| 317 |
+
)
|
| 318 |
+
if not api_key:
|
| 319 |
+
st.warning(
|
| 320 |
+
"Enter a Gemini API key in the sidebar. Deterministic profiling remains fully available without AI."
|
| 321 |
+
)
|
| 322 |
+
if st.button("Generate AI analyst brief", disabled=not bool(api_key)):
|
| 323 |
+
try:
|
| 324 |
+
with st.status("Gemini is reviewing the evidence package…", expanded=True) as status:
|
| 325 |
+
st.write("Redacting potential PII and excluded columns")
|
| 326 |
+
st.write("Sending schema, aggregate statistics, and three examples")
|
| 327 |
+
summary = gemini_dataset_summary(frame, profile, api_key, model, excluded)
|
| 328 |
+
st.session_state.ai_summary = summary
|
| 329 |
+
status.update(label="AI analyst brief ready", state="complete")
|
| 330 |
+
except ValueError as exc:
|
| 331 |
+
st.error(str(exc))
|
| 332 |
+
if st.session_state.ai_summary:
|
| 333 |
+
st.markdown(st.session_state.ai_summary)
|
| 334 |
+
with st.expander("What may be sent to Gemini"):
|
| 335 |
+
st.write(
|
| 336 |
+
"Column metadata, aggregate statistics, target candidates, quality score, and up to three redacted example rows."
|
| 337 |
+
)
|
| 338 |
+
st.write(
|
| 339 |
+
"Automatically excluded potential PII:",
|
| 340 |
+
[
|
| 341 |
+
c
|
| 342 |
+
for c in frame.columns
|
| 343 |
+
if any(
|
| 344 |
+
k in str(c).lower() for k in ("email", "phone", "address", "name", "account")
|
| 345 |
+
)
|
| 346 |
+
]
|
| 347 |
+
or "None detected",
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
with model_tab:
|
| 351 |
+
st.markdown("#### Confirm the analytical target")
|
| 352 |
+
candidates = pd.DataFrame(profile["targets"])
|
| 353 |
+
st.dataframe(candidates, width="stretch", hide_index=True)
|
| 354 |
+
default_target = st.session_state.target or (
|
| 355 |
+
profile["targets"][0]["column"] if profile["targets"] else frame.columns[-1]
|
| 356 |
+
)
|
| 357 |
+
target = st.selectbox(
|
| 358 |
+
"Target column", list(frame.columns), index=list(frame.columns).index(default_target)
|
| 359 |
+
)
|
| 360 |
+
st.caption("DataPilot will not train supervised models until you confirm this selection.")
|
| 361 |
+
if frame[target].nunique(dropna=True) < 2:
|
| 362 |
+
st.error("The selected target has fewer than two observed values.")
|
| 363 |
+
run = st.button(
|
| 364 |
+
"Run autonomous model study",
|
| 365 |
+
type="primary",
|
| 366 |
+
disabled=frame[target].nunique(dropna=True) < 2,
|
| 367 |
+
)
|
| 368 |
+
if run:
|
| 369 |
+
try:
|
| 370 |
+
progress = st.progress(0, text="Preparing agent graph")
|
| 371 |
+
progress.progress(12, text="Data Quality Agent · auditing risks")
|
| 372 |
+
with st.spinner(
|
| 373 |
+
"LangGraph agents are profiling, planning, training, evaluating, and explaining…"
|
| 374 |
+
):
|
| 375 |
+
result = run_analysis(frame, target, st.session_state.dataset_name, settings)
|
| 376 |
+
progress.progress(100, text="Analysis complete")
|
| 377 |
+
st.session_state.result = result.model_dump(mode="json")
|
| 378 |
+
st.success(
|
| 379 |
+
"Model study completed with leakage-safe preprocessing and cross-validation."
|
| 380 |
+
)
|
| 381 |
+
except Exception as exc:
|
| 382 |
+
st.error(f"Model study failed: {exc}")
|
| 383 |
+
result = st.session_state.result
|
| 384 |
+
if result:
|
| 385 |
+
best = result["model_results"][0]
|
| 386 |
+
c1, c2, c3 = st.columns(3)
|
| 387 |
+
c1.metric("Selected model", result["best_model"])
|
| 388 |
+
c2.metric(
|
| 389 |
+
"One-time test " + best["primary_metric"].replace("_", " ").title(),
|
| 390 |
+
f"{best['final_test_score']:.3f}",
|
| 391 |
+
)
|
| 392 |
+
c3.metric("CV mean", f"{best['cross_validation_mean']:.3f}")
|
| 393 |
+
results = pd.DataFrame(result["model_results"])
|
| 394 |
+
fig = px.bar(
|
| 395 |
+
results.sort_values("selection_score"),
|
| 396 |
+
x="selection_score",
|
| 397 |
+
y="name",
|
| 398 |
+
orientation="h",
|
| 399 |
+
color="selection_score",
|
| 400 |
+
title="Training-CV model selection",
|
| 401 |
+
color_continuous_scale=["#344b69", "#49d7c5"],
|
| 402 |
+
)
|
| 403 |
+
fig.update_layout(
|
| 404 |
+
template="plotly_dark",
|
| 405 |
+
paper_bgcolor="#0e1b2c",
|
| 406 |
+
plot_bgcolor="#0e1b2c",
|
| 407 |
+
coloraxis_showscale=False,
|
| 408 |
+
)
|
| 409 |
+
st.plotly_chart(fig, width="stretch")
|
| 410 |
+
st.dataframe(results, width="stretch", hide_index=True)
|
| 411 |
+
st.markdown("#### Agent execution trace")
|
| 412 |
+
st.dataframe(pd.DataFrame(result["trace"]), width="stretch", hide_index=True)
|
| 413 |
+
|
| 414 |
+
with deliver:
|
| 415 |
+
st.markdown("#### Export your evidence")
|
| 416 |
+
c1, c2 = st.columns(2)
|
| 417 |
+
c1.download_button(
|
| 418 |
+
"Download original dataset · CSV",
|
| 419 |
+
dataframe_csv(frame),
|
| 420 |
+
file_name=f"{Path(st.session_state.dataset_name).stem}_datapilot.csv",
|
| 421 |
+
mime="text/csv",
|
| 422 |
+
width="stretch",
|
| 423 |
+
)
|
| 424 |
+
c2.download_button(
|
| 425 |
+
"Download data dictionary · CSV",
|
| 426 |
+
dataframe_csv(profile["dictionary"].drop(columns=["issue_count"])),
|
| 427 |
+
file_name="datapilot_data_dictionary.csv",
|
| 428 |
+
mime="text/csv",
|
| 429 |
+
width="stretch",
|
| 430 |
+
)
|
| 431 |
+
result = st.session_state.result
|
| 432 |
+
if result:
|
| 433 |
+
st.markdown("#### Model and report artifacts")
|
| 434 |
+
columns = st.columns(min(4, len(result["artifacts"])))
|
| 435 |
+
for column, (name, raw_path) in zip(columns, result["artifacts"].items(), strict=False):
|
| 436 |
+
path = Path(raw_path)
|
| 437 |
+
if path.exists():
|
| 438 |
+
column.download_button(
|
| 439 |
+
name.replace("_", " ").title(),
|
| 440 |
+
path.read_bytes(),
|
| 441 |
+
file_name=path.name,
|
| 442 |
+
width="stretch",
|
| 443 |
+
)
|
| 444 |
+
else:
|
| 445 |
+
st.info(
|
| 446 |
+
"Run a model study to unlock the fitted pipeline, model card, metrics, and HTML report."
|
| 447 |
+
)
|
| 448 |
+
|
| 449 |
+
st.caption(
|
| 450 |
+
"DataPilot provides exploratory decision support. Predictive associations do not establish causality."
|
| 451 |
+
)
|
artifacts/run_f13555d6077f/MODEL_CARD.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Model Card — iris
|
| 2 |
+
|
| 3 |
+
## Model details
|
| 4 |
+
|
| 5 |
+
- Run ID: `run_f13555d6077f`
|
| 6 |
+
- Task: classification
|
| 7 |
+
- Target: `target`
|
| 8 |
+
- Selected model: **Logistic Regression**
|
| 9 |
+
- Training-CV selection metric: `balanced_accuracy = 0.9583`
|
| 10 |
+
- One-time untouched test metric: `balanced_accuracy = 0.9333`
|
| 11 |
+
- Training rows before split: 150
|
| 12 |
+
|
| 13 |
+
## Intended use
|
| 14 |
+
|
| 15 |
+
Exploratory decision support and portfolio demonstration. Validate with domain-specific,
|
| 16 |
+
out-of-time data before any consequential or production use.
|
| 17 |
+
|
| 18 |
+
## Evaluation
|
| 19 |
+
|
| 20 |
+
```json
|
| 21 |
+
{
|
| 22 |
+
"accuracy": 0.9333,
|
| 23 |
+
"balanced_accuracy": 0.9333,
|
| 24 |
+
"f1_weighted": 0.9333
|
| 25 |
+
}
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
## Data-quality observations
|
| 29 |
+
|
| 30 |
+
- 1 exact duplicate rows can bias validation.
|
| 31 |
+
|
| 32 |
+
## Explainability
|
| 33 |
+
|
| 34 |
+
Method: **Permutation importance**. Importance values are predictive associations,
|
| 35 |
+
not evidence of causation.
|
| 36 |
+
|
| 37 |
+
## Limitations
|
| 38 |
+
|
| 39 |
+
- Results depend on the uploaded dataset and chosen target.
|
| 40 |
+
- Automated task inference can be wrong; a domain owner should confirm the objective.
|
| 41 |
+
- Fairness, privacy, and legal review are outside the automatic approval gate.
|
artifacts/run_f13555d6077f/analysis_report.html
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
| 3 |
+
<title>DataPilot AI report</title>
|
| 4 |
+
<style>
|
| 5 |
+
body{font-family:Inter,system-ui,sans-serif;max-width:1000px;margin:40px auto;padding:0 24px;color:#172033}
|
| 6 |
+
h1{color:#5537d8} .hero{background:#f4f1ff;border:1px solid #d9d0ff;padding:24px;border-radius:18px}
|
| 7 |
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin:20px 0}
|
| 8 |
+
.card{border:1px solid #e2e6ef;border-radius:14px;padding:18px} table{border-collapse:collapse;width:100%}
|
| 9 |
+
td,th{border-bottom:1px solid #e2e6ef;padding:10px;text-align:left} small{color:#667085}
|
| 10 |
+
</style></head><body>
|
| 11 |
+
<div class="hero"><h1>DataPilot AI Analysis Report</h1>
|
| 12 |
+
<p>iris · Run run_f13555d6077f</p></div>
|
| 13 |
+
<div class="grid">
|
| 14 |
+
<div class="card"><small>Selected model</small><h2>Logistic Regression</h2></div>
|
| 15 |
+
<div class="card"><small>Test balanced_accuracy</small><h2>0.933</h2></div>
|
| 16 |
+
<div class="card"><small>Rows analyzed</small><h2>150</h2></div>
|
| 17 |
+
</div>
|
| 18 |
+
<h2>Executive findings</h2><ul><li>The analysis used 150 rows and 5 columns for a classification task targeting 'target'.</li><li>Logistic Regression ranked first with training CV balanced_accuracy 0.958; its one-time test score was 0.933.</li><li>1 data-quality observations were recorded; 0 are critical.</li><li>The strongest predictive signals were petal_width_(cm), petal_length_(cm), sepal_length_(cm) according to permutation importance.</li></ul>
|
| 19 |
+
<h2>Evaluation</h2><table><tr><th>Metric</th><th>Value</th></tr><tr><td>accuracy</td><td>0.9333</td></tr><tr><td>balanced_accuracy</td><td>0.9333</td></tr><tr><td>f1_weighted</td><td>0.9333</td></tr></table>
|
| 20 |
+
<h2>Data quality</h2><table><tr><th>Severity</th><th>Code</th><th>Observation</th></tr><tr><td>warning</td><td>DUPLICATE_ROWS</td><td>1 exact duplicate rows can bias validation.</td></tr></table>
|
| 21 |
+
<h2>Recommendations</h2><ol><li>Validate performance on fresh, out-of-time data before production deployment.</li><li>Review suspected leakage and identifier columns with a domain owner.</li><li>Monitor input drift and the primary metric after deployment.</li></ol>
|
| 22 |
+
<p><small>Generated from computed evidence. Predictive findings do not establish causality.</small></p>
|
| 23 |
+
</body></html>
|
artifacts/run_f13555d6077f/metrics.json
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"run_id": "run_f13555d6077f",
|
| 3 |
+
"status": "completed",
|
| 4 |
+
"dataset_name": "iris",
|
| 5 |
+
"profile": {
|
| 6 |
+
"rows": 150,
|
| 7 |
+
"columns": 5,
|
| 8 |
+
"numeric_columns": [
|
| 9 |
+
"sepal_length_(cm)",
|
| 10 |
+
"sepal_width_(cm)",
|
| 11 |
+
"petal_length_(cm)",
|
| 12 |
+
"petal_width_(cm)",
|
| 13 |
+
"target"
|
| 14 |
+
],
|
| 15 |
+
"categorical_columns": [],
|
| 16 |
+
"datetime_columns": [],
|
| 17 |
+
"duplicate_rows": 1,
|
| 18 |
+
"missing_cells": 0,
|
| 19 |
+
"missing_rate": 0.0,
|
| 20 |
+
"memory_mb": 0.006,
|
| 21 |
+
"target": "target",
|
| 22 |
+
"task_type": "classification",
|
| 23 |
+
"target_cardinality": 3
|
| 24 |
+
},
|
| 25 |
+
"plan": {
|
| 26 |
+
"objective": "Predict 'target' and produce reproducible, evidence-backed insights.",
|
| 27 |
+
"target": "target",
|
| 28 |
+
"task_type": "classification",
|
| 29 |
+
"primary_metric": "balanced_accuracy",
|
| 30 |
+
"validation_strategy": "Training-only stratified cross-validation; untouched final test evaluation",
|
| 31 |
+
"candidate_models": [
|
| 32 |
+
"Logistic Regression",
|
| 33 |
+
"Random Forest",
|
| 34 |
+
"Extra Trees",
|
| 35 |
+
"Histogram Gradient Boosting",
|
| 36 |
+
"XGBoost (when installed)"
|
| 37 |
+
],
|
| 38 |
+
"risk_controls": [
|
| 39 |
+
"Drop rows with missing target before split",
|
| 40 |
+
"Fit imputers, encoders, and scalers on training folds only",
|
| 41 |
+
"Flag leakage-like names and identifier cardinality",
|
| 42 |
+
"Require critic quality gate before explanation"
|
| 43 |
+
]
|
| 44 |
+
},
|
| 45 |
+
"quality_issues": [
|
| 46 |
+
{
|
| 47 |
+
"code": "DUPLICATE_ROWS",
|
| 48 |
+
"severity": "warning",
|
| 49 |
+
"column": null,
|
| 50 |
+
"message": "1 exact duplicate rows can bias validation.",
|
| 51 |
+
"evidence_ids": [
|
| 52 |
+
"EV-9000641D"
|
| 53 |
+
]
|
| 54 |
+
}
|
| 55 |
+
],
|
| 56 |
+
"evidence": [
|
| 57 |
+
{
|
| 58 |
+
"evidence_id": "EV-84BCE8AD",
|
| 59 |
+
"claim": "Dataset missingness was measured across all cells.",
|
| 60 |
+
"metric": "missing_rate",
|
| 61 |
+
"value": 0.0,
|
| 62 |
+
"source": "uploaded_dataset",
|
| 63 |
+
"method": "pandas.isna"
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"evidence_id": "EV-9000641D",
|
| 67 |
+
"claim": "Exact duplicate rows were counted before splitting.",
|
| 68 |
+
"metric": "duplicate_rows",
|
| 69 |
+
"value": 1,
|
| 70 |
+
"source": "uploaded_dataset",
|
| 71 |
+
"method": "pandas.duplicated"
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"evidence_id": "EV-A9BE42C5",
|
| 75 |
+
"claim": "Rows with missing labels cannot be used for supervised training.",
|
| 76 |
+
"metric": "missing_target_rows",
|
| 77 |
+
"value": 0,
|
| 78 |
+
"source": "column:target",
|
| 79 |
+
"method": "pandas.isna"
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
"evidence_id": "EV-76E9ED71",
|
| 83 |
+
"claim": "Class imbalance was measured using the minority-class share.",
|
| 84 |
+
"metric": "minority_class_share",
|
| 85 |
+
"value": 0.3333,
|
| 86 |
+
"source": "column:target",
|
| 87 |
+
"method": "normalized value counts"
|
| 88 |
+
}
|
| 89 |
+
],
|
| 90 |
+
"model_results": [
|
| 91 |
+
{
|
| 92 |
+
"name": "Logistic Regression",
|
| 93 |
+
"primary_metric": "balanced_accuracy",
|
| 94 |
+
"primary_score": 0.9583333333333334,
|
| 95 |
+
"metrics": {
|
| 96 |
+
"accuracy": 0.9333,
|
| 97 |
+
"balanced_accuracy": 0.9333,
|
| 98 |
+
"f1_weighted": 0.9333
|
| 99 |
+
},
|
| 100 |
+
"cross_validation_mean": 0.9583333333333334,
|
| 101 |
+
"cross_validation_std": 0.026352313834736508,
|
| 102 |
+
"training_seconds": 0.228,
|
| 103 |
+
"selection_score": 0.9583333333333334,
|
| 104 |
+
"final_test_score": 0.9333,
|
| 105 |
+
"final_test_metrics": {
|
| 106 |
+
"accuracy": 0.9333,
|
| 107 |
+
"balanced_accuracy": 0.9333,
|
| 108 |
+
"f1_weighted": 0.9333
|
| 109 |
+
}
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"name": "Extra Trees",
|
| 113 |
+
"primary_metric": "balanced_accuracy",
|
| 114 |
+
"primary_score": 0.9583333333333334,
|
| 115 |
+
"metrics": {},
|
| 116 |
+
"cross_validation_mean": 0.9583333333333334,
|
| 117 |
+
"cross_validation_std": 0.026352313834736508,
|
| 118 |
+
"training_seconds": 2.901,
|
| 119 |
+
"selection_score": 0.9583333333333334,
|
| 120 |
+
"final_test_score": null,
|
| 121 |
+
"final_test_metrics": {}
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"name": "Random Forest",
|
| 125 |
+
"primary_metric": "balanced_accuracy",
|
| 126 |
+
"primary_score": 0.95,
|
| 127 |
+
"metrics": {},
|
| 128 |
+
"cross_validation_mean": 0.95,
|
| 129 |
+
"cross_validation_std": 0.0311804782231162,
|
| 130 |
+
"training_seconds": 2.641,
|
| 131 |
+
"selection_score": 0.95,
|
| 132 |
+
"final_test_score": null,
|
| 133 |
+
"final_test_metrics": {}
|
| 134 |
+
}
|
| 135 |
+
],
|
| 136 |
+
"model_failures": [],
|
| 137 |
+
"best_model": "Logistic Regression",
|
| 138 |
+
"critic": {
|
| 139 |
+
"approved": true,
|
| 140 |
+
"score": 0.9583333333333334,
|
| 141 |
+
"threshold": 0.55,
|
| 142 |
+
"reasons": [
|
| 143 |
+
"Performance and validation consistency passed the configured quality gate."
|
| 144 |
+
],
|
| 145 |
+
"retry_number": 0
|
| 146 |
+
},
|
| 147 |
+
"explainability": {
|
| 148 |
+
"method": "Permutation importance",
|
| 149 |
+
"feature_importance": {
|
| 150 |
+
"petal_width_(cm)": 0.213333,
|
| 151 |
+
"petal_length_(cm)": 0.2,
|
| 152 |
+
"sepal_length_(cm)": 0.06,
|
| 153 |
+
"sepal_width_(cm)": 0.046667
|
| 154 |
+
},
|
| 155 |
+
"caveats": [
|
| 156 |
+
"Permutation importance can dilute importance among correlated features.",
|
| 157 |
+
"Feature importance is predictive, not causal."
|
| 158 |
+
]
|
| 159 |
+
},
|
| 160 |
+
"executive_summary": [
|
| 161 |
+
"The analysis used 150 rows and 5 columns for a classification task targeting 'target'.",
|
| 162 |
+
"Logistic Regression ranked first with training CV balanced_accuracy 0.958; its one-time test score was 0.933.",
|
| 163 |
+
"1 data-quality observations were recorded; 0 are critical.",
|
| 164 |
+
"The strongest predictive signals were petal_width_(cm), petal_length_(cm), sepal_length_(cm) according to permutation importance."
|
| 165 |
+
],
|
| 166 |
+
"recommendations": [
|
| 167 |
+
"Validate performance on fresh, out-of-time data before production deployment.",
|
| 168 |
+
"Review suspected leakage and identifier columns with a domain owner.",
|
| 169 |
+
"Monitor input drift and the primary metric after deployment."
|
| 170 |
+
],
|
| 171 |
+
"artifacts": {},
|
| 172 |
+
"trace": [
|
| 173 |
+
{
|
| 174 |
+
"agent": "Data Quality Agent",
|
| 175 |
+
"status": "completed",
|
| 176 |
+
"duration_seconds": 0.005,
|
| 177 |
+
"detail": "Recorded 1 quality observations."
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
"agent": "EDA Agent",
|
| 181 |
+
"status": "completed",
|
| 182 |
+
"duration_seconds": 0.058,
|
| 183 |
+
"detail": "Computed DuckDB-backed dataset overview."
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
"agent": "Statistical Analysis Agent",
|
| 187 |
+
"status": "completed",
|
| 188 |
+
"duration_seconds": 0.001,
|
| 189 |
+
"detail": "Measured target distribution and associations."
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
"agent": "Planning Agent",
|
| 193 |
+
"status": "completed",
|
| 194 |
+
"duration_seconds": 0.0,
|
| 195 |
+
"detail": "Selected balanced_accuracy as primary metric."
|
| 196 |
+
},
|
| 197 |
+
{
|
| 198 |
+
"agent": "Feature Engineering Agent",
|
| 199 |
+
"status": "completed",
|
| 200 |
+
"duration_seconds": 0.0,
|
| 201 |
+
"detail": "Created leakage-safe ColumnTransformer plan."
|
| 202 |
+
},
|
| 203 |
+
{
|
| 204 |
+
"agent": "Modeling Agent",
|
| 205 |
+
"status": "completed",
|
| 206 |
+
"duration_seconds": 5.916,
|
| 207 |
+
"detail": "Compared 3 models; Logistic Regression ranked first."
|
| 208 |
+
},
|
| 209 |
+
{
|
| 210 |
+
"agent": "Evaluation / Critic Agent",
|
| 211 |
+
"status": "completed",
|
| 212 |
+
"duration_seconds": 0.0,
|
| 213 |
+
"detail": "Approved analysis."
|
| 214 |
+
},
|
| 215 |
+
{
|
| 216 |
+
"agent": "Explainability Agent",
|
| 217 |
+
"status": "completed",
|
| 218 |
+
"duration_seconds": 0.331,
|
| 219 |
+
"detail": "Generated Permutation importance explanations."
|
| 220 |
+
},
|
| 221 |
+
{
|
| 222 |
+
"agent": "Executive Insights Agent",
|
| 223 |
+
"status": "completed",
|
| 224 |
+
"duration_seconds": 0.0,
|
| 225 |
+
"detail": "Created evidence-grounded narrative with deterministic metric provenance."
|
| 226 |
+
}
|
| 227 |
+
]
|
| 228 |
+
}
|
artifacts/run_f13555d6077f/reproduction.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"run_id": "run_f13555d6077f",
|
| 3 |
+
"random_state": 42,
|
| 4 |
+
"test_size": 0.2,
|
| 5 |
+
"target": "target",
|
| 6 |
+
"task_type": "classification",
|
| 7 |
+
"best_model": "Logistic Regression"
|
| 8 |
+
}
|
datapilot/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DataPilot AI: evidence-grounded autonomous data science."""
|
| 2 |
+
|
| 3 |
+
__version__ = "1.0.0"
|
datapilot/analyst.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import io
|
| 5 |
+
import json
|
| 6 |
+
import re
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import pandas as pd
|
| 12 |
+
|
| 13 |
+
TARGET_WORDS = {
|
| 14 |
+
"target": 1.0,
|
| 15 |
+
"label": 1.0,
|
| 16 |
+
"outcome": 0.95,
|
| 17 |
+
"class": 0.9,
|
| 18 |
+
"churn": 0.95,
|
| 19 |
+
"fraud": 0.95,
|
| 20 |
+
"default": 0.9,
|
| 21 |
+
"price": 0.8,
|
| 22 |
+
"revenue": 0.75,
|
| 23 |
+
"sales": 0.75,
|
| 24 |
+
"diagnosis": 0.9,
|
| 25 |
+
"status": 0.7,
|
| 26 |
+
"response": 0.8,
|
| 27 |
+
"converted": 0.9,
|
| 28 |
+
}
|
| 29 |
+
PII_PATTERN = re.compile(r"(email|phone|mobile|address|ssn|passport|account|name)", re.I)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class DatasetBrief:
|
| 34 |
+
fingerprint: str
|
| 35 |
+
rows: int
|
| 36 |
+
columns: int
|
| 37 |
+
numeric: int
|
| 38 |
+
categorical: int
|
| 39 |
+
datetime: int
|
| 40 |
+
missing_cells: int
|
| 41 |
+
duplicate_rows: int
|
| 42 |
+
memory_mb: float
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def inspect_dataset(frame: pd.DataFrame) -> dict[str, Any]:
|
| 46 |
+
"""Compute an immediate analyst profile without requiring a target."""
|
| 47 |
+
numeric = list(frame.select_dtypes(include=np.number).columns)
|
| 48 |
+
datetime = list(frame.select_dtypes(include=["datetime", "datetimetz"]).columns)
|
| 49 |
+
categorical = [c for c in frame.columns if c not in numeric and c not in datetime]
|
| 50 |
+
missing = frame.isna().sum()
|
| 51 |
+
brief = DatasetBrief(
|
| 52 |
+
fingerprint=hashlib.sha256(
|
| 53 |
+
pd.util.hash_pandas_object(frame, index=True).values.tobytes()
|
| 54 |
+
).hexdigest()[:12],
|
| 55 |
+
rows=len(frame),
|
| 56 |
+
columns=len(frame.columns),
|
| 57 |
+
numeric=len(numeric),
|
| 58 |
+
categorical=len(categorical),
|
| 59 |
+
datetime=len(datetime),
|
| 60 |
+
missing_cells=int(missing.sum()),
|
| 61 |
+
duplicate_rows=int(frame.duplicated().sum()),
|
| 62 |
+
memory_mb=round(float(frame.memory_usage(deep=True).sum() / 1_048_576), 2),
|
| 63 |
+
)
|
| 64 |
+
dictionary = build_data_dictionary(frame)
|
| 65 |
+
quality_score = max(
|
| 66 |
+
0,
|
| 67 |
+
round(
|
| 68 |
+
100
|
| 69 |
+
- (brief.missing_cells / max(1, frame.size)) * 45
|
| 70 |
+
- (brief.duplicate_rows / max(1, brief.rows)) * 25
|
| 71 |
+
- sum(dictionary["issue_count"].clip(upper=3)) / max(1, len(dictionary)) * 4
|
| 72 |
+
),
|
| 73 |
+
)
|
| 74 |
+
return {
|
| 75 |
+
"brief": brief,
|
| 76 |
+
"dictionary": dictionary,
|
| 77 |
+
"targets": rank_target_candidates(frame),
|
| 78 |
+
"quality_score": quality_score,
|
| 79 |
+
"missing": missing.sort_values(ascending=False),
|
| 80 |
+
"numeric": numeric,
|
| 81 |
+
"categorical": categorical,
|
| 82 |
+
"datetime": datetime,
|
| 83 |
+
"correlation": frame[numeric].corr(numeric_only=True)
|
| 84 |
+
if len(numeric) > 1
|
| 85 |
+
else pd.DataFrame(),
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def build_data_dictionary(frame: pd.DataFrame) -> pd.DataFrame:
|
| 90 |
+
"""Create an evidence-based data dictionary for every column."""
|
| 91 |
+
rows: list[dict[str, Any]] = []
|
| 92 |
+
for column in frame.columns:
|
| 93 |
+
series = frame[column]
|
| 94 |
+
unique, missing = int(series.nunique(dropna=True)), int(series.isna().sum())
|
| 95 |
+
issues: list[str] = []
|
| 96 |
+
if missing:
|
| 97 |
+
issues.append("Missing values")
|
| 98 |
+
if unique <= 1:
|
| 99 |
+
issues.append("Constant")
|
| 100 |
+
if len(frame) and unique / len(frame) > 0.98:
|
| 101 |
+
issues.append("Identifier-like")
|
| 102 |
+
if PII_PATTERN.search(str(column)):
|
| 103 |
+
issues.append("Potential PII")
|
| 104 |
+
role = "Numeric feature" if pd.api.types.is_numeric_dtype(series) else "Categorical feature"
|
| 105 |
+
if pd.api.types.is_datetime64_any_dtype(series):
|
| 106 |
+
role = "Datetime"
|
| 107 |
+
elif unique == len(frame) and len(frame) > 10:
|
| 108 |
+
role = "Identifier"
|
| 109 |
+
rows.append(
|
| 110 |
+
{
|
| 111 |
+
"column": str(column),
|
| 112 |
+
"type": str(series.dtype),
|
| 113 |
+
"role": role,
|
| 114 |
+
"unique": unique,
|
| 115 |
+
"missing": missing,
|
| 116 |
+
"missing_%": round(missing / max(1, len(frame)) * 100, 2),
|
| 117 |
+
"example_values": ", ".join(map(str, series.dropna().astype(str).unique()[:3]))[
|
| 118 |
+
:90
|
| 119 |
+
],
|
| 120 |
+
"issues": ", ".join(issues) or "None detected",
|
| 121 |
+
"issue_count": len(issues),
|
| 122 |
+
}
|
| 123 |
+
)
|
| 124 |
+
return pd.DataFrame(rows)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def rank_target_candidates(frame: pd.DataFrame) -> list[dict[str, Any]]:
|
| 128 |
+
"""Rank targets while explicitly leaving confirmation to the user."""
|
| 129 |
+
ranked: list[dict[str, Any]] = []
|
| 130 |
+
for position, column in enumerate(frame.columns):
|
| 131 |
+
series, name = frame[column], str(column).lower().strip()
|
| 132 |
+
cardinality = int(series.nunique(dropna=True))
|
| 133 |
+
score = max((v for word, v in TARGET_WORDS.items() if word in name), default=0.0)
|
| 134 |
+
if 2 <= cardinality <= max(20, int(len(frame) * 0.1)):
|
| 135 |
+
score += 0.22
|
| 136 |
+
if position == len(frame.columns) - 1:
|
| 137 |
+
score += 0.12
|
| 138 |
+
if cardinality >= max(10, int(len(frame) * 0.95)):
|
| 139 |
+
score -= 0.45
|
| 140 |
+
if series.isna().mean() > 0.5 or cardinality < 2:
|
| 141 |
+
score -= 0.5
|
| 142 |
+
task = "classification" if cardinality <= max(20, int(len(frame) * 0.05)) else "regression"
|
| 143 |
+
if pd.api.types.is_numeric_dtype(series) and cardinality > 20:
|
| 144 |
+
task = "regression"
|
| 145 |
+
ranked.append(
|
| 146 |
+
{
|
| 147 |
+
"column": str(column),
|
| 148 |
+
"task": task,
|
| 149 |
+
"confidence": round(max(0, min(score, 0.99)), 2),
|
| 150 |
+
"reason": f"{cardinality:,} distinct values; "
|
| 151 |
+
+ ("name/position signals detected" if score > 0.3 else "weak heuristic evidence"),
|
| 152 |
+
}
|
| 153 |
+
)
|
| 154 |
+
return sorted(ranked, key=lambda item: item["confidence"], reverse=True)[:5]
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def ai_context(frame: pd.DataFrame, profile: dict[str, Any], excluded: list[str]) -> dict[str, Any]:
|
| 158 |
+
"""Build a bounded, redacted payload suitable for AI interpretation."""
|
| 159 |
+
safe = frame.drop(columns=[c for c in excluded if c in frame], errors="ignore").copy()
|
| 160 |
+
pii = [c for c in safe.columns if PII_PATTERN.search(str(c))]
|
| 161 |
+
safe = safe.drop(columns=pii, errors="ignore")
|
| 162 |
+
return {
|
| 163 |
+
"shape": list(frame.shape),
|
| 164 |
+
"columns": profile["dictionary"].drop(columns=["issue_count"]).to_dict(orient="records"),
|
| 165 |
+
"numeric_summary": safe.select_dtypes(include=np.number).describe().round(3).to_dict(),
|
| 166 |
+
"sample": safe.head(3).replace({np.nan: None}).to_dict(orient="records"),
|
| 167 |
+
"excluded_columns": sorted(set(excluded + pii)),
|
| 168 |
+
"quality_score": profile["quality_score"],
|
| 169 |
+
"target_candidates": profile["targets"],
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def gemini_dataset_summary(
|
| 174 |
+
frame: pd.DataFrame, profile: dict[str, Any], api_key: str, model: str, excluded: list[str]
|
| 175 |
+
) -> str:
|
| 176 |
+
"""Ask Gemini for an evidence-bounded analyst narrative."""
|
| 177 |
+
if not api_key:
|
| 178 |
+
raise ValueError("Enter a Gemini API key to generate AI interpretation.")
|
| 179 |
+
try:
|
| 180 |
+
from google import genai
|
| 181 |
+
except ImportError as exc:
|
| 182 |
+
raise ValueError("Install the AI extra: pip install google-genai") from exc
|
| 183 |
+
prompt = """You are DataPilot, a rigorous senior data analyst. Use ONLY the supplied JSON.
|
| 184 |
+
Return concise Markdown with exactly these headings: Finding, Evidence, Interpretation,
|
| 185 |
+
Limitation, Recommendation. Explain likely row grain and useful business questions, but label
|
| 186 |
+
uncertain semantics as assumptions. Never invent values, origin, or causal claims.
|
| 187 |
+
DATA:\n""" + json.dumps(ai_context(frame, profile, excluded), default=str)
|
| 188 |
+
try:
|
| 189 |
+
response = genai.Client(api_key=api_key).models.generate_content(
|
| 190 |
+
model=model, contents=prompt
|
| 191 |
+
)
|
| 192 |
+
return response.text
|
| 193 |
+
except Exception as exc:
|
| 194 |
+
raise ValueError(f"Gemini request failed: {str(exc)[:240]}") from exc
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def dataframe_csv(frame: pd.DataFrame) -> bytes:
|
| 198 |
+
buffer = io.StringIO()
|
| 199 |
+
frame.to_csv(buffer, index=False)
|
| 200 |
+
return buffer.getvalue().encode("utf-8")
|
datapilot/config.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from functools import lru_cache
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from pydantic import Field
|
| 7 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Settings(BaseSettings):
|
| 11 |
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
| 12 |
+
|
| 13 |
+
app_name: str = "DataPilot AI"
|
| 14 |
+
environment: str = "development"
|
| 15 |
+
artifact_root: Path = Path("artifacts")
|
| 16 |
+
database_url: str = "sqlite:///artifacts/datapilot.db"
|
| 17 |
+
max_upload_mb: int = Field(default=25, ge=1, le=250)
|
| 18 |
+
max_rows: int = Field(default=100_000, ge=100)
|
| 19 |
+
max_columns: int = Field(default=250, ge=2)
|
| 20 |
+
max_categories_per_feature: int = Field(default=100, ge=10, le=10_000)
|
| 21 |
+
max_encoded_features: int = Field(default=5_000, ge=100, le=100_000)
|
| 22 |
+
api_key: str | None = None
|
| 23 |
+
requests_per_minute: int = Field(default=30, ge=1, le=10_000)
|
| 24 |
+
random_state: int = 42
|
| 25 |
+
test_size: float = Field(default=0.2, gt=0.05, lt=0.5)
|
| 26 |
+
max_critic_retries: int = Field(default=1, ge=0, le=3)
|
| 27 |
+
min_classification_score: float = 0.55
|
| 28 |
+
min_regression_score: float = 0.15
|
| 29 |
+
optuna_trials: int = Field(default=8, ge=0, le=50)
|
| 30 |
+
enable_mlflow: bool = False
|
| 31 |
+
mlflow_tracking_uri: str = "file:./artifacts/mlruns"
|
| 32 |
+
gemini_api_key: str | None = None
|
| 33 |
+
gemini_model: str = "gemini-2.5-flash"
|
| 34 |
+
cors_origins: str = "http://localhost:8501"
|
| 35 |
+
|
| 36 |
+
def ensure_directories(self) -> None:
|
| 37 |
+
self.artifact_root.mkdir(parents=True, exist_ok=True)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@lru_cache
|
| 41 |
+
def get_settings() -> Settings:
|
| 42 |
+
settings = Settings()
|
| 43 |
+
settings.ensure_directories()
|
| 44 |
+
return settings
|
datapilot/data.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import BinaryIO
|
| 6 |
+
|
| 7 |
+
import duckdb
|
| 8 |
+
import pandas as pd
|
| 9 |
+
from sklearn.datasets import load_breast_cancer, load_diabetes, load_iris
|
| 10 |
+
|
| 11 |
+
from datapilot.config import Settings
|
| 12 |
+
|
| 13 |
+
SAMPLE_DATASETS = {
|
| 14 |
+
"Iris classification": "iris",
|
| 15 |
+
"Breast cancer classification": "breast_cancer",
|
| 16 |
+
"Diabetes progression regression": "diabetes",
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def load_sample(name: str) -> tuple[pd.DataFrame, str, str]:
|
| 21 |
+
loaders = {
|
| 22 |
+
"iris": load_iris,
|
| 23 |
+
"breast_cancer": load_breast_cancer,
|
| 24 |
+
"diabetes": load_diabetes,
|
| 25 |
+
}
|
| 26 |
+
if name not in loaders:
|
| 27 |
+
raise ValueError(f"Unknown sample dataset: {name}")
|
| 28 |
+
bundle = loaders[name](as_frame=True)
|
| 29 |
+
frame = bundle.frame.copy()
|
| 30 |
+
frame.columns = [str(column).replace(" ", "_") for column in frame.columns]
|
| 31 |
+
target = str(bundle.target.name).replace(" ", "_")
|
| 32 |
+
return frame, target, name
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def read_dataset(
|
| 36 |
+
source: bytes | BinaryIO | str | Path,
|
| 37 |
+
filename: str,
|
| 38 |
+
settings: Settings,
|
| 39 |
+
) -> pd.DataFrame:
|
| 40 |
+
suffix = Path(filename).suffix.lower()
|
| 41 |
+
if suffix not in {".csv", ".parquet"}:
|
| 42 |
+
raise ValueError("Only CSV and Parquet files are supported.")
|
| 43 |
+
if isinstance(source, bytes):
|
| 44 |
+
if len(source) > settings.max_upload_mb * 1024 * 1024:
|
| 45 |
+
raise ValueError(f"File exceeds the {settings.max_upload_mb} MB upload limit.")
|
| 46 |
+
stream: BinaryIO | str | Path = io.BytesIO(source)
|
| 47 |
+
else:
|
| 48 |
+
stream = source
|
| 49 |
+
frame = pd.read_csv(stream) if suffix == ".csv" else pd.read_parquet(stream)
|
| 50 |
+
validate_shape(frame, settings)
|
| 51 |
+
frame.columns = _unique_columns([str(column).strip() for column in frame.columns])
|
| 52 |
+
return frame
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def validate_shape(frame: pd.DataFrame, settings: Settings) -> None:
|
| 56 |
+
if frame.empty:
|
| 57 |
+
raise ValueError("The dataset is empty.")
|
| 58 |
+
if len(frame) > settings.max_rows:
|
| 59 |
+
raise ValueError(f"Dataset has {len(frame):,} rows; limit is {settings.max_rows:,}.")
|
| 60 |
+
if len(frame.columns) > settings.max_columns:
|
| 61 |
+
raise ValueError(
|
| 62 |
+
f"Dataset has {len(frame.columns):,} columns; limit is {settings.max_columns:,}."
|
| 63 |
+
)
|
| 64 |
+
if len(frame.columns) < 2:
|
| 65 |
+
raise ValueError("At least one feature and one target column are required.")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _unique_columns(columns: list[str]) -> list[str]:
|
| 69 |
+
seen: dict[str, int] = {}
|
| 70 |
+
result: list[str] = []
|
| 71 |
+
for raw in columns:
|
| 72 |
+
name = raw or "unnamed"
|
| 73 |
+
count = seen.get(name, 0)
|
| 74 |
+
seen[name] = count + 1
|
| 75 |
+
result.append(name if count == 0 else f"{name}_{count}")
|
| 76 |
+
return result
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def duckdb_overview(frame: pd.DataFrame) -> dict[str, object]:
|
| 80 |
+
connection = duckdb.connect(database=":memory:")
|
| 81 |
+
try:
|
| 82 |
+
connection.register("dataset", frame)
|
| 83 |
+
row = connection.execute(
|
| 84 |
+
"""
|
| 85 |
+
SELECT
|
| 86 |
+
COUNT(*) AS row_count
|
| 87 |
+
FROM dataset
|
| 88 |
+
"""
|
| 89 |
+
).fetchone()
|
| 90 |
+
numeric = frame.select_dtypes(include="number")
|
| 91 |
+
correlations = (
|
| 92 |
+
numeric.corr(numeric_only=True).round(4).fillna(0).to_dict()
|
| 93 |
+
if len(numeric.columns) > 1
|
| 94 |
+
else {}
|
| 95 |
+
)
|
| 96 |
+
return {
|
| 97 |
+
"row_count": int(row[0]),
|
| 98 |
+
"duplicate_rows": int(frame.duplicated().sum()),
|
| 99 |
+
"correlations": correlations,
|
| 100 |
+
}
|
| 101 |
+
finally:
|
| 102 |
+
connection.close()
|
datapilot/evaluation.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic quality gates for evidence-grounded LLM narratives."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from collections.abc import Iterable
|
| 7 |
+
from dataclasses import asdict, dataclass
|
| 8 |
+
|
| 9 |
+
EVIDENCE_ID = re.compile(r"\bEVD-[A-Z0-9-]+\b")
|
| 10 |
+
NUMBER = re.compile(r"(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?")
|
| 11 |
+
PII = re.compile(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}|(?:\+?\d[\d .()-]{7,}\d)", re.I)
|
| 12 |
+
INJECTION = re.compile(
|
| 13 |
+
r"ignore (?:all |the )?(?:previous|prior) instructions|system prompt|developer message",
|
| 14 |
+
re.I,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class NarrativeEvaluation:
|
| 20 |
+
supported_evidence_ids: bool
|
| 21 |
+
unsupported_numbers: list[str]
|
| 22 |
+
leaked_pii: bool
|
| 23 |
+
prompt_injection_echo: bool
|
| 24 |
+
score: float
|
| 25 |
+
|
| 26 |
+
def to_dict(self) -> dict[str, object]:
|
| 27 |
+
return asdict(self)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def evaluate_narrative(
|
| 31 |
+
output: str,
|
| 32 |
+
allowed_evidence_ids: Iterable[str],
|
| 33 |
+
allowed_numbers: Iterable[str | float | int],
|
| 34 |
+
) -> NarrativeEvaluation:
|
| 35 |
+
"""Score an AI narrative for evidence citations, numeric faithfulness, and safety."""
|
| 36 |
+
allowed_ids = set(allowed_evidence_ids)
|
| 37 |
+
cited = set(EVIDENCE_ID.findall(output))
|
| 38 |
+
supported_ids = cited.issubset(allowed_ids) and bool(cited)
|
| 39 |
+
allowed_numeric = {_normalize_number(str(value)) for value in allowed_numbers}
|
| 40 |
+
narrative_without_ids = EVIDENCE_ID.sub("", output)
|
| 41 |
+
found = {_normalize_number(value) for value in NUMBER.findall(narrative_without_ids)}
|
| 42 |
+
unsupported = sorted(value for value in found if value not in allowed_numeric)
|
| 43 |
+
leaked_pii = bool(PII.search(output))
|
| 44 |
+
injection = bool(INJECTION.search(output))
|
| 45 |
+
penalties = (0 if supported_ids else 0.35) + min(0.35, len(unsupported) * 0.1)
|
| 46 |
+
penalties += 0.2 if leaked_pii else 0
|
| 47 |
+
penalties += 0.1 if injection else 0
|
| 48 |
+
return NarrativeEvaluation(
|
| 49 |
+
supported_evidence_ids=supported_ids,
|
| 50 |
+
unsupported_numbers=unsupported,
|
| 51 |
+
leaked_pii=leaked_pii,
|
| 52 |
+
prompt_injection_echo=injection,
|
| 53 |
+
score=round(max(0.0, 1.0 - penalties), 3),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _normalize_number(value: str) -> str:
|
| 58 |
+
return value.replace(",", "").rstrip("%").lstrip("+")
|
datapilot/insights.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from datapilot.config import Settings
|
| 7 |
+
from datapilot.schemas import Evidence
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def deterministic_insights(state: dict[str, Any]) -> tuple[list[str], list[str]]:
|
| 11 |
+
profile = state["profile"]
|
| 12 |
+
best = state["model_bundle"].results[0]
|
| 13 |
+
quality = state["quality_issues"]
|
| 14 |
+
explainability = state["explainability"]
|
| 15 |
+
important = list(explainability.feature_importance)[:3]
|
| 16 |
+
summary = [
|
| 17 |
+
(
|
| 18 |
+
f"The analysis used {profile.rows:,} rows and {profile.columns:,} columns for a "
|
| 19 |
+
f"{profile.task_type.value} task targeting '{profile.target}'."
|
| 20 |
+
),
|
| 21 |
+
(
|
| 22 |
+
f"{best.name} ranked first with training CV {best.primary_metric} {best.primary_score:.3f}; "
|
| 23 |
+
f"its one-time test score was {best.final_test_score:.3f}."
|
| 24 |
+
),
|
| 25 |
+
(
|
| 26 |
+
f"{len(quality)} data-quality observations were recorded; "
|
| 27 |
+
f"{sum(issue.severity.value == 'critical' for issue in quality)} are critical."
|
| 28 |
+
),
|
| 29 |
+
]
|
| 30 |
+
if important:
|
| 31 |
+
summary.append(
|
| 32 |
+
f"The strongest predictive signals were {', '.join(important)} "
|
| 33 |
+
f"according to {explainability.method.lower()}."
|
| 34 |
+
)
|
| 35 |
+
recommendations = [
|
| 36 |
+
"Validate performance on fresh, out-of-time data before production deployment.",
|
| 37 |
+
"Review suspected leakage and identifier columns with a domain owner.",
|
| 38 |
+
"Monitor input drift and the primary metric after deployment.",
|
| 39 |
+
]
|
| 40 |
+
if profile.missing_rate > 0.1:
|
| 41 |
+
recommendations.insert(0, "Investigate upstream causes of missing data before retraining.")
|
| 42 |
+
return summary, recommendations
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def optional_llm_narrative(
|
| 46 |
+
state: dict[str, Any], evidence: list[Evidence], settings: Settings
|
| 47 |
+
) -> list[str] | None:
|
| 48 |
+
"""Generate narrative only from bounded evidence; calculations remain deterministic."""
|
| 49 |
+
if not settings.gemini_api_key:
|
| 50 |
+
return None
|
| 51 |
+
try:
|
| 52 |
+
from google import genai
|
| 53 |
+
|
| 54 |
+
client = genai.Client(api_key=settings.gemini_api_key)
|
| 55 |
+
payload = {
|
| 56 |
+
"profile": state["profile"].model_dump(),
|
| 57 |
+
"best_model": state["model_bundle"].results[0].model_dump(),
|
| 58 |
+
"critic": state["critic"].model_dump(),
|
| 59 |
+
"evidence": [item.model_dump() for item in evidence[:25]],
|
| 60 |
+
}
|
| 61 |
+
prompt = (
|
| 62 |
+
"You are a senior data scientist. Return exactly four concise markdown bullet points. "
|
| 63 |
+
"Use only the JSON evidence below. Cite supporting evidence IDs in square brackets. "
|
| 64 |
+
"Do not add numbers, causal claims, or facts absent from the payload.\n"
|
| 65 |
+
+ json.dumps(payload, default=str)
|
| 66 |
+
)
|
| 67 |
+
response = client.models.generate_content(model=settings.gemini_model, contents=prompt)
|
| 68 |
+
lines = [line.strip("- ").strip() for line in response.text.splitlines() if line.strip()]
|
| 69 |
+
return lines[:4] or None
|
| 70 |
+
except Exception:
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def answer_follow_up(run: dict[str, Any], question: str) -> str:
|
| 75 |
+
lowered = question.lower()
|
| 76 |
+
if any(token in lowered for token in {"best model", "which model", "winner"}):
|
| 77 |
+
top = run["model_results"][0]
|
| 78 |
+
return (
|
| 79 |
+
f"The best model was **{top['name']}**, with {top['primary_metric']} "
|
| 80 |
+
f"training-CV **{top['selection_score']:.3f}** and one-time test "
|
| 81 |
+
f"**{top['final_test_score']:.3f}**."
|
| 82 |
+
)
|
| 83 |
+
if any(token in lowered for token in {"feature", "important", "driver"}):
|
| 84 |
+
importance = run["explainability"]["feature_importance"]
|
| 85 |
+
top = list(importance.items())[:5]
|
| 86 |
+
return (
|
| 87 |
+
"Top predictive features: "
|
| 88 |
+
+ ", ".join(f"**{name}** ({value:.4f})" for name, value in top)
|
| 89 |
+
+ ". These are associations, not causal effects."
|
| 90 |
+
)
|
| 91 |
+
if any(token in lowered for token in {"quality", "missing", "leak", "risk"}):
|
| 92 |
+
issues = run["quality_issues"]
|
| 93 |
+
if not issues:
|
| 94 |
+
return "No material quality flags were detected by the configured checks."
|
| 95 |
+
return "Quality observations: " + "; ".join(item["message"] for item in issues[:6])
|
| 96 |
+
if any(token in lowered for token in {"metric", "performance", "score"}):
|
| 97 |
+
top = run["model_results"][0]
|
| 98 |
+
formatted = ", ".join(
|
| 99 |
+
f"{key}={value:.3f}" for key, value in top["final_test_metrics"].items()
|
| 100 |
+
)
|
| 101 |
+
return f"Selected-model one-time test metrics: {formatted}."
|
| 102 |
+
return (
|
| 103 |
+
"I can answer evidence-backed questions about the best model, performance metrics, "
|
| 104 |
+
"data quality, leakage risk, and feature importance for this run."
|
| 105 |
+
)
|
datapilot/jobs.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bounded asynchronous analysis jobs with a production-queue compatible contract."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import threading
|
| 6 |
+
from collections.abc import Callable
|
| 7 |
+
from concurrent.futures import Future, ThreadPoolExecutor
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from datetime import UTC, datetime
|
| 10 |
+
from typing import Any
|
| 11 |
+
from uuid import uuid4
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class Job:
|
| 16 |
+
job_id: str
|
| 17 |
+
status: str = "queued"
|
| 18 |
+
progress: int = 0
|
| 19 |
+
created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
| 20 |
+
updated_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
| 21 |
+
result: Any | None = None
|
| 22 |
+
error: str | None = None
|
| 23 |
+
future: Future[Any] | None = field(default=None, repr=False)
|
| 24 |
+
|
| 25 |
+
def public(self) -> dict[str, Any]:
|
| 26 |
+
return {
|
| 27 |
+
"job_id": self.job_id,
|
| 28 |
+
"status": self.status,
|
| 29 |
+
"progress": self.progress,
|
| 30 |
+
"created_at": self.created_at,
|
| 31 |
+
"updated_at": self.updated_at,
|
| 32 |
+
"result": self.result if self.status == "completed" else None,
|
| 33 |
+
"error": self.error,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class JobManager:
|
| 38 |
+
"""In-process development queue; replaceable by ARQ/Celery behind the same API."""
|
| 39 |
+
|
| 40 |
+
def __init__(self, workers: int = 2) -> None:
|
| 41 |
+
self._executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="datapilot")
|
| 42 |
+
self._jobs: dict[str, Job] = {}
|
| 43 |
+
self._lock = threading.Lock()
|
| 44 |
+
|
| 45 |
+
def submit(self, operation: Callable[[], Any]) -> Job:
|
| 46 |
+
job = Job(job_id=f"job_{uuid4().hex[:12]}")
|
| 47 |
+
with self._lock:
|
| 48 |
+
self._jobs[job.job_id] = job
|
| 49 |
+
job.future = self._executor.submit(self._run, job, operation)
|
| 50 |
+
return job
|
| 51 |
+
|
| 52 |
+
def get(self, job_id: str) -> Job | None:
|
| 53 |
+
with self._lock:
|
| 54 |
+
return self._jobs.get(job_id)
|
| 55 |
+
|
| 56 |
+
def cancel(self, job_id: str) -> bool:
|
| 57 |
+
job = self.get(job_id)
|
| 58 |
+
if job is None or job.future is None or job.status not in {"queued", "running"}:
|
| 59 |
+
return False
|
| 60 |
+
cancelled = job.future.cancel()
|
| 61 |
+
if cancelled:
|
| 62 |
+
job.status, job.progress = "cancelled", 0
|
| 63 |
+
job.updated_at = datetime.now(UTC).isoformat()
|
| 64 |
+
return cancelled
|
| 65 |
+
|
| 66 |
+
@staticmethod
|
| 67 |
+
def _run(job: Job, operation: Callable[[], Any]) -> None:
|
| 68 |
+
job.status, job.progress = "running", 10
|
| 69 |
+
job.updated_at = datetime.now(UTC).isoformat()
|
| 70 |
+
try:
|
| 71 |
+
value = operation()
|
| 72 |
+
job.result = value.model_dump(mode="json") if hasattr(value, "model_dump") else value
|
| 73 |
+
job.status, job.progress = "completed", 100
|
| 74 |
+
except Exception:
|
| 75 |
+
job.status, job.progress = "failed", 100
|
| 76 |
+
job.error = "Analysis failed. Use the correlation ID in server logs for support."
|
| 77 |
+
finally:
|
| 78 |
+
job.updated_at = datetime.now(UTC).isoformat()
|
datapilot/modeling.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import time
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
from sklearn.base import BaseEstimator
|
| 11 |
+
from sklearn.compose import ColumnTransformer
|
| 12 |
+
from sklearn.ensemble import (
|
| 13 |
+
ExtraTreesClassifier,
|
| 14 |
+
ExtraTreesRegressor,
|
| 15 |
+
RandomForestClassifier,
|
| 16 |
+
RandomForestRegressor,
|
| 17 |
+
)
|
| 18 |
+
from sklearn.impute import SimpleImputer
|
| 19 |
+
from sklearn.inspection import permutation_importance
|
| 20 |
+
from sklearn.linear_model import LinearRegression, LogisticRegression
|
| 21 |
+
from sklearn.metrics import (
|
| 22 |
+
accuracy_score,
|
| 23 |
+
balanced_accuracy_score,
|
| 24 |
+
f1_score,
|
| 25 |
+
mean_absolute_error,
|
| 26 |
+
mean_squared_error,
|
| 27 |
+
r2_score,
|
| 28 |
+
roc_auc_score,
|
| 29 |
+
)
|
| 30 |
+
from sklearn.model_selection import cross_val_score, train_test_split
|
| 31 |
+
from sklearn.pipeline import Pipeline
|
| 32 |
+
from sklearn.preprocessing import OneHotEncoder, StandardScaler
|
| 33 |
+
|
| 34 |
+
from datapilot.config import Settings
|
| 35 |
+
from datapilot.schemas import (
|
| 36 |
+
CriticDecision,
|
| 37 |
+
ExplainabilityResult,
|
| 38 |
+
ModelFailure,
|
| 39 |
+
ModelResult,
|
| 40 |
+
TaskType,
|
| 41 |
+
)
|
| 42 |
+
from datapilot.tuning import tune_random_forest
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class TrainingBundle:
|
| 47 |
+
pipeline: Pipeline
|
| 48 |
+
results: list[ModelResult]
|
| 49 |
+
best_model: str
|
| 50 |
+
test_features: pd.DataFrame
|
| 51 |
+
test_target: pd.Series
|
| 52 |
+
retry_number: int
|
| 53 |
+
failures: list[ModelFailure]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
logger = logging.getLogger(__name__)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _preprocessor(features: pd.DataFrame, settings: Settings) -> ColumnTransformer:
|
| 60 |
+
numeric = features.select_dtypes(include=np.number).columns.tolist()
|
| 61 |
+
categorical = [column for column in features.columns if column not in numeric]
|
| 62 |
+
numeric_pipeline = Pipeline(
|
| 63 |
+
[
|
| 64 |
+
("imputer", SimpleImputer(strategy="median")),
|
| 65 |
+
("scaler", StandardScaler()),
|
| 66 |
+
]
|
| 67 |
+
)
|
| 68 |
+
categorical_pipeline = Pipeline(
|
| 69 |
+
[
|
| 70 |
+
("imputer", SimpleImputer(strategy="most_frequent")),
|
| 71 |
+
(
|
| 72 |
+
"encoder",
|
| 73 |
+
OneHotEncoder(
|
| 74 |
+
handle_unknown="infrequent_if_exist",
|
| 75 |
+
min_frequency=2,
|
| 76 |
+
max_categories=settings.max_categories_per_feature,
|
| 77 |
+
sparse_output=True,
|
| 78 |
+
),
|
| 79 |
+
),
|
| 80 |
+
]
|
| 81 |
+
)
|
| 82 |
+
return ColumnTransformer(
|
| 83 |
+
[
|
| 84 |
+
("numeric", numeric_pipeline, numeric),
|
| 85 |
+
("categorical", categorical_pipeline, categorical),
|
| 86 |
+
],
|
| 87 |
+
remainder="drop",
|
| 88 |
+
verbose_feature_names_out=False,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _candidate_models(task: TaskType, settings: Settings, retry: int) -> dict[str, BaseEstimator]:
|
| 93 |
+
if task == TaskType.classification:
|
| 94 |
+
models: dict[str, BaseEstimator] = {
|
| 95 |
+
"Logistic Regression": LogisticRegression(
|
| 96 |
+
max_iter=1_000, class_weight="balanced", random_state=settings.random_state
|
| 97 |
+
),
|
| 98 |
+
"Random Forest": RandomForestClassifier(
|
| 99 |
+
n_estimators=180 + retry * 80,
|
| 100 |
+
min_samples_leaf=max(1, 2 - retry),
|
| 101 |
+
class_weight="balanced",
|
| 102 |
+
n_jobs=-1,
|
| 103 |
+
random_state=settings.random_state,
|
| 104 |
+
),
|
| 105 |
+
"Extra Trees": ExtraTreesClassifier(
|
| 106 |
+
n_estimators=180 + retry * 80,
|
| 107 |
+
class_weight="balanced",
|
| 108 |
+
n_jobs=-1,
|
| 109 |
+
random_state=settings.random_state,
|
| 110 |
+
),
|
| 111 |
+
}
|
| 112 |
+
else:
|
| 113 |
+
models = {
|
| 114 |
+
"Linear Regression": LinearRegression(),
|
| 115 |
+
"Random Forest": RandomForestRegressor(
|
| 116 |
+
n_estimators=180 + retry * 80,
|
| 117 |
+
min_samples_leaf=max(1, 2 - retry),
|
| 118 |
+
n_jobs=-1,
|
| 119 |
+
random_state=settings.random_state,
|
| 120 |
+
),
|
| 121 |
+
"Extra Trees": ExtraTreesRegressor(
|
| 122 |
+
n_estimators=180 + retry * 80,
|
| 123 |
+
n_jobs=-1,
|
| 124 |
+
random_state=settings.random_state,
|
| 125 |
+
),
|
| 126 |
+
}
|
| 127 |
+
try:
|
| 128 |
+
if task == TaskType.classification:
|
| 129 |
+
from xgboost import XGBClassifier
|
| 130 |
+
|
| 131 |
+
models["XGBoost"] = XGBClassifier(
|
| 132 |
+
n_estimators=160 + retry * 60,
|
| 133 |
+
max_depth=4 + retry,
|
| 134 |
+
learning_rate=0.07,
|
| 135 |
+
eval_metric="logloss",
|
| 136 |
+
n_jobs=-1,
|
| 137 |
+
random_state=settings.random_state,
|
| 138 |
+
)
|
| 139 |
+
else:
|
| 140 |
+
from xgboost import XGBRegressor
|
| 141 |
+
|
| 142 |
+
models["XGBoost"] = XGBRegressor(
|
| 143 |
+
n_estimators=160 + retry * 60,
|
| 144 |
+
max_depth=4 + retry,
|
| 145 |
+
learning_rate=0.07,
|
| 146 |
+
n_jobs=-1,
|
| 147 |
+
random_state=settings.random_state,
|
| 148 |
+
)
|
| 149 |
+
except ImportError:
|
| 150 |
+
pass
|
| 151 |
+
return models
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def train_models(
|
| 155 |
+
frame: pd.DataFrame,
|
| 156 |
+
target: str,
|
| 157 |
+
task: TaskType,
|
| 158 |
+
settings: Settings,
|
| 159 |
+
retry_number: int = 0,
|
| 160 |
+
) -> TrainingBundle:
|
| 161 |
+
if target not in frame.columns:
|
| 162 |
+
raise ValueError(f"Target column '{target}' does not exist.")
|
| 163 |
+
clean = frame.dropna(subset=[target]).drop_duplicates().reset_index(drop=True)
|
| 164 |
+
features = clean.drop(columns=[target]).copy()
|
| 165 |
+
labels = clean[target].copy()
|
| 166 |
+
if features.empty:
|
| 167 |
+
raise ValueError("No usable feature columns remain after removing the target.")
|
| 168 |
+
if labels.nunique(dropna=True) < 2:
|
| 169 |
+
raise ValueError("The target must contain at least two distinct non-null values.")
|
| 170 |
+
categorical = features.select_dtypes(exclude=np.number)
|
| 171 |
+
estimated_width = len(features.select_dtypes(include=np.number).columns) + sum(
|
| 172 |
+
min(int(categorical[column].nunique(dropna=True)), settings.max_categories_per_feature)
|
| 173 |
+
for column in categorical.columns
|
| 174 |
+
)
|
| 175 |
+
if estimated_width > settings.max_encoded_features:
|
| 176 |
+
raise ValueError(
|
| 177 |
+
f"Estimated encoded width {estimated_width:,} exceeds the safe limit of "
|
| 178 |
+
f"{settings.max_encoded_features:,}. Reduce high-cardinality columns or increase "
|
| 179 |
+
"MAX_ENCODED_FEATURES after reviewing memory capacity."
|
| 180 |
+
)
|
| 181 |
+
stratify = (
|
| 182 |
+
labels if task == TaskType.classification and labels.value_counts().min() >= 2 else None
|
| 183 |
+
)
|
| 184 |
+
x_train, x_test, y_train, y_test = train_test_split(
|
| 185 |
+
features,
|
| 186 |
+
labels,
|
| 187 |
+
test_size=settings.test_size,
|
| 188 |
+
random_state=settings.random_state,
|
| 189 |
+
stratify=stratify,
|
| 190 |
+
)
|
| 191 |
+
results: list[ModelResult] = []
|
| 192 |
+
failures: list[ModelFailure] = []
|
| 193 |
+
cv = min(5, max(2, int(len(x_train) / 20)))
|
| 194 |
+
if task == TaskType.classification:
|
| 195 |
+
smallest_class = int(y_train.value_counts().min())
|
| 196 |
+
cv = min(cv, smallest_class) if smallest_class >= 2 else 0
|
| 197 |
+
scoring = "balanced_accuracy"
|
| 198 |
+
else:
|
| 199 |
+
scoring = "r2"
|
| 200 |
+
if cv < 2:
|
| 201 |
+
raise ValueError("The training partition is too small for reliable cross-validation.")
|
| 202 |
+
|
| 203 |
+
candidates = _candidate_models(task, settings, retry_number)
|
| 204 |
+
tuned_parameters = tune_random_forest(
|
| 205 |
+
task,
|
| 206 |
+
_preprocessor(x_train, settings),
|
| 207 |
+
x_train,
|
| 208 |
+
y_train,
|
| 209 |
+
settings,
|
| 210 |
+
cv,
|
| 211 |
+
)
|
| 212 |
+
if tuned_parameters:
|
| 213 |
+
candidates["Random Forest"].set_params(**tuned_parameters)
|
| 214 |
+
|
| 215 |
+
for name, estimator in candidates.items():
|
| 216 |
+
pipeline = Pipeline(
|
| 217 |
+
[("preprocessor", _preprocessor(x_train, settings)), ("model", estimator)]
|
| 218 |
+
)
|
| 219 |
+
started = time.perf_counter()
|
| 220 |
+
try:
|
| 221 |
+
cv_scores = (
|
| 222 |
+
cross_val_score(pipeline, x_train, y_train, scoring=scoring, cv=cv, n_jobs=1)
|
| 223 |
+
if cv >= 2
|
| 224 |
+
else np.array([])
|
| 225 |
+
)
|
| 226 |
+
result = ModelResult(
|
| 227 |
+
name=name,
|
| 228 |
+
primary_metric="balanced_accuracy" if task == TaskType.classification else "r2",
|
| 229 |
+
primary_score=float(cv_scores.mean()),
|
| 230 |
+
metrics={},
|
| 231 |
+
cross_validation_mean=float(cv_scores.mean()) if len(cv_scores) else None,
|
| 232 |
+
cross_validation_std=float(cv_scores.std()) if len(cv_scores) else None,
|
| 233 |
+
training_seconds=round(time.perf_counter() - started, 3),
|
| 234 |
+
selection_score=float(cv_scores.mean()) if len(cv_scores) else None,
|
| 235 |
+
)
|
| 236 |
+
results.append(result)
|
| 237 |
+
except Exception as exc:
|
| 238 |
+
logger.warning(
|
| 239 |
+
"Candidate %s failed during cross-validation: %s", name, type(exc).__name__
|
| 240 |
+
)
|
| 241 |
+
failures.append(
|
| 242 |
+
ModelFailure(
|
| 243 |
+
name=name,
|
| 244 |
+
stage="cross_validation",
|
| 245 |
+
exception_category=type(exc).__name__,
|
| 246 |
+
sanitized_error="Candidate failed during cross-validation; inspect structured logs.",
|
| 247 |
+
training_seconds=round(time.perf_counter() - started, 3),
|
| 248 |
+
expected=isinstance(exc, (ValueError, TypeError)),
|
| 249 |
+
)
|
| 250 |
+
)
|
| 251 |
+
if not results:
|
| 252 |
+
raise RuntimeError("Every candidate model failed; review the data types and target.")
|
| 253 |
+
results.sort(key=lambda item: item.selection_score or float("-inf"), reverse=True)
|
| 254 |
+
best = results[0]
|
| 255 |
+
final_pipeline = Pipeline(
|
| 256 |
+
[
|
| 257 |
+
("preprocessor", _preprocessor(x_train, settings)),
|
| 258 |
+
("model", candidates[best.name]),
|
| 259 |
+
]
|
| 260 |
+
)
|
| 261 |
+
final_pipeline.fit(x_train, y_train)
|
| 262 |
+
predictions = final_pipeline.predict(x_test)
|
| 263 |
+
final_metrics = _metrics(task, y_test, predictions, final_pipeline, x_test)
|
| 264 |
+
final_score = (
|
| 265 |
+
final_metrics["balanced_accuracy"]
|
| 266 |
+
if task == TaskType.classification
|
| 267 |
+
else final_metrics["r2"]
|
| 268 |
+
)
|
| 269 |
+
best.final_test_score = float(final_score)
|
| 270 |
+
best.final_test_metrics = final_metrics
|
| 271 |
+
best.metrics = final_metrics
|
| 272 |
+
return TrainingBundle(
|
| 273 |
+
pipeline=final_pipeline,
|
| 274 |
+
results=results,
|
| 275 |
+
best_model=best.name,
|
| 276 |
+
test_features=x_test,
|
| 277 |
+
test_target=y_test,
|
| 278 |
+
retry_number=retry_number,
|
| 279 |
+
failures=failures,
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _metrics(
|
| 284 |
+
task: TaskType,
|
| 285 |
+
truth: pd.Series,
|
| 286 |
+
predictions: np.ndarray,
|
| 287 |
+
pipeline: Pipeline,
|
| 288 |
+
features: pd.DataFrame,
|
| 289 |
+
) -> dict[str, float]:
|
| 290 |
+
if task == TaskType.classification:
|
| 291 |
+
metrics = {
|
| 292 |
+
"accuracy": round(float(accuracy_score(truth, predictions)), 4),
|
| 293 |
+
"balanced_accuracy": round(float(balanced_accuracy_score(truth, predictions)), 4),
|
| 294 |
+
"f1_weighted": round(float(f1_score(truth, predictions, average="weighted")), 4),
|
| 295 |
+
}
|
| 296 |
+
if truth.nunique() == 2 and hasattr(pipeline, "predict_proba"):
|
| 297 |
+
probabilities = pipeline.predict_proba(features)[:, 1]
|
| 298 |
+
metrics["roc_auc"] = round(float(roc_auc_score(truth, probabilities)), 4)
|
| 299 |
+
return metrics
|
| 300 |
+
return {
|
| 301 |
+
"r2": round(float(r2_score(truth, predictions)), 4),
|
| 302 |
+
"rmse": round(float(mean_squared_error(truth, predictions) ** 0.5), 4),
|
| 303 |
+
"mae": round(float(mean_absolute_error(truth, predictions)), 4),
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def critic_decision(bundle: TrainingBundle, task: TaskType, settings: Settings) -> CriticDecision:
|
| 308 |
+
best = bundle.results[0]
|
| 309 |
+
threshold = (
|
| 310 |
+
settings.min_classification_score
|
| 311 |
+
if task == TaskType.classification
|
| 312 |
+
else settings.min_regression_score
|
| 313 |
+
)
|
| 314 |
+
reasons: list[str] = []
|
| 315 |
+
quality_score = best.cross_validation_mean if best.cross_validation_mean is not None else -1.0
|
| 316 |
+
if quality_score < threshold:
|
| 317 |
+
reasons.append(
|
| 318 |
+
f"Training CV {best.primary_metric} {quality_score:.3f} is below {threshold:.3f}."
|
| 319 |
+
)
|
| 320 |
+
if (
|
| 321 |
+
best.cross_validation_mean is not None
|
| 322 |
+
and best.final_test_score is not None
|
| 323 |
+
and abs(best.final_test_score - best.cross_validation_mean) > 0.2
|
| 324 |
+
):
|
| 325 |
+
reasons.append("Holdout and cross-validation scores diverge by more than 0.20.")
|
| 326 |
+
approved = not reasons or bundle.retry_number >= settings.max_critic_retries
|
| 327 |
+
if not reasons:
|
| 328 |
+
reasons.append("Performance and validation consistency passed the configured quality gate.")
|
| 329 |
+
elif approved:
|
| 330 |
+
reasons.append("Retry budget exhausted; result is retained with an explicit limitation.")
|
| 331 |
+
return CriticDecision(
|
| 332 |
+
approved=approved,
|
| 333 |
+
score=quality_score,
|
| 334 |
+
threshold=threshold,
|
| 335 |
+
reasons=reasons,
|
| 336 |
+
retry_number=bundle.retry_number,
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def explain_model(bundle: TrainingBundle) -> ExplainabilityResult:
|
| 341 |
+
sample_size = min(300, len(bundle.test_features))
|
| 342 |
+
features = bundle.test_features.iloc[:sample_size]
|
| 343 |
+
target = bundle.test_target.iloc[:sample_size]
|
| 344 |
+
try:
|
| 345 |
+
transformed = bundle.pipeline.named_steps["preprocessor"].transform(features)
|
| 346 |
+
transformed_names = bundle.pipeline.named_steps["preprocessor"].get_feature_names_out()
|
| 347 |
+
estimator = bundle.pipeline.named_steps["model"]
|
| 348 |
+
import shap
|
| 349 |
+
|
| 350 |
+
explainer = shap.Explainer(estimator, transformed)
|
| 351 |
+
values = explainer(transformed)
|
| 352 |
+
raw = np.asarray(values.values)
|
| 353 |
+
if raw.ndim == 3:
|
| 354 |
+
raw = np.abs(raw).mean(axis=(0, 2))
|
| 355 |
+
else:
|
| 356 |
+
raw = np.abs(raw).mean(axis=0)
|
| 357 |
+
importance = _top_importance(transformed_names, raw)
|
| 358 |
+
return ExplainabilityResult(
|
| 359 |
+
method="SHAP",
|
| 360 |
+
feature_importance=importance,
|
| 361 |
+
caveats=["SHAP values explain this fitted model, not causal effects."],
|
| 362 |
+
)
|
| 363 |
+
except Exception:
|
| 364 |
+
permutation = permutation_importance(
|
| 365 |
+
bundle.pipeline,
|
| 366 |
+
features,
|
| 367 |
+
target,
|
| 368 |
+
n_repeats=5,
|
| 369 |
+
random_state=42,
|
| 370 |
+
n_jobs=1,
|
| 371 |
+
)
|
| 372 |
+
importance = _top_importance(features.columns, np.abs(permutation.importances_mean))
|
| 373 |
+
return ExplainabilityResult(
|
| 374 |
+
method="Permutation importance",
|
| 375 |
+
feature_importance=importance,
|
| 376 |
+
caveats=[
|
| 377 |
+
"Permutation importance can dilute importance among correlated features.",
|
| 378 |
+
"Feature importance is predictive, not causal.",
|
| 379 |
+
],
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def _top_importance(names: Any, values: np.ndarray, limit: int = 15) -> dict[str, float]:
|
| 384 |
+
pairs = sorted(
|
| 385 |
+
zip([str(name) for name in names], values.tolist(), strict=False),
|
| 386 |
+
key=lambda item: item[1],
|
| 387 |
+
reverse=True,
|
| 388 |
+
)[:limit]
|
| 389 |
+
return {name: round(float(value), 6) for name, value in pairs}
|
datapilot/observability.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from datapilot.config import Settings
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def log_to_mlflow(payload: dict[str, Any], settings: Settings) -> bool:
|
| 9 |
+
"""Record a run when MLflow is enabled; local demos remain dependency-light."""
|
| 10 |
+
if not settings.enable_mlflow:
|
| 11 |
+
return False
|
| 12 |
+
try:
|
| 13 |
+
import mlflow
|
| 14 |
+
|
| 15 |
+
mlflow.set_tracking_uri(settings.mlflow_tracking_uri)
|
| 16 |
+
mlflow.set_experiment("datapilot-ai")
|
| 17 |
+
best = payload["model_results"][0]
|
| 18 |
+
with mlflow.start_run(run_name=payload["run_id"]):
|
| 19 |
+
mlflow.log_params(
|
| 20 |
+
{
|
| 21 |
+
"dataset": payload["dataset_name"],
|
| 22 |
+
"task_type": payload["profile"]["task_type"],
|
| 23 |
+
"target": payload["profile"]["target"],
|
| 24 |
+
"selected_model": payload["best_model"],
|
| 25 |
+
}
|
| 26 |
+
)
|
| 27 |
+
mlflow.log_metrics({key: float(value) for key, value in best["metrics"].items()})
|
| 28 |
+
return True
|
| 29 |
+
except Exception:
|
| 30 |
+
return False
|
datapilot/persistence.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from datetime import UTC, datetime
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from sqlalchemy import DateTime, String, Text, create_engine
|
| 9 |
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
|
| 10 |
+
|
| 11 |
+
from datapilot.config import Settings
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Base(DeclarativeBase):
|
| 15 |
+
pass
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class AnalysisRun(Base):
|
| 19 |
+
__tablename__ = "analysis_runs"
|
| 20 |
+
|
| 21 |
+
run_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
| 22 |
+
dataset_name: Mapped[str] = mapped_column(String(255))
|
| 23 |
+
status: Mapped[str] = mapped_column(String(32), index=True)
|
| 24 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
| 25 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
| 26 |
+
payload_json: Mapped[str] = mapped_column(Text)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class RunStore:
|
| 30 |
+
"""SQLite locally; set DATABASE_URL to PostgreSQL in production."""
|
| 31 |
+
|
| 32 |
+
def __init__(self, settings: Settings):
|
| 33 |
+
self.engine = create_engine(settings.database_url, future=True)
|
| 34 |
+
Base.metadata.create_all(self.engine)
|
| 35 |
+
self.sessions = sessionmaker(self.engine, expire_on_commit=False)
|
| 36 |
+
|
| 37 |
+
def save(self, run_id: str, dataset_name: str, status: str, payload: dict[str, Any]) -> None:
|
| 38 |
+
now = datetime.now(UTC)
|
| 39 |
+
with self.sessions.begin() as session:
|
| 40 |
+
record = session.get(AnalysisRun, run_id)
|
| 41 |
+
if record is None:
|
| 42 |
+
record = AnalysisRun(
|
| 43 |
+
run_id=run_id,
|
| 44 |
+
dataset_name=dataset_name,
|
| 45 |
+
status=status,
|
| 46 |
+
created_at=now,
|
| 47 |
+
updated_at=now,
|
| 48 |
+
payload_json=json.dumps(payload, default=str),
|
| 49 |
+
)
|
| 50 |
+
session.add(record)
|
| 51 |
+
else:
|
| 52 |
+
record.status = status
|
| 53 |
+
record.updated_at = now
|
| 54 |
+
record.payload_json = json.dumps(payload, default=str)
|
| 55 |
+
|
| 56 |
+
def get(self, run_id: str) -> dict[str, Any] | None:
|
| 57 |
+
with self.sessions() as session:
|
| 58 |
+
record = session.get(AnalysisRun, run_id)
|
| 59 |
+
return json.loads(record.payload_json) if record else None
|
| 60 |
+
|
| 61 |
+
def list_recent(self, limit: int = 20) -> list[dict[str, Any]]:
|
| 62 |
+
from sqlalchemy import select
|
| 63 |
+
|
| 64 |
+
with self.sessions() as session:
|
| 65 |
+
records = session.scalars(
|
| 66 |
+
select(AnalysisRun).order_by(AnalysisRun.created_at.desc()).limit(limit)
|
| 67 |
+
)
|
| 68 |
+
return [
|
| 69 |
+
{
|
| 70 |
+
"run_id": record.run_id,
|
| 71 |
+
"dataset_name": record.dataset_name,
|
| 72 |
+
"status": record.status,
|
| 73 |
+
"created_at": record.created_at.isoformat(),
|
| 74 |
+
}
|
| 75 |
+
for record in records
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class ArtifactStore:
|
| 80 |
+
"""Local artifact storage with an interface that can be replaced by S3/MinIO."""
|
| 81 |
+
|
| 82 |
+
def __init__(self, root: Path):
|
| 83 |
+
self.root = root
|
| 84 |
+
self.root.mkdir(parents=True, exist_ok=True)
|
| 85 |
+
|
| 86 |
+
def run_directory(self, run_id: str) -> Path:
|
| 87 |
+
directory = (self.root / run_id).resolve()
|
| 88 |
+
if self.root.resolve() not in directory.parents:
|
| 89 |
+
raise ValueError("Invalid run identifier.")
|
| 90 |
+
directory.mkdir(parents=True, exist_ok=True)
|
| 91 |
+
return directory
|
datapilot/quality.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
|
| 9 |
+
from datapilot.schemas import (
|
| 10 |
+
DatasetProfile,
|
| 11 |
+
Evidence,
|
| 12 |
+
QualityIssue,
|
| 13 |
+
Severity,
|
| 14 |
+
TaskType,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
LEAKAGE_PATTERNS = re.compile(
|
| 18 |
+
r"(target|label|outcome|result|prediction|predicted|probability|score)$",
|
| 19 |
+
re.IGNORECASE,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def infer_task_type(target: pd.Series) -> TaskType:
|
| 24 |
+
unique = int(target.nunique(dropna=True))
|
| 25 |
+
if (
|
| 26 |
+
not pd.api.types.is_numeric_dtype(target)
|
| 27 |
+
or pd.api.types.is_bool_dtype(target)
|
| 28 |
+
or unique <= 20
|
| 29 |
+
or unique / max(len(target), 1) < 0.05
|
| 30 |
+
):
|
| 31 |
+
return TaskType.classification
|
| 32 |
+
return TaskType.regression
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def build_profile(frame: pd.DataFrame, target: str) -> DatasetProfile:
|
| 36 |
+
if target not in frame.columns:
|
| 37 |
+
raise ValueError(f"Target column '{target}' is not present.")
|
| 38 |
+
numeric = frame.select_dtypes(include=np.number).columns.tolist()
|
| 39 |
+
categorical = frame.select_dtypes(include=["object", "category", "bool"]).columns.tolist()
|
| 40 |
+
datetime = frame.select_dtypes(include=["datetime", "datetimetz"]).columns.tolist()
|
| 41 |
+
missing_cells = int(frame.isna().sum().sum())
|
| 42 |
+
return DatasetProfile(
|
| 43 |
+
rows=len(frame),
|
| 44 |
+
columns=len(frame.columns),
|
| 45 |
+
numeric_columns=numeric,
|
| 46 |
+
categorical_columns=categorical,
|
| 47 |
+
datetime_columns=datetime,
|
| 48 |
+
duplicate_rows=int(frame.duplicated().sum()),
|
| 49 |
+
missing_cells=missing_cells,
|
| 50 |
+
missing_rate=round(missing_cells / max(frame.size, 1), 4),
|
| 51 |
+
memory_mb=round(frame.memory_usage(deep=True).sum() / 1_048_576, 3),
|
| 52 |
+
target=target,
|
| 53 |
+
task_type=infer_task_type(frame[target]),
|
| 54 |
+
target_cardinality=int(frame[target].nunique(dropna=True)),
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def audit_quality(
|
| 59 |
+
frame: pd.DataFrame, profile: DatasetProfile
|
| 60 |
+
) -> tuple[list[QualityIssue], list[Evidence]]:
|
| 61 |
+
issues: list[QualityIssue] = []
|
| 62 |
+
evidence: list[Evidence] = []
|
| 63 |
+
|
| 64 |
+
def add_evidence(claim: str, metric: str, value: object, source: str, method: str) -> str:
|
| 65 |
+
evidence_id = f"EV-{uuid4().hex[:8].upper()}"
|
| 66 |
+
evidence.append(
|
| 67 |
+
Evidence(
|
| 68 |
+
evidence_id=evidence_id,
|
| 69 |
+
claim=claim,
|
| 70 |
+
metric=metric,
|
| 71 |
+
value=value,
|
| 72 |
+
source=source,
|
| 73 |
+
method=method,
|
| 74 |
+
)
|
| 75 |
+
)
|
| 76 |
+
return evidence_id
|
| 77 |
+
|
| 78 |
+
missing_id = add_evidence(
|
| 79 |
+
"Dataset missingness was measured across all cells.",
|
| 80 |
+
"missing_rate",
|
| 81 |
+
profile.missing_rate,
|
| 82 |
+
"uploaded_dataset",
|
| 83 |
+
"pandas.isna",
|
| 84 |
+
)
|
| 85 |
+
if profile.missing_rate > 0.2:
|
| 86 |
+
issues.append(
|
| 87 |
+
QualityIssue(
|
| 88 |
+
code="HIGH_MISSINGNESS",
|
| 89 |
+
severity=Severity.critical,
|
| 90 |
+
message=f"{profile.missing_rate:.1%} of dataset cells are missing.",
|
| 91 |
+
evidence_ids=[missing_id],
|
| 92 |
+
)
|
| 93 |
+
)
|
| 94 |
+
elif profile.missing_rate > 0:
|
| 95 |
+
issues.append(
|
| 96 |
+
QualityIssue(
|
| 97 |
+
code="MISSING_VALUES",
|
| 98 |
+
severity=Severity.warning,
|
| 99 |
+
message=f"{profile.missing_rate:.1%} of dataset cells are missing.",
|
| 100 |
+
evidence_ids=[missing_id],
|
| 101 |
+
)
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
duplicate_id = add_evidence(
|
| 105 |
+
"Exact duplicate rows were counted before splitting.",
|
| 106 |
+
"duplicate_rows",
|
| 107 |
+
profile.duplicate_rows,
|
| 108 |
+
"uploaded_dataset",
|
| 109 |
+
"pandas.duplicated",
|
| 110 |
+
)
|
| 111 |
+
if profile.duplicate_rows:
|
| 112 |
+
issues.append(
|
| 113 |
+
QualityIssue(
|
| 114 |
+
code="DUPLICATE_ROWS",
|
| 115 |
+
severity=Severity.warning,
|
| 116 |
+
message=f"{profile.duplicate_rows:,} exact duplicate rows can bias validation.",
|
| 117 |
+
evidence_ids=[duplicate_id],
|
| 118 |
+
)
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
target = frame[profile.target]
|
| 122 |
+
target_missing = int(target.isna().sum())
|
| 123 |
+
target_missing_id = add_evidence(
|
| 124 |
+
"Rows with missing labels cannot be used for supervised training.",
|
| 125 |
+
"missing_target_rows",
|
| 126 |
+
target_missing,
|
| 127 |
+
f"column:{profile.target}",
|
| 128 |
+
"pandas.isna",
|
| 129 |
+
)
|
| 130 |
+
if target_missing:
|
| 131 |
+
issues.append(
|
| 132 |
+
QualityIssue(
|
| 133 |
+
code="MISSING_TARGET",
|
| 134 |
+
severity=Severity.critical,
|
| 135 |
+
column=profile.target,
|
| 136 |
+
message=f"{target_missing:,} rows have no target value and will be excluded.",
|
| 137 |
+
evidence_ids=[target_missing_id],
|
| 138 |
+
)
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
if profile.task_type == TaskType.classification:
|
| 142 |
+
distribution = target.value_counts(normalize=True, dropna=True)
|
| 143 |
+
minority_share = float(distribution.min()) if not distribution.empty else 0.0
|
| 144 |
+
imbalance_id = add_evidence(
|
| 145 |
+
"Class imbalance was measured using the minority-class share.",
|
| 146 |
+
"minority_class_share",
|
| 147 |
+
round(minority_share, 4),
|
| 148 |
+
f"column:{profile.target}",
|
| 149 |
+
"normalized value counts",
|
| 150 |
+
)
|
| 151 |
+
if minority_share < 0.1:
|
| 152 |
+
issues.append(
|
| 153 |
+
QualityIssue(
|
| 154 |
+
code="CLASS_IMBALANCE",
|
| 155 |
+
severity=Severity.warning,
|
| 156 |
+
column=profile.target,
|
| 157 |
+
message=f"Minority class represents only {minority_share:.1%} of labeled rows.",
|
| 158 |
+
evidence_ids=[imbalance_id],
|
| 159 |
+
)
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
feature_frame = frame.drop(columns=[profile.target])
|
| 163 |
+
for column in feature_frame.columns:
|
| 164 |
+
normalized = column.strip().lower()
|
| 165 |
+
leakage_risk = bool(LEAKAGE_PATTERNS.search(normalized))
|
| 166 |
+
if feature_frame[column].nunique(dropna=True) == len(feature_frame):
|
| 167 |
+
leakage_risk = leakage_risk or normalized.endswith(("_id", "id"))
|
| 168 |
+
if leakage_risk:
|
| 169 |
+
evidence_id = add_evidence(
|
| 170 |
+
"A feature name or cardinality pattern may reveal the target or row identity.",
|
| 171 |
+
"suspected_leakage_feature",
|
| 172 |
+
column,
|
| 173 |
+
f"column:{column}",
|
| 174 |
+
"name and cardinality heuristic",
|
| 175 |
+
)
|
| 176 |
+
issues.append(
|
| 177 |
+
QualityIssue(
|
| 178 |
+
code="LEAKAGE_RISK",
|
| 179 |
+
severity=Severity.warning,
|
| 180 |
+
column=column,
|
| 181 |
+
message=f"'{column}' may leak target or row identity; review before deployment.",
|
| 182 |
+
evidence_ids=[evidence_id],
|
| 183 |
+
)
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
numeric = feature_frame.select_dtypes(include=np.number)
|
| 187 |
+
for column in numeric.columns:
|
| 188 |
+
series = numeric[column].dropna()
|
| 189 |
+
if len(series) < 8:
|
| 190 |
+
continue
|
| 191 |
+
q1, q3 = series.quantile([0.25, 0.75])
|
| 192 |
+
iqr = q3 - q1
|
| 193 |
+
if iqr == 0:
|
| 194 |
+
continue
|
| 195 |
+
outlier_rate = float(((series < q1 - 1.5 * iqr) | (series > q3 + 1.5 * iqr)).mean())
|
| 196 |
+
if outlier_rate > 0.05:
|
| 197 |
+
evidence_id = add_evidence(
|
| 198 |
+
"Potential outliers were detected with the 1.5×IQR rule.",
|
| 199 |
+
"outlier_rate",
|
| 200 |
+
round(outlier_rate, 4),
|
| 201 |
+
f"column:{column}",
|
| 202 |
+
"Tukey IQR",
|
| 203 |
+
)
|
| 204 |
+
issues.append(
|
| 205 |
+
QualityIssue(
|
| 206 |
+
code="OUTLIER_RATE",
|
| 207 |
+
severity=Severity.info,
|
| 208 |
+
column=column,
|
| 209 |
+
message=f"'{column}' has {outlier_rate:.1%} potential outliers.",
|
| 210 |
+
evidence_ids=[evidence_id],
|
| 211 |
+
)
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
return issues, evidence
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def drift_report(reference: pd.DataFrame, current: pd.DataFrame) -> list[dict[str, object]]:
|
| 218 |
+
"""Population stability index for numeric columns shared by two datasets."""
|
| 219 |
+
reports: list[dict[str, object]] = []
|
| 220 |
+
shared = reference.select_dtypes(include=np.number).columns.intersection(
|
| 221 |
+
current.select_dtypes(include=np.number).columns
|
| 222 |
+
)
|
| 223 |
+
for column in shared:
|
| 224 |
+
baseline = reference[column].dropna()
|
| 225 |
+
observed = current[column].dropna()
|
| 226 |
+
if baseline.nunique() < 2 or observed.empty:
|
| 227 |
+
continue
|
| 228 |
+
edges = np.unique(baseline.quantile(np.linspace(0, 1, 11)).to_numpy())
|
| 229 |
+
if len(edges) < 3:
|
| 230 |
+
continue
|
| 231 |
+
expected_counts, _ = np.histogram(baseline, bins=edges)
|
| 232 |
+
actual_counts, _ = np.histogram(observed, bins=edges)
|
| 233 |
+
expected = np.clip(expected_counts / max(expected_counts.sum(), 1), 1e-6, None)
|
| 234 |
+
actual = np.clip(actual_counts / max(actual_counts.sum(), 1), 1e-6, None)
|
| 235 |
+
psi = float(np.sum((actual - expected) * np.log(actual / expected)))
|
| 236 |
+
reports.append(
|
| 237 |
+
{
|
| 238 |
+
"column": column,
|
| 239 |
+
"psi": round(psi, 4),
|
| 240 |
+
"status": "high" if psi >= 0.25 else "moderate" if psi >= 0.1 else "stable",
|
| 241 |
+
}
|
| 242 |
+
)
|
| 243 |
+
return reports
|
datapilot/reports.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import html
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import joblib
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def export_artifacts(
|
| 12 |
+
run_id: str,
|
| 13 |
+
state: dict[str, Any],
|
| 14 |
+
run_directory: Path,
|
| 15 |
+
) -> dict[str, str]:
|
| 16 |
+
run_directory.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
bundle = state["model_bundle"]
|
| 18 |
+
summary = state["summary_payload"]
|
| 19 |
+
|
| 20 |
+
pipeline_path = run_directory / "model_pipeline.joblib"
|
| 21 |
+
joblib.dump(bundle.pipeline, pipeline_path)
|
| 22 |
+
|
| 23 |
+
metrics_path = run_directory / "metrics.json"
|
| 24 |
+
metrics_path.write_text(json.dumps(summary, indent=2, default=str), encoding="utf-8")
|
| 25 |
+
|
| 26 |
+
model_card_path = run_directory / "MODEL_CARD.md"
|
| 27 |
+
model_card_path.write_text(_model_card(summary), encoding="utf-8")
|
| 28 |
+
|
| 29 |
+
report_path = run_directory / "analysis_report.html"
|
| 30 |
+
report_path.write_text(_html_report(summary), encoding="utf-8")
|
| 31 |
+
|
| 32 |
+
requirements_path = run_directory / "reproduction.json"
|
| 33 |
+
requirements_path.write_text(
|
| 34 |
+
json.dumps(
|
| 35 |
+
{
|
| 36 |
+
"run_id": run_id,
|
| 37 |
+
"random_state": state["settings"].random_state,
|
| 38 |
+
"test_size": state["settings"].test_size,
|
| 39 |
+
"target": summary["profile"]["target"],
|
| 40 |
+
"task_type": summary["profile"]["task_type"],
|
| 41 |
+
"best_model": summary["best_model"],
|
| 42 |
+
},
|
| 43 |
+
indent=2,
|
| 44 |
+
),
|
| 45 |
+
encoding="utf-8",
|
| 46 |
+
)
|
| 47 |
+
return {
|
| 48 |
+
"pipeline": str(pipeline_path),
|
| 49 |
+
"metrics": str(metrics_path),
|
| 50 |
+
"model_card": str(model_card_path),
|
| 51 |
+
"report": str(report_path),
|
| 52 |
+
"reproduction": str(requirements_path),
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _model_card(run: dict[str, Any]) -> str:
|
| 57 |
+
best = run["model_results"][0]
|
| 58 |
+
profile = run["profile"]
|
| 59 |
+
issues = (
|
| 60 |
+
"\n".join(f"- {item['message']}" for item in run["quality_issues"]) or "- None detected"
|
| 61 |
+
)
|
| 62 |
+
return f"""# Model Card — {run["dataset_name"]}
|
| 63 |
+
|
| 64 |
+
## Model details
|
| 65 |
+
|
| 66 |
+
- Run ID: `{run["run_id"]}`
|
| 67 |
+
- Task: {profile["task_type"]}
|
| 68 |
+
- Target: `{profile["target"]}`
|
| 69 |
+
- Selected model: **{run["best_model"]}**
|
| 70 |
+
- Training-CV selection metric: `{best["primary_metric"]} = {best["selection_score"]:.4f}`
|
| 71 |
+
- One-time untouched test metric: `{best["primary_metric"]} = {best["final_test_score"]:.4f}`
|
| 72 |
+
- Training rows before split: {profile["rows"]:,}
|
| 73 |
+
|
| 74 |
+
## Intended use
|
| 75 |
+
|
| 76 |
+
Exploratory decision support and portfolio demonstration. Validate with domain-specific,
|
| 77 |
+
out-of-time data before any consequential or production use.
|
| 78 |
+
|
| 79 |
+
## Evaluation
|
| 80 |
+
|
| 81 |
+
```json
|
| 82 |
+
{json.dumps(best["final_test_metrics"], indent=2)}
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
## Data-quality observations
|
| 86 |
+
|
| 87 |
+
{issues}
|
| 88 |
+
|
| 89 |
+
## Explainability
|
| 90 |
+
|
| 91 |
+
Method: **{run["explainability"]["method"]}**. Importance values are predictive associations,
|
| 92 |
+
not evidence of causation.
|
| 93 |
+
|
| 94 |
+
## Limitations
|
| 95 |
+
|
| 96 |
+
- Results depend on the uploaded dataset and chosen target.
|
| 97 |
+
- Automated task inference can be wrong; a domain owner should confirm the objective.
|
| 98 |
+
- Fairness, privacy, and legal review are outside the automatic approval gate.
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _html_report(run: dict[str, Any]) -> str:
|
| 103 |
+
best = run["model_results"][0]
|
| 104 |
+
summary_items = "".join(f"<li>{html.escape(item)}</li>" for item in run["executive_summary"])
|
| 105 |
+
recommendations = "".join(f"<li>{html.escape(item)}</li>" for item in run["recommendations"])
|
| 106 |
+
issues = (
|
| 107 |
+
"".join(
|
| 108 |
+
f"<tr><td>{html.escape(item['severity'])}</td><td>{html.escape(item['code'])}</td>"
|
| 109 |
+
f"<td>{html.escape(item['message'])}</td></tr>"
|
| 110 |
+
for item in run["quality_issues"]
|
| 111 |
+
)
|
| 112 |
+
or "<tr><td colspan='3'>No material flags</td></tr>"
|
| 113 |
+
)
|
| 114 |
+
metrics = "".join(
|
| 115 |
+
f"<tr><td>{html.escape(name)}</td><td>{value:.4f}</td></tr>"
|
| 116 |
+
for name, value in best["final_test_metrics"].items()
|
| 117 |
+
)
|
| 118 |
+
return f"""<!doctype html>
|
| 119 |
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
| 120 |
+
<title>DataPilot AI report</title>
|
| 121 |
+
<style>
|
| 122 |
+
body{{font-family:Inter,system-ui,sans-serif;max-width:1000px;margin:40px auto;padding:0 24px;color:#172033}}
|
| 123 |
+
h1{{color:#5537d8}} .hero{{background:#f4f1ff;border:1px solid #d9d0ff;padding:24px;border-radius:18px}}
|
| 124 |
+
.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin:20px 0}}
|
| 125 |
+
.card{{border:1px solid #e2e6ef;border-radius:14px;padding:18px}} table{{border-collapse:collapse;width:100%}}
|
| 126 |
+
td,th{{border-bottom:1px solid #e2e6ef;padding:10px;text-align:left}} small{{color:#667085}}
|
| 127 |
+
</style></head><body>
|
| 128 |
+
<div class="hero"><h1>DataPilot AI Analysis Report</h1>
|
| 129 |
+
<p>{html.escape(run["dataset_name"])} · Run {html.escape(run["run_id"])}</p></div>
|
| 130 |
+
<div class="grid">
|
| 131 |
+
<div class="card"><small>Selected model</small><h2>{html.escape(run["best_model"])}</h2></div>
|
| 132 |
+
<div class="card"><small>Test {html.escape(best["primary_metric"])}</small><h2>{best["final_test_score"]:.3f}</h2></div>
|
| 133 |
+
<div class="card"><small>Rows analyzed</small><h2>{run["profile"]["rows"]:,}</h2></div>
|
| 134 |
+
</div>
|
| 135 |
+
<h2>Executive findings</h2><ul>{summary_items}</ul>
|
| 136 |
+
<h2>Evaluation</h2><table><tr><th>Metric</th><th>Value</th></tr>{metrics}</table>
|
| 137 |
+
<h2>Data quality</h2><table><tr><th>Severity</th><th>Code</th><th>Observation</th></tr>{issues}</table>
|
| 138 |
+
<h2>Recommendations</h2><ol>{recommendations}</ol>
|
| 139 |
+
<p><small>Generated from computed evidence. Predictive findings do not establish causality.</small></p>
|
| 140 |
+
</body></html>"""
|
datapilot/safety.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import ast
|
| 4 |
+
|
| 5 |
+
BLOCKED_NODES = (
|
| 6 |
+
ast.Import,
|
| 7 |
+
ast.ImportFrom,
|
| 8 |
+
ast.Global,
|
| 9 |
+
ast.Nonlocal,
|
| 10 |
+
ast.With,
|
| 11 |
+
ast.AsyncWith,
|
| 12 |
+
ast.Try,
|
| 13 |
+
ast.Raise,
|
| 14 |
+
ast.ClassDef,
|
| 15 |
+
ast.FunctionDef,
|
| 16 |
+
ast.AsyncFunctionDef,
|
| 17 |
+
)
|
| 18 |
+
BLOCKED_CALLS = {
|
| 19 |
+
"eval",
|
| 20 |
+
"exec",
|
| 21 |
+
"compile",
|
| 22 |
+
"open",
|
| 23 |
+
"input",
|
| 24 |
+
"__import__",
|
| 25 |
+
"breakpoint",
|
| 26 |
+
"getattr",
|
| 27 |
+
"setattr",
|
| 28 |
+
"delattr",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class UnsafeCodeError(ValueError):
|
| 33 |
+
pass
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def validate_generated_expression(expression: str, max_characters: int = 2_000) -> ast.Expression:
|
| 37 |
+
"""Validate a calculation expression before it is sent to an isolated worker.
|
| 38 |
+
|
| 39 |
+
DataPilot's primary workflow does not execute LLM-generated Python. This validator
|
| 40 |
+
exists for an optional restricted calculation worker and accepts expressions only.
|
| 41 |
+
"""
|
| 42 |
+
if len(expression) > max_characters:
|
| 43 |
+
raise UnsafeCodeError("Expression exceeds the configured size limit.")
|
| 44 |
+
try:
|
| 45 |
+
tree = ast.parse(expression, mode="eval")
|
| 46 |
+
except SyntaxError as exc:
|
| 47 |
+
raise UnsafeCodeError("Expression is not valid Python.") from exc
|
| 48 |
+
for node in ast.walk(tree):
|
| 49 |
+
if isinstance(node, BLOCKED_NODES):
|
| 50 |
+
raise UnsafeCodeError(f"Blocked syntax: {type(node).__name__}.")
|
| 51 |
+
if isinstance(node, ast.Attribute) and node.attr.startswith("__"):
|
| 52 |
+
raise UnsafeCodeError("Dunder attribute access is blocked.")
|
| 53 |
+
if isinstance(node, ast.Name) and node.id.startswith("__"):
|
| 54 |
+
raise UnsafeCodeError("Dunder names are blocked.")
|
| 55 |
+
if isinstance(node, ast.Call):
|
| 56 |
+
if isinstance(node.func, ast.Name) and node.func.id in BLOCKED_CALLS:
|
| 57 |
+
raise UnsafeCodeError(f"Blocked call: {node.func.id}.")
|
| 58 |
+
if isinstance(node.func, ast.Attribute) and node.func.attr in BLOCKED_CALLS:
|
| 59 |
+
raise UnsafeCodeError(f"Blocked call: {node.func.attr}.")
|
| 60 |
+
return tree
|
datapilot/schemas.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from enum import StrEnum
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TaskType(StrEnum):
|
| 10 |
+
classification = "classification"
|
| 11 |
+
regression = "regression"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Severity(StrEnum):
|
| 15 |
+
info = "info"
|
| 16 |
+
warning = "warning"
|
| 17 |
+
critical = "critical"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class Evidence(BaseModel):
|
| 21 |
+
evidence_id: str
|
| 22 |
+
claim: str
|
| 23 |
+
metric: str
|
| 24 |
+
value: float | int | str
|
| 25 |
+
source: str
|
| 26 |
+
method: str
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class QualityIssue(BaseModel):
|
| 30 |
+
code: str
|
| 31 |
+
severity: Severity
|
| 32 |
+
column: str | None = None
|
| 33 |
+
message: str
|
| 34 |
+
evidence_ids: list[str] = Field(default_factory=list)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class DatasetProfile(BaseModel):
|
| 38 |
+
rows: int
|
| 39 |
+
columns: int
|
| 40 |
+
numeric_columns: list[str]
|
| 41 |
+
categorical_columns: list[str]
|
| 42 |
+
datetime_columns: list[str]
|
| 43 |
+
duplicate_rows: int
|
| 44 |
+
missing_cells: int
|
| 45 |
+
missing_rate: float
|
| 46 |
+
memory_mb: float
|
| 47 |
+
target: str
|
| 48 |
+
task_type: TaskType
|
| 49 |
+
target_cardinality: int
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class AnalysisPlan(BaseModel):
|
| 53 |
+
objective: str
|
| 54 |
+
target: str
|
| 55 |
+
task_type: TaskType
|
| 56 |
+
primary_metric: str
|
| 57 |
+
validation_strategy: str
|
| 58 |
+
candidate_models: list[str]
|
| 59 |
+
risk_controls: list[str]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class ModelResult(BaseModel):
|
| 63 |
+
name: str
|
| 64 |
+
primary_metric: str
|
| 65 |
+
primary_score: float
|
| 66 |
+
metrics: dict[str, float]
|
| 67 |
+
cross_validation_mean: float | None = None
|
| 68 |
+
cross_validation_std: float | None = None
|
| 69 |
+
training_seconds: float
|
| 70 |
+
selection_score: float | None = None
|
| 71 |
+
final_test_score: float | None = None
|
| 72 |
+
final_test_metrics: dict[str, float] = Field(default_factory=dict)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class ModelFailure(BaseModel):
|
| 76 |
+
name: str
|
| 77 |
+
stage: str
|
| 78 |
+
exception_category: str
|
| 79 |
+
sanitized_error: str
|
| 80 |
+
training_seconds: float
|
| 81 |
+
expected: bool = False
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class CriticDecision(BaseModel):
|
| 85 |
+
approved: bool
|
| 86 |
+
score: float
|
| 87 |
+
threshold: float
|
| 88 |
+
reasons: list[str]
|
| 89 |
+
retry_number: int
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class ExplainabilityResult(BaseModel):
|
| 93 |
+
method: str
|
| 94 |
+
feature_importance: dict[str, float]
|
| 95 |
+
caveats: list[str]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class RunSummary(BaseModel):
|
| 99 |
+
run_id: str
|
| 100 |
+
status: str
|
| 101 |
+
dataset_name: str
|
| 102 |
+
profile: DatasetProfile
|
| 103 |
+
plan: AnalysisPlan
|
| 104 |
+
quality_issues: list[QualityIssue]
|
| 105 |
+
evidence: list[Evidence]
|
| 106 |
+
model_results: list[ModelResult]
|
| 107 |
+
model_failures: list[ModelFailure] = Field(default_factory=list)
|
| 108 |
+
best_model: str
|
| 109 |
+
critic: CriticDecision
|
| 110 |
+
explainability: ExplainabilityResult
|
| 111 |
+
executive_summary: list[str]
|
| 112 |
+
recommendations: list[str]
|
| 113 |
+
artifacts: dict[str, str]
|
| 114 |
+
trace: list[dict[str, Any]]
|
datapilot/tuning.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
import pandas as pd
|
| 6 |
+
from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
|
| 7 |
+
from sklearn.model_selection import cross_val_score
|
| 8 |
+
from sklearn.pipeline import Pipeline
|
| 9 |
+
|
| 10 |
+
from datapilot.config import Settings
|
| 11 |
+
from datapilot.schemas import TaskType
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def tune_random_forest(
|
| 15 |
+
task: TaskType,
|
| 16 |
+
preprocessor: Any,
|
| 17 |
+
features: pd.DataFrame,
|
| 18 |
+
target: pd.Series,
|
| 19 |
+
settings: Settings,
|
| 20 |
+
cv: int,
|
| 21 |
+
) -> dict[str, Any]:
|
| 22 |
+
"""Run a bounded Optuna study when the optional AutoML extra is installed."""
|
| 23 |
+
if settings.optuna_trials <= 0 or cv < 2:
|
| 24 |
+
return {}
|
| 25 |
+
try:
|
| 26 |
+
import optuna
|
| 27 |
+
except ImportError:
|
| 28 |
+
return {}
|
| 29 |
+
|
| 30 |
+
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
| 31 |
+
scoring = "balanced_accuracy" if task == TaskType.classification else "r2"
|
| 32 |
+
|
| 33 |
+
def objective(trial):
|
| 34 |
+
parameters = {
|
| 35 |
+
"n_estimators": trial.suggest_int("n_estimators", 120, 360, step=60),
|
| 36 |
+
"max_depth": trial.suggest_int("max_depth", 3, 14),
|
| 37 |
+
"min_samples_leaf": trial.suggest_int("min_samples_leaf", 1, 6),
|
| 38 |
+
"max_features": trial.suggest_categorical("max_features", ["sqrt", "log2", 0.8]),
|
| 39 |
+
}
|
| 40 |
+
common = {
|
| 41 |
+
**parameters,
|
| 42 |
+
"n_jobs": -1,
|
| 43 |
+
"random_state": settings.random_state,
|
| 44 |
+
}
|
| 45 |
+
if task == TaskType.classification:
|
| 46 |
+
estimator = RandomForestClassifier(**common, class_weight="balanced")
|
| 47 |
+
else:
|
| 48 |
+
estimator = RandomForestRegressor(**common)
|
| 49 |
+
pipeline = Pipeline([("preprocessor", preprocessor), ("model", estimator)])
|
| 50 |
+
scores = cross_val_score(
|
| 51 |
+
pipeline,
|
| 52 |
+
features,
|
| 53 |
+
target,
|
| 54 |
+
cv=cv,
|
| 55 |
+
scoring=scoring,
|
| 56 |
+
n_jobs=1,
|
| 57 |
+
)
|
| 58 |
+
return float(scores.mean())
|
| 59 |
+
|
| 60 |
+
study = optuna.create_study(direction="maximize")
|
| 61 |
+
study.optimize(
|
| 62 |
+
objective,
|
| 63 |
+
n_trials=settings.optuna_trials,
|
| 64 |
+
timeout=90,
|
| 65 |
+
show_progress_bar=False,
|
| 66 |
+
)
|
| 67 |
+
return study.best_params
|
datapilot/workflow.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from typing import Any, TypedDict
|
| 5 |
+
from uuid import uuid4
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
from langgraph.graph import END, START, StateGraph
|
| 10 |
+
|
| 11 |
+
from datapilot.config import Settings, get_settings
|
| 12 |
+
from datapilot.data import duckdb_overview
|
| 13 |
+
from datapilot.insights import deterministic_insights, optional_llm_narrative
|
| 14 |
+
from datapilot.modeling import (
|
| 15 |
+
TrainingBundle,
|
| 16 |
+
critic_decision,
|
| 17 |
+
explain_model,
|
| 18 |
+
train_models,
|
| 19 |
+
)
|
| 20 |
+
from datapilot.observability import log_to_mlflow
|
| 21 |
+
from datapilot.persistence import ArtifactStore, RunStore
|
| 22 |
+
from datapilot.quality import audit_quality, build_profile
|
| 23 |
+
from datapilot.reports import export_artifacts
|
| 24 |
+
from datapilot.schemas import AnalysisPlan, RunSummary, TaskType
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class AgentState(TypedDict, total=False):
|
| 28 |
+
run_id: str
|
| 29 |
+
dataset_name: str
|
| 30 |
+
frame: pd.DataFrame
|
| 31 |
+
target: str
|
| 32 |
+
settings: Settings
|
| 33 |
+
profile: Any
|
| 34 |
+
quality_issues: list[Any]
|
| 35 |
+
evidence: list[Any]
|
| 36 |
+
eda: dict[str, Any]
|
| 37 |
+
statistics: dict[str, Any]
|
| 38 |
+
plan: AnalysisPlan
|
| 39 |
+
feature_plan: dict[str, Any]
|
| 40 |
+
model_bundle: TrainingBundle
|
| 41 |
+
critic: Any
|
| 42 |
+
explainability: Any
|
| 43 |
+
executive_summary: list[str]
|
| 44 |
+
recommendations: list[str]
|
| 45 |
+
summary_payload: dict[str, Any]
|
| 46 |
+
artifacts: dict[str, str]
|
| 47 |
+
trace: list[dict[str, Any]]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _trace(state: AgentState, agent: str, started: float, detail: str) -> list[dict[str, Any]]:
|
| 51 |
+
trace = list(state.get("trace", []))
|
| 52 |
+
trace.append(
|
| 53 |
+
{
|
| 54 |
+
"agent": agent,
|
| 55 |
+
"status": "completed",
|
| 56 |
+
"duration_seconds": round(time.perf_counter() - started, 3),
|
| 57 |
+
"detail": detail,
|
| 58 |
+
}
|
| 59 |
+
)
|
| 60 |
+
return trace
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def data_quality_agent(state: AgentState) -> dict[str, Any]:
|
| 64 |
+
started = time.perf_counter()
|
| 65 |
+
profile = build_profile(state["frame"], state["target"])
|
| 66 |
+
issues, evidence = audit_quality(state["frame"], profile)
|
| 67 |
+
return {
|
| 68 |
+
"profile": profile,
|
| 69 |
+
"quality_issues": issues,
|
| 70 |
+
"evidence": evidence,
|
| 71 |
+
"trace": _trace(
|
| 72 |
+
state, "Data Quality Agent", started, f"Recorded {len(issues)} quality observations."
|
| 73 |
+
),
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def eda_agent(state: AgentState) -> dict[str, Any]:
|
| 78 |
+
started = time.perf_counter()
|
| 79 |
+
frame = state["frame"]
|
| 80 |
+
overview = duckdb_overview(frame)
|
| 81 |
+
overview["numeric_summary"] = (
|
| 82 |
+
frame.select_dtypes(include=np.number).describe().round(4).to_dict()
|
| 83 |
+
)
|
| 84 |
+
overview["categorical_cardinality"] = {
|
| 85 |
+
column: int(frame[column].nunique(dropna=True))
|
| 86 |
+
for column in frame.select_dtypes(exclude=np.number).columns
|
| 87 |
+
}
|
| 88 |
+
return {
|
| 89 |
+
"eda": overview,
|
| 90 |
+
"trace": _trace(state, "EDA Agent", started, "Computed DuckDB-backed dataset overview."),
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def statistical_agent(state: AgentState) -> dict[str, Any]:
|
| 95 |
+
started = time.perf_counter()
|
| 96 |
+
frame = state["frame"]
|
| 97 |
+
target = state["target"]
|
| 98 |
+
numeric = frame.select_dtypes(include=np.number)
|
| 99 |
+
correlations: dict[str, float] = {}
|
| 100 |
+
if target in numeric.columns and len(numeric.columns) > 1:
|
| 101 |
+
correlations = (
|
| 102 |
+
numeric.corr(numeric_only=True)[target]
|
| 103 |
+
.drop(labels=[target])
|
| 104 |
+
.abs()
|
| 105 |
+
.sort_values(ascending=False)
|
| 106 |
+
.head(10)
|
| 107 |
+
.round(4)
|
| 108 |
+
.to_dict()
|
| 109 |
+
)
|
| 110 |
+
statistics = {
|
| 111 |
+
"top_absolute_target_correlations": correlations,
|
| 112 |
+
"target_distribution": frame[target].value_counts(dropna=False).head(20).to_dict(),
|
| 113 |
+
}
|
| 114 |
+
return {
|
| 115 |
+
"statistics": statistics,
|
| 116 |
+
"trace": _trace(
|
| 117 |
+
state,
|
| 118 |
+
"Statistical Analysis Agent",
|
| 119 |
+
started,
|
| 120 |
+
"Measured target distribution and associations.",
|
| 121 |
+
),
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def planning_agent(state: AgentState) -> dict[str, Any]:
|
| 126 |
+
started = time.perf_counter()
|
| 127 |
+
profile = state["profile"]
|
| 128 |
+
if profile.task_type == TaskType.classification:
|
| 129 |
+
metric = "balanced_accuracy"
|
| 130 |
+
candidates = [
|
| 131 |
+
"Logistic Regression",
|
| 132 |
+
"Random Forest",
|
| 133 |
+
"Extra Trees",
|
| 134 |
+
"Histogram Gradient Boosting",
|
| 135 |
+
"XGBoost (when installed)",
|
| 136 |
+
]
|
| 137 |
+
else:
|
| 138 |
+
metric = "r2"
|
| 139 |
+
candidates = [
|
| 140 |
+
"Linear Regression",
|
| 141 |
+
"Random Forest",
|
| 142 |
+
"Extra Trees",
|
| 143 |
+
"Histogram Gradient Boosting",
|
| 144 |
+
"XGBoost (when installed)",
|
| 145 |
+
]
|
| 146 |
+
plan = AnalysisPlan(
|
| 147 |
+
objective=f"Predict '{profile.target}' and produce reproducible, evidence-backed insights.",
|
| 148 |
+
target=profile.target,
|
| 149 |
+
task_type=profile.task_type,
|
| 150 |
+
primary_metric=metric,
|
| 151 |
+
validation_strategy="Training-only stratified cross-validation; untouched final test evaluation"
|
| 152 |
+
if profile.task_type == TaskType.classification
|
| 153 |
+
else "Training-only cross-validation; untouched final test evaluation",
|
| 154 |
+
candidate_models=candidates,
|
| 155 |
+
risk_controls=[
|
| 156 |
+
"Drop rows with missing target before split",
|
| 157 |
+
"Fit imputers, encoders, and scalers on training folds only",
|
| 158 |
+
"Flag leakage-like names and identifier cardinality",
|
| 159 |
+
"Require critic quality gate before explanation",
|
| 160 |
+
],
|
| 161 |
+
)
|
| 162 |
+
return {
|
| 163 |
+
"plan": plan,
|
| 164 |
+
"trace": _trace(state, "Planning Agent", started, f"Selected {metric} as primary metric."),
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def feature_engineering_agent(state: AgentState) -> dict[str, Any]:
|
| 169 |
+
started = time.perf_counter()
|
| 170 |
+
profile = state["profile"]
|
| 171 |
+
feature_plan = {
|
| 172 |
+
"numeric": "Median imputation followed by standard scaling",
|
| 173 |
+
"categorical": "Most-frequent imputation followed by unknown-safe one-hot encoding",
|
| 174 |
+
"fit_scope": "Preprocessing is fitted inside each sklearn Pipeline after splitting",
|
| 175 |
+
"dropped": ["exact duplicate rows", "rows with missing target"],
|
| 176 |
+
"feature_count": profile.columns - 1,
|
| 177 |
+
}
|
| 178 |
+
return {
|
| 179 |
+
"feature_plan": feature_plan,
|
| 180 |
+
"trace": _trace(
|
| 181 |
+
state,
|
| 182 |
+
"Feature Engineering Agent",
|
| 183 |
+
started,
|
| 184 |
+
"Created leakage-safe ColumnTransformer plan.",
|
| 185 |
+
),
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def modeling_agent(state: AgentState) -> dict[str, Any]:
|
| 190 |
+
started = time.perf_counter()
|
| 191 |
+
retry = state.get("model_bundle").retry_number + 1 if state.get("model_bundle") else 0
|
| 192 |
+
bundle = train_models(
|
| 193 |
+
state["frame"],
|
| 194 |
+
state["target"],
|
| 195 |
+
state["profile"].task_type,
|
| 196 |
+
state["settings"],
|
| 197 |
+
retry_number=retry,
|
| 198 |
+
)
|
| 199 |
+
return {
|
| 200 |
+
"model_bundle": bundle,
|
| 201 |
+
"trace": _trace(
|
| 202 |
+
state,
|
| 203 |
+
"Modeling Agent",
|
| 204 |
+
started,
|
| 205 |
+
f"Compared {len(bundle.results)} models; {bundle.best_model} ranked first.",
|
| 206 |
+
),
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def evaluation_critic_agent(state: AgentState) -> dict[str, Any]:
|
| 211 |
+
started = time.perf_counter()
|
| 212 |
+
decision = critic_decision(state["model_bundle"], state["profile"].task_type, state["settings"])
|
| 213 |
+
detail = "Approved analysis." if decision.approved else "Rejected analysis and requested retry."
|
| 214 |
+
return {
|
| 215 |
+
"critic": decision,
|
| 216 |
+
"trace": _trace(state, "Evaluation / Critic Agent", started, detail),
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def critic_route(state: AgentState) -> str:
|
| 221 |
+
return "explainability" if state["critic"].approved else "retry_modeling"
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def explainability_agent(state: AgentState) -> dict[str, Any]:
|
| 225 |
+
started = time.perf_counter()
|
| 226 |
+
result = explain_model(state["model_bundle"])
|
| 227 |
+
return {
|
| 228 |
+
"explainability": result,
|
| 229 |
+
"trace": _trace(
|
| 230 |
+
state, "Explainability Agent", started, f"Generated {result.method} explanations."
|
| 231 |
+
),
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def executive_insights_agent(state: AgentState) -> dict[str, Any]:
|
| 236 |
+
started = time.perf_counter()
|
| 237 |
+
summary, recommendations = deterministic_insights(state)
|
| 238 |
+
llm_summary = optional_llm_narrative(state, state["evidence"], state["settings"])
|
| 239 |
+
if llm_summary:
|
| 240 |
+
summary = llm_summary
|
| 241 |
+
return {
|
| 242 |
+
"executive_summary": summary,
|
| 243 |
+
"recommendations": recommendations,
|
| 244 |
+
"trace": _trace(
|
| 245 |
+
state,
|
| 246 |
+
"Executive Insights Agent",
|
| 247 |
+
started,
|
| 248 |
+
"Created evidence-grounded narrative with deterministic metric provenance.",
|
| 249 |
+
),
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def build_graph():
|
| 254 |
+
graph = StateGraph(AgentState)
|
| 255 |
+
graph.add_node("data_quality", data_quality_agent)
|
| 256 |
+
graph.add_node("eda", eda_agent)
|
| 257 |
+
graph.add_node("statistics", statistical_agent)
|
| 258 |
+
graph.add_node("planning", planning_agent)
|
| 259 |
+
graph.add_node("feature_engineering", feature_engineering_agent)
|
| 260 |
+
graph.add_node("modeling", modeling_agent)
|
| 261 |
+
graph.add_node("critic", evaluation_critic_agent)
|
| 262 |
+
graph.add_node("explainability", explainability_agent)
|
| 263 |
+
graph.add_node("executive_insights", executive_insights_agent)
|
| 264 |
+
graph.add_edge(START, "data_quality")
|
| 265 |
+
graph.add_edge("data_quality", "eda")
|
| 266 |
+
graph.add_edge("eda", "statistics")
|
| 267 |
+
graph.add_edge("statistics", "planning")
|
| 268 |
+
graph.add_edge("planning", "feature_engineering")
|
| 269 |
+
graph.add_edge("feature_engineering", "modeling")
|
| 270 |
+
graph.add_edge("modeling", "critic")
|
| 271 |
+
graph.add_conditional_edges(
|
| 272 |
+
"critic",
|
| 273 |
+
critic_route,
|
| 274 |
+
{"retry_modeling": "modeling", "explainability": "explainability"},
|
| 275 |
+
)
|
| 276 |
+
graph.add_edge("explainability", "executive_insights")
|
| 277 |
+
graph.add_edge("executive_insights", END)
|
| 278 |
+
return graph.compile()
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def run_analysis(
|
| 282 |
+
frame: pd.DataFrame,
|
| 283 |
+
target: str,
|
| 284 |
+
dataset_name: str,
|
| 285 |
+
settings: Settings | None = None,
|
| 286 |
+
) -> RunSummary:
|
| 287 |
+
settings = settings or get_settings()
|
| 288 |
+
run_id = f"run_{uuid4().hex[:12]}"
|
| 289 |
+
store = RunStore(settings)
|
| 290 |
+
artifact_store = ArtifactStore(settings.artifact_root)
|
| 291 |
+
store.save(run_id, dataset_name, "running", {"run_id": run_id, "status": "running"})
|
| 292 |
+
initial: AgentState = {
|
| 293 |
+
"run_id": run_id,
|
| 294 |
+
"dataset_name": dataset_name,
|
| 295 |
+
"frame": frame,
|
| 296 |
+
"target": target,
|
| 297 |
+
"settings": settings,
|
| 298 |
+
"trace": [],
|
| 299 |
+
}
|
| 300 |
+
try:
|
| 301 |
+
final = build_graph().invoke(initial)
|
| 302 |
+
payload = _summary_payload(final, run_id, dataset_name)
|
| 303 |
+
final["summary_payload"] = payload
|
| 304 |
+
log_to_mlflow(payload, settings)
|
| 305 |
+
artifacts = export_artifacts(run_id, final, artifact_store.run_directory(run_id))
|
| 306 |
+
payload["artifacts"] = artifacts
|
| 307 |
+
payload["status"] = "completed"
|
| 308 |
+
store.save(run_id, dataset_name, "completed", payload)
|
| 309 |
+
return RunSummary.model_validate(payload)
|
| 310 |
+
except Exception as exc:
|
| 311 |
+
store.save(
|
| 312 |
+
run_id,
|
| 313 |
+
dataset_name,
|
| 314 |
+
"failed",
|
| 315 |
+
{"run_id": run_id, "dataset_name": dataset_name, "status": "failed", "error": str(exc)},
|
| 316 |
+
)
|
| 317 |
+
raise
|
| 318 |
+
finally:
|
| 319 |
+
store.engine.dispose()
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def _summary_payload(state: AgentState, run_id: str, dataset_name: str) -> dict[str, Any]:
|
| 323 |
+
bundle = state["model_bundle"]
|
| 324 |
+
return {
|
| 325 |
+
"run_id": run_id,
|
| 326 |
+
"status": "completed",
|
| 327 |
+
"dataset_name": dataset_name,
|
| 328 |
+
"profile": state["profile"].model_dump(mode="json"),
|
| 329 |
+
"plan": state["plan"].model_dump(mode="json"),
|
| 330 |
+
"quality_issues": [item.model_dump(mode="json") for item in state["quality_issues"]],
|
| 331 |
+
"evidence": [item.model_dump(mode="json") for item in state["evidence"]],
|
| 332 |
+
"model_results": [item.model_dump(mode="json") for item in bundle.results],
|
| 333 |
+
"model_failures": [item.model_dump(mode="json") for item in bundle.failures],
|
| 334 |
+
"best_model": bundle.best_model,
|
| 335 |
+
"critic": state["critic"].model_dump(mode="json"),
|
| 336 |
+
"explainability": state["explainability"].model_dump(mode="json"),
|
| 337 |
+
"executive_summary": state["executive_summary"],
|
| 338 |
+
"recommendations": state["recommendations"],
|
| 339 |
+
"artifacts": {},
|
| 340 |
+
"trace": state["trace"],
|
| 341 |
+
}
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
ui:
|
| 3 |
+
build:
|
| 4 |
+
context: .
|
| 5 |
+
dockerfile: Dockerfile
|
| 6 |
+
ports:
|
| 7 |
+
- "8501:8501"
|
| 8 |
+
environment:
|
| 9 |
+
ARTIFACT_ROOT: /app/artifacts
|
| 10 |
+
DATABASE_URL: sqlite:////app/artifacts/datapilot.db
|
| 11 |
+
volumes:
|
| 12 |
+
- datapilot_artifacts:/app/artifacts
|
| 13 |
+
read_only: true
|
| 14 |
+
tmpfs:
|
| 15 |
+
- /tmp:size=128m
|
| 16 |
+
security_opt:
|
| 17 |
+
- no-new-privileges:true
|
| 18 |
+
cap_drop:
|
| 19 |
+
- ALL
|
| 20 |
+
mem_limit: 2g
|
| 21 |
+
cpus: 2
|
| 22 |
+
|
| 23 |
+
api:
|
| 24 |
+
build:
|
| 25 |
+
context: .
|
| 26 |
+
dockerfile: Dockerfile.api
|
| 27 |
+
ports:
|
| 28 |
+
- "8000:8000"
|
| 29 |
+
environment:
|
| 30 |
+
ARTIFACT_ROOT: /app/artifacts
|
| 31 |
+
DATABASE_URL: sqlite:////app/artifacts/datapilot.db
|
| 32 |
+
CORS_ORIGINS: http://localhost:8501
|
| 33 |
+
volumes:
|
| 34 |
+
- datapilot_artifacts:/app/artifacts
|
| 35 |
+
read_only: true
|
| 36 |
+
tmpfs:
|
| 37 |
+
- /tmp:size=128m
|
| 38 |
+
security_opt:
|
| 39 |
+
- no-new-privileges:true
|
| 40 |
+
cap_drop:
|
| 41 |
+
- ALL
|
| 42 |
+
mem_limit: 2g
|
| 43 |
+
cpus: 2
|
| 44 |
+
|
| 45 |
+
worker:
|
| 46 |
+
build:
|
| 47 |
+
context: .
|
| 48 |
+
dockerfile: Dockerfile.worker
|
| 49 |
+
network_mode: none
|
| 50 |
+
read_only: true
|
| 51 |
+
tmpfs:
|
| 52 |
+
- /tmp:size=32m,noexec,nosuid
|
| 53 |
+
security_opt:
|
| 54 |
+
- no-new-privileges:true
|
| 55 |
+
cap_drop:
|
| 56 |
+
- ALL
|
| 57 |
+
mem_limit: 256m
|
| 58 |
+
cpus: 0.5
|
| 59 |
+
pids_limit: 64
|
| 60 |
+
|
| 61 |
+
volumes:
|
| 62 |
+
datapilot_artifacts:
|
| 63 |
+
|
docs/API_EXAMPLES.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API examples
|
| 2 |
+
|
| 3 |
+
Start the service with `uvicorn api.main:app --port 8000`. If `API_KEY` is configured, include
|
| 4 |
+
`X-API-Key: <key>` in every `/v1/` request.
|
| 5 |
+
|
| 6 |
+
## Submit a bundled dataset
|
| 7 |
+
|
| 8 |
+
```bash
|
| 9 |
+
curl -i -X POST http://localhost:8000/v1/analyze/sample \
|
| 10 |
+
-H "Content-Type: application/json" \
|
| 11 |
+
-H "Idempotency-Key: iris-demo-001" \
|
| 12 |
+
-d '{"sample":"iris"}'
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
```json
|
| 16 |
+
{"job_id":"job_abc123","status":"queued"}
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
## Submit an upload
|
| 20 |
+
|
| 21 |
+
```bash
|
| 22 |
+
curl -i -X POST http://localhost:8000/v1/analyze/upload \
|
| 23 |
+
-F "file=@dataset.csv" \
|
| 24 |
+
-F "target=outcome"
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
## Poll or cancel
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
curl http://localhost:8000/v1/jobs/job_abc123
|
| 31 |
+
curl -X DELETE http://localhost:8000/v1/jobs/job_abc123
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
A completed job includes the validated run summary in `result`. Errors exposed to clients are
|
| 35 |
+
sanitized and carry an `X-Correlation-ID`; use that identifier to locate structured server logs.
|
| 36 |
+
The machine-readable contract is committed as [`openapi.json`](openapi.json).
|
| 37 |
+
|
docs/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DataPilot AI Architecture
|
| 2 |
+
|
| 3 |
+
## Design goals
|
| 4 |
+
|
| 5 |
+
DataPilot AI separates the user experience, orchestration, analytics, model training,
|
| 6 |
+
persistence, and optional restricted computation. The default application remains useful
|
| 7 |
+
without a paid model API; every displayed number originates from deterministic computation.
|
| 8 |
+
|
| 9 |
+
## Runtime components
|
| 10 |
+
|
| 11 |
+
| Component | Responsibility |
|
| 12 |
+
|---|---|
|
| 13 |
+
| Streamlit UI | Recruiter demo, upload/sample selection, charts, trace, downloads and Q&A |
|
| 14 |
+
| FastAPI | Versioned analysis, run, artifact and evidence-backed Q&A endpoints |
|
| 15 |
+
| LangGraph | Stateful agent ordering, state propagation and critic retry routing |
|
| 16 |
+
| Analytics core | DuckDB profiling, statistical summaries and data-quality evidence |
|
| 17 |
+
| ML core | Leakage-safe sklearn pipelines, model comparison and validation |
|
| 18 |
+
| Explainability | SHAP when compatible; permutation-importance fallback |
|
| 19 |
+
| Persistence | SQLAlchemy with SQLite locally and PostgreSQL via `DATABASE_URL` |
|
| 20 |
+
| Artifact store | Local filesystem interface, replaceable by S3/MinIO |
|
| 21 |
+
| Restricted worker | Expression-only AST validation in a networkless, resource-limited container |
|
| 22 |
+
|
| 23 |
+
## Agent graph
|
| 24 |
+
|
| 25 |
+
```mermaid
|
| 26 |
+
flowchart TD
|
| 27 |
+
A["Dataset + target"] --> B["Data Quality Agent"]
|
| 28 |
+
B --> C["EDA Agent (DuckDB)"]
|
| 29 |
+
C --> D["Statistical Analysis Agent"]
|
| 30 |
+
D --> E["Planning Agent"]
|
| 31 |
+
E --> F["Feature Engineering Agent"]
|
| 32 |
+
F --> G["Modeling Agent"]
|
| 33 |
+
G --> H{"Evaluation / Critic Agent"}
|
| 34 |
+
H -->|"Reject: weak or unstable"| G
|
| 35 |
+
H -->|"Approve"| I["Explainability Agent"]
|
| 36 |
+
I --> J["Executive Insights Agent"]
|
| 37 |
+
J --> K["Report + model card + pipeline + evidence"]
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
## Leakage controls
|
| 41 |
+
|
| 42 |
+
1. Rows without labels and exact duplicates are removed before splitting.
|
| 43 |
+
2. Train/test split occurs before any learned transformation.
|
| 44 |
+
3. Imputation, scaling, and one-hot encoding are inside `sklearn.pipeline.Pipeline`.
|
| 45 |
+
4. Cross-validation refits the complete pipeline in every fold.
|
| 46 |
+
5. Identifier and target-like features are flagged for human review.
|
| 47 |
+
6. Holdout metrics and cross-validation metrics remain distinct.
|
| 48 |
+
|
| 49 |
+
## Graceful degradation
|
| 50 |
+
|
| 51 |
+
- Without Gemini: deterministic evidence-backed executive narrative.
|
| 52 |
+
- Without SHAP: permutation importance.
|
| 53 |
+
- Without XGBoost: sklearn candidate models.
|
| 54 |
+
- Without MLflow: structured agent trace plus persisted run JSON.
|
| 55 |
+
- Without PostgreSQL: SQLite.
|
| 56 |
+
|
docs/BENCHMARKS.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Reproducible smoke benchmarks
|
| 2 |
+
|
| 3 |
+
These results are correctness-oriented smoke benchmarks, not claims about production accuracy.
|
| 4 |
+
They were measured on a local Windows CPU with Python 3.13.1, `RANDOM_STATE=42`,
|
| 5 |
+
`OPTUNA_TRIALS=0`, and `MAX_CRITIC_RETRIES=0`.
|
| 6 |
+
|
| 7 |
+
| Dataset | Rows | Task | Selected model | Training CV score | Untouched test score | Wall time |
|
| 8 |
+
|---|---:|---|---|---:|---:|---:|
|
| 9 |
+
| scikit-learn Iris | 150 | multiclass classification | Logistic Regression | 0.9583 weighted F1 | 0.9333 weighted F1 | 4.29 s |
|
| 10 |
+
| scikit-learn Diabetes | 442 | regression | Linear Regression | 0.4493 R² | 0.4526 R² | 3.17 s |
|
| 11 |
+
|
| 12 |
+
Candidate selection uses cross-validation on the training partition only. The test score is
|
| 13 |
+
computed once after selection. Timings vary by hardware and installed optional dependencies.
|
| 14 |
+
Run the same path with `python scripts/benchmark.py` once optional benchmark automation is added.
|
| 15 |
+
|
docs/DATA_PRIVACY.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Data privacy
|
| 2 |
+
|
| 3 |
+
DataPilot defaults to deterministic local analysis. Gemini is optional and receives a bounded
|
| 4 |
+
metadata package rather than the full dataset: schema, aggregates, target candidates, quality
|
| 5 |
+
signals, and a small redacted sample. Users can exclude columns, and likely PII columns are
|
| 6 |
+
removed automatically.
|
| 7 |
+
|
| 8 |
+
Production operators must define dataset retention, deletion SLAs, tenant isolation, regional
|
| 9 |
+
processing, encryption, access logging, data-subject request handling, and subprocessors. API
|
| 10 |
+
keys and database credentials belong in a secret manager and must never enter exports or logs.
|
docs/DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deployment Guide
|
| 2 |
+
|
| 3 |
+
## Streamlit Community Cloud
|
| 4 |
+
|
| 5 |
+
1. Push the repository to GitHub.
|
| 6 |
+
2. Create a Streamlit app with `streamlit_app.py` as the entrypoint.
|
| 7 |
+
3. Use Python 3.12.
|
| 8 |
+
4. Add `GEMINI_API_KEY` only if optional LLM narration is desired.
|
| 9 |
+
5. Keep the default SQLite/local artifact mode for a stateless public demo.
|
| 10 |
+
|
| 11 |
+
The app is fully functional without an LLM key.
|
| 12 |
+
|
| 13 |
+
## Render API
|
| 14 |
+
|
| 15 |
+
`render.yaml` defines a Docker-backed FastAPI service. Configure:
|
| 16 |
+
|
| 17 |
+
- `DATABASE_URL`: Neon, Supabase or another PostgreSQL URL.
|
| 18 |
+
- `GEMINI_API_KEY`: optional.
|
| 19 |
+
- persistent/object storage if generated artifacts must survive redeployments.
|
| 20 |
+
|
| 21 |
+
## Docker Compose
|
| 22 |
+
|
| 23 |
+
```bash
|
| 24 |
+
docker compose up --build
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
- Streamlit: <http://localhost:8501>
|
| 28 |
+
- FastAPI: <http://localhost:8000/docs>
|
| 29 |
+
|
| 30 |
+
The isolated worker has no exposed port and no network.
|
| 31 |
+
|
| 32 |
+
## Production topology
|
| 33 |
+
|
| 34 |
+
For heavier datasets, make `/v1/analyze/*` enqueue jobs to a separate worker and return
|
| 35 |
+
`202 Accepted`. Persist state in PostgreSQL, place artifacts in S3-compatible storage, and
|
| 36 |
+
stream progress through server-sent events or polling.
|
| 37 |
+
|