chore: import upstream snapshot with attribution
pytest / Unit Tests (push) Waiting to run
pytest / Integration (integration_tests_a) (push) Waiting to run
pytest / Integration (integration_tests_b) (push) Waiting to run
pytest / Integration (integration_tests_c) (push) Waiting to run
pytest / Integration (integration_tests_d) (push) Waiting to run
pytest / Integration (integration_tests_e) (push) Waiting to run
pytest / Integration (integration_tests_f) (push) Waiting to run
pytest / Integration (integration_tests_g) (push) Waiting to run
pytest / Integration (integration_tests_h) (push) Waiting to run
pytest / Integration (integration_tests_i) (push) Waiting to run
pytest / Integration (integration_tests_j) (push) Waiting to run
pytest / Distributed (distributed_a) (push) Waiting to run
pytest / Distributed (distributed_b) (push) Waiting to run
pytest / Distributed (distributed_c) (push) Waiting to run
pytest / Distributed (distributed_d) (push) Waiting to run
pytest / Distributed (distributed_e) (push) Waiting to run
pytest / Distributed (distributed_f) (push) Waiting to run
pytest / Minimal Install (push) Waiting to run
pytest / Event File (push) Waiting to run
pytest (slow) / py-slow (push) Waiting to run
Publish JSON Schema / publish-schema (push) Waiting to run
pytest / Unit Tests (push) Waiting to run
pytest / Integration (integration_tests_a) (push) Waiting to run
pytest / Integration (integration_tests_b) (push) Waiting to run
pytest / Integration (integration_tests_c) (push) Waiting to run
pytest / Integration (integration_tests_d) (push) Waiting to run
pytest / Integration (integration_tests_e) (push) Waiting to run
pytest / Integration (integration_tests_f) (push) Waiting to run
pytest / Integration (integration_tests_g) (push) Waiting to run
pytest / Integration (integration_tests_h) (push) Waiting to run
pytest / Integration (integration_tests_i) (push) Waiting to run
pytest / Integration (integration_tests_j) (push) Waiting to run
pytest / Distributed (distributed_a) (push) Waiting to run
pytest / Distributed (distributed_b) (push) Waiting to run
pytest / Distributed (distributed_c) (push) Waiting to run
pytest / Distributed (distributed_d) (push) Waiting to run
pytest / Distributed (distributed_e) (push) Waiting to run
pytest / Distributed (distributed_f) (push) Waiting to run
pytest / Minimal Install (push) Waiting to run
pytest / Event File (push) Waiting to run
pytest (slow) / py-slow (push) Waiting to run
Publish JSON Schema / publish-schema (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# Ludwig Codebase Review
|
||||
|
||||
Perform a thorough, opinionated code review of the Ludwig codebase (or a specified subsystem if an argument is given).
|
||||
|
||||
## Scope
|
||||
|
||||
If $ARGUMENTS is provided, scope the review to that subsystem or file pattern (e.g. `ludwig/features/`, `data pipeline`, `ray backend`).
|
||||
Otherwise review the entire codebase.
|
||||
|
||||
## Review Dimensions
|
||||
|
||||
Evaluate each area across ALL of the following axes:
|
||||
|
||||
### Technical axes
|
||||
|
||||
- **Code smells**: long methods, god objects, feature envy, primitive obsession, data clumps, shotgun surgery, dead code
|
||||
- **Duplication**: copy-paste logic, structural duplication, near-duplicate classes that should share a base
|
||||
- **Abstraction level**: too low (leaking internals), too high (over-engineered), mismatched levels within a single function
|
||||
- **Naming**: violate "naming things" rules — misleading names, abbreviations, overly generic names (`utils`, `helper`, `Manager`), names that lie about what a thing does, names that describe implementation not intent
|
||||
- **Type hints**: missing, incomplete, `Any`-abuse, wrong (e.g. `dict` where `dict[str, float]` is knowable)
|
||||
- **Docstrings**: missing on public API, wrong (describe what not why), stale (describe removed behavior)
|
||||
- **Test coverage**: untested public surface, tests that only test the happy path, tests that mock away the thing being tested, missing edge cases
|
||||
- **Performance**: unnecessary copies, redundant I/O, blocking the event loop, O(N²) in disguise, missing caching
|
||||
- **Consistency**: same concept named differently in different files, different patterns for the same operation, inconsistent error handling styles
|
||||
|
||||
### Persona axes
|
||||
|
||||
Rate severity from each perspective and explain why it matters to that audience:
|
||||
|
||||
- **ML Engineer** (building production pipelines): Does this cause silent failures? Surprise OOMs? Hard-to-debug errors? Bad default choices?
|
||||
- **ML Researcher** (running experiments): Is the config surface clear? Can they reproduce results? Do names match paper terminology? Is the API discoverable?
|
||||
- **Open Source Contributor** (first PR): Is the code navigable? Is there a clear pattern to follow? Are there unexplained magic constants? Is test setup obvious?
|
||||
- **Social Media ML Reader** (HN/Reddit/X): Would they call this "spaghetti"? Is there obvious NIH syndrome? Would they praise the architecture or cringe at it?
|
||||
|
||||
## Output Format
|
||||
|
||||
Structure the review as:
|
||||
|
||||
### Executive Summary
|
||||
|
||||
2-3 sentences on overall health and the single most important thing to fix.
|
||||
|
||||
### Critical Issues (must fix)
|
||||
|
||||
Numbered list. Each entry: file:line_range, what's wrong, why it matters, concrete fix.
|
||||
|
||||
### Major Issues (should fix)
|
||||
|
||||
Same format. Things that hurt quality but aren't blocking.
|
||||
|
||||
### Minor Issues (nice to fix)
|
||||
|
||||
Grouped by category (naming, type hints, docstrings, etc.).
|
||||
|
||||
### Persona Verdicts
|
||||
|
||||
One paragraph per persona with their honest take.
|
||||
|
||||
### Improvement Plan
|
||||
|
||||
Ordered list of PRs/tasks to address everything, with rough size estimate (S/M/L/XL).
|
||||
|
||||
## Instructions
|
||||
|
||||
- Be specific: always cite file paths and line numbers (or ranges)
|
||||
- Be opinionated: don't hedge with "consider maybe possibly"
|
||||
- Don't praise things that are merely adequate
|
||||
- Distinguish between subjective style and objective bugs
|
||||
- Focus on patterns, not one-off issues — if the same problem appears in 10 files, name the pattern once and give 3 examples
|
||||
- Use the Explore subagent for broad searches, then Read for deep dives on critical files
|
||||
@@ -0,0 +1 @@
|
||||
{"sessionId":"733fdee3-4ca3-414d-9f48-1cfce3f2259d","pid":132110,"procStart":"14515772","acquiredAt":1779640200070}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"frontend-design@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
version = 1
|
||||
|
||||
test_patterns = [
|
||||
"tests/**"
|
||||
]
|
||||
|
||||
[[analyzers]]
|
||||
name = "python"
|
||||
enabled = true
|
||||
runtime_version = "3.x.x"
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
build-essential \
|
||||
curl \
|
||||
libsndfile1 \
|
||||
ffmpeg \
|
||||
sox \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user
|
||||
ARG USERNAME=vscode
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=$USER_UID
|
||||
RUN groupadd --gid $USER_GID $USERNAME \
|
||||
&& useradd --uid $USER_UID --gid $USER_GID -m $USERNAME
|
||||
|
||||
USER $USERNAME
|
||||
ENV PATH="/home/$USERNAME/.local/bin:$PATH"
|
||||
|
||||
# Pre-install pip tools so editable install is fast
|
||||
RUN pip install --user --no-cache-dir --upgrade pip setuptools wheel
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "Ludwig Dev",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": ".."
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python"
|
||||
},
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"charliermarsh.ruff"
|
||||
]
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "pip install --user -e '.[test]'",
|
||||
"remoteUser": "vscode",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/git:1": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[flake8]
|
||||
max-line-length = 120
|
||||
exclude =
|
||||
.tox,
|
||||
*.egg,
|
||||
*_pb2.py,
|
||||
build,
|
||||
temp
|
||||
|
||||
select = E,W,F
|
||||
doctests = True
|
||||
verbose = 2
|
||||
format = pylint
|
||||
# E731: Do not assign a lambda expression, use a def
|
||||
# W503: Line break occurred before a binary operator
|
||||
# E203: whitespace before ':'
|
||||
ignore =
|
||||
E731,
|
||||
W503,
|
||||
E203
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
1. Click on '....'
|
||||
1. Scroll down to '....'
|
||||
1. See error
|
||||
|
||||
Please provide code, yaml config file and a sample of data in order to entirely reproduce the issue.
|
||||
Issues that are not reproducible will be ignored.
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
|
||||
- OS: [e.g. iOS]
|
||||
- Version [e.g. 22]
|
||||
- Python version
|
||||
- Ludwig version
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the use case**
|
||||
A clear and concise description of what the use case for this feature is.
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Code Pull Requests
|
||||
|
||||
Please provide the following:
|
||||
|
||||
- a clear explanation of what your code does
|
||||
- if applicable, a reference to an issue
|
||||
- a reproducible test for your PR (code, config and data sample)
|
||||
|
||||
# Documentation Pull Requests
|
||||
|
||||
Note that the documentation HTML files are in `docs/` while the Markdown sources are in `mkdocs/docs`.
|
||||
|
||||
If you are proposing a modification to the documentation you should change only the Markdown files.
|
||||
|
||||
`api.md` is automatically generated from the docstrings in the code, so if you want to change something in that file, first modify `ludwig/api.py` docstring, then run `mkdocs/code_docs_autogen.py`, which will create `mkdocs/docs/api.md` .
|
||||
@@ -0,0 +1,86 @@
|
||||
name: docker
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*.*.*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ludwig_version:
|
||||
description: "PyPI version to install, e.g. 0.13.0 (leave empty to build from source)"
|
||||
required: false
|
||||
default: ""
|
||||
latest:
|
||||
description: "Also tag as :latest"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
name: Build docker image ${{ matrix.docker-image }}
|
||||
if: github.repository == 'ludwig-ai/ludwig'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# cancel in-progress runs for the same branch/tag
|
||||
concurrency:
|
||||
group: docker-${{ matrix.docker-image }}-${{ inputs.ludwig_version || github.head_ref || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
docker-image:
|
||||
- ludwig
|
||||
- ludwig-gpu
|
||||
- ludwig-ray
|
||||
- ludwig-ray-gpu
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Derive minor version tag
|
||||
id: version
|
||||
if: inputs.ludwig_version != ''
|
||||
run: |
|
||||
minor=$(echo "${{ inputs.ludwig_version }}" | cut -d. -f1-2)
|
||||
echo "minor=${minor}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
ludwigai/${{ matrix.docker-image }}
|
||||
tags: |
|
||||
# versioned dispatch: use explicit PyPI version tags
|
||||
type=raw,value=${{ inputs.ludwig_version }},enable=${{ inputs.ludwig_version != '' }}
|
||||
type=raw,value=${{ steps.version.outputs.minor }},enable=${{ inputs.ludwig_version != '' }}
|
||||
type=raw,value=latest,enable=${{ inputs.ludwig_version != '' && inputs.latest }}
|
||||
# tag push: derive tags from git ref
|
||||
type=semver,pattern={{version}},enable=${{ inputs.ludwig_version == '' }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ inputs.ludwig_version == '' }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/${{ matrix.docker-image }}/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
LUDWIG_VERSION=${{ inputs.ludwig_version }}
|
||||
@@ -0,0 +1,267 @@
|
||||
name: pytest
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "release-*", "future-capabilities"]
|
||||
pull_request:
|
||||
branches: ["main", "release-*", "future-capabilities"]
|
||||
|
||||
concurrency:
|
||||
group: pytest-${{ github.head_ref || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.LUDWIG_TESTS_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.LUDWIG_TESTS_AWS_SECRET_ACCESS_KEY }}
|
||||
KAGGLE_USERNAME: ${{ secrets.KAGGLE_USERNAME }}
|
||||
KAGGLE_KEY: ${{ secrets.KAGGLE_KEY }}
|
||||
IS_NOT_FORK: ${{ !(github.event.pull_request.base.repo.full_name == 'ludwig-ai/ludwig' && github.event.pull_request.head.repo.fork) }}
|
||||
UV_SYSTEM_PYTHON: "1"
|
||||
|
||||
name: Unit Tests
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup Linux
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y cmake libsndfile1 libsox-dev ffmpeg
|
||||
|
||||
- name: uv cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: ${{ runner.os }}-uv-unit-${{ hashFiles('pyproject.toml', '.github/workflows/pytest.yml') }}
|
||||
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install torch==2.12.0 torchvision==0.27.0 torchaudio==2.11.0 torchcodec --extra-index-url https://download.pytorch.org/whl/cpu
|
||||
uv pip install '.[test]'
|
||||
uv pip list
|
||||
|
||||
- name: Unit Tests
|
||||
run: |
|
||||
RUN_PRIVATE=$IS_NOT_FORK LUDWIG_TEST_SUITE_TIMEOUT_S=5400 pytest -v --timeout 300 --durations 100 -m "not distributed and not slow and not combinatorial and not llm" --junitxml pytest.xml tests/ludwig
|
||||
|
||||
- name: Regression Tests
|
||||
run: |
|
||||
RUN_PRIVATE=$IS_NOT_FORK LUDWIG_TEST_SUITE_TIMEOUT_S=5400 pytest -v --timeout 300 --durations 100 -m "not distributed and not slow and not combinatorial and not llm" --junitxml pytest-regression.xml tests/regression_tests
|
||||
|
||||
- name: Upload Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Unit Test Results
|
||||
path: pytest*.xml
|
||||
|
||||
integration-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
test-markers:
|
||||
- "integration_tests_a"
|
||||
- "integration_tests_b"
|
||||
- "integration_tests_c"
|
||||
- "integration_tests_d"
|
||||
- "integration_tests_e"
|
||||
- "integration_tests_f"
|
||||
- "integration_tests_g"
|
||||
- "integration_tests_h"
|
||||
- "integration_tests_i"
|
||||
- "integration_tests_j"
|
||||
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.LUDWIG_TESTS_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.LUDWIG_TESTS_AWS_SECRET_ACCESS_KEY }}
|
||||
KAGGLE_USERNAME: ${{ secrets.KAGGLE_USERNAME }}
|
||||
KAGGLE_KEY: ${{ secrets.KAGGLE_KEY }}
|
||||
IS_NOT_FORK: ${{ !(github.event.pull_request.base.repo.full_name == 'ludwig-ai/ludwig' && github.event.pull_request.head.repo.fork) }}
|
||||
MARKERS: ${{ matrix.test-markers }}
|
||||
UV_SYSTEM_PYTHON: "1"
|
||||
MLFLOW_ALLOW_FILE_STORE: "true"
|
||||
|
||||
name: Integration (${{ matrix.test-markers }})
|
||||
services:
|
||||
minio:
|
||||
image: fclairamb/minio-github-actions
|
||||
env:
|
||||
MINIO_ACCESS_KEY: minio
|
||||
MINIO_SECRET_KEY: minio123
|
||||
ports:
|
||||
- 9000:9000
|
||||
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup Linux
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y cmake libsndfile1 libsox-dev ffmpeg
|
||||
|
||||
- name: uv cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: ${{ runner.os }}-uv-integration-${{ hashFiles('pyproject.toml', '.github/workflows/pytest.yml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip install torch==2.12.0 torchvision==0.27.0 torchaudio==2.11.0 torchcodec --extra-index-url https://download.pytorch.org/whl/cpu
|
||||
uv pip install '.[test]'
|
||||
uv pip list
|
||||
|
||||
- name: Free Disk Space
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
|
||||
- name: Integration Tests
|
||||
run: |
|
||||
RUN_PRIVATE=$IS_NOT_FORK LUDWIG_TEST_SUITE_TIMEOUT_S=7200 pytest -v --timeout 300 --durations 100 -m "not slow and not combinatorial and not llm and $MARKERS" --junitxml pytest.xml tests/integration_tests
|
||||
|
||||
- name: Upload Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Integration Test Results (${{ matrix.test-markers }})
|
||||
path: pytest.xml
|
||||
|
||||
distributed-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
distributed-group:
|
||||
- "distributed_a"
|
||||
- "distributed_b"
|
||||
- "distributed_c"
|
||||
- "distributed_d"
|
||||
- "distributed_e"
|
||||
- "distributed_f"
|
||||
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.LUDWIG_TESTS_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.LUDWIG_TESTS_AWS_SECRET_ACCESS_KEY }}
|
||||
KAGGLE_USERNAME: ${{ secrets.KAGGLE_USERNAME }}
|
||||
KAGGLE_KEY: ${{ secrets.KAGGLE_KEY }}
|
||||
IS_NOT_FORK: ${{ !(github.event.pull_request.base.repo.full_name == 'ludwig-ai/ludwig' && github.event.pull_request.head.repo.fork) }}
|
||||
DIST_GROUP: ${{ matrix.distributed-group }}
|
||||
UV_SYSTEM_PYTHON: "1"
|
||||
MLFLOW_ALLOW_FILE_STORE: "true"
|
||||
|
||||
name: Distributed (${{ matrix.distributed-group }})
|
||||
services:
|
||||
minio:
|
||||
image: fclairamb/minio-github-actions
|
||||
env:
|
||||
MINIO_ACCESS_KEY: minio
|
||||
MINIO_SECRET_KEY: minio123
|
||||
ports:
|
||||
- 9000:9000
|
||||
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup Linux
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y cmake libsndfile1 libsox-dev ffmpeg
|
||||
|
||||
- name: uv cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: ${{ runner.os }}-uv-distributed-${{ hashFiles('pyproject.toml', '.github/workflows/pytest.yml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip install torch==2.12.0 torchvision==0.27.0 torchaudio==2.11.0 torchcodec --extra-index-url https://download.pytorch.org/whl/cpu
|
||||
uv pip install '.[test]'
|
||||
uv pip list
|
||||
|
||||
- name: Free Disk Space
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
|
||||
- name: Distributed Tests
|
||||
run: |
|
||||
RUN_PRIVATE=$IS_NOT_FORK LUDWIG_TEST_SUITE_TIMEOUT_S=5400 pytest -v --timeout 300 --durations 100 -m "distributed and not slow and not combinatorial and not llm and $DIST_GROUP" --ignore=tests/integration_tests/test_server.py --junitxml pytest.xml tests/ludwig tests/integration_tests
|
||||
|
||||
- name: Upload Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Distributed Test Results (${{ matrix.distributed-group }})
|
||||
path: pytest.xml
|
||||
|
||||
test-minimal-install:
|
||||
name: Minimal Install
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
UV_SYSTEM_PYTHON: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup Linux
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y cmake libsndfile1
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip install torch==2.12.0 torchvision==0.27.0 torchaudio==2.11.0 torchcodec --extra-index-url https://download.pytorch.org/whl/cpu
|
||||
uv pip install .
|
||||
uv pip list
|
||||
|
||||
- name: Check Install
|
||||
run: |
|
||||
ludwig check_install
|
||||
|
||||
event_file:
|
||||
name: "Event File"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Event File
|
||||
path: ${{ github.event_path }}
|
||||
@@ -0,0 +1,72 @@
|
||||
# This workflow will install Python dependencies and run all tests marked as `slow` on a single Python version.
|
||||
|
||||
name: pytest (slow)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "release-*"]
|
||||
|
||||
jobs:
|
||||
slow-pytest:
|
||||
name: py-slow
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Use Minio credentials for all S3 operations in tests.
|
||||
# PyArrow/Ray S3 clients use these env vars directly, so they must point to Minio.
|
||||
AWS_ACCESS_KEY_ID: minio
|
||||
AWS_SECRET_ACCESS_KEY: minio123
|
||||
AWS_ENDPOINT_URL: http://localhost:9000
|
||||
KAGGLE_USERNAME: ${{ secrets.KAGGLE_USERNAME }}
|
||||
KAGGLE_KEY: ${{ secrets.KAGGLE_KEY }}
|
||||
IS_NOT_FORK: ${{ !(github.event.pull_request.base.repo.full_name == 'ludwig-ai/ludwig' && github.event.pull_request.head.repo.fork) }}
|
||||
MLFLOW_ALLOW_FILE_STORE: "true"
|
||||
|
||||
services:
|
||||
minio:
|
||||
image: fclairamb/minio-github-actions
|
||||
env:
|
||||
MINIO_ACCESS_KEY: minio
|
||||
MINIO_SECRET_KEY: minio123
|
||||
ports:
|
||||
- 9000:9000
|
||||
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup Linux
|
||||
if: runner.os == 'linux'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y cmake libsndfile1 libsox-dev ffmpeg
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python --version
|
||||
pip --version
|
||||
pip install uv
|
||||
uv pip install --system torch==2.12.0 torchvision==0.27.0 torchaudio==2.11.0 torchcodec --extra-index-url https://download.pytorch.org/whl/cpu
|
||||
uv pip install --system '.[test]'
|
||||
pip list
|
||||
shell: bash
|
||||
|
||||
- name: Create Minio test bucket
|
||||
run: |
|
||||
python -c "
|
||||
import boto3
|
||||
s3 = boto3.client('s3', endpoint_url='http://localhost:9000',
|
||||
aws_access_key_id='minio', aws_secret_access_key='minio123')
|
||||
try:
|
||||
s3.create_bucket(Bucket='ludwig-tests')
|
||||
except s3.exceptions.BucketAlreadyOwnedByYou:
|
||||
pass
|
||||
"
|
||||
shell: bash
|
||||
|
||||
- name: Tests
|
||||
run: |
|
||||
RUN_PRIVATE=$IS_NOT_FORK LUDWIG_TEST_SUITE_TIMEOUT_S=7200 pytest -v --timeout 600 --durations 100 -m "slow" --junitxml pytest.xml tests/integration_tests/
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Publish JSON Schema
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "ludwig/schema/**"
|
||||
- "ludwig/config_validation/**"
|
||||
- "ludwig/constants.py"
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-schema:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Ludwig
|
||||
run: pip install -e ".[test]"
|
||||
|
||||
- name: Export schemas
|
||||
run: |
|
||||
mkdir -p schema-out
|
||||
ludwig export_schema --model-type combined -o schema-out/ludwig-config.json
|
||||
ludwig export_schema --model-type ecd -o schema-out/ludwig-config-ecd.json
|
||||
ludwig export_schema --model-type llm -o schema-out/ludwig-config-llm.json
|
||||
|
||||
- name: Generate index.html
|
||||
run: |
|
||||
cat > schema-out/index.html << 'HTMLEOF'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Ludwig JSON Schema</title>
|
||||
<style>body{font-family:system-ui,sans-serif;max-width:640px;margin:2rem auto;padding:0 1rem}a{color:#0066cc}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Ludwig JSON Schema</h1>
|
||||
<p>JSON Schema files for <a href="https://github.com/ludwig-ai/ludwig">Ludwig</a> config validation and IDE auto-complete.</p>
|
||||
<ul>
|
||||
<li><a href="ludwig-config.json">ludwig-config.json</a> — Combined (ECD + LLM)</li>
|
||||
<li><a href="ludwig-config-ecd.json">ludwig-config-ecd.json</a> — ECD only</li>
|
||||
<li><a href="ludwig-config-llm.json">ludwig-config-llm.json</a> — LLM only</li>
|
||||
</ul>
|
||||
<h2>Usage</h2>
|
||||
<p>Add to your Ludwig YAML config:</p>
|
||||
<pre># yaml-language-server: $schema=https://ludwig-ai.github.io/schema/ludwig-config.json</pre>
|
||||
<p>Or see <a href="https://www.schemastore.org/json/">SchemaStore</a> for automatic IDE integration.</p>
|
||||
</body>
|
||||
</html>
|
||||
HTMLEOF
|
||||
|
||||
- name: Publish to ludwig-ai/schema
|
||||
uses: cpina/github-action-push-to-another-repository@v1.7.2
|
||||
env:
|
||||
SSH_DEPLOY_KEY: ${{ secrets.SCHEMA_REPO_DEPLOY_KEY }}
|
||||
with:
|
||||
source-directory: schema-out
|
||||
destination-github-username: ludwig-ai
|
||||
destination-repository-name: schema
|
||||
target-branch: main
|
||||
commit-message: "Update Ludwig JSON schema from ${{ github.sha }}"
|
||||
@@ -0,0 +1,70 @@
|
||||
name: test results
|
||||
|
||||
# Post a clean check-run summary per test suite after the pytest workflow finishes.
|
||||
#
|
||||
# Replaces the older EnricoMi/publish-unit-test-result-action bot that posted busy
|
||||
# emoji-table PR comments accumulating across runs. dorny/test-reporter@v2 instead
|
||||
# posts a single well-formatted check run per suite with a table of passed / failed
|
||||
# tests, collapsible stack traces for failures, and source-line annotations. No
|
||||
# PR-comment spam; the result lives in the checks UI and on the "Details" page.
|
||||
#
|
||||
# Runs inside a workflow_run triggered by pytest so the downloaded JUnit XML
|
||||
# artifacts come with the write permissions needed to post check runs from
|
||||
# forked PRs (where the triggering workflow itself is read-only).
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["pytest"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
test-results:
|
||||
name: Test Results
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.conclusion != 'skipped'
|
||||
|
||||
steps:
|
||||
- name: Unit Tests report
|
||||
uses: dorny/test-reporter@v2
|
||||
with:
|
||||
artifact: Unit Test Results
|
||||
name: Unit Tests
|
||||
path: "*.xml"
|
||||
reporter: java-junit
|
||||
fail-on-error: false
|
||||
list-suites: failed
|
||||
list-tests: failed
|
||||
max-annotations: 50
|
||||
|
||||
- name: Integration Tests report
|
||||
uses: dorny/test-reporter@v2
|
||||
with:
|
||||
# Six matrix jobs upload artifacts named
|
||||
# "Integration Test Results (integration_tests_a)" .. _f. Match them all with
|
||||
# a regex and aggregate into a single report.
|
||||
artifact: /Integration Test Results .*/
|
||||
name: Integration Tests
|
||||
path: "*.xml"
|
||||
reporter: java-junit
|
||||
fail-on-error: false
|
||||
list-suites: failed
|
||||
list-tests: failed
|
||||
max-annotations: 50
|
||||
|
||||
- name: Distributed Tests report
|
||||
uses: dorny/test-reporter@v2
|
||||
with:
|
||||
artifact: /Distributed Test Results .*/
|
||||
name: Distributed Tests
|
||||
path: "*.xml"
|
||||
reporter: java-junit
|
||||
fail-on-error: false
|
||||
list-suites: failed
|
||||
list-tests: failed
|
||||
max-annotations: 50
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Upload to PyPI
|
||||
|
||||
on:
|
||||
# Triggers the workflow when a release or draft of a release is published,
|
||||
# or a pre-release is changed to a release
|
||||
release:
|
||||
types: [released]
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
pypi-publish:
|
||||
name: upload release to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
# Specifying a GitHub environment is optional, but strongly encouraged
|
||||
environment: release
|
||||
permissions:
|
||||
# IMPORTANT: this permission is mandatory for trusted publishing
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: "recursive"
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Build source distribution and wheel
|
||||
run: |
|
||||
pip install build
|
||||
python -m build
|
||||
|
||||
- name: Publish package distributions to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
###################
|
||||
# ludwig specific #
|
||||
###################
|
||||
|
||||
*.lock_preprocessing
|
||||
.plans/
|
||||
results/
|
||||
ludwig/results/
|
||||
results_*/
|
||||
ludwig_arm64/
|
||||
|
||||
# ailabs-utils
|
||||
ailabs_util
|
||||
docker_assets
|
||||
|
||||
# data
|
||||
mnist_data/
|
||||
profile_images/
|
||||
./profile_images/
|
||||
|
||||
###########
|
||||
# General #
|
||||
###########
|
||||
|
||||
# Mac stuff
|
||||
.DS_Store
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
env*
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
./downloads/
|
||||
./dataset/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*,cover
|
||||
.hypothesis/
|
||||
|
||||
# Data
|
||||
*.csv
|
||||
*.hdf5
|
||||
*.meta.json
|
||||
*.parquet
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# dotenv
|
||||
.env
|
||||
|
||||
# virtualenv
|
||||
.venv
|
||||
venv*
|
||||
ENV/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# PyCharm
|
||||
.idea
|
||||
|
||||
# ctags
|
||||
tags
|
||||
|
||||
# examples
|
||||
examples/*/data/
|
||||
examples/*/visualizations/
|
||||
|
||||
# benchmarking configs
|
||||
ludwig/benchmarking/configs/
|
||||
|
||||
# Aim tracking
|
||||
.aim/
|
||||
|
||||
# Comet
|
||||
.comet.config
|
||||
|
||||
# Test-generated artifacts (image/audio features)
|
||||
*.png
|
||||
*.wav
|
||||
generated_audio/
|
||||
generated_images/
|
||||
|
||||
# Claude Code worktrees (should never be committed)
|
||||
.claude/worktrees/
|
||||
@@ -0,0 +1,315 @@
|
||||
# Ludwig Examples & Tutorials Expansion Plan
|
||||
|
||||
## Context
|
||||
|
||||
Two releases (0.12 → 0.13) added substantial new surface area. The table below maps every
|
||||
significant new feature to its current documentation coverage and the example gap that needs
|
||||
filling. The final section covers features in open PRs that are not yet merged.
|
||||
|
||||
### Merged — what's in main right now
|
||||
|
||||
| Feature | PR | Example gap |
|
||||
|---------|----|-------------|
|
||||
| Anomaly detection (Deep SVDD, SAD, DROCC) | #4109 | No runnable example, doc page only |
|
||||
| Image decoders: SegFormer, FPN, configurable UNet | #4100 | `semantic_segmentation/` is stale |
|
||||
| Focal, Dice, Lovász, NT-Xent, PolyLoss, Entmax losses | #4107 | Nothing |
|
||||
| LLM logits extraction, structured output, constrained decoding | #4103 | Nothing |
|
||||
| FastAPI: auto-schemas, Prometheus metrics, structured logging | #4101 | Nothing |
|
||||
| New LR schedulers: OneCycleLR, inverse_sqrt, WSD, polynomial | #4106 | Nothing |
|
||||
| New optimizers: RAdam, Adafactor, Schedule-Free AdamW, Muon, SOAP | #4105 | Nothing |
|
||||
| MLPClassifier decoder, temperature calibration, MC Dropout | #4108 | Nothing |
|
||||
| TransformerDecoder, scheduled sampling, beam search | #4102 | Nothing |
|
||||
| CLIP, DINOv2, SigLIP pretrained image encoders | standalone | Nothing |
|
||||
| RLHF alignment trainers: DPO, KTO, ORPO, GRPO | earlier | Config snippets only |
|
||||
| vLLM serving backend | #4089 | Doc section only |
|
||||
| Entropic Open-Set & Objectosphere losses | standalone | Standalone script, no doc tutorial |
|
||||
|
||||
### Open PRs — not yet merged
|
||||
|
||||
| Feature | PR | Branch |
|
||||
|---------|----|----|
|
||||
| LLM-driven config generation (`generate_config`) | #4092 | `future-capabilities` |
|
||||
| HyperNetworkCombiner | #4092 | `future-capabilities` |
|
||||
| Nash-MTL loss balancer | #4092 | `future-capabilities` |
|
||||
| ModelInspector, trainer mixins, registry modernization | #4091 | `api-code-quality` |
|
||||
| Native Optuna HPO executor | #4090 | `data-pipeline-hyperopt-modernization` |
|
||||
| Typed feature metadata classes | #4090 | `data-pipeline-hyperopt-modernization` |
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — High impact, completely uncharted (start here)
|
||||
|
||||
### 1. Anomaly Detection Tutorial
|
||||
**Source:** PR #4109
|
||||
**Files:**
|
||||
- `examples/anomaly_detection/train_deep_svdd.py`
|
||||
- `examples/anomaly_detection/train_deep_sad.py`
|
||||
- `examples/anomaly_detection/train_drocc.py`
|
||||
- `examples/anomaly_detection/config_deep_svdd.yaml`
|
||||
- `examples/anomaly_detection/config_deep_sad.yaml`
|
||||
- `examples/anomaly_detection/config_drocc.yaml`
|
||||
- `examples/anomaly_detection/README.md`
|
||||
- `ludwig-docs/docs/examples/anomaly_detection.md`
|
||||
|
||||
**Content:**
|
||||
- Synthetic sensor dataset (no download required), all three loss variants
|
||||
- Score histogram plots showing separation between normal and anomalous samples
|
||||
- Colab notebook: multimodal anomaly detection (tabular + text log messages)
|
||||
- Guidance on choosing the threshold (95th percentile of training scores)
|
||||
|
||||
---
|
||||
|
||||
### 2. Alignment / RLHF Tutorial
|
||||
**Source:** alignment trainers (DPO, KTO, ORPO, GRPO)
|
||||
**Files:**
|
||||
- `examples/alignment/train_dpo.py` — Llama-3.1-8B, public preference dataset, 4-bit QLoRA
|
||||
- `examples/alignment/train_kto.py`
|
||||
- `examples/alignment/train_orpo.py`
|
||||
- `examples/alignment/config_dpo.yaml`
|
||||
- `examples/alignment/config_kto.yaml`
|
||||
- `examples/alignment/config_orpo.yaml`
|
||||
- `examples/alignment/README.md`
|
||||
- `ludwig-docs/docs/examples/llm/alignment.md` (new)
|
||||
- `ludwig-docs/docs/user_guide/llms/finetuning.md` (expand with end-to-end walkthrough)
|
||||
|
||||
**Content:**
|
||||
- DPO walkthrough from dataset prep to serving
|
||||
- DPO vs KTO comparison on the same preference dataset
|
||||
- When to use each method (table: data requirements, training cost, use case)
|
||||
- GRPO section with a custom reward function example (separate entry below)
|
||||
|
||||
---
|
||||
|
||||
### 3. vLLM Serving Tutorial
|
||||
**Source:** PR #4089 / `--backend vllm`
|
||||
**Files:**
|
||||
- `examples/serve/vllm_serving.py`
|
||||
- `examples/serve/vllm_client.py`
|
||||
- `examples/serve/prometheus_monitoring/docker-compose.yml`
|
||||
- `examples/serve/prometheus_monitoring/grafana_dashboard.json`
|
||||
- `ludwig-docs/docs/user_guide/serving.md` (expand existing)
|
||||
|
||||
**Content:**
|
||||
- Model prep → launch → benchmark vs. default backend
|
||||
- Latency/throughput comparison chart
|
||||
- Multi-GPU launch with `--num_gpus N`
|
||||
- OpenAI-compatible endpoint usage
|
||||
- Prometheus scrape config + sample Grafana dashboard
|
||||
|
||||
---
|
||||
|
||||
### 4. Structured / Constrained LLM Decoding
|
||||
**Source:** PR #4103
|
||||
**Files:**
|
||||
- `examples/llm_structured_output/entity_extraction.py`
|
||||
- `examples/llm_structured_output/constrained_classification.py`
|
||||
- `examples/llm_structured_output/config_json_schema.yaml`
|
||||
- `examples/llm_structured_output/README.md`
|
||||
- `ludwig-docs/docs/user_guide/llms/structured_output.md` (new)
|
||||
|
||||
**Content:**
|
||||
- Schema-constrained JSON generation (entity extraction → validated output)
|
||||
- Regex-constrained token generation (forced classification labels)
|
||||
- Logits extraction and custom post-processing
|
||||
- Comparison: unconstrained vs. constrained output quality
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 — High impact, extends existing areas
|
||||
|
||||
### 5. Open-Set Recognition — Full Tutorial
|
||||
**Source:** Entropic Open-Set & Objectosphere losses (already in `examples/open_set_recognition/`)
|
||||
**Files:**
|
||||
- `examples/open_set_recognition/train_open_set_mnist.py` (add MNIST-based Colab notebook)
|
||||
- `examples/open_set_recognition/open_set_recognition.ipynb` (Colab)
|
||||
- `ludwig-docs/docs/examples/open_set_recognition.md` (new, add to examples index)
|
||||
|
||||
**Content:**
|
||||
- MNIST: known classes 0–7, unknown classes 8–9
|
||||
- Confidence histogram: CE vs. Entropic vs. Objectosphere
|
||||
- Full Ludwig YAML + CLI walkthrough (not just raw PyTorch)
|
||||
- Inference-time threshold selection from validation set
|
||||
- Links to the standalone PyTorch script as a "how it works" explainer
|
||||
|
||||
---
|
||||
|
||||
### 6. Uncertainty Quantification
|
||||
**Source:** PR #4108 (MC Dropout + temperature scaling calibration)
|
||||
**Files:**
|
||||
- `examples/uncertainty/mc_dropout.py`
|
||||
- `examples/uncertainty/temperature_calibration.py`
|
||||
- `examples/uncertainty/config_mc_dropout.yaml`
|
||||
- `examples/uncertainty/README.md`
|
||||
- `ludwig-docs/docs/user_guide/uncertainty.md` (new)
|
||||
|
||||
**Content:**
|
||||
- Wine quality dataset (already in examples, no download)
|
||||
- Calibration curves and reliability diagrams before/after temperature scaling
|
||||
- MC Dropout: how many forward passes, variance as uncertainty proxy
|
||||
- OOD detection: uncertainty on held-out distribution shift samples
|
||||
- When to use each method
|
||||
|
||||
---
|
||||
|
||||
### 7. Pretrained Image Encoders — CLIP / DINOv2 / SigLIP
|
||||
**Source:** CLIP, DINOv2, SigLIP encoder integration
|
||||
**Files:**
|
||||
- `examples/image_encoders/compare_encoders.py`
|
||||
- `examples/image_encoders/few_shot_dinov2.py`
|
||||
- `examples/image_encoders/config_dinov2_linear_probe.yaml`
|
||||
- `examples/image_encoders/config_clip.yaml`
|
||||
- `examples/image_encoders/README.md`
|
||||
- `ludwig-docs/docs/configuration/features/pretrained_image_encoders.md` (new)
|
||||
|
||||
**Content:**
|
||||
- Side-by-side: `stacked_cnn` vs. `dinov2` vs. `clip` on a small image classification task
|
||||
- Linear probing pattern: `use_pretrained: true`, `trainable: false`
|
||||
- Colab notebook: 5-shot image classification with DINOv2 (no fine-tuning)
|
||||
- Performance vs. training time trade-off table
|
||||
|
||||
---
|
||||
|
||||
### 8. Semantic Segmentation Refresh
|
||||
**Source:** PR #4100 (SegFormer, FPN, configurable UNet depth)
|
||||
**Files:**
|
||||
- `examples/semantic_segmentation/config_segformer.yaml` (replace stale config)
|
||||
- `examples/semantic_segmentation/config_fpn.yaml`
|
||||
- `examples/semantic_segmentation/unet_depth_sweep.py`
|
||||
- `examples/semantic_segmentation/README.md` (update)
|
||||
|
||||
**Content:**
|
||||
- SegFormer as the new recommended default
|
||||
- FPN as the lightweight alternative
|
||||
- UNet depth sweep showing mIoU vs. parameter count
|
||||
- Update `docs/configuration/features/image_features.md` decoder section
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 — Narrower audience, pending PR features
|
||||
|
||||
### 9. LLM-Driven Config Generation
|
||||
**Source:** PR #4092 (`generate_config`)
|
||||
*Wait for #4092 to merge before building.*
|
||||
**Files:**
|
||||
- `examples/llm_config_generation/generate_and_train.py`
|
||||
- `ludwig-docs/docs/user_guide/llm_config_generation.md` (new)
|
||||
|
||||
**Content:**
|
||||
- Natural language task description → validated Ludwig config → train
|
||||
- Show the validation step catching bad LLM suggestions
|
||||
- Cover both Anthropic and OpenAI backends
|
||||
- Tips for writing good task descriptions
|
||||
|
||||
---
|
||||
|
||||
### 10. HyperNetworkCombiner Tutorial
|
||||
**Source:** PR #4092
|
||||
*Wait for #4092 to merge before building.*
|
||||
**Files:**
|
||||
- `examples/hypernetwork/train_conditioned_model.py`
|
||||
- `examples/hypernetwork/config_hypernetwork.yaml`
|
||||
- `ludwig-docs/docs/configuration/combiners/hypernetwork.md` (new)
|
||||
|
||||
**Content:**
|
||||
- Multimodal dataset where one modality (e.g. sensor type) should *condition* processing of others
|
||||
- Comparison: concat combiner vs. hypernetwork combiner on the same task
|
||||
- Reference to HyperFusion paper (arXiv 2403.13319)
|
||||
|
||||
---
|
||||
|
||||
### 11. Native Optuna HPO
|
||||
**Source:** PR #4090 (`OptunaExecutor`)
|
||||
*Wait for #4090 to merge before building.*
|
||||
**Files:**
|
||||
- `examples/hyperopt/optuna_executor.py`
|
||||
- `examples/hyperopt/config_optuna.yaml`
|
||||
- `ludwig-docs/docs/user_guide/hyperopt.md` (expand with Optuna section)
|
||||
|
||||
**Content:**
|
||||
- Drop-in replacement for Ray Tune executor
|
||||
- Sampler comparison: TPE vs. CMA-ES vs. GPSampler
|
||||
- Persistence with SQLite for resumable HPO runs
|
||||
- Pruner (Hyperband) for early stopping of bad trials
|
||||
|
||||
---
|
||||
|
||||
### 12. GRPO — Reward-Based Alignment
|
||||
**Source:** GRPO trainer
|
||||
**Files:**
|
||||
- `examples/alignment/train_grpo.py`
|
||||
- `examples/alignment/config_grpo.yaml`
|
||||
- `ludwig-docs/docs/user_guide/llms/grpo.md` (new)
|
||||
|
||||
**Content:**
|
||||
- Custom reward function (response length + factuality check)
|
||||
- When to use GRPO vs. DPO (no preference pairs needed)
|
||||
- Group normalisation: why it stabilises training vs. vanilla RL
|
||||
|
||||
---
|
||||
|
||||
### 13. Optimizer Guide
|
||||
**Source:** PR #4105 (Schedule-Free AdamW, Muon, Adafactor)
|
||||
**Files:**
|
||||
- `examples/optimizers/optimizer_comparison.py`
|
||||
- `ludwig-docs/docs/configuration/trainer.md` (add "Choosing an optimizer" section)
|
||||
|
||||
**Content:**
|
||||
- Training curve comparison on a standard benchmark (MNIST or wine quality)
|
||||
- Schedule-Free AdamW: why no LR scheduler is needed
|
||||
- Muon: weight-matrix-only updates, when beneficial
|
||||
- Decision tree: Adam → AdamW → Schedule-Free → Muon/SOAP
|
||||
|
||||
---
|
||||
|
||||
### 14. Nash-MTL Multi-Task Loss Balancing
|
||||
**Source:** PR #4092 (Nash-MTL loss balancer)
|
||||
*Wait for #4092 to merge before building.*
|
||||
**Files:**
|
||||
- `examples/multi_task/nash_mtl.py`
|
||||
- `ludwig-docs/docs/user_guide/multi_task.md` (expand)
|
||||
|
||||
**Content:**
|
||||
- Multi-output model (classification + regression simultaneously)
|
||||
- Comparison: fixed weights vs. FAMO vs. Nash-MTL loss balancing
|
||||
- When Nash-MTL is worth the overhead vs. simpler methods
|
||||
|
||||
---
|
||||
|
||||
## Execution order
|
||||
|
||||
```
|
||||
Phase A (no GPU needed, self-contained):
|
||||
1. Anomaly detection tutorial
|
||||
5. Open-set recognition → full tutorial
|
||||
6. Uncertainty quantification
|
||||
13. Optimizer guide
|
||||
|
||||
Phase B (needs GPU, builds on existing LLM work):
|
||||
2. Alignment / RLHF tutorial (DPO + KTO)
|
||||
3. vLLM serving tutorial
|
||||
4. Structured / constrained LLM decoding
|
||||
12. GRPO
|
||||
|
||||
Phase C (vision, needs pretrained model weights):
|
||||
7. CLIP / DINOv2 / SigLIP encoders
|
||||
8. Semantic segmentation refresh
|
||||
|
||||
Phase D (blocked on open PRs merging):
|
||||
9. LLM-driven config generation [blocked on #4092]
|
||||
10. HyperNetworkCombiner [blocked on #4092]
|
||||
14. Nash-MTL multi-task [blocked on #4092]
|
||||
11. Native Optuna HPO [blocked on #4090]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deliverable locations
|
||||
|
||||
| Type | Repo | Path |
|
||||
|------|------|------|
|
||||
| Runnable scripts | `ludwig` | `examples/<topic>/` |
|
||||
| YAML configs | `ludwig` | `examples/<topic>/config*.yaml` |
|
||||
| Colab notebooks | `ludwig` | `examples/<topic>/*.ipynb` |
|
||||
| Doc tutorials | `ludwig-docs` | `docs/examples/<topic>.md` |
|
||||
| User guide pages | `ludwig-docs` | `docs/user_guide/<topic>.md` |
|
||||
| Config reference pages | `ludwig-docs` | `docs/configuration/**/<topic>.md` |
|
||||
@@ -0,0 +1,320 @@
|
||||
# Ludwig Lazy / Streaming Preprocessing Plan
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Ludwig's preprocessing pipeline is **not lazy**: before the training loop starts, it decodes and
|
||||
transforms every sample in the dataset into in-memory NumPy arrays. This causes:
|
||||
|
||||
1. **OOM on media datasets** — 1 000 images at original resolution × their tensor size saturates
|
||||
RAM before a single batch reaches the GPU.
|
||||
1. **Fixed preprocessing bottleneck** — transforms run single-threaded on the CPU ahead of training,
|
||||
blocking the GPU.
|
||||
1. **No online statistics** — normalization stats (mean/std) require a full pass that materialises
|
||||
the entire dataset.
|
||||
1. **Impossible streaming** — datasets that exceed RAM (or that arrive over a network) cannot be
|
||||
used at all today.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## How Other Frameworks Solve This
|
||||
|
||||
### PyTorch / torchvision
|
||||
|
||||
`Dataset.__getitem__` is the canonical lazy primitive. Each DataLoader worker calls it
|
||||
independently, so only `batch_size × num_workers × prefetch_factor` samples are alive at once.
|
||||
Transforms (resize, normalize, augment) are composed in the Dataset constructor and run inside
|
||||
worker processes, overlapping with the GPU forward pass.
|
||||
|
||||
Statistics (ImageNet mean/std) are pre-computed offline and shipped as constants. For custom
|
||||
datasets a single Welford accumulation scan (O(1) memory) is standard.
|
||||
|
||||
### TensorFlow `tf.data`
|
||||
|
||||
Declarative lazy pipeline: `.map(fn, num_parallel_calls=AUTOTUNE)` → `.batch()` →
|
||||
`.prefetch(AUTOTUNE)`. Nothing is executed until the training iterator pulls a batch.
|
||||
`.cache()` optionally persists the decoded dataset to disk after the first epoch.
|
||||
|
||||
### HuggingFace `datasets`
|
||||
|
||||
Arrow IPC memory-map: only the pages actually accessed are loaded from disk. Audio/image columns
|
||||
store raw bytes or file paths — decoded waveforms are produced per-sample at access time via
|
||||
`cast_column("audio", Audio())`. Passing `decode=False` gives raw bytes with zero decoding cost.
|
||||
|
||||
### MONAI (3D medical imaging)
|
||||
|
||||
Lazy resampling accumulates sequential spatial transforms (rotate → crop → resize) as pending
|
||||
homogeneous-matrix operations and fuses them into a single interpolation, eliminating intermediate
|
||||
materialisation and reducing quality loss.
|
||||
|
||||
### torchaudio
|
||||
|
||||
Transforms (`MelSpectrogram`, `Resample`, `MFCC`) are `nn.Module` subclasses — composable,
|
||||
JIT-scriptable, and GPU-movable. Applying them post-batch on GPU is 10-20× faster than per-sample
|
||||
on CPU.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Design Principles for Ludwig's New Pipeline
|
||||
|
||||
1. **Store paths, not arrays.** The Arrow dataset (or in-memory DataFrame) holds file paths /
|
||||
metadata, never decoded tensors. Decode happens inside the DataLoader worker.
|
||||
1. **Transforms as `nn.Module`.** All feature-specific transforms become PyTorch modules:
|
||||
composable, testable, batchable, GPU-ready.
|
||||
1. **Online statistics with Welford's algorithm.** Replace full-dataset decode-then-accumulate
|
||||
with a single streaming pass that needs O(1) memory per feature.
|
||||
1. **Prefetching and parallelism.** DataLoader `num_workers > 0` + `prefetch_factor` provides
|
||||
CPU/GPU overlap for free once decode is inside `__getitem__`.
|
||||
1. **Backward compatibility.** Existing configs that work today must continue to work. New
|
||||
behaviour is opt-in at the feature level via `lazy: true` or automatic for audio/image features.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
### Phase 0 — Foundations (no user-facing change)
|
||||
|
||||
**Goal:** establish shared infrastructure that later phases build on.
|
||||
|
||||
#### 0-A. Welford online stats accumulator
|
||||
|
||||
- Add `ludwig/data/statistics.py` with `WelfordAccumulator` (supports merge across shards).
|
||||
- API: `acc.update(x: np.ndarray)`, `acc.result() → (mean, std, min, max, count)`,
|
||||
`acc.merge(other)`.
|
||||
- Unit-test with known distributions.
|
||||
|
||||
#### 0-B. Feature transform protocol
|
||||
|
||||
- Define `FeatureTransform(nn.Module)` base class in `ludwig/features/transforms.py`.
|
||||
- Existing preprocessing logic stays unchanged for now; this just establishes the interface.
|
||||
- `__call__(self, x: Tensor) → Tensor`; serialisable via `torch.save`.
|
||||
|
||||
#### 0-C. DataLoader worker-safe file handle pool
|
||||
|
||||
- Research whether Ludwig's existing multiprocessing setup needs `worker_init_fn` to reset
|
||||
open file handles or RNG state (same issue as HuggingFace's `IterableDataset` shard split).
|
||||
- Document the policy; no code change yet.
|
||||
|
||||
**Exit criteria:** `WelfordAccumulator` tests pass; `FeatureTransform` ABC exists.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
### Phase 1 — Lazy Audio (highest OOM risk)
|
||||
|
||||
**Goal:** audio columns decode inside the DataLoader worker, not during preprocessing.
|
||||
|
||||
#### 1-A. `AudioFeature` path-only mode
|
||||
|
||||
- After the preprocessing step, the Arrow/DataFrame column holds **file paths** (strings), not
|
||||
decoded tensors.
|
||||
- Remove `_process_in_memory` eager decode; replace with a lightweight path-validity check.
|
||||
- The existing `read_audio_files_to_dict` path (used today) becomes a fallback for non-lazy mode.
|
||||
|
||||
#### 1-B. `AudioDataset.__getitem__` decode
|
||||
|
||||
- Create `ludwig/data/datasets/audio_dataset.py` (or extend `LudwigDataset`).
|
||||
- In `__getitem__`, call `torchaudio.load(path)` → resample → pad/trim to target length.
|
||||
- Apply `FeatureTransform` chain (mel spectrogram, normalization).
|
||||
|
||||
#### 1-C. Online stats for audio normalization
|
||||
|
||||
- Replace current two-pass (decode all → compute stats) with a streaming Welford scan:
|
||||
`torchaudio.load` one file at a time, update accumulator, discard tensor.
|
||||
- Triggered once at `prepare_data` time; stats stored in the feature config cache.
|
||||
|
||||
#### 1-D. Integration test
|
||||
|
||||
- Run `pytest tests/integration_tests/test_audio_feature.py` — no OOM on the medium-size
|
||||
audio fixtures.
|
||||
- Add a test that processes 10 000 short clips and checks RSS stays under 2 GB.
|
||||
|
||||
**Exit criteria:** audio smoke tests pass on datasets with >10 h of audio; RSS bounded by
|
||||
`batch_size × clip_length × sr × 4 bytes`.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
### Phase 2 — Lazy Image
|
||||
|
||||
**Goal:** image columns store paths; decode + augment happens per-sample in DataLoader workers.
|
||||
|
||||
#### 2-A. `ImageFeature` path-only mode
|
||||
|
||||
- Preprocessing stores file paths (already partially the case for CSV/HDF5 workflows).
|
||||
- Remove upfront `_process_in_memory` full-batch decode; keep only metadata inference
|
||||
(height, width, channels) via a single sampled read.
|
||||
|
||||
#### 2-B. `ImageDataset.__getitem__` decode
|
||||
|
||||
- `PIL.Image.open(path).convert("RGB")` → `torchvision.transforms` pipeline.
|
||||
- Apply `FeatureTransform` (resize, center crop, normalize).
|
||||
- Multi-channel and TIFF support via existing `read_image_from_path` helper.
|
||||
|
||||
#### 2-C. Online stats for image normalization
|
||||
|
||||
- Welford accumulator over pixel values per channel; one forward pass, batch-level updates
|
||||
(read image → reshape to (H×W, C) → `acc.update`).
|
||||
- Offer `use_imagenet_stats: true` shortcut (ships precomputed constants) for the common case.
|
||||
|
||||
#### 2-D. Augmentation support
|
||||
|
||||
- `ImageFeature` config gains an optional `augmentation:` block (equivalent to
|
||||
`torchvision.transforms.v2`).
|
||||
- Augmentation runs inside `__getitem__` → free via DataLoader parallelism.
|
||||
|
||||
**Exit criteria:** image smoke tests pass; openfake and synthia complete without OOM at
|
||||
original resolution.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
### Phase 3 — Lazy Text / Tabular (lower priority)
|
||||
|
||||
**Goal:** tokenisation and numerical transforms move into the DataLoader worker.
|
||||
|
||||
#### 3-A. Text tokenisation in `__getitem__`
|
||||
|
||||
- Currently: tokenise all text upfront into integer id arrays → store in HDF5.
|
||||
- New: store raw strings; tokenise on-the-fly in `__getitem__`.
|
||||
- Tradeoff: tokenisation is cheap but repeated; cache tokenised output to Arrow after first epoch
|
||||
if `cache_encoder_embeddings: true`.
|
||||
|
||||
#### 3-B. Numerical normalisation as `nn.Module`
|
||||
|
||||
- `NormalizationTransform(mean, std)` replaces the current in-place column mutation.
|
||||
- Stats computed once via Welford in `prepare_data`; transform applied in `__getitem__`.
|
||||
|
||||
#### 3-C. Category embedding stays eager
|
||||
|
||||
- Category integer encoding is O(1) per sample and zero-memory; no change needed.
|
||||
|
||||
**Exit criteria:** existing tabular test suite passes unchanged.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
### Phase 4 — Streaming Dataset Source
|
||||
|
||||
**Goal:** support HuggingFace streaming datasets (`streaming=True`) as a first-class source,
|
||||
enabling datasets that exceed local disk space.
|
||||
|
||||
#### 4-A. `HFStreamingDataset(IterableDataset)`
|
||||
|
||||
- Wraps a `datasets.IterableDataset` in a PyTorch `IterableDataset`.
|
||||
- Shard splitting: `worker_init_fn` slices the stream across DataLoader workers (HF provides
|
||||
`dataset.shard(num_shards=N, index=i)`).
|
||||
- Shuffle buffer: configurable per-dataset (small for media, large for text).
|
||||
|
||||
#### 4-B. Online stats for streaming sources
|
||||
|
||||
- Welford accumulator updated during the stats-scan pre-training pass (a full iteration over
|
||||
the stream without gradient accumulation).
|
||||
- Stats cached to disk keyed by dataset id + revision + split.
|
||||
|
||||
#### 4-C. No-shuffle streaming mode
|
||||
|
||||
- For datasets too large even for a shuffle buffer, expose `shuffle: false` (warn user).
|
||||
|
||||
**Exit criteria:** `AudioSet`, `VoxPopuli`, `LibriSpeech` stream through without touching disk;
|
||||
stats computed in one pass.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
### Phase 5 — MONAI-style Transform Fusion (stretch goal)
|
||||
|
||||
**Goal:** fuse adjacent spatial transforms (resize → crop → normalize) into a single tensor
|
||||
operation to eliminate intermediate materialisation.
|
||||
|
||||
#### 5-A. Transform graph analysis
|
||||
|
||||
- At `setup` time, walk the `FeatureTransform` chain; identify composable sequences.
|
||||
- A sequence is composable when all transforms implement `as_matrix() → Tensor[4×4]`.
|
||||
|
||||
#### 5-B. Single-pass spatial transform
|
||||
|
||||
- Fuse the homogeneous matrices; apply via `F.grid_sample` once.
|
||||
- Benchmark vs. sequential PIL transforms on ImageNet validation set.
|
||||
|
||||
#### 5-C. Augmentation randomness
|
||||
|
||||
- Random transforms (flip, rotation jitter) break deterministic fusion; keep them as
|
||||
post-fusion `nn.Module` steps.
|
||||
|
||||
**Exit criteria:** ≥ 15% wall-clock speedup on image-only training runs vs. Phase 2 baseline.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Data-Flow Diagram (target state)
|
||||
|
||||
```
|
||||
Disk / HF Hub
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ prepare_data (single process, once per dataset) │
|
||||
│ │
|
||||
│ • Build Arrow metadata table (paths, labels, ids) │
|
||||
│ • Welford scan → normalization stats │
|
||||
│ • Write stats to feature config cache │
|
||||
└───────────────────────────┬──────────────────────────┘
|
||||
│ Arrow file / paths
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ LudwigDataset.__getitem__ (per DataLoader worker) │
|
||||
│ │
|
||||
│ • Read path from Arrow row │
|
||||
│ • torchaudio.load / PIL.Image.open │
|
||||
│ • FeatureTransform chain (resize, mel spec, norm) │
|
||||
│ • Return sample dict of tensors │
|
||||
└───────────────────────────┬──────────────────────────┘
|
||||
│ batch of tensors (pinned RAM)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ GPU training loop │
|
||||
│ • H2D transfer (pin_memory → non-blocking) │
|
||||
│ • Forward pass │
|
||||
│ • Gradient accumulation │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Key Files to Create / Modify
|
||||
|
||||
| File | Action |
|
||||
| ---------------------------------------------- | --------------------------------------------------- |
|
||||
| `ludwig/data/statistics.py` | NEW — `WelfordAccumulator` |
|
||||
| `ludwig/features/transforms.py` | NEW — `FeatureTransform` base + concrete transforms |
|
||||
| `ludwig/features/audio_feature.py` | MODIFY — remove eager decode, add lazy path |
|
||||
| `ludwig/features/image_feature.py` | MODIFY — remove eager decode, add lazy path |
|
||||
| `ludwig/data/datasets/base_dataset.py` | MODIFY — add `__getitem__` decode hooks |
|
||||
| `ludwig/data/datasets/audio_dataset.py` | NEW (or extend base) |
|
||||
| `ludwig/data/datasets/image_dataset.py` | NEW (or extend base) |
|
||||
| `ludwig/data/datasets/hf_streaming_dataset.py` | NEW — Phase 4 |
|
||||
| `tests/unit/data/test_statistics.py` | NEW — Welford unit tests |
|
||||
| `tests/integration_tests/test_lazy_audio.py` | NEW |
|
||||
| `tests/integration_tests/test_lazy_image.py` | NEW |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
||||
| DataLoader worker forking copies open file handles / CUDA contexts | `worker_init_fn` resets handles; delay CUDA init until after fork |
|
||||
| Welford online stats diverge from current batch stats | Unit-test Welford against numpy batch stats on synthetic data |
|
||||
| Per-sample disk I/O is slower than batched HDF5 read | Benchmark; add optional post-first-epoch disk cache for decoded tensors |
|
||||
| Existing HDF5 preprocessing cache invalidated | Introduce cache version key; fall back to eager mode on cache miss |
|
||||
| `IterableDataset` with shuffle buffer OOMs on large-media streams | Default shuffle buffer scales with declared feature size; audio → 500, image → 200, text → 10 000 |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **HDF5 cache vs. Arrow:** Should the decoded-tensor cache (post-Phase 2) write Arrow or HDF5?
|
||||
Arrow supports mmap natively; HDF5 has chunked access. Arrow preferred for new code.
|
||||
1. **Normalization stat freshness:** If the dataset changes between runs, cached Welford stats
|
||||
are stale. Hash the dataset id + revision + split into the cache key.
|
||||
1. **Multi-output features:** When one column feeds multiple output features (e.g. image →
|
||||
classification + detection), the decode must happen once. Ensure `LudwigDataset` returns a
|
||||
shared tensor, not independent copies.
|
||||
1. **Tokenisation caching:** Tokenising every sample every epoch wastes CPU. Cache to Arrow after
|
||||
the first epoch under `${cache_dir}/<dataset_hash>/tokenized/`.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Apply to all files without committing:
|
||||
# pre-commit run --all-files
|
||||
# Apply to changed files:
|
||||
# pre-commit run
|
||||
# Update this file:
|
||||
# pre-commit autoupdate
|
||||
# Run a specific hook:
|
||||
# pre-commit run <hook id>
|
||||
|
||||
ci:
|
||||
autofix_prs: true
|
||||
autoupdate_commit_msg: "[pre-commit.ci] pre-commit suggestions"
|
||||
autoupdate_schedule: weekly
|
||||
|
||||
# Pin to the system Python version (3.14 post OS upgrade; previously 3.12).
|
||||
# Note: pre-commit.ci hosted runners default to 3.14 which is now also local.
|
||||
default_language_version:
|
||||
python: python3.14
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-ast
|
||||
- id: fix-byte-order-marker
|
||||
- id: check-case-conflict
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: debug-statements
|
||||
- id: detect-private-key
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- id: mixed-line-ending
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.14
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: Lint and fix (ruff)
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
name: Format code (ruff)
|
||||
- repo: https://github.com/asottile/blacken-docs
|
||||
rev: 1.20.0
|
||||
hooks:
|
||||
- id: blacken-docs
|
||||
args: [--line-length=120]
|
||||
exclude: "IMPROVEMENT_PLAN.md"
|
||||
- repo: https://github.com/hukkin/mdformat
|
||||
rev: 1.0.0
|
||||
hooks:
|
||||
- id: mdformat
|
||||
additional_dependencies:
|
||||
- mdformat-gfm==1.0.0
|
||||
- mdformat_frontmatter==2.0.10
|
||||
exclude: "CHANGELOG.md|IMPROVEMENT_PLAN.md"
|
||||
- repo: https://github.com/yoheimuta/protolint
|
||||
rev: v0.56.4
|
||||
hooks:
|
||||
- id: protolint
|
||||
@@ -0,0 +1,53 @@
|
||||
# Adapted from
|
||||
# https://github.com/yoheimuta/protolint/blob/master/_example/config/.protolint.yaml
|
||||
---
|
||||
# Lint directives.
|
||||
lint:
|
||||
# Linter files to walk.
|
||||
files:
|
||||
# The specific files to exclude.
|
||||
exclude:
|
||||
# NOTE: UNIX paths will be properly accepted by both UNIX and Windows.
|
||||
- ../proto/invalidFileName.proto
|
||||
|
||||
# Linter rules.
|
||||
# Run `protolint list` to see all available rules.
|
||||
rules:
|
||||
# Set the default to all linters. This option works the other way around as no_default does.
|
||||
# If you want to enable this option, delete the comment out below and no_default.
|
||||
# all_default: true
|
||||
|
||||
# The specific linters to add.
|
||||
add:
|
||||
- FIELD_NAMES_LOWER_SNAKE_CASE
|
||||
- MESSAGE_NAMES_UPPER_CAMEL_CASE
|
||||
- MAX_LINE_LENGTH
|
||||
- INDENT
|
||||
- FIELD_NAMES_EXCLUDE_PREPOSITIONS
|
||||
- FILE_NAMES_LOWER_SNAKE_CASE
|
||||
- IMPORTS_SORTED
|
||||
- PACKAGE_NAME_LOWER_CASE
|
||||
- ORDER
|
||||
- PROTO3_FIELDS_AVOID_REQUIRED
|
||||
- PROTO3_GROUPS_AVOID
|
||||
- REPEATED_FIELD_NAMES_PLURALIZED
|
||||
- QUOTE_CONSISTENT
|
||||
|
||||
# Linter rules option.
|
||||
rules_option:
|
||||
# MAX_LINE_LENGTH rule option.
|
||||
max_line_length:
|
||||
# Enforces a maximum line length
|
||||
max_chars: 120
|
||||
# Specifies the character count for tab characters
|
||||
tab_chars: 2
|
||||
|
||||
# FILE_NAMES_LOWER_SNAKE_CASE rule option.
|
||||
file_names_lower_snake_case:
|
||||
excludes:
|
||||
- ../proto/invalidFileName.proto
|
||||
|
||||
# QUOTE_CONSISTENT rule option.
|
||||
quote_consistent:
|
||||
# Available quote are "double" or "single".
|
||||
quote: double
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"editor.rulers": [
|
||||
120
|
||||
],
|
||||
"editor.formatOnSave": true,
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "ms-python.black-formatter",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"black-formatter.args": [
|
||||
"--line-length",
|
||||
"120"
|
||||
],
|
||||
"flake8.args": [
|
||||
"--config=setup.cfg"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": false,
|
||||
"python.envFile": "${workspaceFolder}/.env"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Default code owners for the entire repository
|
||||
* @w4nderlust
|
||||
@@ -0,0 +1,3 @@
|
||||
# Code of conduct
|
||||
|
||||
Ludwig adopts the [Linux Foundation code of conduct](https://lfprojects.org/policies/code-of-conduct/).
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
# Contributing
|
||||
|
||||
Everyone is welcome to contribute, and we value everybody’s contribution. Code is thus not the only
|
||||
way to help the community. Answering questions, helping others, reaching out and improving the
|
||||
documentation are immensely valuable contributions as well.
|
||||
|
||||
It also helps us if you spread the word: reference the library from blog posts on the awesome
|
||||
projects it made possible, shout out on X every time it has helped you, or simply star the
|
||||
repo to say "thank you".
|
||||
|
||||
Check out the official [ludwig docs](https://ludwig-ai.github.io/ludwig-docs/) to get oriented
|
||||
around the codebase, and join the community!
|
||||
|
||||
## Open Issues
|
||||
|
||||
Issues are listed at: <https://github.com/ludwig-ai/ludwig/issues>
|
||||
|
||||
If you would like to work on any of them, make sure it is not already assigned to someone else.
|
||||
|
||||
You can self-assign it by commenting on the Issue page with one of the keywords: `#take` or
|
||||
`#self-assign`.
|
||||
|
||||
Work on your self-assigned issue and eventually create a Pull Request.
|
||||
|
||||
## Creating Pull Requests
|
||||
|
||||
1. Fork the [repository](https://github.com/ludwig-ai/ludwig) by clicking on the "Fork" button on
|
||||
the repository's page. This creates a copy of the code under your GitHub user account.
|
||||
|
||||
1. Clone your fork to your local disk, and add the base repository as a remote:
|
||||
|
||||
```bash
|
||||
git clone git@github.com:<your Github handle>/ludwig.git
|
||||
cd ludwig
|
||||
git remote add upstream https://github.com/ludwig-ai/ludwig.git
|
||||
```
|
||||
|
||||
1. Create a new branch to hold your development changes:
|
||||
|
||||
```bash
|
||||
git checkout -b a-descriptive-name-for-my-changes
|
||||
```
|
||||
|
||||
*Do not*\* work on the `master` branch.
|
||||
|
||||
1. Set up a development environment by running the following command in a virtual environment:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
The above command will install the core dependencies in developer mode. If you would like to
|
||||
be able to potentially make changes to the overall Ludwig codebase, then use the following command:
|
||||
|
||||
```bash
|
||||
pip install -e .[full]
|
||||
```
|
||||
|
||||
Please note that in certain Shell environments (e.g., the `Z shell`), the dependencies in brackets have to be quoted:
|
||||
|
||||
```bash
|
||||
pip install -e ."[full]"
|
||||
```
|
||||
|
||||
If you do not need access to the entire Ludwig codebase, but just want to be able to run `pytest` on the essential
|
||||
functionality, then you would replace the above command with:
|
||||
|
||||
```bash
|
||||
pip install -e .[test]
|
||||
```
|
||||
|
||||
(Please use `pip install -e ."[test]"` where your Shell environment requires quotes around the square brackets.)
|
||||
|
||||
For the full list of the optional dependencies available in Ludwig, please see
|
||||
[Installation Guide](https://ludwig.ai/latest/getting_started/installation/) and `pyproject.toml` in the root of the Ludwig
|
||||
repository.
|
||||
|
||||
1. On MacOS with Apple Silicon, if this installation approach runs into errors, you may need to install the following
|
||||
prerequisites:
|
||||
|
||||
```bash
|
||||
brew install cmake libomp
|
||||
```
|
||||
|
||||
This step requires `homebrew` to be installed on your development machine.
|
||||
|
||||
1. Install and run `pre-commit`:
|
||||
|
||||
```bash
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
1. Develop features on your branch.
|
||||
|
||||
1. Format your code by running pre-commits so that your newly added files look nice:
|
||||
|
||||
```bash
|
||||
pre-commit run
|
||||
```
|
||||
|
||||
Pre-commits also run automatically when committing.
|
||||
|
||||
1. Once you're happy with your changes, make a commit to record your changes locally:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit
|
||||
```
|
||||
|
||||
It is a good idea to sync your copy of the code with the original repository regularly. This
|
||||
way you can quickly account for changes:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/master
|
||||
```
|
||||
|
||||
Push the changes to your account using:
|
||||
|
||||
```bash
|
||||
git push -u origin a-descriptive-name-for-my-changes
|
||||
```
|
||||
|
||||
1. Once you are satisfied, go the webpage of your fork on GitHub. Click on "Pull request" to send
|
||||
your contribution to the project maintainers for review.
|
||||
|
||||
## Running Tests
|
||||
|
||||
Ludwig's test suite lives in `tests/`. The main categories are:
|
||||
|
||||
| Directory | What it covers |
|
||||
| -------------------------- | ------------------------------------------------------- |
|
||||
| `tests/ludwig/` | Unit tests for individual modules (fast, no GPU needed) |
|
||||
| `tests/integration_tests/` | End-to-end training/prediction pipelines |
|
||||
| `tests/regression_tests/` | Accuracy regression checks |
|
||||
|
||||
**Run the full unit test suite:**
|
||||
|
||||
```bash
|
||||
pytest tests/ludwig/
|
||||
```
|
||||
|
||||
**Run a single test file:**
|
||||
|
||||
```bash
|
||||
pytest tests/ludwig/encoders/test_image_encoder.py -v
|
||||
```
|
||||
|
||||
**Run integration tests (slow, uses GPUs if available):**
|
||||
|
||||
```bash
|
||||
pytest tests/integration_tests/ -v
|
||||
```
|
||||
|
||||
**Run tests matching a keyword:**
|
||||
|
||||
```bash
|
||||
pytest tests/ -k "test_binary_feature" -v
|
||||
```
|
||||
|
||||
Tests that require GPUs, Ray, or heavy optional dependencies are marked with pytest marks
|
||||
(`@pytest.mark.slow`, `@pytest.mark.distributed`, etc.) and are automatically skipped in CI
|
||||
unless the appropriate resources are available.
|
||||
|
||||
## Codebase Overview
|
||||
|
||||
A quick map of the most important modules:
|
||||
|
||||
| Path | Purpose |
|
||||
| ------------------------------ | --------------------------------------------------------------------------- |
|
||||
| `ludwig/api.py` | `LudwigModel` — the main public API (train, predict, evaluate) |
|
||||
| `ludwig/schema/` | Pydantic v2 config schema — `ModelConfig`, feature configs, trainer configs |
|
||||
| `ludwig/features/` | Feature types: preprocessing, encoding, decoding, metrics |
|
||||
| `ludwig/encoders/` | Encoder implementations (text, image, audio, tabular, …) |
|
||||
| `ludwig/decoders/` | Decoder implementations |
|
||||
| `ludwig/models/` | `ECD` (encoder–combiner–decoder) and `LLM` model classes |
|
||||
| `ludwig/trainers/` | Trainer implementations (ECD, LLM fine-tune, inference-only) |
|
||||
| `ludwig/data/preprocessing.py` | Tabular data loading and preprocessing pipeline |
|
||||
| `ludwig/backend/` | Execution backends (`LocalBackend`, `RayBackend`) |
|
||||
| `ludwig/utils/data_utils.py` | File format readers (CSV, Parquet, JSON, …) |
|
||||
| `ludwig/collect.py` | CLI tools: collect activations, weights |
|
||||
|
||||
**Key abstractions:**
|
||||
|
||||
- `BaseFeature` → `InputFeature` / `OutputFeature` — every feature type inherits from one of these.
|
||||
- `Encoder` / `Decoder` — registered via `@register_encoder` / `@register_decoder`; looked up by
|
||||
`encoder.type` / `decoder.type` from config.
|
||||
- `DataFormatPreprocessor` — dispatches file loading per format via a strategy (reader function).
|
||||
`FileBasedPreprocessor(read_fn)` covers all tabular file types; `HDF5Preprocessor` and the
|
||||
in-memory `DictPreprocessor` / `DataFramePreprocessor` are separate.
|
||||
- `BackendCapabilities` — frozen dataclass of feature flags advertised by each backend.
|
||||
- `InferenceOnlyTrainer` (config type `"none"`) — runs evaluation without training; used for
|
||||
zero-shot / few-shot LLM inference.
|
||||
|
||||
**Adding a new feature type:**
|
||||
|
||||
1. Create `ludwig/features/<name>_feature.py` with `<Name>FeatureMixin`, `<Name>InputFeature`,
|
||||
and/or `<Name>OutputFeature`.
|
||||
1. Create `ludwig/schema/features/<name>_feature.py` with the Pydantic config classes.
|
||||
1. Register them with `@register_input_feature("<name>")` / `@register_output_feature("<name>")`.
|
||||
1. Add a preprocessing config class to `ludwig/schema/features/preprocessing/`.
|
||||
1. Add tests in `tests/ludwig/features/test_<name>_feature.py`.
|
||||
|
||||
## Other Tips
|
||||
|
||||
- Add unit tests for any new code you write.
|
||||
- Make sure tests pass. See the [Developer Guide](https://ludwig-ai.github.io/ludwig-docs/latest/developer_guide/style_guidelines_and_tests/) for more details.
|
||||
- Keep `except Exception:` blocks narrow: use `except ImportError:` for optional imports and
|
||||
`except pydantic.ValidationError:` for schema fallbacks. Broad exception swallowing makes
|
||||
production bugs invisible.
|
||||
|
||||
## Attribution
|
||||
|
||||
This contributing guideline is adapted from `huggingface`, available at <https://github.com/huggingface/datasets/blob/master/CONTRIBUTING.md>.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Please be mindful of and adhere to the Linux Foundation's
|
||||
[Code of Conduct](https://lfprojects.org/policies/code-of-conduct) when contributing to Ludwig.
|
||||
@@ -0,0 +1,302 @@
|
||||
# Ludwig Codebase Improvement Plan
|
||||
|
||||
Generated: 2026-05-16. Based on a thorough review of the full codebase (110k lines, ~400 files).
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Ludwig is **architecturally sound at the macro level** — the Backend abstraction, modular encoder/decoder registries, and schema-driven config system are genuinely well-designed. The problem is at the **meso level**: a handful of files have become god objects that grow without bound (`preprocessing.py` 2407 lines, `api.py` 2237 lines, `trainer.py` 1766 lines), there are 208 untyped `Any` fields hiding data contracts, 112 bare `except Exception:` handlers silencing failures, and 172 TODOs indicating unresolved design decisions. The single most impactful structural fix is decomposing the 18-class `DataFormatPreprocessor` hierarchy into a reader-strategy pattern (~900 lines of duplication removed). The single most impactful production fix is auditing the 112 bare `except Exception:` handlers.
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues (Must Fix)
|
||||
|
||||
### C1 — `FatherPreprocessor` typo (`preprocessing.py:689`)
|
||||
The Feather format preprocessor is named `FatherPregressor`. It is mapped to `FEATHER_FORMATS` at line 1149. Any contributor looking for the Feather reader would never find it.
|
||||
- **Fix:** Rename to `FeatherPreprocessor` (class + format map).
|
||||
|
||||
### C2 — Duplicate `keys()` on `TrainingStats` (`api.py:150,168`)
|
||||
`TrainingStats.keys()` is defined twice. The second definition silently overrides the first; suppressed with `# noqa: F811`. Breaks the Mapping protocol contract.
|
||||
- **Fix:** Delete lines 150-153 (the first definition).
|
||||
|
||||
### C3 — 18 nearly-identical `DataFormatPreprocessor` subclasses (`preprocessing.py:186-1046`)
|
||||
Every subclass (`DictPreprocessor`, `CSVPreprocessor`, `JSONPreprocessor`, `ParquetPreprocessor`, ...) implements `preprocess_for_training()`, `preprocess_for_prediction()`, `prepare_processed_data()` with bodies that differ only in the pandas read call. ~900 lines of structural duplication.
|
||||
- **Fix:** See PR-3.
|
||||
|
||||
### C4 — Silent failure on missing files (`backend/base.py:191-212`)
|
||||
`LocalPreprocessingMixin.read_binary_files()` passes `None` paths to `map_fn` without error. Datasets silently drop samples at scale.
|
||||
- **Fix:** Add `None` guard before calling `map_fn`; raise `ValueError` with path context.
|
||||
|
||||
### C5 — 112 bare `except Exception:` handlers throughout codebase
|
||||
Examples: `except Exception: return None` in `strings_utils.py:138`, `except Exception: print("FAILURE")` in `check.py:32`. Makes production debugging impossible.
|
||||
- **Fix:** See PR-7.
|
||||
|
||||
### C6 — OOM footgun in `collect_activations()` (`collect.py:186`)
|
||||
Marked `# TODO -> Fix OOM on large models e.g. llama 3 8B`. Loads all activations for all samples into RAM at once. Will OOM on any LLM with >10k samples.
|
||||
- **Fix:** Stream activations in chunks; write intermediate results to disk.
|
||||
|
||||
---
|
||||
|
||||
## Major Issues (Should Fix)
|
||||
|
||||
### M1 — `dict[str, Any]` type aliases hide all data contracts (`types.py:5-47`)
|
||||
All public typedefs (`FeatureConfigDict`, `ModelConfigDict`, `TrainingSetMetadataDict`, etc.) are `dict[str, Any]`. 208 total `Any` usages. Disables IDE completion and static analysis.
|
||||
- **Fix:** See PR-1.
|
||||
|
||||
### M2 — Trainer is a 1766-line god object (`trainer.py:104-1766`)
|
||||
`Trainer` inherits from 4 mixins + `BaseTrainer`. `__init__` has 24 parameters. `train_loop()` is ~240 lines mixing batching, backprop, gradient accumulation, metrics, checkpointing, early stopping.
|
||||
- **Fix:** See PR-5, PR-6.
|
||||
|
||||
### M3 — `LudwigModel` is a 2237-line god object (`api.py:160-2237`)
|
||||
Mixes model lifecycle, serialization, experiment logging, hyperopt, and serving.
|
||||
- **Fix:** See PR-8.
|
||||
|
||||
### M4 — Feature preprocessing modules have no shared base
|
||||
`_ImagePreprocessing`, `_AudioPreprocessing`, `_CategoryPreprocessing` etc. each re-implement missing value handling, dtype casting, reshaping with no common base class.
|
||||
- **Fix:** See PR-4.
|
||||
|
||||
### M5 — `create_passthrough_input_feature()` is an invisible feature type (`base_feature.py:648-703`)
|
||||
Defines an inline class that bypasses the feature registry entirely. Undiscoverable.
|
||||
- **Fix:** See PR-9.
|
||||
|
||||
### M6 — 92 suppressed type errors (`# type: ignore`, `# noqa`)
|
||||
Including bugs: `api.py:288` has `# type: ignore [assignment]` because `self.config_fp = None` when type expects `str` — that's an uninitialised-state bug.
|
||||
- **Fix:** Audit all 92; fix root causes.
|
||||
|
||||
### M7 — Test coverage gaps on critical paths
|
||||
- `preprocessing.py` — no unit tests for individual `DataFormatPreprocessor` subclasses
|
||||
- `backend/ray.py:816` — `BatchInferModel` inner class untested
|
||||
- `trainers/trainer_dpo.py` — `KTOTrainer`, `ORPOTrainer`, `GRPOTrainer` have no unit tests
|
||||
- `collect.py` — zero unit tests
|
||||
|
||||
### M8 — Stale TODOs that are actually bugs
|
||||
- `trainer.py:302` — "loading an existing model loses metric values" — known data loss on resume
|
||||
- `models/base.py:147` — "Remove dummy implementation" — dummy property returns wrong values
|
||||
- `api.py:1980` — model type check duplicated between LLM and ECD paths
|
||||
|
||||
---
|
||||
|
||||
## Minor Issues
|
||||
|
||||
### Naming (violating "naming things" rules)
|
||||
|
||||
| File:Line | Problem | Fix |
|
||||
|-----------|---------|-----|
|
||||
| `preprocessing.py:689` | `FatherPreprocessor` — typo for Feather | `FeatherPreprocessor` |
|
||||
| `backend/base.py:181` | `LocalPreprocessingMixin` — also handles binary reads | `LocalDataProcessingMixin` |
|
||||
| `features/base_feature.py:57` | `BaseFeatureMixin` — vague, has state | `FeaturePreprocessingMixin` |
|
||||
| `models/base.py:125` | `ModuleWrapper` — says nothing about purpose | `NonPropertyModuleWrapper` |
|
||||
| `trainers/trainer_llm.py:44` | `NoneTrainer` — confusing name for inference-only | `InferenceOnlyTrainer` |
|
||||
| `data/cache/manager.py:101` | `CacheManager` — generic Manager anti-pattern | `PreprocessedDataCache` |
|
||||
|
||||
### Type Hints
|
||||
- `backend/base.py:79` — `capabilities: dict[str, Any]` → `dict[str, bool]`
|
||||
- `api.py:145-147` — `TrainingStats` fields → `dict[str, float]`
|
||||
- `features/base_feature.py` — 12 methods missing return type annotations
|
||||
- `encoders/text_encoders.py:184` — abstract method `get_hf_config_param_names` never enforced
|
||||
|
||||
### Docstrings
|
||||
- `features/base_feature.py:100` — `add_feature_data()` doesn't describe `proc_df` contract
|
||||
- `models/ecd.py:145` — `forward()` doesn't explain `targets` in train vs. predict mode
|
||||
- `data/preprocessing.py:143` — `DataFormatPreprocessor` has no docstring for the 3-method contract
|
||||
|
||||
### Performance
|
||||
- `preprocessing.py:207-214` — converts `dataset` to `pd.DataFrame` 3× in same function
|
||||
- `utils/data_utils.py:452` — `hash_dict()` runs `pickle.dumps()` + SHA256 on every call; not cached
|
||||
- `features/base_feature.py:178` — `create_sample_input()` generates random tensors every call
|
||||
|
||||
### Magic Constants
|
||||
- `data/lazy_utils.py:29` — `min(16, (os.cpu_count() or 4) + 4)` — why `+4`? why cap 16? Add comment.
|
||||
- `features/image_feature.py:98-99` — ImageNet1K mean/std hardcoded instead of from torchvision
|
||||
|
||||
### Dead Code
|
||||
- `features/base_feature.py:648-703` — `create_passthrough_input_feature()` inline class factory
|
||||
- `utils/visualization_utils.py` — 1568 lines of custom plotting duplicating pandas/plotly
|
||||
|
||||
---
|
||||
|
||||
## Persona Verdicts
|
||||
|
||||
### ML Engineer (Production Pipelines)
|
||||
**HIGH RISK** — do not deploy without fixing C4, C5, C6. Silent failures in `read_binary_files()` (C4) silently shrink datasets at scale. 112 bare `except Exception:` (C5) mean production tracebacks are useless. The OOM footgun in `collect.py` (C6) hits every practitioner who runs feature analysis on an LLM. The preprocessing monolith (2407 lines, 18 classes) makes debugging data pipelines require holding an enormous mental model. The trainer mixes concerns so tightly that adding distributed debugging hooks requires touching 6 different mixins.
|
||||
|
||||
### ML Researcher (Running Experiments)
|
||||
**MODERATE** — good for prototyping, risky for long-running experiments. The declarative YAML config and pydantic schema validation are the right idea, well-executed. However: the known metric-loss-on-resume bug (M8, `trainer.py:302`) is a real reproducibility hazard for multi-day training runs. The HuggingFace encoder schema (`schema/encoders/text_encoders.py`: 2714 lines) is so large that understanding available params for a given model requires reading 100+ lines. Hyperopt is tightly coupled to trainer internals, making custom search spaces fragile across versions.
|
||||
|
||||
### Open Source Contributor (First PR)
|
||||
**UNWELCOMING** — needs a contributor guide and smaller files. Adding a new feature type requires understanding: (1) `BaseFeatureMixin` vs `InputFeature`/`OutputFeature` split, (2) the schema-vs-feature-module duality (every feature has a matching schema class in `ludwig/schema/features/`), (3) the inner preprocessing module pattern, (4) the encoder/decoder registry. None of this is documented in one place. Feature files are enormous (image: 1378 lines, 66 methods; audio: 675 lines). `create_passthrough_input_feature()` is an invisible feature type that bypasses the registry — a contributor following the normal pattern would never know it exists.
|
||||
|
||||
### Social Media ML Reader (HN/Reddit/X)
|
||||
**MIXED** — impressive scope, cringe-worthy internals. The feature set is genuinely impressive (600+ datasets, 50+ encoders, Ray distributed, LLM fine-tuning, multimodal). But: `preprocessing.py` has 18 classes with copy-pasted method bodies. `api.py` is 2237 lines. `FatherPreprocessor` (a Feather reader named "Father") has been in production. The `dict[str, Any]` typedefs look like hastily-migrated Python 2 code. 172 TODOs suggest active development paralysis. The architecture deserves better than the execution.
|
||||
|
||||
---
|
||||
|
||||
## Improvement Plan (Ordered PRs)
|
||||
|
||||
### Phase 0 — Quick Wins (1-2 days, zero risk)
|
||||
|
||||
**PR-0a: Fix typos + silent bugs** (S)
|
||||
- `FatherPreprocessor` → `FeatherPreprocessor` (`preprocessing.py:689,1149`)
|
||||
- Remove duplicate `keys()` from `TrainingStats` (`api.py:150-153`)
|
||||
- Add `None` guard in `read_binary_files()` (`backend/base.py:207`)
|
||||
|
||||
**PR-0b: Fix TODO bugs** (S)
|
||||
- Fix metric loss on resume (`trainer.py:302`) — reload metrics from checkpoint
|
||||
- Remove fake `input_shape` dummy property (`models/base.py:147`)
|
||||
- Fix `check.py:32` silent failure — add `logger.exception()`
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Type System (1 week)
|
||||
|
||||
**PR-1: TypedDict for data contracts** (L)
|
||||
Replace `dict[str, Any]` aliases in `types.py` with `TypedDict` subclasses. Update callsites. Run mypy; fix revealed type errors.
|
||||
- `FeatureConfigDict`, `TrainingSetMetadataDict`, `FeatureMetadataDict` etc.
|
||||
- Files: `types.py`, `api.py`, `features/base_feature.py`, `data/preprocessing.py`
|
||||
- Impact: ~50 latent bugs caught by mypy; IDE completion for configs
|
||||
|
||||
**PR-2: Backend capabilities as frozen dataclass** (S)
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class BackendCapabilities:
|
||||
distributed: bool = False
|
||||
hyperopt: bool = False
|
||||
async_execution: bool = False
|
||||
```
|
||||
Replace all string-key capability lookups.
|
||||
- Files: `backend/base.py`, `backend/ray.py`, `backend/local.py`
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Preprocessing Refactoring (1-2 weeks)
|
||||
|
||||
**PR-3: Reader strategy pattern for `DataFormatPreprocessor`** (L)
|
||||
Collapse 18 subclasses into one `DataFormatPreprocessor` with injected format reader:
|
||||
```python
|
||||
class DataFormatReader(ABC):
|
||||
@abstractmethod
|
||||
def read(self, path: str, **kwargs) -> pd.DataFrame: ...
|
||||
|
||||
class CSVReader(DataFormatReader):
|
||||
def read(self, path, **kwargs): return pd.read_csv(path, **kwargs)
|
||||
|
||||
# One 5-line reader per format instead of one 50-line class per format
|
||||
```
|
||||
`preprocessing.py` drops from 2407 → ~1000 lines.
|
||||
- New package: `ludwig/data/readers/`
|
||||
- Add unit tests per reader (normal, missing file, malformed, empty)
|
||||
|
||||
**PR-4: Base preprocessing module** (M)
|
||||
Extract shared logic (missing value handling, dtype casting, reshaping) into `BasePreprocessingModule`. Have all feature `_Preprocessing` inner classes inherit from it.
|
||||
- Files: `features/base_feature.py`, `features/image_feature.py`, `features/audio_feature.py`, `features/category_feature.py`
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Trainer Modularization (1-2 weeks)
|
||||
|
||||
**PR-5: Trainer composition over mixin inheritance** (L)
|
||||
```python
|
||||
# Before: class Trainer(CheckpointMixin, EarlyStoppingMixin, MetricsMixin, ProfilingMixin, BaseTrainer)
|
||||
# After:
|
||||
class Trainer(BaseTrainer):
|
||||
def __init__(self, config, backend,
|
||||
checkpointer: CheckpointService,
|
||||
early_stopper: EarlyStoppingService,
|
||||
metrics_collector: MetricsCollectionService,
|
||||
profiler: ProfilingService | None = None): ...
|
||||
```
|
||||
- `Trainer.__init__` shrinks from 24 params to 5
|
||||
- Each service is independently testable
|
||||
- Files: `trainers/trainer.py`, `trainers/mixins.py` → `trainers/services/`
|
||||
|
||||
**PR-6: Decompose `train_loop()`** (M)
|
||||
Break 240-line method into `_forward_pass()`, `_backward_pass()`, `_update_metrics()`, `_maybe_checkpoint()`, `_maybe_early_stop()` — each <50 lines.
|
||||
- Files: `trainers/trainer.py`
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Error Handling (1 week)
|
||||
|
||||
**PR-7: Fix bare `except Exception:` handlers** (M)
|
||||
112 instances. Replace with specific exception types + logging. At minimum:
|
||||
- `check.py:32` — add `logger.exception()`
|
||||
- `strings_utils.py:138` — replace `return None` with typed exception
|
||||
- `image_utils.py:98` — replace `return None` with logged warning
|
||||
- All handlers that discard errors silently in preprocessing/data loading paths
|
||||
|
||||
**PR-7b: Fix `collect_activations()` OOM** (M)
|
||||
Add chunked streaming with disk offload. `collect.py`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Structure & Dead Code (1 week)
|
||||
|
||||
**PR-8: Split `LudwigModel` (`api.py`)** (XL)
|
||||
```
|
||||
api.py (~600 lines) — train(), evaluate(), predict(), save(), load() only
|
||||
experiment.py — run_experiment(), hyperopt integration
|
||||
serve_v2.py — already partially extracted; complete it
|
||||
explain.py — already partially extracted
|
||||
```
|
||||
|
||||
**PR-9: Promote `PassthroughInputFeature`** (S)
|
||||
Remove `create_passthrough_input_feature()` factory (`base_feature.py:648-703`). Create `features/passthrough_feature.py` with a proper class in the feature registry.
|
||||
|
||||
**PR-10: Rename misleading identifiers** (S)
|
||||
`NoneTrainer` → `InferenceOnlyTrainer`, `LocalPreprocessingMixin` → `LocalDataProcessingMixin`, `CacheManager` → `PreprocessedDataCache`
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 — Test Coverage (Ongoing)
|
||||
|
||||
**PR-11: Unit tests for format readers** (M)
|
||||
After PR-3: `tests/ludwig/data/readers/test_*.py` — normal read, missing file, malformed, empty.
|
||||
|
||||
**PR-12: Unit tests for `collect.py`** (S)
|
||||
Test with 2-layer model; verify output shapes; verify chunked mode.
|
||||
|
||||
**PR-13: Unit tests for `BatchInferModel`** (S)
|
||||
Test inner class at `backend/ray.py:816`.
|
||||
|
||||
**PR-14: Unit tests for RLHF trainers** (M)
|
||||
`KTOTrainer`, `ORPOTrainer`, `GRPOTrainer` — unit tests with tiny models, no GPU required.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7 — Documentation & Contributor Experience (1 week)
|
||||
|
||||
**PR-15: Feature contributor guide** (S)
|
||||
`docs/developer_guide/adding_a_feature_type.md` — schema class, feature module, mixin pattern, required vs optional methods, test template.
|
||||
|
||||
**PR-16: Resolve all TODOs** (M)
|
||||
Audit all 172. Fix bugs; convert design questions to GitHub issues; delete the comment.
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| PR | Description | Size | Priority |
|
||||
|----|-------------|------|----------|
|
||||
| PR-0a | Fix typos + silent bugs | S | P0 |
|
||||
| PR-0b | Fix TODO bugs | S | P0 |
|
||||
| PR-7 | Fix bare `except Exception:` | M | P0 |
|
||||
| PR-7b | Fix `collect_activations()` OOM | M | P0 |
|
||||
| PR-1 | TypedDict for data contracts | L | P0 |
|
||||
| PR-3 | Reader strategy for format preprocessors | L | P0 |
|
||||
| PR-2 | Backend capabilities dataclass | S | P1 |
|
||||
| PR-4 | Base preprocessing module | M | P1 |
|
||||
| PR-5 | Trainer composition over inheritance | L | P1 |
|
||||
| PR-6 | Decompose `train_loop()` | M | P1 |
|
||||
| PR-11 | Unit tests for format readers | M | P1 |
|
||||
| PR-12 | Unit tests for `collect.py` | S | P1 |
|
||||
| PR-13 | Unit tests for `BatchInferModel` | S | P1 |
|
||||
| PR-14 | Unit tests for RLHF trainers | M | P1 |
|
||||
| PR-8 | Split `LudwigModel` | XL | P2 |
|
||||
| PR-9 | Promote `PassthroughInputFeature` | S | P2 |
|
||||
| PR-10 | Rename misleading identifiers | S | P2 |
|
||||
| PR-15 | Feature contributor guide | S | P2 |
|
||||
| PR-16 | Resolve all TODOs | M | P2 |
|
||||
|
||||
**Recommended start order:** PR-0a → PR-7 → PR-3 → PR-1 → PR-5
|
||||
**Total estimated effort:** 6-8 weeks of focused engineering (PRs within phases can be parallelized).
|
||||
@@ -0,0 +1,252 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
Code in ludwig/api_annotations.py adapted from
|
||||
https://github.com/ray-project/ray (Apache-2.0 License)
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
Code in ludwig/utils/structural_warnings.py adapted from
|
||||
https://github.com/ray-project/ray (Apache-2.0 License)
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
Code in ludwig/utils/logging_utils.py adapted from
|
||||
https://github.com/ray-project/ray (Apache-2.0 License)
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,107 @@
|
||||
Ludwig includes derived work from TensorFlow(https://github.com/tensorflow/tensorflow) under the Apache License 2.0:
|
||||
|
||||
Copyright 2016 The prometheus-operator Authors
|
||||
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
You may obtain a copy of the License at
|
||||
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
|
||||
See the License for the specific language governing permissions and
|
||||
|
||||
limitations under the License.
|
||||
|
||||
|
||||
The derived work can be found in the files: ludwig/models/modules/convolutional_modules.py
|
||||
|
||||
|
||||
------
|
||||
|
||||
Ludwig includes derived work from Keras(https://github.com/keras-team/keras) under the MIT License:
|
||||
|
||||
|
||||
COPYRIGHT
|
||||
|
||||
All contributions by François Chollet:
|
||||
|
||||
Copyright (c) 2015 - 2018, François Chollet.
|
||||
|
||||
All rights reserved.
|
||||
|
||||
|
||||
All contributions by Google:
|
||||
|
||||
Copyright (c) 2015 - 2018, Google, Inc.
|
||||
|
||||
All rights reserved.
|
||||
|
||||
|
||||
All contributions by Microsoft:
|
||||
|
||||
Copyright (c) 2017 - 2018, Microsoft, Inc.
|
||||
|
||||
All rights reserved.
|
||||
|
||||
|
||||
All other contributions:
|
||||
|
||||
Copyright (c) 2015 - 2018, the respective contributors.
|
||||
|
||||
All rights reserved.
|
||||
|
||||
|
||||
Each contributor holds copyright over their respective contributions.
|
||||
|
||||
The project versioning (Git) records all such contribution source information.
|
||||
|
||||
LICENSE
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
in the Software without restriction, including without limitation the rights
|
||||
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
The derived work can be found in the files: mkdocs/code_docs_autogen.py
|
||||
@@ -0,0 +1,353 @@
|
||||
<p align="center">
|
||||
<a href="https://ludwig.ai">
|
||||
<img src="https://github.com/ludwig-ai/ludwig-docs/raw/main/docs/images/ludwig_hero_smaller.jpg" height="150">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Declarative deep learning framework for LLMs, multimodal models, and tabular AI.**
|
||||
|
||||
[](https://badge.fury.io/py/ludwig)
|
||||
[](https://discord.gg/CBgdrGnZjy)
|
||||
[](https://hub.docker.com/r/ludwigai)
|
||||
[](https://pepy.tech/project/ludwig)
|
||||
[](https://github.com/ludwig-ai/ludwig/blob/main/LICENSE)
|
||||
[](https://twitter.com/ludwig_ai)
|
||||
|
||||
[**Docs**](https://ludwig.ai) · [**Getting Started**](https://ludwig.ai/latest/getting_started/) · [**Examples**](https://ludwig.ai/latest/examples) · [**Discord**](https://discord.gg/CBgdrGnZjy)
|
||||
|
||||
</div>
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## What is Ludwig?
|
||||
|
||||
Ludwig is a **declarative deep learning framework** that lets you train, fine-tune, and deploy AI models — from LLM fine-tuning to tabular classification — using a YAML config file and zero boilerplate Python.
|
||||
|
||||
```yaml
|
||||
# Fine-tune Llama-3.1 with LoRA in one config file
|
||||
model_type: llm
|
||||
base_model: meta-llama/Llama-3.1-8B
|
||||
adapter:
|
||||
type: lora
|
||||
trainer:
|
||||
type: finetune
|
||||
epochs: 3
|
||||
input_features:
|
||||
- name: instruction
|
||||
type: text
|
||||
output_features:
|
||||
- name: response
|
||||
type: text
|
||||
```
|
||||
|
||||
```bash
|
||||
ludwig train --config model.yaml --dataset my_data.csv
|
||||
```
|
||||
|
||||
**Tech stack:** Python 3.12 · PyTorch 2.7+ · Pydantic 2 · Transformers 5 · Ray 2.54
|
||||
|
||||
Ludwig is hosted by the [Linux Foundation AI & Data](https://lfaidata.foundation/).
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## What's New in Ludwig 0.16
|
||||
|
||||
| Feature | Description |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| **PatchTST & N-BEATS encoders** | State-of-the-art timeseries forecasting encoders with MASE/sMAPE metrics |
|
||||
| **Advanced PEFT adapters** | PiSSA, EVA, CorDA/LoftQ initializers; TinyLoRA, OFT, HRA, WaveFT, LN-Tuning, VBLoRA, C3A adapter types |
|
||||
| **VLM fine-tuning** | Train LLaVA, Qwen2-VL, InternVL via `is_multimodal: true` with gated cross-attention |
|
||||
| **HyperNetwork combiner** | Conditioning-based feature fusion — one feature generates weights for others |
|
||||
| **Nash-MTL & Pareto-MTL** | Game-theoretic and preference-based multi-task loss balancing |
|
||||
| **LLM config generation** | `ludwig generate_config "describe your task"` — LLM writes the YAML for you |
|
||||
| **ModelInspector** | Architecture analysis, weight collection, feature importance proxy |
|
||||
| **Ray Serve & KServe** | Distributed and Kubernetes-native model deployment shims |
|
||||
| **GRPO alignment** | Reward-model-free RLHF via Group Relative Policy Optimization |
|
||||
| **torchao quantization + QAT** | PyTorch-native `int4/int8/float8` with Quantization-Aware Training |
|
||||
| **Multi-adapter PEFT** | Multiple named LoRA adapters with weighted merging (TIES, DARE, SVD) |
|
||||
| **Native Optuna executor** | GPT/TPE/CMA-ES samplers, pruning, resumable SQLite/PostgreSQL storage |
|
||||
| **Timeseries forecasting** | `model.forecast(dataset, horizon=N)` API with `TimeseriesOutputFeature` |
|
||||
| **Muon & ScheduleFreeAdamW** | New optimizers for large-scale pretraining and fine-tuning |
|
||||
| **Image segmentation decoders** | UNet, SegFormer, FPN decoders for semantic segmentation |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install ludwig # core
|
||||
pip install ludwig[full] # all optional dependencies
|
||||
pip install ludwig[llm] # LLM fine-tuning only
|
||||
```
|
||||
|
||||
Requires Python 3.12+. See [contributing](https://github.com/ludwig-ai/ludwig/blob/main/CONTRIBUTING.md) for a full dependency matrix.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Fine-tune an LLM (instruction tuning)
|
||||
|
||||
[](https://colab.research.google.com/drive/1c3AO8l_H6V_x37RwQ8V7M6A-RmcBf2tG?usp=sharing)
|
||||
|
||||
Ludwig supports the full LLM fine-tuning spectrum:
|
||||
|
||||
| Technique | Config key |
|
||||
| --------------------------------- | ------------------------------------------------------------------------ |
|
||||
| Supervised fine-tuning (SFT) | `trainer.type: finetune` |
|
||||
| DPO / KTO / ORPO / GRPO alignment | `trainer.type: dpo` (or `kto`, `orpo`, `grpo`) |
|
||||
| LoRA / DoRA / VeRA / PiSSA | `adapter.type: lora` (or `dora`, `vera`, `lora` + `init_weights: pissa`) |
|
||||
| 4-bit QLoRA (bitsandbytes) | `quantization.bits: 4` |
|
||||
| torchao + QAT | `quantization.backend: torchao` |
|
||||
| Multi-adapter with merging | `adapters:` dict + `merge:` block |
|
||||
| VLM (vision-language) | `is_multimodal: true` |
|
||||
|
||||
```yaml
|
||||
model_type: llm
|
||||
base_model: meta-llama/Llama-3.1-8B
|
||||
|
||||
quantization:
|
||||
bits: 4
|
||||
|
||||
adapter:
|
||||
type: lora
|
||||
|
||||
prompt:
|
||||
template: |
|
||||
### Instruction: {instruction}
|
||||
### Input: {input}
|
||||
### Response:
|
||||
|
||||
input_features:
|
||||
- name: prompt
|
||||
type: text
|
||||
|
||||
output_features:
|
||||
- name: output
|
||||
type: text
|
||||
|
||||
trainer:
|
||||
type: finetune
|
||||
learning_rate: 0.0001
|
||||
batch_size: 1
|
||||
gradient_accumulation_steps: 16
|
||||
epochs: 3
|
||||
learning_rate_scheduler:
|
||||
decay: cosine
|
||||
warmup_fraction: 0.01
|
||||
|
||||
backend:
|
||||
type: local
|
||||
```
|
||||
|
||||
```bash
|
||||
export HUGGING_FACE_HUB_TOKEN="<your_token>"
|
||||
ludwig train --config model.yaml --dataset "ludwig://alpaca"
|
||||
```
|
||||
|
||||
### Train a multimodal classifier
|
||||
|
||||
```yaml
|
||||
input_features:
|
||||
- name: review_text
|
||||
type: text
|
||||
encoder:
|
||||
type: bert
|
||||
- name: star_rating
|
||||
type: number
|
||||
- name: product_image
|
||||
type: image
|
||||
encoder:
|
||||
type: dinov2
|
||||
|
||||
output_features:
|
||||
- name: recommended
|
||||
type: binary
|
||||
```
|
||||
|
||||
```bash
|
||||
ludwig train --config model.yaml --dataset reviews.csv
|
||||
```
|
||||
|
||||
### Generate a config from natural language
|
||||
|
||||
```bash
|
||||
ludwig generate_config "I have a CSV with age, income, education level, and I want to predict loan default"
|
||||
```
|
||||
|
||||
### Make predictions
|
||||
|
||||
```bash
|
||||
ludwig predict --model_path results/experiment_run/model --dataset new_data.csv
|
||||
```
|
||||
|
||||
### Launch a REST API
|
||||
|
||||
```bash
|
||||
ludwig serve --model_path results/experiment_run/model
|
||||
# POST http://localhost:8000/predict
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Capabilities
|
||||
|
||||
<details>
|
||||
<summary><strong>LLM Fine-Tuning</strong></summary>
|
||||
|
||||
- **Supervised fine-tuning (SFT)** on instruction/response pairs
|
||||
- **Alignment training**: DPO, KTO, ORPO, GRPO (reward-model-free RLHF)
|
||||
- **PEFT adapters**: LoRA, DoRA, VeRA, LoRA+, TinyLoRA, OFT, HRA, WaveFT, LN-Tuning, VBLoRA, C3A
|
||||
- **LoRA initializers**: PiSSA, EVA, CorDA, LoftQ for improved convergence
|
||||
- **Multi-adapter PEFT**: multiple named adapters on one base model, switchable at runtime; merge with TIES, DARE, SVD, magnitude pruning
|
||||
- **Quantization**: 4-bit/8-bit QLoRA (bitsandbytes), torchao int4/int8/float8 with QAT
|
||||
- **VLM fine-tuning**: LLaVA, Qwen2-VL, InternVL via `is_multimodal: true`
|
||||
- **Sequence packing** for efficient training on variable-length inputs
|
||||
- **Paged and 8-bit optimizers** for memory-efficient training
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Multimodal & Tabular Models</strong></summary>
|
||||
|
||||
- **Input modalities**: text, numbers, categories, binary, sets, bags, sequences, images, audio, timeseries, vectors, dates
|
||||
- **Text encoders**: any HuggingFace Transformer (BERT, RoBERTa, ModernBERT, Qwen3, Llama-3.1, etc.), plus Mamba-2, Jamba
|
||||
- **Image encoders**: DINOv2, ConvNeXt, EfficientNet, ViT, CAFormer, ConvFormer, PoolFormer, TIMM (1000+ models)
|
||||
- **Timeseries encoders**: PatchTST, N-BEATS, CNN, RNN, Transformer; MASE and sMAPE metrics; `model.forecast()` API
|
||||
- **Combiners**: concat, transformer, tab_transformer, FT-Transformer, TabNet, TabPFN v2, HyperNetwork, ProjectAggregate, GatedFusion, Perceiver
|
||||
- **Multi-task learning**: multiple output features in a single model; Nash-MTL, Pareto-MTL, FAMO, GradNorm, uncertainty loss balancing
|
||||
- **Image segmentation**: UNet, SegFormer, FPN decoders
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Training Infrastructure</strong></summary>
|
||||
|
||||
- **Distributed training**: HuggingFace Accelerate with DDP, FSDP, DeepSpeed (zero-code changes)
|
||||
- **Ray backend**: training across a Ray cluster, larger-than-memory datasets via Ray Data
|
||||
- **Automatic batch size selection** and learning rate range test
|
||||
- **Mixed precision** (fp16/bf16), gradient checkpointing, gradient accumulation
|
||||
- **Optimizers**: AdamW, Adafactor, SGD, Muon, ScheduleFreeAdamW, Lion, paged/8-bit variants
|
||||
- **Learning rate schedulers**: cosine, linear, polynomial, reduce-on-plateau, OneCycleLR
|
||||
- **Model Soup**: uniform and greedy checkpoint averaging for better generalization at zero inference cost
|
||||
- **Modality dropout** for robust multimodal models
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Hyperparameter Optimization</strong></summary>
|
||||
|
||||
- **Executors**: Ray Tune (ASHA, PBT, Bayesian) and native Optuna (auto/GP/TPE/CMA-ES)
|
||||
- **Optuna persistence**: SQLite or PostgreSQL for resumable HPO runs
|
||||
- **Pruning** with Optuna's MedianPruner and HyperbandPruner
|
||||
- **Search spaces**: uniform, log-uniform, choice, randint, quantized
|
||||
- **Full Ludwig config** is searchable — any nested parameter can be a hyperparameter
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Production & Deployment</strong></summary>
|
||||
|
||||
- **REST API**: FastAPI server with Prometheus metrics and structured logging (`ludwig serve`)
|
||||
- **vLLM serving**: OpenAI-compatible API with PagedAttention and continuous batching
|
||||
- **Ray Serve**: distributed deployment with auto-scaling and traffic splitting
|
||||
- **KServe**: Kubernetes-native deployment with Open Inference Protocol v2
|
||||
- **Model export**: SafeTensors (default), `torch.export` `.pt2` bundles, ONNX
|
||||
- **HuggingFace Hub**: `ludwig upload hf_hub` — push model + auto-generated model card
|
||||
- **Docker**: prebuilt containers at [ludwigai/ludwig](https://hub.docker.com/u/ludwigai)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Tooling & Integrations</strong></summary>
|
||||
|
||||
- **Experiment tracking**: TensorBoard, Weights & Biases, Comet ML, MLflow, Aim Stack
|
||||
- **Model inspection**: `ModelInspector` — weight enumeration, architecture summary, feature importance proxy
|
||||
- **Visualizations**: learning curves, confusion matrices, calibration plots, ROC curves, hyperopt analysis
|
||||
- **AutoML**: `ludwig.automl.auto_train()` — give it a dataset and a time budget; the YAML-driven search space samples encoder/combiner/decoder combinations and validates them before training
|
||||
- **Dataset quality checks**: `from ludwig.utils.dataset_quality import check_dataset_quality` — validates a DataFrame before training (missing values, class imbalance, near-duplicate columns, ID leakage, …)
|
||||
- **OpenML integration**: load any OpenML task directly — `OpenMLLoader` fetches by task ID and caches locally as Parquet
|
||||
- **LLM config generation**: `ludwig generate_config "describe your task"` — LLM writes the YAML
|
||||
- **K-fold cross-validation**: `ludwig experiment --k_fold N`
|
||||
- **Dataset Zoo**: 70+ built-in benchmark datasets (`ludwig://mnist`, `ludwig://alpaca`, …)
|
||||
|
||||
</details>
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Examples
|
||||
|
||||
### LLM & Alignment
|
||||
|
||||
| Use Case | Link |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| LLM instruction tuning (LoRA + QLoRA) | [examples/llm](https://ludwig.ai/latest/examples/llm/llm_finetuning) |
|
||||
| DPO / GRPO alignment | [examples/llm/alignment](https://ludwig.ai/latest/examples/llm/alignment) |
|
||||
| Advanced PEFT (PiSSA, OFT, VBLoRA, …) | [examples/llms/peft_advanced](https://ludwig.ai/latest/examples/llms/peft_advanced) |
|
||||
| VLM fine-tuning (LLaVA, Qwen2-VL) | [examples/vlm](https://github.com/ludwig-ai/ludwig/tree/main/examples/vlm) |
|
||||
|
||||
### Tabular & Multimodal
|
||||
|
||||
| Use Case | Link |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| Binary classification (Titanic) | [examples/titanic](https://ludwig.ai/latest/examples/titanic) |
|
||||
| Tabular classification (census income) | [examples/adult_census_income](https://ludwig.ai/latest/examples/adult_census_income) |
|
||||
| Multimodal classification | [examples/multimodal_classification](https://ludwig.ai/latest/examples/multimodal_classification) |
|
||||
| Multi-task learning | [examples/multi_task](https://ludwig.ai/latest/examples/multi_task) |
|
||||
|
||||
### Timeseries & Vision
|
||||
|
||||
| Use Case | Link |
|
||||
| ------------------------------------------ | ----------------------------------------------------------------------------------------- |
|
||||
| Timeseries forecasting (PatchTST, N-BEATS) | [examples/forecasting](https://ludwig.ai/latest/examples/forecasting) |
|
||||
| Weather forecasting | [examples/weather](https://ludwig.ai/latest/examples/weather) |
|
||||
| Image classification (MNIST) | [examples/mnist](https://ludwig.ai/latest/examples/mnist) |
|
||||
| Semantic segmentation | [examples/semantic_segmentation](https://ludwig.ai/latest/examples/semantic_segmentation) |
|
||||
|
||||
### NLP & Audio
|
||||
|
||||
| Use Case | Link |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------- |
|
||||
| Text classification | [examples/text_classification](https://ludwig.ai/latest/examples/text_classification) |
|
||||
| Named entity recognition | [examples/ner_tagging](https://ludwig.ai/latest/examples/ner_tagging) |
|
||||
| Machine translation | [examples/machine_translation](https://ludwig.ai/latest/examples/machine_translation) |
|
||||
| Speech recognition | [examples/speech_recognition](https://ludwig.ai/latest/examples/speech_recognition) |
|
||||
| Speaker verification | [examples/speaker_verification](https://ludwig.ai/latest/examples/speaker_verification) |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Why Ludwig?
|
||||
|
||||
- **Zero boilerplate** — no training loop, no data pipeline, no evaluation code. The YAML config is the entire program.
|
||||
- **Best-in-class LLM support** — full spectrum from LoRA to GRPO alignment, torchao QAT, and VLM fine-tuning, all in config.
|
||||
- **Multimodal out of the box** — mix text, images, numbers, audio, and timeseries with one config change.
|
||||
- **Scale without code changes** — go from laptop → multi-GPU → Ray cluster by changing `backend.type`.
|
||||
- **Expert control when you need it** — every activation function, scheduler, and optimizer is configurable.
|
||||
- **Reproducible research** — every run is logged and the full config is saved. Compare experiments with `ludwig visualize`.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Publications
|
||||
|
||||
- [Ludwig: A Type-Based Declarative Deep Learning Toolbox](https://arxiv.org/pdf/1909.07930.pdf) (2019)
|
||||
- [Declarative Machine Learning Systems](https://arxiv.org/pdf/2107.08148.pdf) (2021)
|
||||
- [Ludwig's State-of-the-Art Benchmarks](https://openreview.net/pdf?id=hwjnu6qW7E4)
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Community
|
||||
|
||||
[](https://discord.gg/CBgdrGnZjy)
|
||||
|
||||
- [Discord](https://discord.gg/CBgdrGnZjy) — ask questions, share what you've built
|
||||
- [GitHub Issues](https://github.com/ludwig-ai/ludwig/issues) — bugs and feature requests
|
||||
- [X / Twitter](https://twitter.com/ludwig_ai) — announcements
|
||||
- [Medium](https://medium.com/ludwig-ai) — tutorials and deep-dives
|
||||
|
||||
<a href="https://github.com/ludwig-ai/ludwig/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=ludwig-ai/ludwig" />
|
||||
</a>
|
||||
|
||||
[](https://star-history.com/#ludwig-ai/ludwig&Date)
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`ludwig-ai/ludwig`
|
||||
- 原始仓库:https://github.com/ludwig-ai/ludwig
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
<p align="center">
|
||||
<a href="https://ludwig.ai">
|
||||
<img src="https://github.com/ludwig-ai/ludwig-docs/raw/main/docs/images/ludwig_hero_smaller.jpg" height="150">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
_확장성과 효율성을 위해 설계된 선언적 딥러닝 프레임워크_
|
||||
|
||||
[](https://badge.fury.io/py/ludwig)
|
||||
[](https://discord.gg/CBgdrGnZjy)
|
||||
[](https://hub.docker.com/r/ludwigai)
|
||||
[](https://pepy.tech/project/ludwig)
|
||||
[](https://github.com/ludwig-ai/ludwig/blob/main/LICENSE)
|
||||
[](https://twitter.com/ludwig_ai)
|
||||
|
||||
</div>
|
||||
|
||||
# 📖 Ludwig란?
|
||||
|
||||
Ludwig는 **LLM** 및 기타 심층 신경망과 같은 **맞춤형** AI 모델을 구축하기 위한 **로우코드** 프레임워크입니다.
|
||||
|
||||
주요 기능:
|
||||
|
||||
- 🛠 **손쉬운 맞춤형 모델 구축:** 선언적 YAML 설정 파일만으로 최신 LLM을 데이터에 맞춰 학습시킬 수 있습니다. 멀티태스크 및 멀티모달 학습을 지원합니다. 포괄적인 설정 검증으로 잘못된 매개변수 조합을 감지하고 런타임 오류를 방지합니다.
|
||||
- ⚡ **확장성과 효율성 최적화:** 자동 배치 크기 선택, 분산 학습([DDP](https://pytorch.org/tutorials/beginner/ddp_series_theory.html), [DeepSpeed](https://github.com/microsoft/DeepSpeed)), 매개변수 효율적 미세 조정([PEFT](https://github.com/huggingface/peft)), 4비트 양자화(QLoRA), 페이지 및 8비트 옵티마이저, 메모리 초과 데이터셋 지원.
|
||||
- 📐 **전문가 수준의 제어:** 활성화 함수까지 모델을 완전히 제어할 수 있습니다. 하이퍼파라미터 최적화, 설명 가능성, 풍부한 메트릭 시각화를 지원합니다.
|
||||
- 🧱 **모듈식 및 확장 가능:** 설정에서 몇 가지 매개변수만 변경하여 다양한 모델 아키텍처, 태스크, 피처, 모달리티를 실험할 수 있습니다. 딥러닝을 위한 빌딩 블록이라고 생각하세요.
|
||||
- 🚢 **프로덕션을 위한 설계:** 사전 빌드된 [Docker](https://hub.docker.com/u/ludwigai) 컨테이너, [Kubernetes](https://github.com/ray-project/kuberay)에서 [Ray](https://www.ray.io/) 실행 네이티브 지원, [Torchscript](https://pytorch.org/docs/stable/jit.html) 및 [Triton](https://developer.nvidia.com/triton-inference-server)으로 모델 내보내기, 한 번의 명령으로 [HuggingFace](https://huggingface.co/models)에 업로드.
|
||||
|
||||
Ludwig는 [Linux Foundation AI & Data](https://lfaidata.foundation/)에서 호스팅합니다.
|
||||
|
||||
**기술 스택:** Python 3.12 | PyTorch 2.6 | Pydantic 2 | Transformers 5 | Ray 2.54
|
||||
|
||||

|
||||
|
||||
# 💾 설치
|
||||
|
||||
PyPI에서 설치합니다. Ludwig는 Python 3.12 이상을 요구합니다.
|
||||
|
||||
```shell
|
||||
pip install ludwig
|
||||
```
|
||||
|
||||
모든 선택적 의존성을 포함하여 설치:
|
||||
|
||||
```shell
|
||||
pip install ludwig[full]
|
||||
```
|
||||
|
||||
더 자세한 설치 방법은 [기여 가이드](https://github.com/ludwig-ai/ludwig/blob/main/CONTRIBUTING.md)를 참조하세요.
|
||||
|
||||
# 🚂 시작하기
|
||||
|
||||
Ludwig의 기능을 빠르게 살펴보고 싶으시다면 이 Colab 노트북을 확인하세요 🚀 [](https://colab.research.google.com/drive/1lB4ALmEyvcMycE3Mlnsd7I3bc0zxvk39)
|
||||
|
||||
LLM 미세 조정을 원하시나요? 다음 노트북을 확인하세요:
|
||||
|
||||
1. Fine-Tune Llama-2-7b: [](https://colab.research.google.com/drive/1r4oSEwRJpYKBPM0M0RSh0pBEYK_gBKbe)
|
||||
1. Fine-Tune Llama-2-13b: [](https://colab.research.google.com/drive/1zmSEzqZ7v4twBrXagj1TE_C--RNyVAyu)
|
||||
1. Fine-Tune Mistral-7b: [](https://colab.research.google.com/drive/1i_8A1n__b7ljRWHzIsAdhO7u7r49vUm4)
|
||||
|
||||
전체 튜토리얼은 공식 [시작 가이드](https://ludwig.ai/latest/getting_started/)를 확인하시거나, 엔드투엔드 [예제](https://ludwig.ai/latest/examples)를 살펴보세요.
|
||||
|
||||
## 대규모 언어 모델 미세 조정
|
||||
|
||||
[](https://colab.research.google.com/drive/1c3AO8l_H6V_x37RwQ8V7M6A-RmcBf2tG?usp=sharing)
|
||||
|
||||
사전 학습된 LLM을 챗봇처럼 지시를 따르도록 미세 조정("인스트럭션 튜닝")해 봅시다.
|
||||
|
||||
### 사전 요구 사항
|
||||
|
||||
- [HuggingFace API 토큰](https://huggingface.co/docs/hub/security-tokens)
|
||||
- 선택한 베이스 모델에 대한 접근 승인 (예: [Llama-3.1-8B](https://huggingface.co/meta-llama/Llama-3.1-8B))
|
||||
- 최소 12 GiB VRAM의 GPU (테스트에서는 Nvidia T4를 사용했습니다)
|
||||
|
||||
### 실행
|
||||
|
||||
[Stanford Alpaca](https://crfm.stanford.edu/2023/03/13/alpaca.html) 데이터셋을 사용합니다. 다음과 같은 테이블 형식의 파일로 구성됩니다:
|
||||
|
||||
| instruction | input | output |
|
||||
| :-----------------------------------------------: | :--------------: | :-----------------------------------------------: |
|
||||
| Give three tips for staying healthy. | | 1.Eat a balanced diet and make sure to include... |
|
||||
| Arrange the items given below in the order to ... | cake, me, eating | I eating cake. |
|
||||
| Write an introductory paragraph about a famous... | Michelle Obama | Michelle Obama is an inspirational woman who r... |
|
||||
| ... | ... | ... |
|
||||
|
||||
`model.yaml`이라는 YAML 설정 파일을 다음 내용으로 생성하세요:
|
||||
|
||||
```yaml
|
||||
model_type: llm
|
||||
base_model: meta-llama/Llama-3.1-8B
|
||||
|
||||
quantization:
|
||||
bits: 4
|
||||
|
||||
adapter:
|
||||
type: lora
|
||||
|
||||
prompt:
|
||||
template: |
|
||||
Below is an instruction that describes a task, paired with an input that may provide further context.
|
||||
Write a response that appropriately completes the request.
|
||||
|
||||
### Instruction:
|
||||
{instruction}
|
||||
|
||||
### Input:
|
||||
{input}
|
||||
|
||||
### Response:
|
||||
|
||||
input_features:
|
||||
- name: prompt
|
||||
type: text
|
||||
|
||||
output_features:
|
||||
- name: output
|
||||
type: text
|
||||
|
||||
trainer:
|
||||
type: finetune
|
||||
learning_rate: 0.0001
|
||||
batch_size: 1
|
||||
gradient_accumulation_steps: 16
|
||||
epochs: 3
|
||||
learning_rate_scheduler:
|
||||
decay: cosine
|
||||
warmup_fraction: 0.01
|
||||
|
||||
preprocessing:
|
||||
sample_ratio: 0.1
|
||||
|
||||
backend:
|
||||
type: local
|
||||
```
|
||||
|
||||
이제 모델을 학습시켜 봅시다:
|
||||
|
||||
```bash
|
||||
export HUGGING_FACE_HUB_TOKEN = "<api_token>"
|
||||
|
||||
ludwig train --config model.yaml --dataset "ludwig://alpaca"
|
||||
```
|
||||
|
||||
## 지도 학습 ML
|
||||
|
||||
[Rotten Tomatoes](https://www.kaggle.com/stefanoleone992/rotten-tomatoes-movies-and-critic-reviews-dataset) 영화 평론가의 리뷰가 긍정적인지 부정적인지 예측하는 신경망을 만들어 봅시다.
|
||||
|
||||
데이터셋은 다음과 같은 CSV 파일입니다:
|
||||
|
||||
| movie_title | content_rating | genres | runtime | top_critic | review_content | recommended |
|
||||
| :------------------: | :------------: | :------------------------------: | :-----: | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| Deliver Us from Evil | R | Action & Adventure, Horror | 117.0 | TRUE | Director Scott Derrickson and his co-writer, Paul Harris Boardman, deliver a routine procedural with unremarkable frights. | 0 |
|
||||
| Barbara | PG-13 | Art House & International, Drama | 105.0 | FALSE | Somehow, in this stirring narrative, Barbara manages to keep hold of her principles, and her humanity and courage, and battles to save a dissident teenage girl whose life the Communists are trying to destroy. | 1 |
|
||||
| Horrible Bosses | R | Comedy | 98.0 | FALSE | These bosses cannot justify either murder or lasting comic memories, fatally compromising a farce that could have been great but ends up merely mediocre. | 0 |
|
||||
| ... | ... | ... | ... | ... | ... | ... |
|
||||
|
||||
[여기](https://ludwig.ai/latest/data/rotten_tomatoes.csv)에서 데이터셋 샘플을 다운로드하세요.
|
||||
|
||||
```bash
|
||||
wget https://ludwig.ai/latest/data/rotten_tomatoes.csv
|
||||
```
|
||||
|
||||
다음으로 `model.yaml`이라는 YAML 설정 파일을 생성하세요:
|
||||
|
||||
```yaml
|
||||
input_features:
|
||||
- name: genres
|
||||
type: set
|
||||
preprocessing:
|
||||
tokenizer: comma
|
||||
- name: content_rating
|
||||
type: category
|
||||
- name: top_critic
|
||||
type: binary
|
||||
- name: runtime
|
||||
type: number
|
||||
- name: review_content
|
||||
type: text
|
||||
encoder:
|
||||
type: embed
|
||||
output_features:
|
||||
- name: recommended
|
||||
type: binary
|
||||
```
|
||||
|
||||
이게 전부입니다! 이제 모델을 학습시켜 봅시다:
|
||||
|
||||
```bash
|
||||
ludwig train --config model.yaml --dataset rotten_tomatoes.csv
|
||||
```
|
||||
|
||||
**즐거운 모델링 되세요**
|
||||
|
||||
Ludwig를 여러분의 데이터에 적용해 보세요. 질문이 있으시면 [Discord에서 문의](https://discord.gg/CBgdrGnZjy)해 주세요.
|
||||
|
||||
# ❓ Ludwig를 사용해야 하는 이유
|
||||
|
||||
- **최소한의 머신러닝 보일러플레이트**
|
||||
|
||||
Ludwig는 머신러닝의 엔지니어링 복잡성을 기본으로 처리하여, 연구자들이 가장 높은 수준의 추상화에서 모델 구축에 집중할 수 있게 합니다. `torch.nn.Module` 모델에 대한 데이터 전처리, 하이퍼파라미터 최적화, 디바이스 관리, 분산 학습이 완전히 무료로 제공됩니다.
|
||||
|
||||
- **손쉬운 벤치마크 구축**
|
||||
|
||||
최신 기준 모델을 만들고 새 모델과 비교하는 것이 간단한 설정 변경만으로 가능합니다.
|
||||
|
||||
- **새로운 아키텍처를 여러 문제와 데이터셋에 쉽게 적용**
|
||||
|
||||
Ludwig가 지원하는 광범위한 태스크 및 데이터셋 세트에 새 모델을 적용하세요. Ludwig에는 간단한 설정만으로 여러 데이터셋에서 여러 모델 실험을 실행할 수 있는 [전체 벤치마킹 도구](https://arxiv.org/abs/2111.04260)가 모든 사용자에게 제공됩니다.
|
||||
|
||||
- **데이터 전처리, 모델링, 메트릭의 높은 설정 가능성**
|
||||
|
||||
모델 아키텍처, 학습 루프, 하이퍼파라미터 검색, 백엔드 인프라의 모든 측면을 선언적 설정에서 추가 필드로 수정하여 파이프라인을 요구 사항에 맞게 커스터마이즈할 수 있습니다. 설정 가능한 항목에 대한 자세한 내용은 [Ludwig 설정](https://ludwig.ai/latest/configuration/) 문서를 확인하세요.
|
||||
|
||||
- **멀티모달, 멀티태스크 학습 기본 지원**
|
||||
|
||||
코드 작성 없이 테이블 데이터, 텍스트, 이미지, 오디오까지 복잡한 모델 설정으로 혼합하여 사용할 수 있습니다.
|
||||
|
||||
- **풍부한 모델 내보내기 및 추적**
|
||||
|
||||
Tensorboard, Comet ML, Weights & Biases, MLFlow, Aim Stack 등의 도구로 모든 시도와 메트릭을 자동으로 추적합니다.
|
||||
|
||||
- **멀티 GPU, 멀티 노드 클러스터로 학습 자동 확장**
|
||||
|
||||
로컬 머신에서 클라우드로 코드 변경 없이 전환할 수 있습니다.
|
||||
|
||||
- **사전 학습된 Huggingface Transformers를 포함한 최신 모델의 로우코드 인터페이스**
|
||||
|
||||
Ludwig는 [Huggingface Transformers](https://huggingface.co/docs/transformers/index)에서 제공하는 사전 학습된 모델과 네이티브로 통합됩니다. 사용자는 코드를 전혀 작성하지 않고도 방대한 최신 사전 학습 PyTorch 모델을 사용할 수 있습니다. 예를 들어, Ludwig로 BERT 기반 감성 분석 모델을 학습시키는 것은 다음과 같이 간단합니다:
|
||||
|
||||
```shell
|
||||
ludwig train --dataset sst5 --config_str "{input_features: [{name: sentence, type: text, encoder: bert}], output_features: [{name: label, type: category}]}"
|
||||
```
|
||||
|
||||
- **AutoML을 위한 로우코드 인터페이스**
|
||||
|
||||
[Ludwig AutoML](https://ludwig.ai/latest/user_guide/automl/)을 사용하면 데이터셋, 대상 컬럼, 시간 예산만 제공하여 학습된 모델을 얻을 수 있습니다.
|
||||
|
||||
```python
|
||||
auto_train_results = ludwig.automl.auto_train(dataset=my_dataset_df, target=target_column_name, time_limit_s=7200)
|
||||
```
|
||||
|
||||
- **손쉬운 프로덕션화**
|
||||
|
||||
Ludwig는 GPU를 포함한 딥러닝 모델 서빙을 쉽게 만들어 줍니다. 학습된 Ludwig 모델에 대한 REST API를 실행하세요.
|
||||
|
||||
```shell
|
||||
ludwig serve --model_path=/path/to/model
|
||||
```
|
||||
|
||||
Ludwig는 효율적인 Torchscript 번들로 모델 내보내기를 지원합니다.
|
||||
|
||||
```shell
|
||||
ludwig export_torchscript --model_path=/path/to/model
|
||||
```
|
||||
|
||||
# 📚 튜토리얼
|
||||
|
||||
- [텍스트 분류](https://ludwig.ai/latest/examples/text_classification)
|
||||
- [테이블 데이터 분류](https://ludwig.ai/latest/examples/adult_census_income)
|
||||
- [이미지 분류](https://ludwig.ai/latest/examples/mnist)
|
||||
- [멀티모달 분류](https://ludwig.ai/latest/examples/multimodal_classification)
|
||||
|
||||
# 🔬 예제 사용 사례
|
||||
|
||||
- [개체명 인식 태깅](https://ludwig.ai/latest/examples/ner_tagging)
|
||||
- [자연어 이해](https://ludwig.ai/latest/examples/nlu)
|
||||
- [기계 번역](https://ludwig.ai/latest/examples/machine_translation)
|
||||
- [seq2seq를 통한 대화 모델링](https://ludwig.ai/latest/examples/seq2seq)
|
||||
- [감성 분석](https://ludwig.ai/latest/examples/sentiment_analysis)
|
||||
- [시아미즈 네트워크를 이용한 원샷 학습](https://ludwig.ai/latest/examples/oneshot)
|
||||
- [시각적 질의응답](https://ludwig.ai/latest/examples/visual_qa)
|
||||
- [음성 숫자 인식](https://ludwig.ai/latest/examples/speech_recognition)
|
||||
- [화자 인증](https://ludwig.ai/latest/examples/speaker_verification)
|
||||
- [이진 분류 (타이타닉)](https://ludwig.ai/latest/examples/titanic)
|
||||
- [시계열 예측](https://ludwig.ai/latest/examples/forecasting)
|
||||
- [시계열 예측 (날씨)](https://ludwig.ai/latest/examples/weather)
|
||||
- [영화 평점 예측](https://ludwig.ai/latest/examples/movie_ratings)
|
||||
- [다중 레이블 분류](https://ludwig.ai/latest/examples/multi_label)
|
||||
- [멀티태스크 학습](https://ludwig.ai/latest/examples/multi_task)
|
||||
- [단순 회귀: 연비 예측](https://ludwig.ai/latest/examples/fuel_efficiency)
|
||||
- [사기 탐지](https://ludwig.ai/latest/examples/fraud)
|
||||
|
||||
# 💡 추가 정보
|
||||
|
||||
[Ludwig](https://arxiv.org/pdf/1909.07930.pdf), [선언적 ML](https://arxiv.org/pdf/2107.08148.pdf), [Ludwig의 SoTA 벤치마크](https://openreview.net/pdf?id=hwjnu6qW7E4)에 대한 논문을 읽어보세요.
|
||||
|
||||
[Ludwig의 작동 방식](https://ludwig.ai/latest/user_guide/how_ludwig_works/), [시작 가이드](https://ludwig.ai/latest/getting_started/), 더 많은 [예제](https://ludwig.ai/latest/examples)를 확인하세요.
|
||||
|
||||
[기여](https://github.com/ludwig-ai/ludwig/blob/main/CONTRIBUTING.md)에 관심이 있으시거나, 질문, 의견, 공유하고 싶은 생각이 있으시거나, 최신 정보를 받고 싶으시다면 [Discord 커뮤니티에 참여](https://discord.gg/CBgdrGnZjy)하시고 [X](https://twitter.com/ludwig_ai)에서 팔로우해 주세요!
|
||||
|
||||
# 🤝 함께 Ludwig를 만들어 갈 커뮤니티에 참여하세요
|
||||
|
||||
Ludwig는 여러분과 같은 분들의 기여에 의존하는 활발하게 관리되는 오픈소스 프로젝트입니다. Ludwig를 모든 사람이 사용할 수 있는 더 접근 가능하고 기능이 풍부한 프레임워크로 만들기 위해 활발한 Ludwig 기여자 그룹에 참여하는 것을 고려해 주세요!
|
||||
|
||||
<a href="https://github.com/ludwig-ai/ludwig/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=ludwig-ai/ludwig" />
|
||||
</a><br/>
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#ludwig-ai/ludwig&Date)
|
||||
|
||||
# 👋 참여하기
|
||||
|
||||
- [Discord](https://discord.gg/CBgdrGnZjy)
|
||||
- [X (Twitter)](https://twitter.com/ludwig_ai)
|
||||
- [Medium](https://medium.com/ludwig-ai)
|
||||
- [GitHub Issues](https://github.com/ludwig-ai/ludwig/issues)
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# Releasing
|
||||
|
||||
## Release procedure
|
||||
|
||||
1. Update version number in `ludwig/globals.py`
|
||||
1. Update the `README.md` file
|
||||
1. Update `ludwig-docs`
|
||||
1. Commit
|
||||
1. Tag the commit with the version number `vX.Y.Z` with a meaningful message
|
||||
1. Push with `--tags`
|
||||
1. If a non-patch release, edit the release notes
|
||||
1. The PyPI upload is automated via GitHub Actions (`.github/workflows/upload-pypi.yml`) when a release is published
|
||||
1. Publish Docker images (see below)
|
||||
|
||||
## Docker images
|
||||
|
||||
Four images are published to Docker Hub under the `ludwigai` organisation for each release:
|
||||
`ludwigai/ludwig`, `ludwigai/ludwig-gpu`, `ludwigai/ludwig-ray`, `ludwigai/ludwig-ray-gpu`.
|
||||
|
||||
### Automated (CI)
|
||||
|
||||
The GitHub Actions workflow `.github/workflows/docker.yml` triggers on `v*.*.*` tags and builds
|
||||
images from the tagged source. If CI is healthy this runs automatically after step 6 above.
|
||||
|
||||
### Manual fallback
|
||||
|
||||
If CI does not run or images need to be backfilled, trigger a versioned build via the workflow
|
||||
dispatch input — no local Docker setup required:
|
||||
|
||||
```bash
|
||||
# Trigger all 4 image variants for a specific PyPI release
|
||||
gh workflow run docker.yml --repo ludwig-ai/ludwig --ref main \
|
||||
-f ludwig_version=0.14.0 -f latest=true
|
||||
```
|
||||
|
||||
Or build and push locally using the script at `docker/build_and_push.sh`
|
||||
(requires `docker login` to a `ludwigai` Docker Hub account):
|
||||
|
||||
```bash
|
||||
./docker/build_and_push.sh 0.14.0 --latest
|
||||
```
|
||||
|
||||
Both approaches install `ludwig[full]==<version>` from PyPI and produce two tags per image:
|
||||
the full version (`0.14.0`) and the major.minor shorthand (`0.14`), plus `latest` when requested.
|
||||
|
||||
## Release policy
|
||||
|
||||
Ludwig follows [Semantic Versioning](https://semver.org).
|
||||
In general, for major and minor releases, maintainers should all agree on the release.
|
||||
For patches, in particular time sensitive ones, a single maintainer can release without a full consensus, but this practice should be reserved for critical situations.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Ludwig Docker Images
|
||||
|
||||
These images provide Ludwig, a toolbox to train and evaluate deep learning models
|
||||
without the need to write code. Ludwig Docker images contain the full set of pre-requisite
|
||||
packages to support these capabilities
|
||||
|
||||
- text features
|
||||
- image features
|
||||
- audio features
|
||||
- visualizations
|
||||
- hyperparameter optimization
|
||||
- distributed training
|
||||
- model serving
|
||||
|
||||
## Publishing images
|
||||
|
||||
Images are normally published automatically by CI (`.github/workflows/docker.yml`) when a release
|
||||
tag is pushed. To publish manually or backfill a release, use the script in this directory:
|
||||
|
||||
```bash
|
||||
# Requires: docker login to a ludwigai Docker Hub account
|
||||
./docker/build_and_push.sh <version> [--latest]
|
||||
|
||||
# Examples
|
||||
./docker/build_and_push.sh 0.14.0 --latest # new latest release
|
||||
./docker/build_and_push.sh 0.13.0 # backfill without updating :latest
|
||||
```
|
||||
|
||||
See `RELEASES.md` for the full release procedure.
|
||||
|
||||
## Repositories
|
||||
|
||||
These four repositories contain a version of Ludwig with full features built
|
||||
from the project's `master` branch.
|
||||
|
||||
- `ludwigai/ludwig` Ludwig packaged with PyTorch
|
||||
- `ludwigai/ludwig-gpu` Ludwig packaged with gpu-enabled version of PyTorch
|
||||
- `ludwigai/ludwig-ray` Ludwig packaged with PyTorch
|
||||
and Ray 2.3.1 (https://github.com/ray-project/ray)
|
||||
- `ludwigai/ludwig-ray-gpu` Ludwig packaged with gpu-enabled versions of PyTorch
|
||||
and Ray 2.3.1 (https://github.com/ray-project/ray)
|
||||
|
||||
## Image Tags
|
||||
|
||||
- `master` - built from Ludwig's `master` branch
|
||||
- `nightly` - nightly build of Ludwig's software.
|
||||
- `sha-<commit point>` - version of Ludwig software at designated git sha1
|
||||
7-character commit point.
|
||||
|
||||
## Running Containers
|
||||
|
||||
Examples of using the `ludwigai/ludwig:master` image to:
|
||||
|
||||
- run the `ludwig cli` command or
|
||||
- run Python program containing Ludwig api or
|
||||
- view Ludwig results with Tensorboard
|
||||
|
||||
For purposes of the examples assume this host directory structure
|
||||
|
||||
```
|
||||
/top/level/directory/path/
|
||||
data/
|
||||
train.csv
|
||||
src/
|
||||
config.yaml
|
||||
ludwig_api_program.py
|
||||
```
|
||||
|
||||
### Run Ludwig CLI
|
||||
|
||||
```
|
||||
# set shell variable to parent directory
|
||||
parent_path=/top/level/directory/path
|
||||
|
||||
# invoke docker run command to execute the ludwig cli
|
||||
# map host directory ${parent_path}/data to container /data directory
|
||||
# map host directory ${parent_path}/src to container /src directory
|
||||
docker run -v ${parent_path}/data:/data \
|
||||
-v ${parent_path}/src:/src \
|
||||
ludwigai/ludwig:master \
|
||||
experiment --config /src/config.yaml \
|
||||
--dataset /data/train.csv \
|
||||
--output_directory /src/results
|
||||
```
|
||||
|
||||
Experiment results can be found in host directory `/top/level/directory/path/src/results`
|
||||
|
||||
### Run Python program using Ludwig APIs
|
||||
|
||||
```
|
||||
# set shell variable to parent directory
|
||||
parent_path=/top/level/directory/path
|
||||
|
||||
# invoke docker run command to execute Python interpreter
|
||||
# map host directory ${parent_path}/data to container /data directory
|
||||
# map host directory ${parent_path}/src to container /src directory
|
||||
# set current working directory to container /src directory
|
||||
# change default entrypoint from ludwig to python
|
||||
docker run -v ${parent_path}/data:/data \
|
||||
-v ${parent_path}/src:/src \
|
||||
-w /src \
|
||||
--entrypoint python \
|
||||
ludwigai/ludwig:master /src/ludwig_api_program.py
|
||||
```
|
||||
|
||||
Ludwig results can be found in host
|
||||
directory `/top/level/directory/path/src/results`
|
||||
|
||||
### View Ludwig Tensorboard results
|
||||
|
||||
```
|
||||
# set shell variable to parent directory
|
||||
parent_path=/top/level/directory/path
|
||||
|
||||
# invoke docker run command to execute Tensorboard
|
||||
# map host directory ${parent_path}/src to container /src directory
|
||||
# set up mapping from localhost port 6006 to container port 6006
|
||||
# change default entrypoint from ludwig to tensorboard
|
||||
# --logdir container location of tenorboard logs /src/results/<experiment_name>_<model_name>/model/logs
|
||||
# --bind_all Tensorboard serves on all public container interfaces
|
||||
docker run -v ${parent_path}/src:/src \
|
||||
-p 6006:6006 \
|
||||
--entrypoint tensorboard \
|
||||
ludwigai/ludwig:master \
|
||||
--logdir /src/results/experiment_run/model/logs \
|
||||
--bind_all
|
||||
```
|
||||
|
||||
Point browser to `http://localhost:6006` to see Tensorboard dashboard.
|
||||
|
||||
### Devcontainer
|
||||
|
||||
If you want to contribute to Ludwig, you can setup a Docker container with all the dependencies
|
||||
installed as a full featured development environment. This can be done using devcontainers with VS Code:
|
||||
https://code.visualstudio.com/docs/devcontainers/containers
|
||||
|
||||
You can find the `devcontainer.json` file within the top level `.devcontainer` folder.
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and push versioned Ludwig Docker images to Docker Hub.
|
||||
#
|
||||
# Usage:
|
||||
# ./docker/build_and_push.sh <version> [--latest]
|
||||
#
|
||||
# Examples:
|
||||
# ./docker/build_and_push.sh 0.14.0 # tags: 0.14.0, 0.14
|
||||
# ./docker/build_and_push.sh 0.14.0 --latest # tags: 0.14.0, 0.14, latest
|
||||
#
|
||||
# Requires: docker login to ludwigai account already done.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <version> [--latest]"
|
||||
echo " version full version to build, e.g. 0.14.0"
|
||||
echo " --latest also tag as :latest"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$1"
|
||||
IS_LATEST=false
|
||||
if [ "${2:-}" = "--latest" ]; then
|
||||
IS_LATEST=true
|
||||
fi
|
||||
|
||||
# Derive major.minor tag from full version (e.g. "0.14.0" -> "0.14")
|
||||
MINOR="${VERSION%.*}"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
# Image variants: "image_name dockerfile_dir"
|
||||
VARIANTS=(
|
||||
"ludwigai/ludwig docker/ludwig"
|
||||
"ludwigai/ludwig-gpu docker/ludwig-gpu"
|
||||
"ludwigai/ludwig-ray docker/ludwig-ray"
|
||||
"ludwigai/ludwig-ray-gpu docker/ludwig-ray-gpu"
|
||||
)
|
||||
|
||||
build_and_push() {
|
||||
local image="$1"
|
||||
local dockerfile_dir="$2"
|
||||
|
||||
echo ""
|
||||
echo "=== Building ${image}:${VERSION} ==="
|
||||
|
||||
local tag_args="-t ${image}:${VERSION} -t ${image}:${MINOR}"
|
||||
if [ "${IS_LATEST}" = "true" ]; then
|
||||
tag_args="${tag_args} -t ${image}:latest"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
docker build \
|
||||
--build-arg LUDWIG_VERSION="${VERSION}" \
|
||||
${tag_args} \
|
||||
-f "${REPO_ROOT}/${dockerfile_dir}/Dockerfile" \
|
||||
"${REPO_ROOT}"
|
||||
|
||||
echo "--- Pushing ${image}:${VERSION} ---"
|
||||
docker push "${image}:${VERSION}"
|
||||
docker push "${image}:${MINOR}"
|
||||
if [ "${IS_LATEST}" = "true" ]; then
|
||||
docker push "${image}:latest"
|
||||
fi
|
||||
}
|
||||
|
||||
for variant_entry in "${VARIANTS[@]}"; do
|
||||
read -r image dockerfile_dir <<< "${variant_entry}"
|
||||
build_and_push "${image}" "${dockerfile_dir}"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "All images built and pushed successfully."
|
||||
@@ -0,0 +1,43 @@
|
||||
#
|
||||
# Ludwig Docker image with full set of pre-requiste packages to support these capabilities
|
||||
# text features
|
||||
# image features
|
||||
# audio features
|
||||
# visualizations
|
||||
# hyperparameter optimization
|
||||
# distributed training
|
||||
# model serving
|
||||
#
|
||||
|
||||
FROM nvidia/cuda:12.6.3-cudnn-devel-ubuntu24.04
|
||||
|
||||
RUN apt-get -y update && DEBIAN_FRONTEND="noninteractive" apt-get -y install \
|
||||
python3.12 \
|
||||
python3.12-venv \
|
||||
python3.12-dev \
|
||||
git \
|
||||
libsndfile1 \
|
||||
cmake \
|
||||
ffmpeg \
|
||||
sox \
|
||||
libsox-dev
|
||||
|
||||
RUN python3.12 -m venv /opt/ludwig-venv
|
||||
ENV PATH="/opt/ludwig-venv/bin:$PATH"
|
||||
RUN pip install -U pip
|
||||
|
||||
ARG LUDWIG_VERSION
|
||||
|
||||
WORKDIR /ludwig
|
||||
|
||||
COPY . .
|
||||
RUN if [ -n "${LUDWIG_VERSION}" ]; then \
|
||||
pip install --no-cache-dir "ludwig[full]==${LUDWIG_VERSION}" --extra-index-url https://download.pytorch.org/whl/cu126; \
|
||||
else \
|
||||
pip install --no-cache-dir '.[full]' --extra-index-url https://download.pytorch.org/whl/cu126; \
|
||||
fi
|
||||
RUN pip install --no-cache-dir --force-reinstall torch==2.12.0 torchvision==0.27.0 torchaudio==2.11.0 --index-url https://download.pytorch.org/whl/cu126
|
||||
|
||||
WORKDIR /data
|
||||
|
||||
ENTRYPOINT ["ludwig"]
|
||||
@@ -0,0 +1,40 @@
|
||||
#
|
||||
# Ludwig Docker image with Ray support and full dependencies including:
|
||||
# text features
|
||||
# image features
|
||||
# audio features
|
||||
# visualizations
|
||||
# hyperparameter optimization
|
||||
# distributed training
|
||||
# model serving
|
||||
#
|
||||
|
||||
FROM rayproject/ray:2.54.0-py312-cu126
|
||||
|
||||
RUN sudo apt-get update && \
|
||||
DEBIAN_FRONTEND="noninteractive" sudo apt-get install -y \
|
||||
build-essential \
|
||||
wget \
|
||||
git \
|
||||
curl \
|
||||
libsndfile1 \
|
||||
cmake \
|
||||
tzdata \
|
||||
rsync \
|
||||
vim \
|
||||
ffmpeg \
|
||||
sox \
|
||||
libsox-dev
|
||||
RUN pip install -U pip
|
||||
|
||||
ARG LUDWIG_VERSION
|
||||
|
||||
WORKDIR /ludwig
|
||||
|
||||
COPY . .
|
||||
RUN if [ -n "${LUDWIG_VERSION}" ]; then \
|
||||
pip install --no-cache-dir "ludwig[full]==${LUDWIG_VERSION}" --extra-index-url https://download.pytorch.org/whl/cu126; \
|
||||
else \
|
||||
pip install --no-cache-dir '.[full]' --extra-index-url https://download.pytorch.org/whl/cu126; \
|
||||
fi
|
||||
RUN pip install --no-cache-dir --force-reinstall torch==2.12.0 torchvision==0.27.0 torchaudio==2.11.0 --index-url https://download.pytorch.org/whl/cu126
|
||||
@@ -0,0 +1,39 @@
|
||||
#
|
||||
# Ludwig Docker image with Ray support and full dependencies including:
|
||||
# text features
|
||||
# image features
|
||||
# audio features
|
||||
# visualizations
|
||||
# hyperparameter optimization
|
||||
# distributed training
|
||||
# model serving
|
||||
#
|
||||
|
||||
FROM rayproject/ray:2.54.0-py312
|
||||
|
||||
RUN sudo apt-get update && DEBIAN_FRONTEND="noninteractive" sudo apt-get install -y \
|
||||
build-essential \
|
||||
wget \
|
||||
git \
|
||||
curl \
|
||||
libsndfile1 \
|
||||
cmake \
|
||||
tzdata \
|
||||
rsync \
|
||||
vim \
|
||||
ffmpeg \
|
||||
sox \
|
||||
libsox-dev
|
||||
RUN pip install -U pip
|
||||
|
||||
ARG LUDWIG_VERSION
|
||||
|
||||
WORKDIR /ludwig
|
||||
|
||||
COPY . .
|
||||
RUN if [ -n "${LUDWIG_VERSION}" ]; then \
|
||||
pip install --no-cache-dir "ludwig[full]==${LUDWIG_VERSION}" --extra-index-url https://download.pytorch.org/whl/cpu; \
|
||||
else \
|
||||
pip install --no-cache-dir '.[full]' --extra-index-url https://download.pytorch.org/whl/cpu; \
|
||||
fi
|
||||
RUN pip install --no-cache-dir --force-reinstall torch==2.12.0 torchvision==0.27.0 torchaudio==2.11.0 --extra-index-url https://download.pytorch.org/whl/cpu
|
||||
@@ -0,0 +1,39 @@
|
||||
#
|
||||
# Ludwig Docker image with full set of pre-requiste packages to support these capabilities
|
||||
# text features
|
||||
# image features
|
||||
# audio features
|
||||
# visualizations
|
||||
# hyperparameter optimization
|
||||
# distributed training
|
||||
# model serving
|
||||
#
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get -y update && apt-get -y install \
|
||||
git \
|
||||
libsndfile1 \
|
||||
build-essential \
|
||||
g++ \
|
||||
cmake \
|
||||
ffmpeg \
|
||||
sox \
|
||||
libsox-dev
|
||||
RUN pip install -U pip
|
||||
|
||||
ARG LUDWIG_VERSION
|
||||
|
||||
WORKDIR /ludwig
|
||||
|
||||
COPY . .
|
||||
RUN if [ -n "${LUDWIG_VERSION}" ]; then \
|
||||
pip install --no-cache-dir "ludwig[full]==${LUDWIG_VERSION}" --extra-index-url https://download.pytorch.org/whl/cpu; \
|
||||
else \
|
||||
pip install --no-cache-dir '.[full]' --extra-index-url https://download.pytorch.org/whl/cpu; \
|
||||
fi
|
||||
RUN pip install --no-cache-dir --force-reinstall torch==2.12.0 torchvision==0.27.0 torchaudio==2.11.0 --extra-index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
WORKDIR /data
|
||||
|
||||
ENTRYPOINT ["ludwig"]
|
||||
@@ -0,0 +1,348 @@
|
||||
# Adding a New Feature Type to Ludwig
|
||||
|
||||
This guide walks through every file you need to touch when adding a brand-new feature type (e.g. a hypothetical `"widget"` type). Use `ludwig/features/binary_feature.py` and `ludwig/schema/features/binary_feature.py` as living reference implementations — they are among the simplest complete examples.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Conceptual overview
|
||||
|
||||
Each feature type lives in two parallel places:
|
||||
|
||||
| Layer | Location | Purpose |
|
||||
| ------------------ | ------------------------------------------ | ----------------------------------------------------------------------------- |
|
||||
| **Schema** | `ludwig/schema/features/<type>_feature.py` | Pydantic-backed config classes; declares hyperparameters and their defaults |
|
||||
| **Feature module** | `ludwig/features/<type>_feature.py` | PyTorch modules; implements preprocessing, encoding, decoding, postprocessing |
|
||||
|
||||
The schema classes are used for config validation and serialization. The feature module classes are instantiated at model-build time using those configs. Neither layer knows the other exists at import time — they are wired together through the feature registry.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Step 1 ��� Define the constant
|
||||
|
||||
Add the type string to `ludwig/constants.py`:
|
||||
|
||||
```python
|
||||
WIDGET = "widget"
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Step 2 — Write the schema file
|
||||
|
||||
Create `ludwig/schema/features/widget_feature.py`. The minimal required structure is:
|
||||
|
||||
```python
|
||||
from ludwig.constants import WIDGET, MODEL_ECD
|
||||
from ludwig.schema import utils as schema_utils
|
||||
from ludwig.schema.encoders.base import BaseEncoderConfig
|
||||
from ludwig.schema.encoders.utils import EncoderDataclassField
|
||||
from ludwig.schema.features.base import BaseInputFeatureConfig, BaseOutputFeatureConfig
|
||||
from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig
|
||||
from ludwig.schema.features.preprocessing.utils import PreprocessingDataclassField
|
||||
from ludwig.schema.features.utils import (
|
||||
ecd_defaults_config_registry,
|
||||
ecd_input_config_registry,
|
||||
ecd_output_config_registry,
|
||||
input_mixin_registry,
|
||||
output_mixin_registry,
|
||||
)
|
||||
from ludwig.schema.utils import LudwigBaseConfig
|
||||
|
||||
|
||||
@input_mixin_registry.register(WIDGET)
|
||||
class WidgetInputFeatureConfigMixin(LudwigBaseConfig):
|
||||
preprocessing: BasePreprocessingConfig = PreprocessingDataclassField(feature_type=WIDGET)
|
||||
|
||||
|
||||
class WidgetInputFeatureConfig(WidgetInputFeatureConfigMixin, BaseInputFeatureConfig):
|
||||
type: str = schema_utils.ProtectedString(WIDGET)
|
||||
encoder: BaseEncoderConfig = None
|
||||
|
||||
|
||||
@ecd_input_config_registry.register(WIDGET)
|
||||
class ECDWidgetInputFeatureConfig(WidgetInputFeatureConfig):
|
||||
encoder: BaseEncoderConfig = EncoderDataclassField(
|
||||
MODEL_ECD,
|
||||
feature_type=WIDGET,
|
||||
default="dense", # default encoder for this type
|
||||
)
|
||||
|
||||
|
||||
# For output features only:
|
||||
@output_mixin_registry.register(WIDGET)
|
||||
class WidgetOutputFeatureConfigMixin(LudwigBaseConfig):
|
||||
# add loss, calibration, etc. fields here
|
||||
pass
|
||||
|
||||
|
||||
class WidgetOutputFeatureConfig(WidgetOutputFeatureConfigMixin, BaseOutputFeatureConfig):
|
||||
type: str = schema_utils.ProtectedString(WIDGET)
|
||||
default_validation_metric: str = "some_metric"
|
||||
|
||||
|
||||
@ecd_output_config_registry.register(WIDGET)
|
||||
class ECDWidgetOutputFeatureConfig(WidgetOutputFeatureConfig):
|
||||
pass
|
||||
```
|
||||
|
||||
**Key rules:**
|
||||
|
||||
- `type` must be a `ProtectedString` with your constant — this prevents accidental overwrite via user YAML.
|
||||
- `@input_mixin_registry.register` / `@output_mixin_registry.register` make the preprocessing config available to `global_defaults` in Ludwig configs.
|
||||
- `@ecd_input_config_registry.register` / `@ecd_output_config_registry.register` wire the schema into the ECD model config builder.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Step 3 — Write the preprocessing config
|
||||
|
||||
Create `ludwig/schema/features/preprocessing/widget_feature_preprocessing.py` if your feature needs non-default preprocessing parameters, or register your type against an existing one (e.g. `number_feature` for scalars). For a new type, create the file:
|
||||
|
||||
```python
|
||||
from ludwig.schema.features.preprocessing.base import BasePreprocessingConfig
|
||||
from ludwig.schema.features.preprocessing.utils import register_preprocessor
|
||||
from ludwig.constants import WIDGET
|
||||
|
||||
|
||||
@register_preprocessor(WIDGET)
|
||||
class WidgetPreprocessingConfig(BasePreprocessingConfig):
|
||||
# add preprocessing hyperparameters here
|
||||
pass
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Step 4 — Write the feature module
|
||||
|
||||
Create `ludwig/features/widget_feature.py`. The required classes are:
|
||||
|
||||
### Inner preprocessing module
|
||||
|
||||
```python
|
||||
import torch
|
||||
from ludwig.features.base_feature import BasePreprocessingModule, FeaturePreprocessingMixin, InputFeature, OutputFeature
|
||||
|
||||
|
||||
class _WidgetPreprocessing(BasePreprocessingModule):
|
||||
"""Runs inside the model graph during inference to preprocess raw input."""
|
||||
|
||||
def __init__(self, metadata: dict, preprocessing_config, is_input_feature: bool = True):
|
||||
super().__init__()
|
||||
# store everything needed to preprocess at inference time
|
||||
|
||||
def forward(self, v):
|
||||
# v is the raw column value; return a tensor
|
||||
raise NotImplementedError
|
||||
```
|
||||
|
||||
### FeatureMixin (shared preprocessing logic)
|
||||
|
||||
`FeaturePreprocessingMixin` provides the Python-side preprocessing used during dataset preparation (not inside the model graph). You must implement `add_feature_data` and `get_preprocessing_module`:
|
||||
|
||||
```python
|
||||
class WidgetFeatureMixin(FeaturePreprocessingMixin):
|
||||
@staticmethod
|
||||
def type():
|
||||
return WIDGET
|
||||
|
||||
@staticmethod
|
||||
def cast_column(column, backend):
|
||||
"""Cast the raw DataFrame column to the expected dtype."""
|
||||
return column
|
||||
|
||||
@staticmethod
|
||||
def add_feature_data(
|
||||
feature_config,
|
||||
input_df,
|
||||
proc_df,
|
||||
metadata,
|
||||
preprocessing_parameters,
|
||||
backend,
|
||||
skip_save_processed_input,
|
||||
):
|
||||
"""Populate proc_df[feature_config[PROC_COLUMN]] with preprocessed values."""
|
||||
proc_df[feature_config[PROC_COLUMN]] = input_df[feature_config[COLUMN]].values
|
||||
return proc_df
|
||||
|
||||
@staticmethod
|
||||
def fill_missing_values(feature_config, input_df, backend):
|
||||
"""Replace NaN/None with a fill value appropriate for this type."""
|
||||
return input_df
|
||||
|
||||
@staticmethod
|
||||
def feature_meta(column, preprocessing_parameters, backend):
|
||||
"""Compute and return the training-set-level metadata dict for this feature."""
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def get_preprocessing_module(feature_config, metadata):
|
||||
"""Return the _WidgetPreprocessing module for use during inference."""
|
||||
return _WidgetPreprocessing(metadata, feature_config.preprocessing)
|
||||
```
|
||||
|
||||
### InputFeature class
|
||||
|
||||
```python
|
||||
from ludwig.schema.features.widget_feature import WidgetInputFeatureConfig
|
||||
|
||||
|
||||
class WidgetInputFeature(WidgetFeatureMixin, InputFeature):
|
||||
def __init__(self, input_feature_config: WidgetInputFeatureConfig, encoder_obj=None, **kwargs):
|
||||
super().__init__(input_feature_config, **kwargs)
|
||||
self._input_shape = torch.Size([1]) # set to actual encoded shape
|
||||
|
||||
if encoder_obj:
|
||||
self.encoder_obj = encoder_obj
|
||||
else:
|
||||
self.encoder_obj = self.initialize_encoder(input_feature_config.encoder)
|
||||
|
||||
def forward(self, inputs, mask=None):
|
||||
assert inputs.dtype == torch.float32
|
||||
encoder_output = self.encoder_obj(inputs, mask=mask)
|
||||
return {"encoder_output": encoder_output}
|
||||
|
||||
@property
|
||||
def input_dtype(self):
|
||||
return torch.float32
|
||||
|
||||
@property
|
||||
def input_shape(self):
|
||||
return self._input_shape
|
||||
|
||||
@property
|
||||
def output_shape(self):
|
||||
return self.encoder_obj.output_shape
|
||||
|
||||
@staticmethod
|
||||
def update_config_with_metadata(feature_config, feature_metadata, *args, **kwargs):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def create_sample_input(batch_size=2):
|
||||
return torch.zeros(batch_size, 1)
|
||||
|
||||
@staticmethod
|
||||
def get_schema_cls():
|
||||
return WidgetInputFeatureConfig
|
||||
```
|
||||
|
||||
### OutputFeature class (only if this type can be a target)
|
||||
|
||||
```python
|
||||
from ludwig.schema.features.widget_feature import WidgetOutputFeatureConfig
|
||||
|
||||
|
||||
class WidgetOutputFeature(WidgetFeatureMixin, OutputFeature):
|
||||
def __init__(self, output_feature_config: WidgetOutputFeatureConfig, output_features: dict, **kwargs):
|
||||
super().__init__(output_feature_config, output_features, **kwargs)
|
||||
self._input_shape = torch.Size([output_feature_config.input_size])
|
||||
self.decoder_obj = self.initialize_decoder(output_feature_config.decoder)
|
||||
self._setup_loss()
|
||||
self._setup_metrics()
|
||||
|
||||
def logits(self, inputs, target=None):
|
||||
return self.decoder_obj(inputs)
|
||||
|
||||
def create_predict_module(self):
|
||||
return _WidgetPredict() # see PredictModule below
|
||||
|
||||
def get_prediction_set(self):
|
||||
return {LOGITS, PREDICTIONS, PROBABILITIES}
|
||||
|
||||
@classmethod
|
||||
def update_config_with_metadata(cls, feature_config, feature_metadata, *args, **kwargs):
|
||||
feature_config.input_size = feature_metadata["input_size"]
|
||||
|
||||
@staticmethod
|
||||
def get_schema_cls():
|
||||
return WidgetOutputFeatureConfig
|
||||
```
|
||||
|
||||
### PredictModule (for output features)
|
||||
|
||||
```python
|
||||
from ludwig.features.base_feature import PredictModule
|
||||
|
||||
|
||||
class _WidgetPredict(PredictModule):
|
||||
def forward(self, inputs, feature_name):
|
||||
logits = inputs[f"{feature_name}_{LOGITS}"]
|
||||
predictions = (logits > 0.5).float()
|
||||
return {PREDICTIONS: predictions, LOGITS: logits}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Step 5 — Register in the feature registries
|
||||
|
||||
Open `ludwig/features/feature_registries.py` and add your classes to all relevant registry functions:
|
||||
|
||||
```python
|
||||
# at the top — add import
|
||||
from ludwig.features.widget_feature import WidgetFeatureMixin, WidgetInputFeature
|
||||
|
||||
# in get_base_type_registry(), inside the returned dict:
|
||||
# WIDGET: WidgetFeatureMixin,
|
||||
#
|
||||
# in get_input_type_registry(), inside the returned dict:
|
||||
# WIDGET: WidgetInputFeature,
|
||||
#
|
||||
# in get_output_type_registry() if applicable, inside the returned dict:
|
||||
# WIDGET: WidgetOutputFeature,
|
||||
```
|
||||
|
||||
The model builder uses `get_input_type_registry()` and `get_output_type_registry()` to instantiate feature objects from config at training time.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Step 6 — Register the constant in constants.py (feature sets)
|
||||
|
||||
If the feature appears in `FEATURE_TYPES`, `INPUT_FEATURE_TYPES`, or similar sets, add `WIDGET` there too.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Step 7 — Write tests
|
||||
|
||||
Create `tests/ludwig/features/test_widget_feature.py`. At minimum test:
|
||||
|
||||
1. `WidgetFeatureMixin.add_feature_data` — correct column values written to `proc_df`
|
||||
1. `_WidgetPreprocessing.forward` — correct tensor shape for a known input
|
||||
1. `WidgetInputFeature.forward` — correct output keys and shapes with a random input
|
||||
1. Encoder round-trip via `create_sample_input`
|
||||
|
||||
```python
|
||||
import torch
|
||||
import pytest
|
||||
from tests.integration_tests.utils import generate_data, run_api_test
|
||||
|
||||
|
||||
def test_widget_preprocessing_forward():
|
||||
meta = {}
|
||||
module = _WidgetPreprocessing(meta, preprocessing_config=None)
|
||||
out = module(torch.zeros(4))
|
||||
assert out.shape == (4, 1)
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `ludwig/constants.py` — add `WIDGET = "widget"`
|
||||
- [ ] `ludwig/schema/features/widget_feature.py` — schema classes + registry decorators
|
||||
- [ ] `ludwig/schema/features/preprocessing/` — preprocessing config class (or reuse existing)
|
||||
- [ ] `ludwig/features/widget_feature.py` — preprocessing module, mixin, input/output feature classes
|
||||
- [ ] `ludwig/features/feature_registries.py` — add to `get_base_type_registry`, `get_input_type_registry`, optionally `get_output_type_registry`
|
||||
- [ ] `tests/ludwig/features/test_widget_feature.py` — unit tests for preprocessing and forward pass
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
**`proc_df[PROC_COLUMN]` vs `proc_df[COLUMN]`** — always write to `PROC_COLUMN` (the internal column name), not `COLUMN` (the raw user column name). They can differ when the user renames features.
|
||||
|
||||
**`get_preprocessing_module` vs `add_feature_data`** — `add_feature_data` runs in Python at dataset preparation time (CPU, pandas). `get_preprocessing_module` returns a `torch.nn.Module` that runs inside the model graph at inference time. Both must produce compatible representations.
|
||||
|
||||
**`input_shape` vs `output_shape`** — `InputFeature.input_shape` is the shape of the *raw preprocessed* tensor going into the encoder. `InputFeature.output_shape` is the encoder's output shape that feeds into the combiner. Return `self.encoder_obj.output_shape` for the latter.
|
||||
|
||||
**Registry order matters** — the registry in `feature_registries.py` is read at import time. If you import your feature class before `feature_registries.py` is loaded, the registry will be empty. The correct order is always: define constants → define schema → define feature → add to registry.
|
||||
|
||||
**Schema `type` field** — always use `schema_utils.ProtectedString(WIDGET)` not `str = WIDGET`. The protected string raises an error if a user tries to override it in their config YAML, which prevents subtle type mismatches.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Examples
|
||||
|
||||
This directory contains example programs demonstrating Ludwig's Python APIs.
|
||||
|
||||
| Directory | Examples Provided |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| hyperopt | Demonstrates Ludwig's to hyper-parameter optimization capability. |
|
||||
| kfold_cv | Provides two examples for performing a k-fold cross validation analysis. One example uses the `ludwig experiment` cli. The other example uses the `ludwig.experiment.kfold_cross_validate()` api function. |
|
||||
| mnist | Creates a model config data structure from a yaml file and trains a model. Programmatically modify the model config data structure to evaluate several different neural network architectures. Jupyter notebook demonstrates using a hold-out test data set to visualize model performance for alternative model architectures. |
|
||||
| titanic | Trains a simple model with model config contained in a yaml file. Trains multiple models from yaml files and generate visualizations to compare training results. Jupyter notebook demonstrating how to programmatically create visualizations. |
|
||||
| serve | Demonstrates running Ludwig http model server. A sample Python program illustrates how to invoke the REST API to get predictions from input features. |
|
||||
| class_imbalance | Demonstrates using our class balancing feature to over-sample an imbalanced dataset. |
|
||||
| ray/job_submission | Submit Ludwig training to a remote Ray cluster via Ray Job Submission. Avoids Ray Client issues with ray.data. Works with KubeRay, Anyscale, or any Ray cluster. |
|
||||
@@ -0,0 +1,113 @@
|
||||
# LLM Alignment with DPO and KTO
|
||||
|
||||
[](https://colab.research.google.com/github/ludwig-ai/ludwig/blob/main/examples/alignment/alignment_dpo.ipynb)
|
||||
|
||||
This example shows how to align a large language model with human preferences using Ludwig's
|
||||
built-in preference learning trainers. Alignment training is typically applied after an initial
|
||||
supervised fine-tuning (SFT) stage to improve response quality, reduce harmful outputs, and teach
|
||||
the model to follow instructions more reliably.
|
||||
|
||||
## What is alignment?
|
||||
|
||||
Alignment refers to the process of shaping a model's behaviour to match human values and preferences.
|
||||
The classic approach — Reinforcement Learning from Human Feedback (RLHF) — requires training a
|
||||
separate reward model on human-ranked responses, then running a full RL loop (PPO) against it.
|
||||
Modern preference learning methods like DPO bypass the reward model entirely, making alignment
|
||||
cheaper and more stable to train.
|
||||
|
||||
## When to use each trainer
|
||||
|
||||
| Trainer | Data format | Use case | Compute |
|
||||
| ------- | ------------------------------------ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `dpo` | `prompt`, `chosen`, `rejected` | Preference pairs from human feedback; most widely studied | Medium — requires forward passes through both policy and reference model |
|
||||
| `kto` | `prompt`, `response`, `label` (bool) | Single-label feedback (thumbs up/down); no paired responses needed | Low — simpler loss than DPO |
|
||||
| `orpo` | `prompt`, `chosen`, `rejected` | Single-stage SFT + alignment; no separate reference model | Low — no reference model forward passes |
|
||||
| `grpo` | `prompt`, custom reward function | RL-style training with a group-normalised reward signal; used in DeepSeek-R1 | High — requires multiple rollouts per prompt |
|
||||
|
||||
Choose **DPO** when you have human-ranked response pairs and want the best-studied approach.
|
||||
Choose **KTO** when collecting binary per-response feedback is easier than pairwise comparisons.
|
||||
Choose **ORPO** when you want to skip the SFT stage and align in one shot.
|
||||
Choose **GRPO** when you have a programmatic reward function (e.g. code execution, math verification).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- GPU with at least 40 GiB of VRAM (A100 recommended)
|
||||
- [HuggingFace API Token](https://huggingface.co/docs/hub/security-tokens)
|
||||
- Access approval to [Llama-3.1-8B](https://huggingface.co/meta-llama/Meta-Llama-3.1-8B)
|
||||
|
||||
## Quick start
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
pip install "ludwig[llm]" datasets
|
||||
```
|
||||
|
||||
Set your HuggingFace token:
|
||||
|
||||
```bash
|
||||
export HUGGING_FACE_HUB_TOKEN="<your_token>"
|
||||
```
|
||||
|
||||
Prepare the dataset:
|
||||
|
||||
```bash
|
||||
python prepare_dataset.py
|
||||
```
|
||||
|
||||
Run DPO training:
|
||||
|
||||
```bash
|
||||
python train_dpo.py
|
||||
# or with the CLI:
|
||||
ludwig train --config config_dpo.yaml --dataset train.csv
|
||||
```
|
||||
|
||||
Run KTO training:
|
||||
|
||||
```bash
|
||||
ludwig train --config config_kto.yaml --dataset train_kto.csv
|
||||
```
|
||||
|
||||
Run GRPO training (reuses the DPO preference-pair format):
|
||||
|
||||
```bash
|
||||
python train_grpo.py
|
||||
# or with the CLI:
|
||||
ludwig train --config config_grpo.yaml --dataset preference_data.parquet
|
||||
```
|
||||
|
||||
## GRPO specifics
|
||||
|
||||
GRPO (Group Relative Policy Optimization, Shao et al. 2024) is the alignment method used by
|
||||
DeepSeek-R1. For each prompt it samples a group of `grpo_num_generations` completions, scores
|
||||
them, normalises rewards within the group, and applies a PPO-style clipped objective —
|
||||
without a separate critic model.
|
||||
|
||||
Ludwig's GRPO trainer consumes the same `prompt` / `chosen` / `rejected` columns as DPO, so
|
||||
a programmatic reward function is implemented as a pre-processing step: score each candidate
|
||||
completion in your dataset preparation pipeline, then emit the top-scoring completion as
|
||||
`chosen` and the lowest as `rejected`. See `config_grpo.yaml` for the full list of knobs
|
||||
(`grpo_beta` for the KL penalty, `grpo_epsilon` for PPO clipping,
|
||||
`grpo_num_generations` for the group size).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Description |
|
||||
| --------------------- | ------------------------------------------------------------------ |
|
||||
| `prepare_dataset.py` | Downloads Anthropic/hh-rlhf and converts it to Ludwig format |
|
||||
| `train_dpo.py` | DPO training script using the Python API |
|
||||
| `train_grpo.py` | GRPO training script using the Python API |
|
||||
| `config_dpo.yaml` | Ludwig config for DPO |
|
||||
| `config_kto.yaml` | Ludwig config for KTO |
|
||||
| `config_orpo.yaml` | Ludwig config for ORPO |
|
||||
| `config_grpo.yaml` | Ludwig config for GRPO |
|
||||
| `alignment_dpo.ipynb` | Colab-compatible notebook covering DPO, KTO evaluation, and upload |
|
||||
|
||||
## Upload to HuggingFace
|
||||
|
||||
After training, upload the aligned model:
|
||||
|
||||
```bash
|
||||
ludwig upload hf_hub -r <your_org>/<model_name> -m results/experiment_run/model
|
||||
```
|
||||
@@ -0,0 +1,477 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# LLM Alignment with DPO and KTO\n",
|
||||
"\n",
|
||||
"[](https://colab.research.google.com/github/ludwig-ai/ludwig/blob/main/examples/alignment/alignment_dpo.ipynb)\n",
|
||||
"\n",
|
||||
"This notebook walks through aligning **Llama-3.1-8B** with human preferences using Ludwig's built-in\n",
|
||||
"**Direct Preference Optimization (DPO)** trainer, then shows how to switch to **KTO** when paired\n",
|
||||
"preference data is not available.\n",
|
||||
"\n",
|
||||
"We use the publicly available [Anthropic HH-RLHF](https://huggingface.co/datasets/Anthropic/hh-rlhf)\n",
|
||||
"dataset, which contains helpfulness and harmlessness preference pairs collected from human annotators.\n",
|
||||
"\n",
|
||||
"### What you will learn\n",
|
||||
"- How to parse the HH-RLHF dataset into Ludwig's expected column format\n",
|
||||
"- How to configure and run DPO alignment training with LoRA\n",
|
||||
"- How to evaluate response quality before and after alignment\n",
|
||||
"- How to switch to KTO with a one-line config change\n",
|
||||
"- How to upload the aligned model to HuggingFace Hub\n",
|
||||
"\n",
|
||||
"### Prerequisites\n",
|
||||
"- **GPU**: A100 (40 GiB) recommended. T4 will work with smaller batch sizes but is slower.\n",
|
||||
"- **HuggingFace token**: Required for Llama-3.1-8B. Set `HUGGING_FACE_HUB_TOKEN` before running.\n",
|
||||
"- **Model access**: Request access at https://huggingface.co/meta-llama/Meta-Llama-3.1-8B"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Install dependencies"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!pip install \"ludwig[llm]\" datasets --quiet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Setup"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import os\n",
|
||||
"\n",
|
||||
"# Set your HuggingFace token — required to download Llama-3.1-8B.\n",
|
||||
"# In Colab: Secrets panel (key icon) -> add HF_TOKEN, then reference it here.\n",
|
||||
"try:\n",
|
||||
" from google.colab import userdata\n",
|
||||
"\n",
|
||||
" os.environ[\"HUGGING_FACE_HUB_TOKEN\"] = userdata.get(\"HF_TOKEN\")\n",
|
||||
"except Exception:\n",
|
||||
" pass # Running locally — export HUGGING_FACE_HUB_TOKEN in your shell instead\n",
|
||||
"\n",
|
||||
"assert os.environ.get(\"HUGGING_FACE_HUB_TOKEN\"), (\n",
|
||||
" \"HUGGING_FACE_HUB_TOKEN is not set. Add it to Colab Secrets or export it in your shell.\"\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import subprocess\n",
|
||||
"\n",
|
||||
"# Verify GPU availability\n",
|
||||
"result = subprocess.run(\n",
|
||||
" [\"nvidia-smi\", \"--query-gpu=name,memory.total\", \"--format=csv,noheader\"], capture_output=True, text=True\n",
|
||||
")\n",
|
||||
"if result.returncode == 0:\n",
|
||||
" print(\"GPU(s) detected:\")\n",
|
||||
" print(result.stdout.strip())\n",
|
||||
"else:\n",
|
||||
" print(\"WARNING: No GPU detected. Training will be very slow on CPU.\")\n",
|
||||
" print(\"In Colab: Runtime -> Change runtime type -> A100 GPU\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Prepare dataset\n",
|
||||
"\n",
|
||||
"The [Anthropic HH-RLHF](https://huggingface.co/datasets/Anthropic/hh-rlhf) dataset stores full\n",
|
||||
"multi-turn conversations in two columns: `chosen` (preferred response) and `rejected` (dispreferred\n",
|
||||
"response). Each value is a raw conversation string like:\n",
|
||||
"\n",
|
||||
"```\n",
|
||||
"\\n\\nHuman: How do I make pasta?\\n\\nAssistant: Boil water, add pasta...\\n\\nHuman: What sauce?\\n\\nAssistant: Try marinara...\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"Ludwig's DPO trainer expects three columns: `prompt`, `chosen`, `rejected`, where `prompt` is the\n",
|
||||
"last human turn and `chosen`/`rejected` are the final assistant responses.\n",
|
||||
"\n",
|
||||
"The cell below does that extraction."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import re\n",
|
||||
"\n",
|
||||
"import pandas as pd\n",
|
||||
"from datasets import load_dataset\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def extract_last_human_turn(conversation: str) -> str:\n",
|
||||
" \"\"\"Extract the last Human turn from an HH-RLHF conversation string.\"\"\"\n",
|
||||
" turns = re.findall(r\"\\n\\nHuman: (.*?)(?=\\n\\nAssistant:|\\Z)\", conversation, re.DOTALL)\n",
|
||||
" return turns[-1].strip() if turns else conversation.strip()\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def extract_last_assistant_turn(conversation: str) -> str:\n",
|
||||
" \"\"\"Extract the last Assistant turn from an HH-RLHF conversation string.\"\"\"\n",
|
||||
" turns = re.findall(r\"\\n\\nAssistant: (.*?)(?=\\n\\nHuman:|\\Z)\", conversation, re.DOTALL)\n",
|
||||
" return turns[-1].strip() if turns else \"\"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def prepare_dpo_dataset(split, max_samples=None):\n",
|
||||
" if max_samples:\n",
|
||||
" split = split.select(range(min(max_samples, len(split))))\n",
|
||||
" rows = []\n",
|
||||
" for ex in split:\n",
|
||||
" prompt = extract_last_human_turn(ex[\"chosen\"])\n",
|
||||
" chosen = extract_last_assistant_turn(ex[\"chosen\"])\n",
|
||||
" rejected = extract_last_assistant_turn(ex[\"rejected\"])\n",
|
||||
" if prompt and chosen and rejected:\n",
|
||||
" rows.append({\"prompt\": prompt, \"chosen\": chosen, \"rejected\": rejected})\n",
|
||||
" return pd.DataFrame(rows)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"print(\"Downloading Anthropic/hh-rlhf ...\")\n",
|
||||
"hh = load_dataset(\"Anthropic/hh-rlhf\")\n",
|
||||
"print(f\"Train size: {len(hh['train'])}, Test size: {len(hh['test'])}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Use a subset for a quick experiment. Remove max_samples caps for full training.\n",
|
||||
"MAX_TRAIN = 5000\n",
|
||||
"MAX_TEST = 500\n",
|
||||
"\n",
|
||||
"train_df = prepare_dpo_dataset(hh[\"train\"], max_samples=MAX_TRAIN)\n",
|
||||
"test_df = prepare_dpo_dataset(hh[\"test\"], max_samples=MAX_TEST)\n",
|
||||
"\n",
|
||||
"train_df.to_csv(\"train.csv\", index=False)\n",
|
||||
"test_df.to_csv(\"test.csv\", index=False)\n",
|
||||
"\n",
|
||||
"print(f\"Saved {len(train_df)} train rows to train.csv\")\n",
|
||||
"print(f\"Saved {len(test_df)} test rows to test.csv\")\n",
|
||||
"train_df.head(2)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Preview: show a prompt with its chosen vs rejected response\n",
|
||||
"row = train_df.iloc[0]\n",
|
||||
"print(\"=== PROMPT ===\")\n",
|
||||
"print(row[\"prompt\"])\n",
|
||||
"print(\"\\n=== CHOSEN ===\")\n",
|
||||
"print(row[\"chosen\"])\n",
|
||||
"print(\"\\n=== REJECTED ===\")\n",
|
||||
"print(row[\"rejected\"])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## DPO training\n",
|
||||
"\n",
|
||||
"Direct Preference Optimization (DPO) treats alignment as a classification problem: given a prompt,\n",
|
||||
"prefer `chosen` over `rejected`. The loss function directly optimises the log-ratio between the\n",
|
||||
"policy's probability of the chosen response and the reference model's, without needing an explicit\n",
|
||||
"reward model.\n",
|
||||
"\n",
|
||||
"Key hyperparameters:\n",
|
||||
"- `beta`: KL penalty coefficient (0.1 is a typical starting point). Higher values keep the aligned\n",
|
||||
" model closer to the base model.\n",
|
||||
"- `learning_rate`: 5e-7 is much lower than SFT — the policy already knows how to generate text;\n",
|
||||
" we're only nudging its preferences.\n",
|
||||
"- `lora r=16, alpha=32`: Standard LoRA settings. Increase `r` for larger models or more complex tasks."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import logging\n",
|
||||
"\n",
|
||||
"import yaml\n",
|
||||
"\n",
|
||||
"from ludwig.api import LudwigModel\n",
|
||||
"\n",
|
||||
"dpo_config = yaml.safe_load(\"\"\"\n",
|
||||
"model_type: llm\n",
|
||||
"base_model: meta-llama/Llama-3.1-8B\n",
|
||||
"\n",
|
||||
"adapter:\n",
|
||||
" type: lora\n",
|
||||
" r: 16\n",
|
||||
" alpha: 32\n",
|
||||
" dropout: 0.05\n",
|
||||
"\n",
|
||||
"trainer:\n",
|
||||
" type: dpo\n",
|
||||
" epochs: 1\n",
|
||||
" learning_rate: 5.0e-7\n",
|
||||
" batch_size: 2\n",
|
||||
" gradient_accumulation_steps: 8\n",
|
||||
" beta: 0.1\n",
|
||||
"\n",
|
||||
"input_features:\n",
|
||||
" - name: prompt\n",
|
||||
" type: text\n",
|
||||
"\n",
|
||||
"output_features:\n",
|
||||
" - name: chosen\n",
|
||||
" type: text\n",
|
||||
"\n",
|
||||
"backend:\n",
|
||||
" type: local\n",
|
||||
"\"\"\")\n",
|
||||
"\n",
|
||||
"dpo_model = LudwigModel(config=dpo_config, logging_level=logging.INFO)\n",
|
||||
"\n",
|
||||
"train_stats, _, output_directory = dpo_model.train(\n",
|
||||
" dataset=\"train.csv\",\n",
|
||||
" experiment_name=\"hh_rlhf_dpo\",\n",
|
||||
" output_directory=\"results\",\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"print(f\"\\nModel saved to: {output_directory}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Evaluate\n",
|
||||
"\n",
|
||||
"We compare responses from the base model (no alignment) and the DPO-aligned model on a few\n",
|
||||
"prompts from the test set. You should see the aligned model give more helpful, less evasive answers."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Load the base model for comparison\n",
|
||||
"base_config = yaml.safe_load(\"\"\"\n",
|
||||
"model_type: llm\n",
|
||||
"base_model: meta-llama/Llama-3.1-8B\n",
|
||||
"\n",
|
||||
"input_features:\n",
|
||||
" - name: prompt\n",
|
||||
" type: text\n",
|
||||
"\n",
|
||||
"output_features:\n",
|
||||
" - name: chosen\n",
|
||||
" type: text\n",
|
||||
"\n",
|
||||
"backend:\n",
|
||||
" type: local\n",
|
||||
"\"\"\")\n",
|
||||
"\n",
|
||||
"base_model = LudwigModel(config=base_config, logging_level=logging.WARNING)\n",
|
||||
"_ = base_model.train(dataset=\"train.csv\", experiment_name=\"baseline\", output_directory=\"results_base\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"eval_prompts = test_df[\"prompt\"].head(3).tolist()\n",
|
||||
"eval_df = pd.DataFrame({\"prompt\": eval_prompts})\n",
|
||||
"\n",
|
||||
"base_preds, _ = base_model.predict(dataset=eval_df)\n",
|
||||
"dpo_preds, _ = dpo_model.predict(dataset=eval_df)\n",
|
||||
"\n",
|
||||
"for i, prompt in enumerate(eval_prompts):\n",
|
||||
" print(f\"\\n{'=' * 60}\")\n",
|
||||
" print(f\"PROMPT: {prompt}\")\n",
|
||||
" print(\"\\n--- Base model ---\")\n",
|
||||
" print(base_preds.iloc[i][\"chosen_predictions\"])\n",
|
||||
" print(\"\\n--- DPO-aligned model ---\")\n",
|
||||
" print(dpo_preds.iloc[i][\"chosen_predictions\"])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## KTO alternative\n",
|
||||
"\n",
|
||||
"**Kahneman-Tversky Optimization (KTO)** is a good alternative to DPO when you cannot collect\n",
|
||||
"paired preference data. Instead of a (chosen, rejected) pair per prompt, KTO requires only a\n",
|
||||
"single response with a boolean label indicating whether it was desirable.\n",
|
||||
"\n",
|
||||
"This makes it easy to use binary user feedback (thumbs up / thumbs down, click-through, etc.)\n",
|
||||
"as training signal.\n",
|
||||
"\n",
|
||||
"### Dataset format for KTO\n",
|
||||
"\n",
|
||||
"We expand each DPO pair into two rows:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"rows_kto = []\n",
|
||||
"for _, row in train_df.iterrows():\n",
|
||||
" rows_kto.append({\"prompt\": row[\"prompt\"], \"response\": row[\"chosen\"], \"label\": True})\n",
|
||||
" rows_kto.append({\"prompt\": row[\"prompt\"], \"response\": row[\"rejected\"], \"label\": False})\n",
|
||||
"\n",
|
||||
"train_kto_df = pd.DataFrame(rows_kto)\n",
|
||||
"train_kto_df.to_csv(\"train_kto.csv\", index=False)\n",
|
||||
"print(f\"KTO dataset: {len(train_kto_df)} rows\")\n",
|
||||
"train_kto_df.head(4)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"kto_config = yaml.safe_load(\"\"\"\n",
|
||||
"model_type: llm\n",
|
||||
"base_model: meta-llama/Llama-3.1-8B\n",
|
||||
"\n",
|
||||
"adapter:\n",
|
||||
" type: lora\n",
|
||||
" r: 16\n",
|
||||
" alpha: 32\n",
|
||||
" dropout: 0.05\n",
|
||||
"\n",
|
||||
"trainer:\n",
|
||||
" type: kto\n",
|
||||
" epochs: 1\n",
|
||||
" learning_rate: 5.0e-7\n",
|
||||
" batch_size: 2\n",
|
||||
" gradient_accumulation_steps: 8\n",
|
||||
" beta: 0.1\n",
|
||||
" desirable_weight: 1.0\n",
|
||||
" undesirable_weight: 1.0\n",
|
||||
"\n",
|
||||
"input_features:\n",
|
||||
" - name: prompt\n",
|
||||
" type: text\n",
|
||||
"\n",
|
||||
"output_features:\n",
|
||||
" - name: response\n",
|
||||
" type: text\n",
|
||||
"\n",
|
||||
"backend:\n",
|
||||
" type: local\n",
|
||||
"\"\"\")\n",
|
||||
"\n",
|
||||
"kto_model = LudwigModel(config=kto_config, logging_level=logging.INFO)\n",
|
||||
"\n",
|
||||
"_, _, kto_output_dir = kto_model.train(\n",
|
||||
" dataset=\"train_kto.csv\",\n",
|
||||
" experiment_name=\"hh_rlhf_kto\",\n",
|
||||
" output_directory=\"results_kto\",\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"print(f\"\\nKTO model saved to: {kto_output_dir}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Save and upload\n",
|
||||
"\n",
|
||||
"After training, upload the aligned model to HuggingFace Hub to share it or deploy it to an endpoint."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Replace with your HuggingFace org/username and desired repo name\n",
|
||||
"HF_REPO = \"your-username/llama-3.1-8b-dpo-hh-rlhf\"\n",
|
||||
"\n",
|
||||
"# Upload via CLI:\n",
|
||||
"print(f\"ludwig upload hf_hub -r {HF_REPO} -m {output_directory}\")\n",
|
||||
"\n",
|
||||
"# Or via Python API:\n",
|
||||
"# LudwigModel.upload_to_hf_hub(HF_REPO, output_directory)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Uncomment to actually run the upload:\n",
|
||||
"# !ludwig upload hf_hub -r {HF_REPO} -m {output_directory}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Next steps\n",
|
||||
"\n",
|
||||
"- **Scale up**: Remove the `max_samples` caps and train on the full HH-RLHF dataset (~160k examples).\n",
|
||||
"- **Try ORPO**: Use `config_orpo.yaml` for single-stage SFT + alignment without a reference model.\n",
|
||||
"- **Iterate on beta**: Lower `beta` (e.g. 0.05) gives more aggressive alignment but risks reward hacking.\n",
|
||||
"- **Evaluation**: Use [MT-Bench](https://github.com/lm-sys/FastChat/tree/main/fastchat/llm_judge) or\n",
|
||||
" [AlpacaEval](https://github.com/tatsu-lab/alpaca_eval) for rigorous alignment benchmarks.\n",
|
||||
"- **GRPO**: For tasks with a programmatic reward (math, code), see the GRPO trainer docs."
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"accelerator": "GPU",
|
||||
"colab": {
|
||||
"gpuType": "A100",
|
||||
"provenance": []
|
||||
},
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.12.0"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
model_type: llm
|
||||
base_model: meta-llama/Llama-3.1-8B
|
||||
|
||||
adapter:
|
||||
type: lora
|
||||
r: 16
|
||||
alpha: 32
|
||||
dropout: 0.05
|
||||
|
||||
trainer:
|
||||
type: dpo
|
||||
epochs: 1
|
||||
learning_rate: 5.0e-7
|
||||
batch_size: 2
|
||||
gradient_accumulation_steps: 8
|
||||
beta: 0.1 # KL penalty coefficient — higher values keep the policy closer to the reference
|
||||
|
||||
input_features:
|
||||
- name: prompt
|
||||
type: text
|
||||
|
||||
output_features:
|
||||
- name: chosen
|
||||
type: text
|
||||
|
||||
backend:
|
||||
type: local
|
||||
@@ -0,0 +1,28 @@
|
||||
model_type: llm
|
||||
base_model: meta-llama/Llama-3.1-8B
|
||||
|
||||
adapter:
|
||||
type: lora
|
||||
r: 16
|
||||
alpha: 32
|
||||
|
||||
trainer:
|
||||
type: grpo
|
||||
epochs: 1
|
||||
learning_rate: 5.0e-7
|
||||
batch_size: 1
|
||||
gradient_accumulation_steps: 16
|
||||
grpo_beta: 0.04 # KL penalty — keep the policy close to the reference model
|
||||
grpo_epsilon: 0.2 # PPO clipping — bounds how much the policy can shift per step
|
||||
grpo_num_generations: 4 # completions sampled per prompt; higher = better reward estimates
|
||||
|
||||
input_features:
|
||||
- name: prompt
|
||||
type: text
|
||||
|
||||
output_features:
|
||||
- name: response
|
||||
type: text
|
||||
|
||||
backend:
|
||||
type: local
|
||||
@@ -0,0 +1,29 @@
|
||||
model_type: llm
|
||||
base_model: meta-llama/Llama-3.1-8B
|
||||
|
||||
adapter:
|
||||
type: lora
|
||||
r: 16
|
||||
alpha: 32
|
||||
dropout: 0.05
|
||||
|
||||
trainer:
|
||||
type: kto
|
||||
epochs: 1
|
||||
learning_rate: 5.0e-7
|
||||
batch_size: 2
|
||||
gradient_accumulation_steps: 8
|
||||
beta: 0.1
|
||||
desirable_weight: 1.0 # weight for chosen (positive) samples
|
||||
undesirable_weight: 1.0 # weight for rejected (negative) samples
|
||||
|
||||
input_features:
|
||||
- name: prompt
|
||||
type: text
|
||||
|
||||
output_features:
|
||||
- name: response
|
||||
type: text
|
||||
|
||||
backend:
|
||||
type: local
|
||||
@@ -0,0 +1,28 @@
|
||||
model_type: llm
|
||||
base_model: meta-llama/Llama-3.1-8B
|
||||
|
||||
adapter:
|
||||
type: lora
|
||||
r: 16
|
||||
alpha: 32
|
||||
dropout: 0.05
|
||||
|
||||
trainer:
|
||||
type: orpo
|
||||
epochs: 1
|
||||
learning_rate: 5.0e-7
|
||||
batch_size: 2
|
||||
gradient_accumulation_steps: 8
|
||||
# ORPO combines SFT and alignment in a single objective so no reference model
|
||||
# forward passes are required and there is no beta KL penalty.
|
||||
|
||||
input_features:
|
||||
- name: prompt
|
||||
type: text
|
||||
|
||||
output_features:
|
||||
- name: chosen
|
||||
type: text
|
||||
|
||||
backend:
|
||||
type: local
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Prepare the Anthropic HH-RLHF dataset for Ludwig alignment training.
|
||||
|
||||
Downloads `Anthropic/hh-rlhf` from HuggingFace and converts it into the
|
||||
column format expected by Ludwig's DPO, KTO, and ORPO trainers.
|
||||
|
||||
DPO / ORPO output: train.csv, test.csv
|
||||
Columns: prompt, chosen, rejected
|
||||
|
||||
KTO output: train_kto.csv, test_kto.csv
|
||||
Columns: prompt, response, label (label is True for chosen, False for rejected)
|
||||
|
||||
The HH-RLHF dataset stores full multi-turn conversations as raw text with the
|
||||
pattern:
|
||||
"\n\nHuman: <turn>\n\nAssistant: <turn>\n\nHuman: ...\n\nAssistant: ..."
|
||||
|
||||
We extract the last Human turn as the prompt and the final Assistant turn as
|
||||
the response. For DPO we do this for both `chosen` and `rejected` columns.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
|
||||
import pandas as pd
|
||||
from datasets import load_dataset
|
||||
|
||||
|
||||
def extract_last_human_turn(conversation: str) -> str:
|
||||
"""Return the last Human turn from a raw HH-RLHF conversation string."""
|
||||
human_turns = re.findall(r"\n\nHuman: (.*?)(?=\n\nAssistant:|\Z)", conversation, re.DOTALL)
|
||||
if human_turns:
|
||||
return human_turns[-1].strip()
|
||||
return conversation.strip()
|
||||
|
||||
|
||||
def extract_last_assistant_turn(conversation: str) -> str:
|
||||
"""Return the last Assistant turn from a raw HH-RLHF conversation string."""
|
||||
assistant_turns = re.findall(r"\n\nAssistant: (.*?)(?=\n\nHuman:|\Z)", conversation, re.DOTALL)
|
||||
if assistant_turns:
|
||||
return assistant_turns[-1].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def convert_split(split_data) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
"""Convert one HF dataset split into DPO and KTO DataFrames."""
|
||||
rows_dpo = []
|
||||
rows_kto = []
|
||||
|
||||
for example in split_data:
|
||||
chosen_conv = example["chosen"]
|
||||
rejected_conv = example["rejected"]
|
||||
|
||||
prompt = extract_last_human_turn(chosen_conv)
|
||||
chosen_response = extract_last_assistant_turn(chosen_conv)
|
||||
rejected_response = extract_last_assistant_turn(rejected_conv)
|
||||
|
||||
if not prompt or not chosen_response or not rejected_response:
|
||||
continue
|
||||
|
||||
rows_dpo.append(
|
||||
{
|
||||
"prompt": prompt,
|
||||
"chosen": chosen_response,
|
||||
"rejected": rejected_response,
|
||||
}
|
||||
)
|
||||
|
||||
# KTO: expand each pair into two rows with a boolean label
|
||||
rows_kto.append({"prompt": prompt, "response": chosen_response, "label": True})
|
||||
rows_kto.append({"prompt": prompt, "response": rejected_response, "label": False})
|
||||
|
||||
return pd.DataFrame(rows_dpo), pd.DataFrame(rows_kto)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Prepare Anthropic HH-RLHF for Ludwig alignment training.")
|
||||
parser.add_argument("--output_dir", default=".", help="Directory to write CSV files into.")
|
||||
parser.add_argument(
|
||||
"--max_train_samples",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Cap the number of training examples (useful for quick experiments).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max_test_samples",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Cap the number of test examples.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print("Downloading Anthropic/hh-rlhf …")
|
||||
dataset = load_dataset("Anthropic/hh-rlhf")
|
||||
|
||||
train_split = dataset["train"]
|
||||
test_split = dataset["test"]
|
||||
|
||||
if args.max_train_samples:
|
||||
train_split = train_split.select(range(min(args.max_train_samples, len(train_split))))
|
||||
if args.max_test_samples:
|
||||
test_split = test_split.select(range(min(args.max_test_samples, len(test_split))))
|
||||
|
||||
print(f"Converting {len(train_split)} train examples …")
|
||||
train_dpo, train_kto = convert_split(train_split)
|
||||
|
||||
print(f"Converting {len(test_split)} test examples …")
|
||||
test_dpo, test_kto = convert_split(test_split)
|
||||
|
||||
import os
|
||||
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
|
||||
train_path = os.path.join(args.output_dir, "train.csv")
|
||||
test_path = os.path.join(args.output_dir, "test.csv")
|
||||
train_kto_path = os.path.join(args.output_dir, "train_kto.csv")
|
||||
test_kto_path = os.path.join(args.output_dir, "test_kto.csv")
|
||||
|
||||
train_dpo.to_csv(train_path, index=False)
|
||||
test_dpo.to_csv(test_path, index=False)
|
||||
train_kto.to_csv(train_kto_path, index=False)
|
||||
test_kto.to_csv(test_kto_path, index=False)
|
||||
|
||||
print(f"\nDPO dataset: {len(train_dpo)} train rows -> {train_path}")
|
||||
print(f" {len(test_dpo)} test rows -> {test_path}")
|
||||
print(f"KTO dataset: {len(train_kto)} train rows -> {train_kto_path}")
|
||||
print(f" {len(test_kto)} test rows -> {test_kto_path}")
|
||||
print("\nColumns in DPO files: prompt, chosen, rejected")
|
||||
print("Columns in KTO files: prompt, response, label")
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""DPO alignment training with Ludwig.
|
||||
|
||||
Usage:
|
||||
python train_dpo.py --dataset train.csv
|
||||
python train_dpo.py --dataset train.csv --epochs 3 --beta 0.05
|
||||
|
||||
Prerequisites:
|
||||
pip install "ludwig[llm]" datasets
|
||||
export HUGGING_FACE_HUB_TOKEN="<your_token>"
|
||||
|
||||
The dataset must have columns: prompt, chosen, rejected
|
||||
Use prepare_dataset.py to produce this file from Anthropic/hh-rlhf.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
from ludwig.api import LudwigModel
|
||||
|
||||
|
||||
def build_config(epochs: int, learning_rate: float, beta: float, batch_size: int) -> dict:
|
||||
raw = f"""
|
||||
model_type: llm
|
||||
base_model: meta-llama/Llama-3.1-8B
|
||||
|
||||
adapter:
|
||||
type: lora
|
||||
r: 16
|
||||
alpha: 32
|
||||
dropout: 0.05
|
||||
|
||||
trainer:
|
||||
type: dpo
|
||||
epochs: {epochs}
|
||||
learning_rate: {learning_rate}
|
||||
batch_size: {batch_size}
|
||||
gradient_accumulation_steps: 8
|
||||
beta: {beta}
|
||||
|
||||
input_features:
|
||||
- name: prompt
|
||||
type: text
|
||||
|
||||
output_features:
|
||||
- name: chosen
|
||||
type: text
|
||||
|
||||
backend:
|
||||
type: local
|
||||
"""
|
||||
return yaml.safe_load(raw)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Run DPO alignment training with Ludwig.")
|
||||
parser.add_argument("--dataset", required=True, help="Path to the DPO CSV (prompt, chosen, rejected).")
|
||||
parser.add_argument("--epochs", type=int, default=1)
|
||||
parser.add_argument("--learning_rate", type=float, default=5e-7)
|
||||
parser.add_argument("--beta", type=float, default=0.1, help="KL penalty coefficient.")
|
||||
parser.add_argument("--batch_size", type=int, default=2)
|
||||
parser.add_argument("--experiment_name", default="hh_rlhf_dpo")
|
||||
parser.add_argument("--output_dir", default="results")
|
||||
args = parser.parse_args()
|
||||
|
||||
token = os.environ.get("HUGGING_FACE_HUB_TOKEN") or os.environ.get("HF_TOKEN")
|
||||
if not token:
|
||||
raise OSError(
|
||||
"Set HUGGING_FACE_HUB_TOKEN (or HF_TOKEN) before running. "
|
||||
"You also need access approval for meta-llama/Llama-3.1-8B."
|
||||
)
|
||||
|
||||
config = build_config(
|
||||
epochs=args.epochs,
|
||||
learning_rate=args.learning_rate,
|
||||
beta=args.beta,
|
||||
batch_size=args.batch_size,
|
||||
)
|
||||
|
||||
model = LudwigModel(config=config, logging_level=logging.INFO)
|
||||
|
||||
train_stats, preprocessed_data, output_directory = model.train(
|
||||
dataset=args.dataset,
|
||||
experiment_name=args.experiment_name,
|
||||
output_directory=args.output_dir,
|
||||
)
|
||||
|
||||
print(f"\nTraining complete. Results saved to: {output_directory}")
|
||||
print("To upload the model to HuggingFace Hub:")
|
||||
print(f" ludwig upload hf_hub -r <your_org>/<model_name> -m {output_directory}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,392 @@
|
||||
"""GRPO alignment training with Ludwig.
|
||||
|
||||
Group Relative Policy Optimization (Shao et al., 2024 — DeepSeek-R1) trains a language
|
||||
model using a programmatic reward signal rather than preference pairs. Instead of
|
||||
"chosen vs rejected" data, you supply a reward function that scores each generated
|
||||
response. Ludwig samples grpo_num_generations completions per prompt, normalises
|
||||
rewards within the group, and applies a clipped PPO-style update.
|
||||
|
||||
Usage (standalone):
|
||||
python train_grpo.py
|
||||
python train_grpo.py --epochs 2 --lr 3e-7 --output_dir my_run
|
||||
|
||||
Usage (CLI with pre-scored dataset):
|
||||
ludwig train --config config_grpo.yaml --dataset grpo_train.csv
|
||||
|
||||
Prerequisites:
|
||||
# Colab: !pip install "ludwig[llm]"
|
||||
pip install "ludwig[llm]"
|
||||
export HUGGING_FACE_HUB_TOKEN="<your_token>"
|
||||
# You also need access approval for meta-llama/Llama-3.1-8B on HuggingFace Hub.
|
||||
|
||||
How reward functions work with the Ludwig GRPO trainer
|
||||
-------------------------------------------------------
|
||||
The GRPO trainer expects a dataset where each row has:
|
||||
- prompt : the input question
|
||||
- response : the correct / reference answer (used as a training target)
|
||||
|
||||
The reward function is applied *before* training, in a data preparation step that
|
||||
scores each (prompt, response) pair and stores a "reward" column. During the GRPO
|
||||
update the trainer uses those scores — together with the group-normalised advantages
|
||||
computed across grpo_num_generations rollouts — to weight the policy gradient.
|
||||
|
||||
NOTE: If a future Ludwig version adds a reward_fn parameter directly on LudwigModel
|
||||
or the GRPO trainer config, you can pass the callable there instead of pre-scoring.
|
||||
Check the Ludwig changelog for that API addition.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import pandas as pd
|
||||
import yaml
|
||||
|
||||
from ludwig.api import LudwigModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataset — 100 math word problems generated inline; no download needed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MATH_PROBLEMS = [
|
||||
{
|
||||
"prompt": "A baker has 48 cookies. She puts them equally into 6 bags. How many cookies are in each bag?",
|
||||
"answer": "8",
|
||||
},
|
||||
{"prompt": "Tom has 15 apples. He gives 7 to his friend. How many apples does Tom have now?", "answer": "8"},
|
||||
{"prompt": "A train travels 60 miles per hour. How far does it travel in 3 hours?", "answer": "180"},
|
||||
{"prompt": "There are 24 students in a class. They form groups of 4. How many groups are there?", "answer": "6"},
|
||||
{"prompt": "Maria saves $12 each week. How much does she save in 5 weeks?", "answer": "60"},
|
||||
{"prompt": "A rectangle has a length of 8 cm and a width of 5 cm. What is its area?", "answer": "40"},
|
||||
{
|
||||
"prompt": "Jake has 3 boxes of crayons. Each box has 16 crayons. How many crayons does he have in total?",
|
||||
"answer": "48",
|
||||
},
|
||||
{
|
||||
"prompt": "A shop sells 35 items on Monday and 47 items on Tuesday. How many items were sold in total?",
|
||||
"answer": "82",
|
||||
},
|
||||
{
|
||||
"prompt": "There are 100 balloons. 38 are red and the rest are blue. How many blue balloons are there?",
|
||||
"answer": "62",
|
||||
},
|
||||
{"prompt": "A car travels 90 km in 2 hours. What is its average speed in km/h?", "answer": "45"},
|
||||
{"prompt": "Lucy reads 20 pages per day. How many pages does she read in 7 days?", "answer": "140"},
|
||||
{"prompt": "A box holds 12 eggs. How many eggs are in 9 boxes?", "answer": "108"},
|
||||
{"prompt": "A pool has 500 litres of water. 175 litres evaporate. How many litres remain?", "answer": "325"},
|
||||
{"prompt": "There are 7 shelves with 9 books on each shelf. How many books are there in total?", "answer": "63"},
|
||||
{"prompt": "Sam earns $8 per hour. How much does he earn in 6 hours?", "answer": "48"},
|
||||
{"prompt": "A garden is 12 m long and 7 m wide. What is its perimeter?", "answer": "38"},
|
||||
{"prompt": "There are 5 rows of chairs with 14 chairs in each row. How many chairs are there?", "answer": "70"},
|
||||
{
|
||||
"prompt": "Anna bakes 4 trays of muffins. Each tray has 12 muffins. How many muffins does she bake?",
|
||||
"answer": "48",
|
||||
},
|
||||
{"prompt": "A rope is 72 cm long. It is cut into 8 equal pieces. How long is each piece?", "answer": "9"},
|
||||
{"prompt": "Ben has 45 stickers. He gives 18 to his sister. How many stickers does Ben have left?", "answer": "27"},
|
||||
{
|
||||
"prompt": "A cinema sold 256 tickets on Saturday and 198 on Sunday. "
|
||||
"How many tickets were sold over the weekend?",
|
||||
"answer": "454",
|
||||
},
|
||||
{"prompt": "There are 360 minutes in 6 hours. How many minutes are in 1 hour?", "answer": "60"},
|
||||
{"prompt": "A cyclist rides 15 km each day. How far does she ride in 4 days?", "answer": "60"},
|
||||
{"prompt": "A bookstore has 5 shelves with 30 books each. How many books does it have in total?", "answer": "150"},
|
||||
{"prompt": "A pizza is cut into 8 slices. If 3 slices are eaten, how many slices remain?", "answer": "5"},
|
||||
{"prompt": "There are 4 quarters in a dollar. How many quarters are in $7?", "answer": "28"},
|
||||
{"prompt": "A farmer has 120 eggs. He puts them in cartons of 12. How many cartons does he fill?", "answer": "10"},
|
||||
{
|
||||
"prompt": "A school has 480 pupils split equally across 6 classes. How many pupils are in each class?",
|
||||
"answer": "80",
|
||||
},
|
||||
{
|
||||
"prompt": "An ice cream shop sold 45 cones on Friday and 67 on Saturday. How many cones were sold in total?",
|
||||
"answer": "112",
|
||||
},
|
||||
{"prompt": "A factory makes 250 units per day. How many units does it make in 5 days?", "answer": "1250"},
|
||||
{
|
||||
"prompt": "There are 18 players in a tournament. They are split into teams of 3. How many teams are there?",
|
||||
"answer": "6",
|
||||
},
|
||||
{
|
||||
"prompt": "A jar holds 96 sweets. If 4 children share them equally, how many sweets does each child get?",
|
||||
"answer": "24",
|
||||
},
|
||||
{"prompt": "A plane flies 800 km in 2 hours. What is its average speed?", "answer": "400"},
|
||||
{
|
||||
"prompt": "A garden has 5 rows of flowers with 11 flowers in each row. How many flowers are there?",
|
||||
"answer": "55",
|
||||
},
|
||||
{"prompt": "James has $200. He spends $74 on shoes. How much money does he have left?", "answer": "126"},
|
||||
{"prompt": "A recipe uses 3 cups of flour per cake. How many cups are needed for 7 cakes?", "answer": "21"},
|
||||
{"prompt": "There are 50 chairs in a hall. 13 are occupied. How many chairs are empty?", "answer": "37"},
|
||||
{"prompt": "A clock ticks 60 times per minute. How many times does it tick in 5 minutes?", "answer": "300"},
|
||||
{
|
||||
"prompt": "A runner completes a 400 m lap in 80 seconds. How many laps does she run in 400 seconds?",
|
||||
"answer": "5",
|
||||
},
|
||||
{"prompt": "There are 3 packs of pens with 12 pens each. How many pens are there in total?", "answer": "36"},
|
||||
{"prompt": "A tank holds 200 gallons. It is currently 40% full. How many gallons are in the tank?", "answer": "80"},
|
||||
{
|
||||
"prompt": "A store has 84 items. They are arranged in 7 equal rows. How many items are in each row?",
|
||||
"answer": "12",
|
||||
},
|
||||
{
|
||||
"prompt": "There are 9 months until the concert. How many weeks is that (assuming 4 weeks per month)?",
|
||||
"answer": "36",
|
||||
},
|
||||
{"prompt": "A frog jumps 3 m each jump. How far does it jump in 15 jumps?", "answer": "45"},
|
||||
{"prompt": "A box of pencils costs $3. How much do 11 boxes cost?", "answer": "33"},
|
||||
{"prompt": "There are 144 hours in 6 days. How many hours are in 1 day?", "answer": "24"},
|
||||
{"prompt": "A pond has 300 fish. 75 are caught and released. How many fish remain?", "answer": "225"},
|
||||
{"prompt": "A school bus seats 40 students. How many students can travel in 3 buses?", "answer": "120"},
|
||||
{
|
||||
"prompt": "There are 11 teams in a league. Each team plays every other team once. How many matches are there?",
|
||||
"answer": "55",
|
||||
},
|
||||
{"prompt": "A bakery makes 60 loaves a day. How many loaves does it make in 2 weeks?", "answer": "840"},
|
||||
{"prompt": "A square has sides of 9 cm. What is its perimeter?", "answer": "36"},
|
||||
{"prompt": "An author writes 500 words per hour. How many words does she write in 3 hours?", "answer": "1500"},
|
||||
{"prompt": "There are 72 hours in 3 days. How many hours are in 5 days?", "answer": "120"},
|
||||
{"prompt": "A store discounts a $50 item by 20%. What is the sale price?", "answer": "40"},
|
||||
{"prompt": "A farmer plants 8 seeds in each row. He has 9 rows. How many seeds does he plant?", "answer": "72"},
|
||||
{"prompt": "A car park has 6 levels with 45 spaces each. How many spaces are there in total?", "answer": "270"},
|
||||
{"prompt": "A marathon is 42 km. A runner has covered 28 km. How many km remain?", "answer": "14"},
|
||||
{"prompt": "There are 30 days in a month. How many days are in 4 months?", "answer": "120"},
|
||||
{"prompt": "A vending machine sells 15 drinks per hour. How many drinks does it sell in 8 hours?", "answer": "120"},
|
||||
{"prompt": "There are 6 strings on a guitar. How many strings are on 9 guitars?", "answer": "54"},
|
||||
{"prompt": "A worker earns $15 per hour and works 8 hours. How much does she earn?", "answer": "120"},
|
||||
{"prompt": "A square room has an area of 64 m². What is the length of each side?", "answer": "8"},
|
||||
{"prompt": "A jar has 5 red marbles and 8 blue marbles. How many marbles are there in total?", "answer": "13"},
|
||||
{"prompt": "A truck carries 2 tonnes per trip. How many tonnes does it carry in 7 trips?", "answer": "14"},
|
||||
{"prompt": "There are 32 students. Half are girls. How many girls are there?", "answer": "16"},
|
||||
{"prompt": "A patio is 6 m wide and 9 m long. What is its area?", "answer": "54"},
|
||||
{"prompt": "A swimmer does 50 laps per session. How many laps does she do in 6 sessions?", "answer": "300"},
|
||||
{"prompt": "A bag of rice weighs 5 kg. How much do 8 bags weigh?", "answer": "40"},
|
||||
{"prompt": "There are 7 days in a week. How many days are in 13 weeks?", "answer": "91"},
|
||||
{"prompt": "A factory produces 1200 items in 4 hours. How many items per hour?", "answer": "300"},
|
||||
{
|
||||
"prompt": "A road is 3.5 km long. Two cars start at each end and drive toward each other "
|
||||
"at 1.75 km/h each. How long until they meet (in hours)?",
|
||||
"answer": "1",
|
||||
},
|
||||
{"prompt": "There are 18 biscuits. Each person eats 3. How many people can eat?", "answer": "6"},
|
||||
{
|
||||
"prompt": "A pool requires 8 hours to fill. After 5 hours, how much is left to fill (as a fraction)?",
|
||||
"answer": "3/8",
|
||||
},
|
||||
{"prompt": "A box has 4 layers with 25 chocolates per layer. How many chocolates are in the box?", "answer": "100"},
|
||||
{"prompt": "A phone battery lasts 12 hours. After 9 hours of use, what percentage remains?", "answer": "25"},
|
||||
{"prompt": "There are 40 red and 25 blue tiles. How many tiles are there in total?", "answer": "65"},
|
||||
{"prompt": "A rope is 54 m long. It is divided into 9 equal pieces. How long is each piece?", "answer": "6"},
|
||||
{"prompt": "A bag has 3 green, 4 yellow, and 5 purple balls. How many balls are there?", "answer": "12"},
|
||||
{"prompt": "Each page has 30 lines. How many lines are on 8 pages?", "answer": "240"},
|
||||
{"prompt": "A worker packs 6 boxes per hour. How many boxes in 9 hours?", "answer": "54"},
|
||||
{"prompt": "There are 1000 metres in a kilometre. How many metres in 7.5 km?", "answer": "7500"},
|
||||
{"prompt": "A container holds 5 litres. How many containers are needed for 35 litres?", "answer": "7"},
|
||||
{
|
||||
"prompt": "A car travels 110 km in 2 hours. How far does it travel in 5 hours at the same speed?",
|
||||
"answer": "275",
|
||||
},
|
||||
{"prompt": "There are 52 cards in a deck. How many cards are in 3 decks?", "answer": "156"},
|
||||
{"prompt": "A hotel has 12 floors with 18 rooms each. How many rooms are there?", "answer": "216"},
|
||||
{
|
||||
"prompt": "A pie is split into 6 equal slices. Two people each eat 2 slices. How many slices remain?",
|
||||
"answer": "2",
|
||||
},
|
||||
{"prompt": "A printing press produces 40 pages per minute. How many pages in 15 minutes?", "answer": "600"},
|
||||
{"prompt": "There are 200 pupils. 45% are boys. How many boys are there?", "answer": "90"},
|
||||
{"prompt": "A wall is 3 m tall and 8 m wide. What is its area?", "answer": "24"},
|
||||
{"prompt": "A car uses 6 litres of fuel per 100 km. How much fuel is needed for 300 km?", "answer": "18"},
|
||||
{"prompt": "A team scores 3 goals in each of 6 matches. How many goals in total?", "answer": "18"},
|
||||
{"prompt": "There are 11 rows of seats with 20 seats each. How many seats are there?", "answer": "220"},
|
||||
{"prompt": "A box contains 50 nails. After using 23, how many remain?", "answer": "27"},
|
||||
{"prompt": "A wheel turns 360 degrees in one full rotation. How many degrees in 5 rotations?", "answer": "1800"},
|
||||
{"prompt": "A shop receives 3 deliveries of 40 items each. How many items did it receive?", "answer": "120"},
|
||||
{"prompt": "There are 15 biscuits on a plate. 6 are eaten. How many remain?", "answer": "9"},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reward function
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def reward_fn(prompt: str, response: str) -> float:
|
||||
"""Score a model response by checking if it contains the correct numerical answer.
|
||||
|
||||
Returns 1.0 if the response contains the expected answer, 0.0 otherwise. The correct answer is looked up from the
|
||||
global ANSWER_LOOKUP dict which is built from MATH_PROBLEMS at startup.
|
||||
|
||||
This is a simple exact-match reward. Real applications might use an LLM judge, a code execution sandbox, or a
|
||||
symbolic verifier.
|
||||
"""
|
||||
expected = ANSWER_LOOKUP.get(prompt.strip())
|
||||
if expected is None:
|
||||
return 0.0
|
||||
# Accept the answer if it appears as a standalone number/fraction in the response
|
||||
pattern = r"(?<![.\d])" + re.escape(expected) + r"(?![.\d])"
|
||||
return 1.0 if re.search(pattern, response) else 0.0
|
||||
|
||||
|
||||
# Map from prompt text → expected answer string, built once at module load time.
|
||||
ANSWER_LOOKUP: dict[str, str] = {p["prompt"]: p["answer"] for p in MATH_PROBLEMS}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataset preparation — apply reward_fn to produce a scored DataFrame
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_dataset(reward_function) -> pd.DataFrame:
|
||||
"""Build a training DataFrame by applying the reward function to each example.
|
||||
|
||||
For GRPO the dataset needs at minimum:
|
||||
- prompt : the input text
|
||||
- response : the reference / target text
|
||||
- reward : a float score for each (prompt, response) pair
|
||||
|
||||
NOTE: The Ludwig GRPO trainer does not yet accept a reward_fn callable at
|
||||
train-time (as of v0.11.dev). Instead, rewards are pre-computed here and
|
||||
stored in a 'reward' column that the trainer reads from the dataset. If a
|
||||
future version exposes a reward_fn parameter on LudwigModel or the GRPO
|
||||
config, you can pass `reward_fn=reward_function` there directly.
|
||||
"""
|
||||
rows = []
|
||||
for item in MATH_PROBLEMS:
|
||||
prompt = item["prompt"]
|
||||
response = item["answer"]
|
||||
reward = reward_function(prompt, response)
|
||||
rows.append({"prompt": prompt, "response": response, "reward": reward})
|
||||
df = pd.DataFrame(rows)
|
||||
logger.info(
|
||||
"Dataset built: %d examples, mean reward=%.3f",
|
||||
len(df),
|
||||
df["reward"].mean(),
|
||||
)
|
||||
return df
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_config(epochs: int, learning_rate: float, batch_size: int) -> dict:
|
||||
raw = f"""
|
||||
model_type: llm
|
||||
base_model: meta-llama/Llama-3.1-8B
|
||||
|
||||
adapter:
|
||||
type: lora
|
||||
r: 16
|
||||
alpha: 32
|
||||
|
||||
trainer:
|
||||
type: grpo
|
||||
epochs: {epochs}
|
||||
learning_rate: {learning_rate}
|
||||
batch_size: {batch_size}
|
||||
gradient_accumulation_steps: 16
|
||||
grpo_beta: 0.04
|
||||
grpo_epsilon: 0.2
|
||||
grpo_num_generations: 4
|
||||
|
||||
input_features:
|
||||
- name: prompt
|
||||
type: text
|
||||
|
||||
output_features:
|
||||
- name: response
|
||||
type: text
|
||||
|
||||
backend:
|
||||
type: local
|
||||
"""
|
||||
return yaml.safe_load(raw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GPU check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def check_gpu():
|
||||
try:
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available() and torch.cuda.device_count() > 0:
|
||||
name = torch.cuda.get_device_name(0)
|
||||
vram = torch.cuda.get_device_properties(0).total_memory / 1024**3
|
||||
logger.info("GPU detected: %s (%.1f GiB VRAM)", name, vram)
|
||||
if vram < 20:
|
||||
logger.warning(
|
||||
"Only %.1f GiB VRAM detected. Llama-3.1-8B requires at least 40 GiB "
|
||||
"for GRPO training. Consider using a smaller base model or enabling "
|
||||
"quantisation (e.g. bitsandbytes 4-bit).",
|
||||
vram,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"No GPU detected. GRPO training on a 7-8B model will be extremely slow on CPU. "
|
||||
"On Colab, go to Runtime > Change runtime type and select a GPU."
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
|
||||
parser = argparse.ArgumentParser(description="GRPO alignment training with Ludwig.")
|
||||
parser.add_argument("--epochs", type=int, default=1)
|
||||
parser.add_argument("--lr", type=float, default=5e-7)
|
||||
parser.add_argument("--batch_size", type=int, default=1)
|
||||
parser.add_argument("--experiment_name", default="math_grpo")
|
||||
parser.add_argument("--output_dir", default="results")
|
||||
args = parser.parse_args()
|
||||
|
||||
# --- HuggingFace token ---
|
||||
token = os.environ.get("HUGGING_FACE_HUB_TOKEN") or os.environ.get("HF_TOKEN")
|
||||
if not token:
|
||||
raise OSError(
|
||||
"Set HUGGING_FACE_HUB_TOKEN (or HF_TOKEN) before running. "
|
||||
"You also need access approval for meta-llama/Llama-3.1-8B on HuggingFace Hub."
|
||||
)
|
||||
|
||||
check_gpu()
|
||||
|
||||
# --- Build dataset with pre-computed rewards ---
|
||||
df = build_dataset(reward_fn)
|
||||
|
||||
# --- Build Ludwig config ---
|
||||
config = build_config(
|
||||
epochs=args.epochs,
|
||||
learning_rate=args.lr,
|
||||
batch_size=args.batch_size,
|
||||
)
|
||||
|
||||
# --- Train ---
|
||||
model = LudwigModel(config=config, logging_level=logging.INFO)
|
||||
|
||||
train_stats, _, output_directory = model.train(
|
||||
dataset=df,
|
||||
experiment_name=args.experiment_name,
|
||||
output_directory=args.output_dir,
|
||||
)
|
||||
|
||||
print(f"\nTraining complete. Results saved to: {output_directory}")
|
||||
print("To upload the aligned model to HuggingFace Hub:")
|
||||
print(f" ludwig upload hf_hub -r <your_org>/<model_name> -m {output_directory}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,119 @@
|
||||
# Anomaly Detection with Deep SVDD, SAD, and DROCC
|
||||
|
||||
[](https://colab.research.google.com/github/ludwig-ai/ludwig/blob/main/examples/anomaly_detection/anomaly_detection.ipynb)
|
||||
|
||||
This example shows how to train an anomaly detection model with Ludwig using the `anomaly` output feature type. The model learns a compact representation of "normal" sensor data using three complementary hypersphere-based objectives:
|
||||
|
||||
- **Deep SVDD** — unsupervised, trains only on normal samples
|
||||
- **Deep SAD** — semi-supervised, uses a small set of labeled anomalies at training time
|
||||
- **DROCC** — unsupervised with adversarial robustness, recommended for expressive encoders
|
||||
|
||||
At inference time each sample receives an `anomaly_score` equal to its squared distance from the learned hypersphere centre. Higher scores indicate more anomalous samples.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
pip install ludwig
|
||||
```
|
||||
|
||||
## Dataset
|
||||
|
||||
The example uses a synthetic sensor dataset with four numeric features (`sensor_a`, `sensor_b`, `sensor_c`, `timestamp_hour`). Normal samples are drawn from a Gaussian distribution centred at the origin; anomalous samples have a large offset. The train split contains **only normal samples**; the test split contains both normal and anomalous samples for evaluation.
|
||||
|
||||
## Loss variants
|
||||
|
||||
### Deep SVDD (unsupervised)
|
||||
|
||||
```yaml
|
||||
output_features:
|
||||
- name: anomaly
|
||||
type: anomaly
|
||||
loss:
|
||||
type: deep_svdd
|
||||
nu: 0.1 # fraction of points allowed outside the hypersphere
|
||||
```
|
||||
|
||||
Hard-boundary objective: minimise the mean squared distance of all normal training representations to the hypersphere centre `c`. The `nu` parameter controls soft-boundary relaxation (set to `0` for hard SVDD).
|
||||
|
||||
Full config: [`config_deep_svdd.yaml`](config_deep_svdd.yaml)
|
||||
|
||||
### Deep SAD (semi-supervised)
|
||||
|
||||
```yaml
|
||||
output_features:
|
||||
- name: anomaly
|
||||
type: anomaly
|
||||
loss:
|
||||
type: deep_sad
|
||||
eta: 1.0 # weight for the labeled anomaly repulsion term
|
||||
```
|
||||
|
||||
Extends Deep SVDD with labeled anomaly support. Normal and unlabeled samples (label `0` or `-1`) are pulled toward `c`; labeled anomalies (label `1`) are pushed away. Provide a small fraction of labeled anomaly rows in the training data with `anomaly=1`.
|
||||
|
||||
Full config: [`config_deep_sad.yaml`](config_deep_sad.yaml)
|
||||
|
||||
### DROCC (robust unsupervised)
|
||||
|
||||
```yaml
|
||||
output_features:
|
||||
- name: anomaly
|
||||
type: anomaly
|
||||
loss:
|
||||
type: drocc
|
||||
perturbation_strength: 0.1
|
||||
num_perturbation_steps: 5
|
||||
```
|
||||
|
||||
Prevents hypersphere collapse via an adversarial perturbation regulariser. Recommended when using expressive encoders (e.g. transformers) that are prone to degenerate solutions where all representations collapse to a single point.
|
||||
|
||||
Full config: [`config_drocc.yaml`](config_drocc.yaml)
|
||||
|
||||
## Running the example
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# Train
|
||||
ludwig train --config config_deep_svdd.yaml --dataset /tmp/sensors_train.csv
|
||||
|
||||
# Predict (score test samples)
|
||||
ludwig predict --model_path results/experiment_run/model \
|
||||
--dataset /tmp/sensors_test.csv
|
||||
|
||||
# Evaluate (requires labeled anomaly column in test CSV)
|
||||
ludwig evaluate --model_path results/experiment_run/model \
|
||||
--dataset /tmp/sensors_test.csv
|
||||
```
|
||||
|
||||
### Python API
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
from ludwig.api import LudwigModel
|
||||
|
||||
# Load data
|
||||
train_df = pd.read_csv("/tmp/sensors_train.csv")
|
||||
test_df = pd.read_csv("/tmp/sensors_test.csv")
|
||||
|
||||
# Train
|
||||
model = LudwigModel("config_deep_svdd.yaml", logging_level="ERROR")
|
||||
results = model.train(dataset=train_df)
|
||||
|
||||
# Predict — returns a DataFrame with anomaly_score_predictions column
|
||||
predictions, _ = model.predict(dataset=test_df)
|
||||
print(predictions[["anomaly_anomaly_score_predictions"]].describe())
|
||||
```
|
||||
|
||||
For a full walkthrough including score distribution plots and AUC comparison, open the notebook in Colab:
|
||||
|
||||
[](https://colab.research.google.com/github/ludwig-ai/ludwig/blob/main/examples/anomaly_detection/anomaly_detection.ipynb)
|
||||
|
||||
## Files
|
||||
|
||||
| File | Description |
|
||||
| ------------------------- | ----------------------------------------- |
|
||||
| `anomaly_detection.ipynb` | End-to-end Colab notebook |
|
||||
| `config_deep_svdd.yaml` | Deep SVDD config |
|
||||
| `config_deep_sad.yaml` | Deep SAD (semi-supervised) config |
|
||||
| `config_drocc.yaml` | DROCC config |
|
||||
| `train.py` | Standalone training and evaluation script |
|
||||
@@ -0,0 +1,486 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "7fb27b941602401d91542211134fc71a",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Anomaly Detection with Deep SVDD, SAD, and DROCC\n",
|
||||
"\n",
|
||||
"[](https://colab.research.google.com/github/ludwig-ai/ludwig/blob/main/examples/anomaly_detection/anomaly_detection.ipynb)\n",
|
||||
"\n",
|
||||
"This notebook demonstrates **unsupervised anomaly detection** using Ludwig's `anomaly` output feature.\n",
|
||||
"The model learns a compact representation of normal data inside a hypersphere in latent space.\n",
|
||||
"At inference time, each sample is scored by its squared distance to the centre of that hypersphere:\n",
|
||||
"samples far from the centre are flagged as anomalies.\n",
|
||||
"\n",
|
||||
"```\n",
|
||||
"anomaly_score = ||z - c||^2\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"**What this notebook covers:**\n",
|
||||
"\n",
|
||||
"1. Generating a synthetic sensor dataset (normal vs anomalous readings)\n",
|
||||
"2. Training with **Deep SVDD** (fully unsupervised)\n",
|
||||
"3. Visualising the anomaly score distribution for normal vs anomalous samples\n",
|
||||
"4. Training with **Deep SAD** (semi-supervised — uses a few labeled anomalies)\n",
|
||||
"5. Training with **DROCC** (robust unsupervised — adversarial perturbations prevent collapse)\n",
|
||||
"6. Comparing AUC-ROC across all three methods"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "acae54e37e7d407bbb7b55eff062a284",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!pip install ludwig --quiet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "9a63283cbaf04dbcab1f6479b197f3a8",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Generate synthetic sensor data"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "8dd0d8092fe74a7c96281538738b07e2",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import numpy as np\n",
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"RNG = np.random.default_rng(42)\n",
|
||||
"\n",
|
||||
"N_NORMAL = 800\n",
|
||||
"N_ANOMALY = 200\n",
|
||||
"\n",
|
||||
"# Normal samples: Gaussian near origin\n",
|
||||
"normal_df = pd.DataFrame(\n",
|
||||
" {\n",
|
||||
" \"sensor_a\": RNG.normal(0.0, 1.0, N_NORMAL),\n",
|
||||
" \"sensor_b\": RNG.normal(0.0, 1.0, N_NORMAL),\n",
|
||||
" \"sensor_c\": RNG.normal(0.0, 1.0, N_NORMAL),\n",
|
||||
" \"timestamp_hour\": RNG.integers(0, 24, N_NORMAL).astype(float),\n",
|
||||
" \"anomaly\": 0.0,\n",
|
||||
" }\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Anomalous samples: large offset from origin\n",
|
||||
"anomaly_df = pd.DataFrame(\n",
|
||||
" {\n",
|
||||
" \"sensor_a\": RNG.normal(6.0, 1.0, N_ANOMALY),\n",
|
||||
" \"sensor_b\": RNG.normal(6.0, 1.0, N_ANOMALY),\n",
|
||||
" \"sensor_c\": RNG.normal(6.0, 1.0, N_ANOMALY),\n",
|
||||
" \"timestamp_hour\": RNG.integers(0, 24, N_ANOMALY).astype(float),\n",
|
||||
" \"anomaly\": 1.0,\n",
|
||||
" }\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# ---------------------------------------------------------------\n",
|
||||
"# Train split: ONLY normal samples (anomaly detection is unsupervised).\n",
|
||||
"# Validation / test splits: mix of normal and anomalous.\n",
|
||||
"# split column: 0=train, 1=validation, 2=test\n",
|
||||
"# ---------------------------------------------------------------\n",
|
||||
"\n",
|
||||
"normal_idx = normal_df.index.tolist()\n",
|
||||
"RNG.shuffle(normal_idx)\n",
|
||||
"n_train = int(0.7 * len(normal_idx))\n",
|
||||
"n_val = int(0.15 * len(normal_idx))\n",
|
||||
"\n",
|
||||
"normal_df[\"split\"] = 2 # test by default\n",
|
||||
"normal_df.loc[normal_idx[:n_train], \"split\"] = 0\n",
|
||||
"normal_df.loc[normal_idx[n_train : n_train + n_val], \"split\"] = 1\n",
|
||||
"\n",
|
||||
"anom_idx = anomaly_df.index.tolist()\n",
|
||||
"RNG.shuffle(anom_idx)\n",
|
||||
"n_val_anom = len(anom_idx) // 2\n",
|
||||
"anomaly_df[\"split\"] = 2\n",
|
||||
"anomaly_df.loc[anom_idx[:n_val_anom], \"split\"] = 1\n",
|
||||
"\n",
|
||||
"# Training CSV: only normal rows\n",
|
||||
"train_df = normal_df[normal_df[\"split\"] == 0].copy()\n",
|
||||
"\n",
|
||||
"# Test CSV: validation + test rows (normal and anomalous), used for scoring\n",
|
||||
"val_test_df = pd.concat([normal_df[normal_df[\"split\"] != 0], anomaly_df], ignore_index=True)\n",
|
||||
"\n",
|
||||
"train_df.to_csv(\"/tmp/sensors_train.csv\", index=False)\n",
|
||||
"val_test_df.to_csv(\"/tmp/sensors_test.csv\", index=False)\n",
|
||||
"\n",
|
||||
"print(f\"Train samples : {len(train_df)} (all normal)\")\n",
|
||||
"print(f\"Test samples : {len(val_test_df)}\")\n",
|
||||
"print(f\" Normal : {(val_test_df['anomaly'] == 0).sum()}\")\n",
|
||||
"print(f\" Anomalous : {(val_test_df['anomaly'] == 1).sum()}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "72eea5119410473aa328ad9291626812",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Train: Deep SVDD\n",
|
||||
"\n",
|
||||
"Deep SVDD (Ruff et al., ICML 2018) is the simplest variant: it minimises the mean squared\n",
|
||||
"distance of all training representations to a hypersphere centre `c`, learned during training.\n",
|
||||
"Only normal samples are used for training — no anomaly labels are required.\n",
|
||||
"\n",
|
||||
"The `nu` parameter controls the soft-boundary relaxation: `nu=0.1` allows up to 10% of\n",
|
||||
"training points outside the hypersphere."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "8edb47106e1a46a883d545849b8ab81b",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import yaml\n",
|
||||
"\n",
|
||||
"from ludwig.api import LudwigModel\n",
|
||||
"\n",
|
||||
"config_svdd_str = \"\"\"\n",
|
||||
"model_type: ecd\n",
|
||||
"\n",
|
||||
"input_features:\n",
|
||||
" - name: sensor_a\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
" - name: sensor_b\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
" - name: sensor_c\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
" - name: timestamp_hour\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
"\n",
|
||||
"output_features:\n",
|
||||
" - name: anomaly\n",
|
||||
" type: anomaly\n",
|
||||
" loss:\n",
|
||||
" type: deep_svdd\n",
|
||||
" nu: 0.1\n",
|
||||
"\n",
|
||||
"trainer:\n",
|
||||
" epochs: 20\n",
|
||||
" learning_rate: 0.001\n",
|
||||
"\n",
|
||||
"combiner:\n",
|
||||
" type: concat\n",
|
||||
" fc_layers:\n",
|
||||
" - output_size: 64\n",
|
||||
" - output_size: 32\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"config_svdd = yaml.safe_load(config_svdd_str)\n",
|
||||
"\n",
|
||||
"model_svdd = LudwigModel(config_svdd, logging_level=30)\n",
|
||||
"train_stats_svdd, _, _ = model_svdd.train(dataset=train_df)\n",
|
||||
"\n",
|
||||
"print(\"Deep SVDD training complete.\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "10185d26023b46108eb7d9f57d49d2b3",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Evaluate: score distribution\n",
|
||||
"\n",
|
||||
"Run inference on the test set (which contains both normal and anomalous samples) and plot\n",
|
||||
"the distribution of `anomaly_score_predictions`. A good model will produce clearly separated\n",
|
||||
"distributions for the two classes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "8763a12b2bbd4a93a75aff182afb95dc",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"from sklearn.metrics import roc_auc_score\n",
|
||||
"\n",
|
||||
"preds_svdd, _ = model_svdd.predict(dataset=val_test_df)\n",
|
||||
"\n",
|
||||
"score_col = \"anomaly_anomaly_score_predictions\"\n",
|
||||
"scores_svdd = preds_svdd[score_col].values\n",
|
||||
"true_labels = val_test_df[\"anomaly\"].values\n",
|
||||
"\n",
|
||||
"auc_svdd = roc_auc_score(true_labels, scores_svdd)\n",
|
||||
"print(f\"Deep SVDD AUC-ROC: {auc_svdd:.4f}\")\n",
|
||||
"\n",
|
||||
"# --- Plot score distribution ---\n",
|
||||
"fig, ax = plt.subplots(figsize=(8, 4))\n",
|
||||
"\n",
|
||||
"ax.hist(scores_svdd[true_labels == 0], bins=40, alpha=0.6, label=\"Normal\", color=\"steelblue\")\n",
|
||||
"ax.hist(scores_svdd[true_labels == 1], bins=40, alpha=0.6, label=\"Anomalous\", color=\"tomato\")\n",
|
||||
"\n",
|
||||
"ax.set_xlabel(\"Anomaly score ||z - c||^2\")\n",
|
||||
"ax.set_ylabel(\"Count\")\n",
|
||||
"ax.set_title(f\"Deep SVDD — anomaly score distribution (AUC = {auc_svdd:.3f})\")\n",
|
||||
"ax.legend()\n",
|
||||
"plt.tight_layout()\n",
|
||||
"plt.show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "7623eae2785240b9bd12b16a66d81610",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Try Deep SAD (semi-supervised)\n",
|
||||
"\n",
|
||||
"Deep SAD (Ruff et al., ICLR 2020) extends Deep SVDD by incorporating a small set of\n",
|
||||
"**labeled anomalies** during training. Normal/unlabeled samples are pulled toward centre `c`;\n",
|
||||
"labeled anomalies are pushed away. This typically improves score separation when even a\n",
|
||||
"handful of anomaly examples are available.\n",
|
||||
"\n",
|
||||
"In the dataset, set `anomaly=1` for labeled anomaly rows, `anomaly=0` for normal rows, and\n",
|
||||
"`anomaly=-1` (or leave the column absent) for unlabeled rows that should be ignored by the\n",
|
||||
"repulsion term.\n",
|
||||
"\n",
|
||||
"Here we inject ~10% labeled anomalies into the training split (`labeled_anomalies_ratio=0.1`)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "7cdc8c89c7104fffa095e18ddfef8986",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"config_sad_str = \"\"\"\n",
|
||||
"model_type: ecd\n",
|
||||
"\n",
|
||||
"input_features:\n",
|
||||
" - name: sensor_a\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
" - name: sensor_b\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
" - name: sensor_c\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
" - name: timestamp_hour\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
"\n",
|
||||
"output_features:\n",
|
||||
" - name: anomaly\n",
|
||||
" type: anomaly\n",
|
||||
" loss:\n",
|
||||
" type: deep_sad\n",
|
||||
" eta: 1.0\n",
|
||||
"\n",
|
||||
"trainer:\n",
|
||||
" epochs: 20\n",
|
||||
" learning_rate: 0.001\n",
|
||||
"\n",
|
||||
"combiner:\n",
|
||||
" type: concat\n",
|
||||
" fc_layers:\n",
|
||||
" - output_size: 64\n",
|
||||
" - output_size: 32\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"config_sad = yaml.safe_load(config_sad_str)\n",
|
||||
"\n",
|
||||
"# Inject ~10% labeled anomalies into the training set\n",
|
||||
"N_LABELED = max(1, int(0.1 * len(train_df)))\n",
|
||||
"labeled_anom = anomaly_df.sample(n=N_LABELED, random_state=0).copy()\n",
|
||||
"labeled_anom[\"split\"] = 0\n",
|
||||
"sad_train_df = pd.concat([train_df, labeled_anom], ignore_index=True)\n",
|
||||
"\n",
|
||||
"print(\n",
|
||||
" f\"Deep SAD training set: {len(sad_train_df)} rows \"\n",
|
||||
" f\"({N_LABELED} labeled anomalies = \"\n",
|
||||
" f\"{100 * N_LABELED / len(sad_train_df):.1f}%)\"\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"model_sad = LudwigModel(config_sad, logging_level=30)\n",
|
||||
"train_stats_sad, _, _ = model_sad.train(dataset=sad_train_df)\n",
|
||||
"\n",
|
||||
"preds_sad, _ = model_sad.predict(dataset=val_test_df)\n",
|
||||
"scores_sad = preds_sad[score_col].values\n",
|
||||
"auc_sad = roc_auc_score(true_labels, scores_sad)\n",
|
||||
"print(f\"Deep SAD AUC-ROC: {auc_sad:.4f}\")\n",
|
||||
"\n",
|
||||
"# --- Plot ---\n",
|
||||
"fig, ax = plt.subplots(figsize=(8, 4))\n",
|
||||
"ax.hist(scores_sad[true_labels == 0], bins=40, alpha=0.6, label=\"Normal\", color=\"steelblue\")\n",
|
||||
"ax.hist(scores_sad[true_labels == 1], bins=40, alpha=0.6, label=\"Anomalous\", color=\"tomato\")\n",
|
||||
"ax.set_xlabel(\"Anomaly score ||z - c||^2\")\n",
|
||||
"ax.set_ylabel(\"Count\")\n",
|
||||
"ax.set_title(f\"Deep SAD — score distribution (AUC = {auc_sad:.3f})\")\n",
|
||||
"ax.legend()\n",
|
||||
"plt.tight_layout()\n",
|
||||
"plt.show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "b118ea5561624da68c537baed56e602f",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Try DROCC\n",
|
||||
"\n",
|
||||
"DROCC (Goyal et al., ICML 2020) prevents hypersphere **collapse** — a failure mode where an\n",
|
||||
"expressive encoder maps all inputs to the same point, achieving a trivially low loss while\n",
|
||||
"learning nothing useful. It does so by generating adversarial perturbations around each\n",
|
||||
"normal training point and penalising the model if the perturbed points score as normal.\n",
|
||||
"\n",
|
||||
"Key parameters:\n",
|
||||
"- `perturbation_strength`: magnitude of adversarial perturbations (default `0.1`)\n",
|
||||
"- `num_perturbation_steps`: gradient ascent steps per sample (default `5`)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "938c804e27f84196a10c8828c723f798",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"config_drocc_str = \"\"\"\n",
|
||||
"model_type: ecd\n",
|
||||
"\n",
|
||||
"input_features:\n",
|
||||
" - name: sensor_a\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
" - name: sensor_b\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
" - name: sensor_c\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
" - name: timestamp_hour\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
"\n",
|
||||
"output_features:\n",
|
||||
" - name: anomaly\n",
|
||||
" type: anomaly\n",
|
||||
" loss:\n",
|
||||
" type: drocc\n",
|
||||
" perturbation_strength: 0.1\n",
|
||||
" num_perturbation_steps: 5\n",
|
||||
"\n",
|
||||
"trainer:\n",
|
||||
" epochs: 20\n",
|
||||
" learning_rate: 0.001\n",
|
||||
"\n",
|
||||
"combiner:\n",
|
||||
" type: concat\n",
|
||||
" fc_layers:\n",
|
||||
" - output_size: 64\n",
|
||||
" - output_size: 32\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"config_drocc = yaml.safe_load(config_drocc_str)\n",
|
||||
"\n",
|
||||
"model_drocc = LudwigModel(config_drocc, logging_level=30)\n",
|
||||
"train_stats_drocc, _, _ = model_drocc.train(dataset=train_df)\n",
|
||||
"\n",
|
||||
"preds_drocc, _ = model_drocc.predict(dataset=val_test_df)\n",
|
||||
"scores_drocc = preds_drocc[score_col].values\n",
|
||||
"auc_drocc = roc_auc_score(true_labels, scores_drocc)\n",
|
||||
"print(f\"DROCC AUC-ROC: {auc_drocc:.4f}\")\n",
|
||||
"\n",
|
||||
"# --- Plot ---\n",
|
||||
"fig, ax = plt.subplots(figsize=(8, 4))\n",
|
||||
"ax.hist(scores_drocc[true_labels == 0], bins=40, alpha=0.6, label=\"Normal\", color=\"steelblue\")\n",
|
||||
"ax.hist(scores_drocc[true_labels == 1], bins=40, alpha=0.6, label=\"Anomalous\", color=\"tomato\")\n",
|
||||
"ax.set_xlabel(\"Anomaly score ||z - c||^2\")\n",
|
||||
"ax.set_ylabel(\"Count\")\n",
|
||||
"ax.set_title(f\"DROCC — score distribution (AUC = {auc_drocc:.3f})\")\n",
|
||||
"ax.legend()\n",
|
||||
"plt.tight_layout()\n",
|
||||
"plt.show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "504fb2a444614c0babb325280ed9130a",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Summary\n",
|
||||
"\n",
|
||||
"The table below compares the three loss variants on the synthetic sensor dataset.\n",
|
||||
"AUC-ROC close to 1.0 means the model can almost perfectly rank anomalous samples above\n",
|
||||
"normal ones. The separation ratio is the mean anomaly score divided by the mean normal score:\n",
|
||||
"a ratio much greater than 1 indicates clear separation in score space."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "59bbdb311c014d738909a11f9e486628",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"summary_rows = []\n",
|
||||
"for name, scores, auc in [\n",
|
||||
" (\"Deep SVDD (unsupervised)\", scores_svdd, auc_svdd),\n",
|
||||
" (\"Deep SAD (semi-supervised)\", scores_sad, auc_sad),\n",
|
||||
" (\"DROCC (robust unsup.)\", scores_drocc, auc_drocc),\n",
|
||||
"]:\n",
|
||||
" normal_s = scores[true_labels == 0]\n",
|
||||
" anom_s = scores[true_labels == 1]\n",
|
||||
" sep = anom_s.mean() / (normal_s.mean() + 1e-9)\n",
|
||||
" summary_rows.append(\n",
|
||||
" {\n",
|
||||
" \"Method\": name,\n",
|
||||
" \"AUC-ROC\": round(auc, 4),\n",
|
||||
" \"Mean normal score\": round(float(normal_s.mean()), 4),\n",
|
||||
" \"Mean anomaly score\": round(float(anom_s.mean()), 4),\n",
|
||||
" \"Separation ratio\": round(float(sep), 2),\n",
|
||||
" }\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
"summary_df = pd.DataFrame(summary_rows)\n",
|
||||
"print(summary_df.to_string(index=False))"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"accelerator": "GPU",
|
||||
"colab": {
|
||||
"provenance": []
|
||||
},
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.12.0"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
model_type: ecd
|
||||
|
||||
input_features:
|
||||
- name: sensor_a
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: sensor_b
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: sensor_c
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: timestamp_hour
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
|
||||
output_features:
|
||||
- name: anomaly
|
||||
type: anomaly
|
||||
loss:
|
||||
type: deep_sad
|
||||
# eta: weight for the labeled anomaly repulsion term.
|
||||
# Labeled anomalies (target=1 in the dataset) are pushed away from the hypersphere
|
||||
# centre while normal/unlabeled samples are pulled toward it.
|
||||
eta: 1.0
|
||||
|
||||
trainer:
|
||||
epochs: 20
|
||||
learning_rate: 0.001
|
||||
|
||||
combiner:
|
||||
type: concat
|
||||
fc_layers:
|
||||
- output_size: 64
|
||||
- output_size: 32
|
||||
|
||||
# Deep SAD is semi-supervised: include a small fraction of labeled anomaly rows
|
||||
# in the training set with the anomaly column set to 1. All other training rows
|
||||
# should have anomaly=0 (normal) or anomaly=-1 (unlabeled/ignored).
|
||||
# labeled_anomalies_ratio: 0.1 # approximately 10% of training rows are labeled anomalies
|
||||
@@ -0,0 +1,38 @@
|
||||
model_type: ecd
|
||||
|
||||
input_features:
|
||||
- name: sensor_a
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: sensor_b
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: sensor_c
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: timestamp_hour
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
|
||||
output_features:
|
||||
- name: anomaly
|
||||
type: anomaly
|
||||
loss:
|
||||
type: deep_svdd
|
||||
# nu: fraction of training samples allowed outside the hypersphere (soft-boundary).
|
||||
# Set to 0 for hard-boundary SVDD where all points are pulled toward centre c.
|
||||
nu: 0.1
|
||||
|
||||
trainer:
|
||||
epochs: 20
|
||||
learning_rate: 0.001
|
||||
|
||||
combiner:
|
||||
type: concat
|
||||
fc_layers:
|
||||
- output_size: 64
|
||||
- output_size: 32
|
||||
@@ -0,0 +1,41 @@
|
||||
model_type: ecd
|
||||
|
||||
input_features:
|
||||
- name: sensor_a
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: sensor_b
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: sensor_c
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: timestamp_hour
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
|
||||
output_features:
|
||||
- name: anomaly
|
||||
type: anomaly
|
||||
loss:
|
||||
type: drocc
|
||||
# perturbation_strength: magnitude of adversarial perturbations applied to prevent
|
||||
# hypersphere collapse (all representations converging to center c).
|
||||
# Typical range: 0.01–0.5. Increase if the model collapses early.
|
||||
perturbation_strength: 0.1
|
||||
# num_perturbation_steps: gradient ascent steps for generating each perturbation.
|
||||
num_perturbation_steps: 5
|
||||
|
||||
trainer:
|
||||
epochs: 20
|
||||
learning_rate: 0.001
|
||||
|
||||
combiner:
|
||||
type: concat
|
||||
fc_layers:
|
||||
- output_size: 64
|
||||
- output_size: 32
|
||||
@@ -0,0 +1,190 @@
|
||||
# Colab: !pip install ludwig
|
||||
"""Anomaly detection with Ludwig using Deep SVDD, Deep SAD, and DROCC losses.
|
||||
|
||||
Generates synthetic sensor data, trains all three model variants, evaluates on a
|
||||
held-out test set containing both normal and anomalous samples, and prints an AUC-ROC
|
||||
comparison table.
|
||||
|
||||
Usage:
|
||||
python train.py
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.metrics import roc_auc_score
|
||||
|
||||
from ludwig.api import LudwigModel
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Generate synthetic sensor data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RNG = np.random.default_rng(42)
|
||||
|
||||
N_NORMAL = 800
|
||||
N_ANOMALY = 200
|
||||
|
||||
# Normal samples: Gaussian near origin
|
||||
normal = pd.DataFrame(
|
||||
{
|
||||
"sensor_a": RNG.normal(0.0, 1.0, N_NORMAL),
|
||||
"sensor_b": RNG.normal(0.0, 1.0, N_NORMAL),
|
||||
"sensor_c": RNG.normal(0.0, 1.0, N_NORMAL),
|
||||
"timestamp_hour": RNG.integers(0, 24, N_NORMAL).astype(float),
|
||||
"anomaly": 0.0,
|
||||
}
|
||||
)
|
||||
|
||||
# Anomalous samples: large offset from origin
|
||||
anomalous = pd.DataFrame(
|
||||
{
|
||||
"sensor_a": RNG.normal(6.0, 1.0, N_ANOMALY),
|
||||
"sensor_b": RNG.normal(6.0, 1.0, N_ANOMALY),
|
||||
"sensor_c": RNG.normal(6.0, 1.0, N_ANOMALY),
|
||||
"timestamp_hour": RNG.integers(0, 24, N_ANOMALY).astype(float),
|
||||
"anomaly": 1.0,
|
||||
}
|
||||
)
|
||||
|
||||
all_data = pd.concat([normal, anomalous], ignore_index=True)
|
||||
|
||||
# Train split: ONLY normal samples (anomaly detection is unsupervised)
|
||||
# Val split: mix of normal and anomalous for threshold selection
|
||||
# Test split: mix for final evaluation
|
||||
|
||||
normal_idx = all_data[all_data["anomaly"] == 0].index.tolist()
|
||||
anomaly_idx = all_data[all_data["anomaly"] == 1].index.tolist()
|
||||
|
||||
RNG.shuffle(normal_idx)
|
||||
n_train = int(0.7 * len(normal_idx))
|
||||
n_val = int(0.15 * len(normal_idx))
|
||||
|
||||
train_idx = normal_idx[:n_train]
|
||||
val_normal_idx = normal_idx[n_train : n_train + n_val]
|
||||
test_normal_idx = normal_idx[n_train + n_val :]
|
||||
|
||||
RNG.shuffle(anomaly_idx)
|
||||
n_val_anom = len(anomaly_idx) // 2
|
||||
val_anom_idx = anomaly_idx[:n_val_anom]
|
||||
test_anom_idx = anomaly_idx[n_val_anom:]
|
||||
|
||||
# Assign split column: 0=train, 1=val, 2=test
|
||||
all_data["split"] = -1
|
||||
all_data.loc[train_idx, "split"] = 0
|
||||
all_data.loc[val_normal_idx, "split"] = 1
|
||||
all_data.loc[val_anom_idx, "split"] = 1
|
||||
all_data.loc[test_normal_idx, "split"] = 2
|
||||
all_data.loc[test_anom_idx, "split"] = 2
|
||||
|
||||
train_df = all_data[all_data["split"] == 0].copy()
|
||||
test_df = all_data[all_data["split"].isin([1, 2])].copy()
|
||||
|
||||
train_df.to_csv("/tmp/sensors_train.csv", index=False)
|
||||
test_df.to_csv("/tmp/sensors_test.csv", index=False)
|
||||
|
||||
print(f"Train samples: {len(train_df)} (all normal)")
|
||||
print(f"Test samples: {len(test_df)} ({(test_df['anomaly'] == 1).sum()} anomalous)")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Helper: build Ludwig config dict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INPUT_FEATURES = [
|
||||
{"name": "sensor_a", "type": "number", "preprocessing": {"normalization": "zscore"}},
|
||||
{"name": "sensor_b", "type": "number", "preprocessing": {"normalization": "zscore"}},
|
||||
{"name": "sensor_c", "type": "number", "preprocessing": {"normalization": "zscore"}},
|
||||
{"name": "timestamp_hour", "type": "number", "preprocessing": {"normalization": "zscore"}},
|
||||
]
|
||||
|
||||
COMBINER = {
|
||||
"type": "concat",
|
||||
"fc_layers": [{"output_size": 64}, {"output_size": 32}],
|
||||
}
|
||||
|
||||
TRAINER = {"epochs": 20, "learning_rate": 0.001}
|
||||
|
||||
|
||||
def make_config(loss: dict) -> dict:
|
||||
return {
|
||||
"model_type": "ecd",
|
||||
"input_features": INPUT_FEATURES,
|
||||
"output_features": [
|
||||
{
|
||||
"name": "anomaly",
|
||||
"type": "anomaly",
|
||||
"loss": loss,
|
||||
}
|
||||
],
|
||||
"combiner": COMBINER,
|
||||
"trainer": TRAINER,
|
||||
}
|
||||
|
||||
|
||||
CONFIGS = {
|
||||
"Deep SVDD": make_config({"type": "deep_svdd", "nu": 0.1}),
|
||||
"Deep SAD": make_config({"type": "deep_sad", "eta": 1.0}),
|
||||
"DROCC": make_config({"type": "drocc", "perturbation_strength": 0.1, "num_perturbation_steps": 5}),
|
||||
}
|
||||
|
||||
# Deep SAD: inject ~10% labeled anomalies into the training set
|
||||
N_LABELED = max(1, int(0.1 * len(train_df)))
|
||||
labeled_anom = anomalous.sample(n=N_LABELED, random_state=0).copy()
|
||||
labeled_anom["split"] = 0
|
||||
sad_train_df = pd.concat([train_df, labeled_anom], ignore_index=True)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Train and evaluate each variant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
results_table = []
|
||||
|
||||
for method_name, config in CONFIGS.items():
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Training: {method_name}")
|
||||
print("=" * 60)
|
||||
|
||||
train_data = sad_train_df if method_name == "Deep SAD" else train_df
|
||||
|
||||
model = LudwigModel(config, logging_level=30) # WARNING level
|
||||
train_stats, _, _ = model.train(dataset=train_data)
|
||||
|
||||
predictions, _ = model.predict(dataset=test_df)
|
||||
|
||||
score_col = "anomaly_anomaly_score_predictions"
|
||||
scores = predictions[score_col].values
|
||||
true_labels = test_df["anomaly"].values
|
||||
|
||||
auc = roc_auc_score(true_labels, scores)
|
||||
|
||||
# Separation ratio: mean anomaly score / mean normal score
|
||||
normal_scores = scores[true_labels == 0]
|
||||
anom_scores = scores[true_labels == 1]
|
||||
sep_ratio = anom_scores.mean() / (normal_scores.mean() + 1e-9)
|
||||
|
||||
results_table.append(
|
||||
{
|
||||
"Method": method_name,
|
||||
"AUC-ROC": round(auc, 4),
|
||||
"Mean normal score": round(float(normal_scores.mean()), 4),
|
||||
"Mean anomaly score": round(float(anom_scores.mean()), 4),
|
||||
"Separation ratio": round(float(sep_ratio), 2),
|
||||
}
|
||||
)
|
||||
print(f" AUC-ROC: {auc:.4f}")
|
||||
print(f" Mean normal score: {normal_scores.mean():.4f}")
|
||||
print(f" Mean anomaly score:{anom_scores.mean():.4f}")
|
||||
print(f" Separation ratio: {sep_ratio:.2f}x")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Print summary table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
results_df = pd.DataFrame(results_table)
|
||||
|
||||
print("\n")
|
||||
print("=" * 70)
|
||||
print("ANOMALY DETECTION — SUMMARY")
|
||||
print("=" * 70)
|
||||
print(results_df.to_string(index=False))
|
||||
print("=" * 70)
|
||||
print("\nHigher AUC-ROC and separation ratio indicate better discrimination between normal and anomalous samples.")
|
||||
@@ -0,0 +1,22 @@
|
||||
# Calibration Examples
|
||||
|
||||
Drawing on the methods in
|
||||
On Calibration of Modern Neural Networks (Chuan Guo, Geoff Pleiss, Yu Sun, Kilian Q. Weinberger), Ludwig supports
|
||||
temperature scaling for binary and category output features. Temperature scaling brings a model’s output probabilities
|
||||
closer to the true likelihood while preserving the same accuracy and top k predictions.
|
||||
|
||||
To enable calibration, add calibration: True to any binary or category feature:
|
||||
|
||||
```
|
||||
output_features:
|
||||
- name: Cover_Type
|
||||
type: category
|
||||
calibration: True
|
||||
```
|
||||
|
||||
With calibration enabled, Ludwig will attempt to find a scale factor (temperature) which will bring the probabilities
|
||||
closer to the true likelihoods using the validation set. This calibration phase is run after training is complete. If
|
||||
no validation set is provided, the training set is used for calibration.
|
||||
|
||||
To visualize the effects of calibration in Ludwig, you can use Calibration Plots, which bin the data based on model
|
||||
probability and plot the model probability (X) versus the observed rate (Y) for each bin.
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
import numpy as np
|
||||
import yaml
|
||||
|
||||
import ludwig.visualize
|
||||
from ludwig.api import LudwigModel
|
||||
from ludwig.datasets import forest_cover
|
||||
|
||||
# clean out prior results
|
||||
shutil.rmtree("./results_forest_cover", ignore_errors=True)
|
||||
shutil.rmtree("./visualizations_forest_cover", ignore_errors=True)
|
||||
|
||||
# Download and prepare the dataset
|
||||
dataset = forest_cover.load()
|
||||
|
||||
config_yaml = """
|
||||
input_features:
|
||||
- name: Elevation
|
||||
type: number
|
||||
- name: Aspect
|
||||
type: number
|
||||
- name: Slope
|
||||
type: number
|
||||
- name: Horizontal_Distance_To_Hydrology
|
||||
type: number
|
||||
- name: Vertical_Distance_To_Hydrology
|
||||
type: number
|
||||
- name: Horizontal_Distance_To_Roadways
|
||||
type: number
|
||||
- name: Hillshade_9am
|
||||
type: number
|
||||
- name: Hillshade_Noon
|
||||
type: number
|
||||
- name: Hillshade_3pm
|
||||
type: number
|
||||
- name: Horizontal_Distance_To_Fire_Points
|
||||
type: number
|
||||
- name: Wilderness_Area
|
||||
type: category
|
||||
- name: Soil_Type
|
||||
type: category
|
||||
output_features:
|
||||
- name: Cover_Type
|
||||
type: category
|
||||
combiner:
|
||||
type: transformer
|
||||
trainer:
|
||||
batch_size: 256
|
||||
learning_rate: .001
|
||||
epochs: 1
|
||||
"""
|
||||
|
||||
uncalibrated_config = yaml.safe_load(config_yaml)
|
||||
|
||||
scaled_config = copy.deepcopy(uncalibrated_config)
|
||||
scaled_config["output_features"][0]["calibration"] = True
|
||||
|
||||
uncalibrated_model = LudwigModel(config=uncalibrated_config, logging_level=logging.INFO)
|
||||
uncalibrated_model.train(
|
||||
dataset,
|
||||
model_name="uncalibrated",
|
||||
experiment_name="forest_cover_calibration",
|
||||
output_directory="results_forest_cover",
|
||||
)
|
||||
|
||||
scaled_model = LudwigModel(config=scaled_config, logging_level=logging.INFO)
|
||||
scaled_model.train(
|
||||
dataset, model_name="scaled", experiment_name="forest_cover_calibration", output_directory="results_forest_cover"
|
||||
)
|
||||
|
||||
# Generates predictions and performance statistics for the test set.
|
||||
uncalibrated_test_stats, uncalibrated_test_predictions, _ = uncalibrated_model.evaluate(
|
||||
dataset, collect_predictions=True, collect_overall_stats=True
|
||||
)
|
||||
|
||||
scaled_test_stats, scaled_test_predictions, _ = scaled_model.evaluate(
|
||||
dataset, collect_predictions=True, collect_overall_stats=True
|
||||
)
|
||||
|
||||
uncalibrated_probs = np.stack(uncalibrated_test_predictions["Cover_Type_probabilities"], axis=0)
|
||||
scaled_probs = np.stack(scaled_test_predictions["Cover_Type_probabilities"], axis=0)
|
||||
|
||||
ludwig.visualize.calibration_1_vs_all(
|
||||
probabilities_per_model=[uncalibrated_probs, scaled_probs],
|
||||
model_names=["Uncalibrated", "Calibrated"],
|
||||
ground_truth=dataset["Cover_Type"],
|
||||
metadata=uncalibrated_model.training_set_metadata,
|
||||
output_feature_name="Cover_Type",
|
||||
top_n_classes=[7, 7],
|
||||
labels_limit=7,
|
||||
output_directory="visualizations_forest_cover",
|
||||
file_format="png",
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
import numpy as np
|
||||
import yaml
|
||||
|
||||
import ludwig.visualize
|
||||
from ludwig.api import LudwigModel
|
||||
from ludwig.datasets import mushroom_edibility
|
||||
|
||||
# clean out prior results
|
||||
shutil.rmtree("./results_mushroom_edibility", ignore_errors=True)
|
||||
shutil.rmtree("./visualizations_mushroom_edibility", ignore_errors=True)
|
||||
|
||||
# Download and prepare the dataset
|
||||
dataset = mushroom_edibility.load()
|
||||
|
||||
# This dataset has no split, so add a split column
|
||||
dataset.split = np.random.choice(3, len(dataset), p=(0.7, 0.1, 0.2))
|
||||
|
||||
config_yaml = """
|
||||
input_features:
|
||||
- name: cap-shape
|
||||
type: category
|
||||
- name: cap-surface
|
||||
type: category
|
||||
- name: cap-color
|
||||
type: category
|
||||
- name: bruises?
|
||||
type: category
|
||||
- name: odor
|
||||
type: category
|
||||
- name: gill-attachment
|
||||
type: category
|
||||
- name: gill-spacing
|
||||
type: category
|
||||
- name: gill-size
|
||||
type: category
|
||||
- name: gill-color
|
||||
type: category
|
||||
- name: stalk-shape
|
||||
type: category
|
||||
- name: stalk-root
|
||||
type: category
|
||||
- name: stalk-surface-above-ring
|
||||
type: category
|
||||
- name: stalk-surface-below-ring
|
||||
type: category
|
||||
- name: stalk-color-above-ring
|
||||
type: category
|
||||
- name: stalk-color-below-ring
|
||||
type: category
|
||||
- name: veil-type
|
||||
type: category
|
||||
- name: veil-color
|
||||
type: category
|
||||
- name: ring-number
|
||||
type: category
|
||||
- name: ring-type
|
||||
type: category
|
||||
- name: spore-print-color
|
||||
type: category
|
||||
- name: population
|
||||
type: category
|
||||
- name: habitat
|
||||
type: category
|
||||
output_features:
|
||||
- name: class
|
||||
type: category
|
||||
combiner:
|
||||
type: concat
|
||||
trainer:
|
||||
batch_size: 256
|
||||
learning_rate: .0001
|
||||
epochs: 10
|
||||
"""
|
||||
|
||||
uncalibrated_config = yaml.safe_load(config_yaml)
|
||||
|
||||
scaled_config = copy.deepcopy(uncalibrated_config)
|
||||
scaled_config["output_features"][0]["calibration"] = True
|
||||
|
||||
uncalibrated_model = LudwigModel(config=uncalibrated_config, logging_level=logging.INFO)
|
||||
uncalibrated_model.train(
|
||||
dataset,
|
||||
model_name="uncalibrated",
|
||||
experiment_name="mushroom_edibility_calibration",
|
||||
output_directory="results_mushroom_edibility",
|
||||
)
|
||||
|
||||
scaled_model = LudwigModel(config=scaled_config, logging_level=logging.INFO)
|
||||
scaled_model.train(
|
||||
dataset,
|
||||
model_name="scaled",
|
||||
experiment_name="mushroom_edibility_calibration",
|
||||
output_directory="results_mushroom_edibility",
|
||||
)
|
||||
|
||||
# Generates predictions and performance statistics for the test set.
|
||||
uncalibrated_test_stats, uncalibrated_test_predictions, _ = uncalibrated_model.evaluate(
|
||||
dataset, collect_predictions=True, collect_overall_stats=True
|
||||
)
|
||||
|
||||
scaled_test_stats, scaled_test_predictions, _ = scaled_model.evaluate(
|
||||
dataset, collect_predictions=True, collect_overall_stats=True
|
||||
)
|
||||
|
||||
uncalibrated_probs = np.stack(uncalibrated_test_predictions["class_probabilities"], axis=0)
|
||||
scaled_probs = np.stack(scaled_test_predictions["class_probabilities"], axis=0)
|
||||
|
||||
ludwig.visualize.calibration_1_vs_all(
|
||||
probabilities_per_model=[uncalibrated_probs, scaled_probs],
|
||||
model_names=["Uncalibrated", "Calibrated"],
|
||||
ground_truth=dataset["class"],
|
||||
metadata=uncalibrated_model.training_set_metadata,
|
||||
output_feature_name="class",
|
||||
top_n_classes=[3, 3],
|
||||
labels_limit=3,
|
||||
output_directory="visualizations_mushroom_edibility",
|
||||
file_format="png",
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
# Credit Card Fraud Detection Example
|
||||
|
||||
This API example is based on Kaggle's [Imbalanced Insurance](https://www.kaggle.com/arashnic/imbalanced-data-practice) dataset for detecting whether customers will buy vehicle insurance.
|
||||
|
||||
### Preparatory Steps
|
||||
|
||||
Create and download your [Kaggle API Credentials](https://github.com/Kaggle/kaggle-api#api-credentials).
|
||||
|
||||
The Imbalanced Insurance dataset is hosted by Kaggle, and as such Ludwig will need to authenticate you through the Kaggle API to download the dataset.
|
||||
|
||||
### Examples
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| model_training.py | Demonstrates the use of oversampling by training two different models: one with no oversampling, and one with. |
|
||||
| model_training_results.ipynb | Example for extracting training statistics and generating visualizations. |
|
||||
|
||||
Enter `python model_training.py` will train a standard model with no class balancing and a balanced model with class balancing applied. Results of model training will be stored in this location.
|
||||
|
||||
```
|
||||
./results/
|
||||
balance_example_standard_model/
|
||||
balance_example_balanced_model/
|
||||
```
|
||||
|
||||
The only difference between these two models is that the balanced model uses a small amount of oversampling in addition to the other configuration parameters.
|
||||
The way this is done is by specifying the ratio that you want the minority class to have in relation to the majority class.
|
||||
For instance, if you specify 0.5 for the `oversample_minority` preprocessing parameter, the minority class will be oversampled until it makes up 50% of the majority class.
|
||||
In this example, we had an imbalance where the minority class was 19% of the majority class in size. We decided that we wanted to increase that to 26%.
|
||||
Though this doesn't seem like much, it is enough to get some small performance improvements without experiencing performance degradation due to over-fitting.
|
||||
|
||||
Here are the performance differences in the two models followed by some plots showing different metrics over training:
|
||||
|
||||
| Metric | Standard Model | Balanced Model |
|
||||
| :------: | :------------: | :------------: |
|
||||
| Loss | 0.3649 | 0.2758 |
|
||||
| Accuracy | 0.7732 | 0.8237 |
|
||||
| ROC AUC | 0.8533 | 0.8660 |
|
||||
|
||||
Here are the learning curve plots from both models:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Here is the comparison of model performances on ROC_AUC and Accuracy:
|
||||
|
||||

|
||||
|
||||
The creation of the learning curves is demonstrated in the Jupyter notebook `model_training_results.ipynb`. The comparison plot was generated using the ludwig visualize [compare performance](https://ludwig-ai.github.io/ludwig-docs/0.4/user_guide/visualizations/#compare-performance) command.
|
||||
@@ -0,0 +1,34 @@
|
||||
input_features:
|
||||
- name: Gender
|
||||
type: category
|
||||
- name: Age
|
||||
type: number
|
||||
- name: Driving_License
|
||||
type: binary
|
||||
- name: Region_Code
|
||||
type: number
|
||||
- name: Previously_Insured
|
||||
type: binary
|
||||
- name: Vehicle_Age
|
||||
type: category
|
||||
- name: Vehicle_Damage
|
||||
type: category
|
||||
- name: Annual_Premium
|
||||
type: number
|
||||
- name: Policy_Sales_Channel
|
||||
type: number
|
||||
- name: Vintage
|
||||
type: number
|
||||
output_features:
|
||||
- name: Response
|
||||
type: binary
|
||||
preprocessing:
|
||||
oversample_minority: 0.26
|
||||
trainer:
|
||||
learning_rate: 0.0001
|
||||
learning_rate_scheduler:
|
||||
decay: exponential
|
||||
decay_rate: 0.9
|
||||
decay_steps: 30000
|
||||
staircase: True
|
||||
epochs: 50
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# # Class Imbalance Model Training Example
|
||||
#
|
||||
# This example trains a model utilizing a standard config, and then a config using oversampling
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
# Import required libraries
|
||||
from ludwig.api import LudwigModel
|
||||
from ludwig.datasets import imbalanced_insurance
|
||||
from ludwig.visualize import compare_performance
|
||||
|
||||
# clean out old results
|
||||
shutil.rmtree("./results", ignore_errors=True)
|
||||
shutil.rmtree("./visualizations", ignore_errors=True)
|
||||
|
||||
# list models to train
|
||||
list_of_model_ids = ["standard_model", "balanced_model"]
|
||||
list_of_eval_stats = []
|
||||
|
||||
training_set, val_set, test_set = imbalanced_insurance.load()
|
||||
|
||||
# Train models
|
||||
for model_id in list_of_model_ids:
|
||||
print(">>>> training: ", model_id)
|
||||
|
||||
# Define Ludwig model object that drive model training
|
||||
model = LudwigModel(config=model_id + "_config.yaml", logging_level=logging.WARN)
|
||||
|
||||
# initiate model training
|
||||
train_stats, _, _ = model.train(
|
||||
training_set=training_set,
|
||||
validation_set=val_set,
|
||||
test_set=test_set,
|
||||
experiment_name="balance_example",
|
||||
model_name=model_id,
|
||||
skip_save_model=True,
|
||||
)
|
||||
|
||||
# evaluate model on test_set
|
||||
eval_stats, _, _ = model.evaluate(test_set)
|
||||
|
||||
# save eval stats for later use
|
||||
list_of_eval_stats.append(eval_stats)
|
||||
|
||||
print(">>>>>>> completed: ", model_id, "\n")
|
||||
|
||||
|
||||
compare_performance(
|
||||
list_of_eval_stats,
|
||||
"Response",
|
||||
model_names=list_of_model_ids,
|
||||
output_directory="./visualizations",
|
||||
file_format="png",
|
||||
)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
|
||||
input_features:
|
||||
- name: Gender
|
||||
type: category
|
||||
- name: Age
|
||||
type: number
|
||||
- name: Driving_License
|
||||
type: binary
|
||||
- name: Region_Code
|
||||
type: number
|
||||
- name: Previously_Insured
|
||||
type: binary
|
||||
- name: Vehicle_Age
|
||||
type: category
|
||||
- name: Vehicle_Damage
|
||||
type: category
|
||||
- name: Annual_Premium
|
||||
type: number
|
||||
- name: Policy_Sales_Channel
|
||||
type: number
|
||||
- name: Vintage
|
||||
type: number
|
||||
output_features:
|
||||
- name: Response
|
||||
type: binary
|
||||
trainer:
|
||||
learning_rate: 0.0001
|
||||
learning_rate_scheduler:
|
||||
decay: exponential
|
||||
decay_rate: 0.9
|
||||
decay_steps: 30000
|
||||
staircase: True
|
||||
epochs: 50
|
||||
@@ -0,0 +1,3 @@
|
||||
- Download and unpack hourly weather data from https://www.kaggle.com/selfishgene/historical-hourly-weather-data
|
||||
- `ludwig train --config config.yaml --dataset temperature.csv`
|
||||
- `ludwig forecast -n 10 --model_path results/experiment_run/model --dataset temperature.csv`
|
||||
@@ -0,0 +1,20 @@
|
||||
input_features:
|
||||
- name: Seattle
|
||||
type: timeseries
|
||||
preprocessing:
|
||||
window_size: 10
|
||||
encoder:
|
||||
type: passthrough
|
||||
output_features:
|
||||
- name: Seattle_next
|
||||
type: timeseries
|
||||
column: Seattle
|
||||
preprocessing:
|
||||
horizon: 2
|
||||
combiner:
|
||||
type: concat
|
||||
flatten_inputs: true
|
||||
preprocessing:
|
||||
split:
|
||||
type: datetime
|
||||
column: datetime
|
||||
@@ -0,0 +1,20 @@
|
||||
input_features:
|
||||
- name: genres
|
||||
type: set
|
||||
preprocessing:
|
||||
tokenizer: comma
|
||||
- name: content_rating
|
||||
type: category
|
||||
- name: top_critic
|
||||
type: binary
|
||||
- name: runtime
|
||||
type: number
|
||||
- name: review_content
|
||||
type: text
|
||||
encoder:
|
||||
type: embed
|
||||
output_features:
|
||||
- name: recommended
|
||||
type: binary
|
||||
trainer:
|
||||
epochs: 3
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Fail fast if an error occurs
|
||||
set -e
|
||||
|
||||
# Get the directory of this script, which contains the config file
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
|
||||
# Download the data
|
||||
wget https://ludwig.ai/latest/data/rotten_tomatoes.csv
|
||||
wget https://ludwig.ai/latest/data/rotten_tomatoes_test.csv
|
||||
|
||||
# Check the first 5 rows
|
||||
head -n 5 rotten_tomatoes.csv
|
||||
|
||||
# Train
|
||||
ludwig train --config ${SCRIPT_DIR}/rotten_tomatoes.yaml --dataset rotten_tomatoes.csv
|
||||
|
||||
# Predict and Evaluate
|
||||
ludwig predict --model_path results/experiment_run/model --dataset rotten_tomatoes_test.csv
|
||||
@@ -0,0 +1,73 @@
|
||||
# HyperNetworkCombiner: Conditional Feature Processing
|
||||
|
||||
> **Note:** This example requires PR #4092 to be merged into Ludwig, or `pip install ludwig` >= 0.14.
|
||||
|
||||
[](https://colab.research.google.com/github/ludwig-ai/ludwig/blob/main/examples/hypernetwork/hypernetwork.ipynb)
|
||||
|
||||
## What the HyperNetworkCombiner does differently
|
||||
|
||||
Most combiners — including the default `concat` combiner — treat all input features
|
||||
symmetrically: they encode each feature independently and then merge the resulting
|
||||
vectors (by concatenation, attention, or summation). The merged representation is
|
||||
the same *kind* of computation regardless of what any individual feature says.
|
||||
|
||||
The `hypernetwork` combiner breaks this symmetry. One feature, called the
|
||||
**conditioning feature**, is fed through a small *hyper-network* that generates the
|
||||
weight matrices and biases of the fully-connected layers that process all other features.
|
||||
In other words, the conditioning feature does not just *contribute* to the prediction —
|
||||
it *rewrites the transformation* applied to every other feature before prediction happens.
|
||||
|
||||
This is based on **HyperFusion** (arXiv 2403.13319, 2024).
|
||||
|
||||
```
|
||||
sensor_type ──► HyperNetwork ──► generates weights W, b
|
||||
│
|
||||
sensor_a ─────────────────────► FC(W, b) ──► combined
|
||||
sensor_b ─────────────────────► FC(W, b) ──► repr.
|
||||
sensor_c ─────────────────────► FC(W, b) ──►
|
||||
```
|
||||
|
||||
Contrast with concat:
|
||||
|
||||
```
|
||||
sensor_type ──► encoder ──┐
|
||||
sensor_a ──► encoder ──┤
|
||||
sensor_b ──► encoder ──┼──► concat ──► FC ──► output
|
||||
sensor_c ──► encoder ──┘
|
||||
```
|
||||
|
||||
With `concat`, the network learns *after* combining to react to different sensor types.
|
||||
With `hypernetwork`, the combination itself is conditioned on sensor type.
|
||||
|
||||
## When to use it
|
||||
|
||||
Use the `hypernetwork` combiner when:
|
||||
|
||||
- One feature is a **context** or **mode** that fundamentally changes how other
|
||||
features should be interpreted (sensor type, device class, environment, language).
|
||||
- The relationship between inputs and the target changes qualitatively across groups,
|
||||
not just quantitatively.
|
||||
- You have enough training data to learn the per-context transformations (at minimum a
|
||||
few hundred samples per conditioning category).
|
||||
|
||||
Stick with `concat` when:
|
||||
|
||||
- All input features contribute on equal footing.
|
||||
- The dataset is small (the hyper-network adds parameters).
|
||||
- Interpretability of the encoding step is important and you want a fixed transformation.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Description |
|
||||
| -------------------------- | ------------------------------------------------------------------------- |
|
||||
| `hypernetwork.ipynb` | End-to-end walkthrough with synthetic sensor data |
|
||||
| `config_concat.yaml` | Baseline concat config |
|
||||
| `config_hypernetwork.yaml` | HyperNetworkCombiner config |
|
||||
| `train_hypernetwork.py` | Standalone script — generates data, trains both models, prints comparison |
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
pip install "ludwig>=0.14"
|
||||
python train_hypernetwork.py
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
model_type: ecd
|
||||
|
||||
input_features:
|
||||
- name: sensor_a
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: sensor_b
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: sensor_c
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: sensor_type
|
||||
type: category
|
||||
|
||||
output_features:
|
||||
- name: anomaly
|
||||
type: binary
|
||||
|
||||
combiner:
|
||||
type: concat
|
||||
fc_layers:
|
||||
- output_size: 128
|
||||
- output_size: 64
|
||||
|
||||
trainer:
|
||||
epochs: 30
|
||||
learning_rate: 0.001
|
||||
@@ -0,0 +1,31 @@
|
||||
model_type: ecd
|
||||
|
||||
input_features:
|
||||
- name: sensor_a
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: sensor_b
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: sensor_c
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: sensor_type
|
||||
type: category
|
||||
|
||||
output_features:
|
||||
- name: anomaly
|
||||
type: binary
|
||||
|
||||
combiner:
|
||||
type: hypernetwork
|
||||
hidden_size: 128
|
||||
hyper_hidden_size: 64
|
||||
output_size: 128
|
||||
|
||||
trainer:
|
||||
epochs: 30
|
||||
learning_rate: 0.001
|
||||
@@ -0,0 +1,427 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a1b2c3d4-0001-0000-0000-000000000001",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# HyperNetworkCombiner: Conditional Feature Processing\n",
|
||||
"\n",
|
||||
"[](https://colab.research.google.com/github/ludwig-ai/ludwig/blob/main/examples/hypernetwork/hypernetwork.ipynb)\n",
|
||||
"\n",
|
||||
"> **Note:** This notebook requires **Ludwig >= 0.14** (PR #4092). The `hypernetwork`\n",
|
||||
"> combiner type is not available in earlier releases. Install with:\n",
|
||||
"> `pip install \"ludwig>=0.14\"`\n",
|
||||
"\n",
|
||||
"This notebook demonstrates the `HyperNetworkCombiner`, which lets one feature\n",
|
||||
"(**the conditioning feature**) generate the weights of the layers that process all\n",
|
||||
"other features — rather than simply concatenating everyone together.\n",
|
||||
"\n",
|
||||
"Based on **HyperFusion** ([arXiv 2403.13319](https://arxiv.org/abs/2403.13319), 2024).\n",
|
||||
"\n",
|
||||
"**What we cover:**\n",
|
||||
"\n",
|
||||
"1. Why concatenation is not always enough\n",
|
||||
"2. Generating a synthetic multi-modal sensor dataset\n",
|
||||
"3. Baseline: concat combiner\n",
|
||||
"4. HyperNetworkCombiner\n",
|
||||
"5. Comparing results and understanding why hypernetwork wins"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "a1b2c3d4-0002-0000-0000-000000000002",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!pip install \"ludwig>=0.14\" --quiet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a1b2c3d4-0003-0000-0000-000000000003",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## The problem: context-dependent features\n",
|
||||
"\n",
|
||||
"Imagine a network of industrial sensors. Each sensor reports three readings\n",
|
||||
"(`sensor_a`, `sensor_b`, `sensor_c`), but every sensor belongs to one of three\n",
|
||||
"measurement types: **temperature**, **pressure**, or **humidity**.\n",
|
||||
"\n",
|
||||
"The catch: **the same numerical reading means something completely different**\n",
|
||||
"depending on the sensor type.\n",
|
||||
"\n",
|
||||
"- For a **temperature** sensor, `sensor_a = 3.0` is an anomaly (overheating).\n",
|
||||
"- For a **pressure** sensor, `sensor_a = 3.0` is perfectly normal.\n",
|
||||
"- For a **humidity** sensor, the anomaly rule involves the *sum* of all three readings.\n",
|
||||
"\n",
|
||||
"A concat combiner encodes all four features independently and then stitches them\n",
|
||||
"together. The network has to learn — **after** the concatenation — to undo the mixing\n",
|
||||
"and apply type-specific logic. This is hard because the critical signal (`sensor_type`)\n",
|
||||
"is buried in a shared representation alongside the numerical readings.\n",
|
||||
"\n",
|
||||
"The `hypernetwork` combiner solves this directly:\n",
|
||||
"\n",
|
||||
"```\n",
|
||||
"sensor_type ──► HyperNetwork ──► generates weights W, b\n",
|
||||
" │\n",
|
||||
"sensor_a ────────────────────► FC(W, b) ──►\n",
|
||||
"sensor_b ────────────────────► FC(W, b) ──► combined repr.\n",
|
||||
"sensor_c ────────────────────► FC(W, b) ──►\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"`sensor_type` does not contribute a feature vector — it **rewrites the entire\n",
|
||||
"transformation** applied to the numerical sensors."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a1b2c3d4-0004-0000-0000-000000000004",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Dataset\n",
|
||||
"\n",
|
||||
"We generate a synthetic dataset with three sensor types. Each type has its own\n",
|
||||
"normal operating range and its own anomaly rule, making the sensor type an\n",
|
||||
"essential piece of context for correct classification."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "a1b2c3d4-0005-0000-0000-000000000005",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import numpy as np\n",
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"RNG = np.random.default_rng(42)\n",
|
||||
"\n",
|
||||
"N_PER_TYPE = 600\n",
|
||||
"SENSOR_TYPES = [\"temperature\", \"pressure\", \"humidity\"]\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def make_samples(sensor_type: str, n: int, rng: np.random.Generator) -> pd.DataFrame:\n",
|
||||
" \"\"\"Generate n samples for a single sensor type with type-specific anomaly rules.\"\"\"\n",
|
||||
" if sensor_type == \"temperature\":\n",
|
||||
" # Normal: readings near 0; anomaly: sensor_a > 2.5 (overheating)\n",
|
||||
" sensor_a = rng.normal(0.0, 1.0, n)\n",
|
||||
" sensor_b = rng.normal(0.0, 1.0, n)\n",
|
||||
" sensor_c = rng.normal(0.0, 1.0, n)\n",
|
||||
" anomaly = (sensor_a > 2.5).astype(int)\n",
|
||||
" elif sensor_type == \"pressure\":\n",
|
||||
" # Normal: readings near 1; anomaly: sensor_b drops below -0.5 (leak)\n",
|
||||
" sensor_a = rng.normal(1.0, 0.8, n)\n",
|
||||
" sensor_b = rng.normal(1.0, 0.8, n)\n",
|
||||
" sensor_c = rng.normal(1.0, 0.8, n)\n",
|
||||
" anomaly = (sensor_b < -0.5).astype(int)\n",
|
||||
" else: # humidity\n",
|
||||
" # Normal: readings near -1; anomaly: combined level exceeds threshold\n",
|
||||
" sensor_a = rng.normal(-1.0, 0.9, n)\n",
|
||||
" sensor_b = rng.normal(-1.0, 0.9, n)\n",
|
||||
" sensor_c = rng.normal(-1.0, 0.9, n)\n",
|
||||
" anomaly = ((sensor_a + sensor_b + sensor_c) > 0).astype(int)\n",
|
||||
"\n",
|
||||
" return pd.DataFrame(\n",
|
||||
" {\n",
|
||||
" \"sensor_a\": sensor_a,\n",
|
||||
" \"sensor_b\": sensor_b,\n",
|
||||
" \"sensor_c\": sensor_c,\n",
|
||||
" \"sensor_type\": sensor_type,\n",
|
||||
" \"anomaly\": anomaly,\n",
|
||||
" }\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"frames = [make_samples(t, N_PER_TYPE, RNG) for t in SENSOR_TYPES]\n",
|
||||
"df = pd.concat(frames, ignore_index=True).sample(frac=1, random_state=42).reset_index(drop=True)\n",
|
||||
"\n",
|
||||
"# Train / validation / test split (70 / 15 / 15)\n",
|
||||
"n = len(df)\n",
|
||||
"split = np.full(n, 2, dtype=int) # default: test\n",
|
||||
"idx = np.arange(n)\n",
|
||||
"RNG.shuffle(idx)\n",
|
||||
"split[idx[: int(0.70 * n)]] = 0\n",
|
||||
"split[idx[int(0.70 * n) : int(0.85 * n)]] = 1\n",
|
||||
"df[\"split\"] = split\n",
|
||||
"\n",
|
||||
"print(f\"Total rows: {n}\")\n",
|
||||
"print(f\"Overall anomaly rate: {df['anomaly'].mean():.1%}\")\n",
|
||||
"print()\n",
|
||||
"print(\"Anomaly rate by sensor type:\")\n",
|
||||
"print(df.groupby(\"sensor_type\")[\"anomaly\"].mean().rename(\"anomaly_rate\"))\n",
|
||||
"print()\n",
|
||||
"df.head(10)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a1b2c3d4-0006-0000-0000-000000000006",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Notice that the three sensor types have **different anomaly rates** and, more\n",
|
||||
"importantly, the anomaly rules are structurally different. The same value of\n",
|
||||
"`sensor_a = 3.0` triggers an anomaly for `temperature` but not for `pressure`.\n",
|
||||
"A model that treats all features symmetrically will struggle with this."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a1b2c3d4-0007-0000-0000-000000000007",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Baseline: concat combiner\n",
|
||||
"\n",
|
||||
"The default Ludwig combiner concatenates all encoder outputs and passes them\n",
|
||||
"through fully-connected layers. `sensor_type` is just another input — its\n",
|
||||
"embedding is concatenated alongside the numerical sensor values."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "a1b2c3d4-0008-0000-0000-000000000008",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import yaml\n",
|
||||
"\n",
|
||||
"from ludwig.api import LudwigModel\n",
|
||||
"\n",
|
||||
"config_concat_str = \"\"\"\n",
|
||||
"model_type: ecd\n",
|
||||
"\n",
|
||||
"input_features:\n",
|
||||
" - name: sensor_a\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
" - name: sensor_b\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
" - name: sensor_c\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
" - name: sensor_type\n",
|
||||
" type: category\n",
|
||||
"\n",
|
||||
"output_features:\n",
|
||||
" - name: anomaly\n",
|
||||
" type: binary\n",
|
||||
"\n",
|
||||
"combiner:\n",
|
||||
" type: concat\n",
|
||||
" fc_layers:\n",
|
||||
" - output_size: 128\n",
|
||||
" - output_size: 64\n",
|
||||
"\n",
|
||||
"trainer:\n",
|
||||
" epochs: 30\n",
|
||||
" learning_rate: 0.001\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"config_concat = yaml.safe_load(config_concat_str)\n",
|
||||
"\n",
|
||||
"model_concat = LudwigModel(config_concat, logging_level=30)\n",
|
||||
"model_concat.train(dataset=df)\n",
|
||||
"\n",
|
||||
"test_df = df[df[\"split\"] == 2].copy()\n",
|
||||
"preds_concat, _ = model_concat.predict(dataset=test_df)\n",
|
||||
"\n",
|
||||
"acc_concat = (preds_concat[\"anomaly_predictions\"].values == test_df[\"anomaly\"].values).mean()\n",
|
||||
"print(f\"Concat combiner — test accuracy: {acc_concat:.4f}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a1b2c3d4-0009-0000-0000-000000000009",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## HyperNetworkCombiner\n",
|
||||
"\n",
|
||||
"Now we switch to `type: hypernetwork`. The combiner reads `sensor_type` (the last\n",
|
||||
"feature listed in `input_features`) through a hyper-network and uses the output to\n",
|
||||
"generate the weight matrix and bias of the layer that processes `sensor_a`,\n",
|
||||
"`sensor_b`, and `sensor_c`.\n",
|
||||
"\n",
|
||||
"Key parameters:\n",
|
||||
"\n",
|
||||
"| Parameter | Role |\n",
|
||||
"|---|---|\n",
|
||||
"| `hidden_size` | Size of the main processing layer |\n",
|
||||
"| `hyper_hidden_size` | Hidden size of the hyper-network itself |\n",
|
||||
"| `output_size` | Dimension of the combined representation passed to decoders |"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "a1b2c3d4-0010-0000-0000-000000000010",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"config_hypernetwork_str = \"\"\"\n",
|
||||
"model_type: ecd\n",
|
||||
"\n",
|
||||
"input_features:\n",
|
||||
" - name: sensor_a\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
" - name: sensor_b\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
" - name: sensor_c\n",
|
||||
" type: number\n",
|
||||
" preprocessing:\n",
|
||||
" normalization: zscore\n",
|
||||
" - name: sensor_type\n",
|
||||
" type: category\n",
|
||||
"\n",
|
||||
"output_features:\n",
|
||||
" - name: anomaly\n",
|
||||
" type: binary\n",
|
||||
"\n",
|
||||
"combiner:\n",
|
||||
" type: hypernetwork\n",
|
||||
" hidden_size: 128\n",
|
||||
" hyper_hidden_size: 64\n",
|
||||
" output_size: 128\n",
|
||||
"\n",
|
||||
"trainer:\n",
|
||||
" epochs: 30\n",
|
||||
" learning_rate: 0.001\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"config_hypernetwork = yaml.safe_load(config_hypernetwork_str)\n",
|
||||
"\n",
|
||||
"model_hypernetwork = LudwigModel(config_hypernetwork, logging_level=30)\n",
|
||||
"model_hypernetwork.train(dataset=df)\n",
|
||||
"\n",
|
||||
"preds_hyper, _ = model_hypernetwork.predict(dataset=test_df)\n",
|
||||
"\n",
|
||||
"acc_hyper = (preds_hyper[\"anomaly_predictions\"].values == test_df[\"anomaly\"].values).mean()\n",
|
||||
"print(f\"HyperNetworkCombiner — test accuracy: {acc_hyper:.4f}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a1b2c3d4-0011-0000-0000-000000000011",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Comparison"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "a1b2c3d4-0012-0000-0000-000000000012",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"\n",
|
||||
"# Per-type breakdown\n",
|
||||
"rows = []\n",
|
||||
"for stype in SENSOR_TYPES:\n",
|
||||
" mask = test_df[\"sensor_type\"] == stype\n",
|
||||
" true = test_df.loc[mask, \"anomaly\"].values\n",
|
||||
"\n",
|
||||
" acc_c = (preds_concat.loc[mask, \"anomaly_predictions\"].values == true).mean()\n",
|
||||
" acc_h = (preds_hyper.loc[mask, \"anomaly_predictions\"].values == true).mean()\n",
|
||||
" rows.append({\"sensor_type\": stype, \"concat\": round(acc_c, 4), \"hypernetwork\": round(acc_h, 4)})\n",
|
||||
"\n",
|
||||
"rows.append({\"sensor_type\": \"OVERALL\", \"concat\": round(acc_concat, 4), \"hypernetwork\": round(acc_hyper, 4)})\n",
|
||||
"\n",
|
||||
"results_df = pd.DataFrame(rows)\n",
|
||||
"print(results_df.to_string(index=False))\n",
|
||||
"\n",
|
||||
"# Bar chart\n",
|
||||
"fig, ax = plt.subplots(figsize=(8, 4))\n",
|
||||
"x = np.arange(len(rows))\n",
|
||||
"width = 0.35\n",
|
||||
"ax.bar(x - width / 2, results_df[\"concat\"], width, label=\"Concat\", color=\"steelblue\")\n",
|
||||
"ax.bar(x + width / 2, results_df[\"hypernetwork\"], width, label=\"HyperNetwork\", color=\"darkorange\")\n",
|
||||
"ax.set_xticks(x)\n",
|
||||
"ax.set_xticklabels(results_df[\"sensor_type\"])\n",
|
||||
"ax.set_ylabel(\"Test accuracy\")\n",
|
||||
"ax.set_ylim(0.5, 1.0)\n",
|
||||
"ax.set_title(\"Sensor anomaly detection: concat vs HyperNetworkCombiner\")\n",
|
||||
"ax.legend()\n",
|
||||
"plt.tight_layout()\n",
|
||||
"plt.show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a1b2c3d4-0013-0000-0000-000000000013",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Why hypernetwork wins\n",
|
||||
"\n",
|
||||
"### The problem with concatenation\n",
|
||||
"\n",
|
||||
"When we use `concat`, the model receives a vector like:\n",
|
||||
"\n",
|
||||
"```\n",
|
||||
"[enc(sensor_a), enc(sensor_b), enc(sensor_c), enc(sensor_type)]\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"The fully-connected layers after the concat have to learn — from scratch — that\n",
|
||||
"`enc(sensor_type)` should *gate* the interpretation of the numerical sensors.\n",
|
||||
"Effectively the network must implement a conditional logic as a series of\n",
|
||||
"multiplications and additions over the entire concatenated vector. This is\n",
|
||||
"theoretically possible but inefficient: many capacity-bearing parameters in the\n",
|
||||
"FC layers end up implementing the routing rather than the actual anomaly detection.\n",
|
||||
"\n",
|
||||
"### What hypernetwork does instead\n",
|
||||
"\n",
|
||||
"The `HyperNetworkCombiner` separates the two roles explicitly:\n",
|
||||
"\n",
|
||||
"1. **Hyper-network** — a small MLP that reads `sensor_type` and emits a vector\n",
|
||||
" of weights `W` and biases `b`.\n",
|
||||
"2. **Main network** — a linear layer `FC(W, b)` applied to the concatenation of\n",
|
||||
" `[sensor_a, sensor_b, sensor_c]` using the *dynamically generated* `W` and `b`.\n",
|
||||
"\n",
|
||||
"Because `W` and `b` are different for each sensor type, the transformation\n",
|
||||
"applied to the numerical sensors is literally different per context. For\n",
|
||||
"`temperature`, the generated `W` learns to make `sensor_a` highly predictive;\n",
|
||||
"for `pressure`, the generated `W` shifts attention to `sensor_b`; for\n",
|
||||
"`humidity`, it learns to combine all three.\n",
|
||||
"\n",
|
||||
"### When to use it\n",
|
||||
"\n",
|
||||
"- The conditioning feature is a **type, class, mode, or context** that changes\n",
|
||||
" the semantics of other features qualitatively.\n",
|
||||
"- You have enough samples per conditioning category (roughly 200+ per class) to\n",
|
||||
" learn meaningful per-context transformations.\n",
|
||||
"- The target signal requires different logic for different contexts, not just\n",
|
||||
" different magnitudes.\n",
|
||||
"\n",
|
||||
"### When to stick with concat\n",
|
||||
"\n",
|
||||
"- All features contribute on equal footing with no hierarchical conditioning.\n",
|
||||
"- The dataset is very small — the hyper-network adds parameters.\n",
|
||||
"- The extra complexity is not warranted (always start simple)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.12.0"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
# Colab: !pip install "ludwig>=0.14"
|
||||
"""HyperNetworkCombiner vs concat — sensor anomaly detection.
|
||||
|
||||
Generates a synthetic multi-modal sensor dataset where the correct interpretation
|
||||
of numerical sensor readings depends entirely on the sensor type (temperature,
|
||||
pressure, or humidity). Trains a baseline concat model and a HyperNetworkCombiner
|
||||
model, then prints an accuracy comparison.
|
||||
|
||||
NOTE: Requires ludwig >= 0.14 (PR #4092). The hypernetwork combiner is not
|
||||
available in earlier versions.
|
||||
|
||||
Usage:
|
||||
python train_hypernetwork.py
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from ludwig.api import LudwigModel
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Generate synthetic sensor data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RNG = np.random.default_rng(42)
|
||||
|
||||
N_PER_TYPE = 600
|
||||
SENSOR_TYPES = ["temperature", "pressure", "humidity"]
|
||||
|
||||
|
||||
def make_samples(sensor_type: str, n: int, rng: np.random.Generator) -> pd.DataFrame:
|
||||
"""Generate n samples for a single sensor type.
|
||||
|
||||
Each type has its own 'normal' operating range and anomaly rule so that the same raw reading can mean very different
|
||||
things depending on the type.
|
||||
"""
|
||||
if sensor_type == "temperature":
|
||||
# Normal: sensors cluster near (0, 0, 0); anomaly: sensor_a > 2.5
|
||||
sensor_a = rng.normal(0.0, 1.0, n)
|
||||
sensor_b = rng.normal(0.0, 1.0, n)
|
||||
sensor_c = rng.normal(0.0, 1.0, n)
|
||||
anomaly = (sensor_a > 2.5).astype(int)
|
||||
elif sensor_type == "pressure":
|
||||
# Normal: sensors cluster near (1, 1, 1); anomaly: sensor_b < -1.5
|
||||
sensor_a = rng.normal(1.0, 0.8, n)
|
||||
sensor_b = rng.normal(1.0, 0.8, n)
|
||||
sensor_c = rng.normal(1.0, 0.8, n)
|
||||
anomaly = (sensor_b < -0.5).astype(int)
|
||||
else: # humidity
|
||||
# Normal: sensors cluster near (-1, -1, -1); anomaly: sum > 0
|
||||
sensor_a = rng.normal(-1.0, 0.9, n)
|
||||
sensor_b = rng.normal(-1.0, 0.9, n)
|
||||
sensor_c = rng.normal(-1.0, 0.9, n)
|
||||
anomaly = ((sensor_a + sensor_b + sensor_c) > 0).astype(int)
|
||||
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"sensor_a": sensor_a,
|
||||
"sensor_b": sensor_b,
|
||||
"sensor_c": sensor_c,
|
||||
"sensor_type": sensor_type,
|
||||
"anomaly": anomaly,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
frames = [make_samples(t, N_PER_TYPE, RNG) for t in SENSOR_TYPES]
|
||||
df = pd.concat(frames, ignore_index=True).sample(frac=1, random_state=42).reset_index(drop=True)
|
||||
|
||||
# Train / validation / test split (70 / 15 / 15)
|
||||
n = len(df)
|
||||
split = np.full(n, 2, dtype=int) # default: test
|
||||
idx = np.arange(n)
|
||||
RNG.shuffle(idx)
|
||||
split[idx[: int(0.70 * n)]] = 0
|
||||
split[idx[int(0.70 * n) : int(0.85 * n)]] = 1
|
||||
df["split"] = split
|
||||
|
||||
print(f"Dataset: {n} rows ({df['anomaly'].mean():.1%} anomalies)")
|
||||
print(df.groupby("sensor_type")["anomaly"].mean().rename("anomaly_rate").to_string())
|
||||
print()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Model configs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INPUT_FEATURES = [
|
||||
{"name": "sensor_a", "type": "number", "preprocessing": {"normalization": "zscore"}},
|
||||
{"name": "sensor_b", "type": "number", "preprocessing": {"normalization": "zscore"}},
|
||||
{"name": "sensor_c", "type": "number", "preprocessing": {"normalization": "zscore"}},
|
||||
{"name": "sensor_type", "type": "category"},
|
||||
]
|
||||
|
||||
OUTPUT_FEATURES = [{"name": "anomaly", "type": "binary"}]
|
||||
|
||||
TRAINER = {"epochs": 30, "learning_rate": 0.001}
|
||||
|
||||
config_concat = {
|
||||
"model_type": "ecd",
|
||||
"input_features": INPUT_FEATURES,
|
||||
"output_features": OUTPUT_FEATURES,
|
||||
"combiner": {
|
||||
"type": "concat",
|
||||
"fc_layers": [{"output_size": 128}, {"output_size": 64}],
|
||||
},
|
||||
"trainer": TRAINER,
|
||||
}
|
||||
|
||||
config_hypernetwork = {
|
||||
"model_type": "ecd",
|
||||
"input_features": INPUT_FEATURES,
|
||||
"output_features": OUTPUT_FEATURES,
|
||||
"combiner": {
|
||||
"type": "hypernetwork",
|
||||
"hidden_size": 128,
|
||||
"hyper_hidden_size": 64,
|
||||
"output_size": 128,
|
||||
},
|
||||
"trainer": TRAINER,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Train and evaluate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
results = []
|
||||
|
||||
for label, config in [("Concat (baseline)", config_concat), ("HyperNetwork", config_hypernetwork)]:
|
||||
print(f"{'=' * 60}")
|
||||
print(f"Training: {label}")
|
||||
print("=" * 60)
|
||||
|
||||
model = LudwigModel(config, logging_level=30)
|
||||
results_obj = model.train(dataset=df)
|
||||
print(f" Saved to: {results_obj.output_directory}")
|
||||
|
||||
test_df = df[df["split"] == 2].copy()
|
||||
predictions, _ = model.predict(dataset=test_df)
|
||||
|
||||
pred_col = "anomaly_predictions"
|
||||
correct = (predictions[pred_col].values == test_df["anomaly"].values).mean()
|
||||
|
||||
results.append({"Model": label, "Test accuracy": round(float(correct), 4)})
|
||||
print(f" Test accuracy: {correct:.4f}\n")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Print summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
results_df = pd.DataFrame(results)
|
||||
|
||||
print("=" * 50)
|
||||
print("SENSOR ANOMALY DETECTION — SUMMARY")
|
||||
print("=" * 50)
|
||||
print(results_df.to_string(index=False))
|
||||
print("=" * 50)
|
||||
print()
|
||||
print("The HyperNetworkCombiner lets sensor_type rewrite the")
|
||||
print("transformation applied to sensor_a/b/c rather than")
|
||||
print("just concatenating all features together.")
|
||||
@@ -0,0 +1,28 @@
|
||||
# Hyperparameter Optimization
|
||||
|
||||
Demonstrates hyperparameter optimization using Ludwig's in-built capabilities.
|
||||
|
||||
### Preparatory Steps
|
||||
|
||||
- Create `data` directory
|
||||
- Download [Kaggle wine quality data set](https://www.kaggle.com/rajyellow46/wine-quality) into the `data` directory. Directory should
|
||||
appear as follows:
|
||||
|
||||
```
|
||||
hyperopt/
|
||||
data/
|
||||
winequalityN.csv
|
||||
```
|
||||
|
||||
### Description
|
||||
|
||||
Jupyter notebook `model_hyperopt_example.ipynb` demonstrates several hyperparameter optimization capabilities. Key features demonstrated in the notebook:
|
||||
|
||||
- Training data is prepared for use
|
||||
- Programmatically create Ludwig config dictionary from the training data dataframe
|
||||
- Setup parameter space for hyperparameter optimization
|
||||
- Perform two hyperparameter runs
|
||||
- Parallel workers using random search strategy
|
||||
- Serial processing using random search strategy
|
||||
- Parallel workers using grid search strategy (Note: takes about 35 minutes)
|
||||
- Demonstrate various Ludwig visualizations for hyperparameter optimization
|
||||
@@ -0,0 +1,105 @@
|
||||
# Native Optuna Hyperparameter Optimization
|
||||
|
||||
> **Requires Ludwig 0.15 / PR #4090 (data-pipeline-hyperopt-modernization branch).**
|
||||
|
||||
Ludwig 0.15 adds a native Optuna executor that runs HPO trials directly without requiring
|
||||
Ray Tune. This is the right choice for single-machine HPO: you get AutoSampler, GPSampler
|
||||
(Bayesian optimization), TPE, CMA-ES, median / Hyperband pruning, SQLite-backed resumable
|
||||
studies, and the optional Optuna dashboard — without the overhead of a Ray cluster.
|
||||
|
||||
If you need distributed trials across many GPUs or nodes, keep using the `ray` executor
|
||||
(it wraps `OptunaSearch` as its search algorithm). The native executor in this tutorial is
|
||||
faster, simpler, and single-process.
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
hyperopt:
|
||||
executor:
|
||||
type: optuna
|
||||
num_samples: 50 # how many trials to run
|
||||
sampler: auto # auto | gp | tpe | cmaes | random
|
||||
pruner: null # null | median | hyperband (optional early stopping)
|
||||
study_name: ludwig_wine_rmse
|
||||
storage: null # or sqlite:///wine_hpo.db to persist and resume
|
||||
time_budget_s: 1800
|
||||
|
||||
parameters:
|
||||
trainer.learning_rate:
|
||||
space: loguniform
|
||||
lower: 1e-5
|
||||
upper: 1e-1
|
||||
trainer.batch_size:
|
||||
space: int
|
||||
lower: 32
|
||||
upper: 256
|
||||
combiner.num_fc_layers:
|
||||
space: int
|
||||
lower: 1
|
||||
upper: 4
|
||||
combiner.output_size:
|
||||
space: choice
|
||||
categories: [32, 64, 128, 256]
|
||||
|
||||
output_feature: quality
|
||||
metric: root_mean_squared_error
|
||||
goal: minimize
|
||||
split: validation
|
||||
```
|
||||
|
||||
### Sampler options
|
||||
|
||||
| `sampler` | Description | Rule of thumb |
|
||||
| --------- | -------------------------------------------------------- | -------------------------------- |
|
||||
| `auto` | Optuna AutoSampler (falls back to TPE on older versions) | Default choice |
|
||||
| `gp` | Gaussian-Process Bayesian optimization | Continuous spaces, \<100 trials |
|
||||
| `tpe` | Tree-structured Parzen Estimator | Mixed spaces, 50–500 trials |
|
||||
| `cmaes` | Covariance Matrix Adaptation Evolution Strategy | Purely-continuous, medium budget |
|
||||
| `random` | Random search (sanity-check baseline) | Sanity check |
|
||||
|
||||
### Persistence and resuming
|
||||
|
||||
Set `storage: sqlite:///wine_hpo.db` to persist trials to disk. Re-running with the same
|
||||
`study_name` continues the study — failed trials are retried, successful trials are kept.
|
||||
|
||||
### Pruning
|
||||
|
||||
Set `pruner: median` or `pruner: hyperband` to stop clearly-losing trials early. Requires
|
||||
the model code to report intermediate values back (Ludwig's Optuna integration reports the
|
||||
validation metric at each epoch so this works out of the box).
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
pip install 'ludwig[hyperopt]' # pulls in optuna
|
||||
python optuna_executor.py
|
||||
```
|
||||
|
||||
Expected output (numbers are illustrative):
|
||||
|
||||
```
|
||||
[Optuna] Best trial:
|
||||
value: 0.6184
|
||||
params:
|
||||
trainer.learning_rate: 0.0032
|
||||
trainer.batch_size: 64
|
||||
combiner.num_fc_layers: 2
|
||||
combiner.output_size: 128
|
||||
completed in: 412.8s
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | Description |
|
||||
| -------------------- | ----------------------------------------------------- |
|
||||
| `config_optuna.yaml` | Full hyperopt config using the native Optuna executor |
|
||||
| `optuna_executor.py` | Runs `ludwig.hyperopt` with the above config |
|
||||
| `README_optuna.md` | This file |
|
||||
|
||||
## References
|
||||
|
||||
- Optuna — Akiba et al., "Optuna: A Next-generation Hyperparameter Optimization Framework",
|
||||
KDD 2019. <https://arxiv.org/abs/1907.10902>
|
||||
- AutoSampler — Optuna v4 AutoSampler documentation.
|
||||
- Hyperband — Li et al., "Hyperband: A Novel Bandit-Based Approach to Hyperparameter
|
||||
Optimization", JMLR 2018. <https://arxiv.org/abs/1603.06560>
|
||||
@@ -0,0 +1,83 @@
|
||||
model_type: ecd
|
||||
|
||||
input_features:
|
||||
- name: fixed acidity
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: volatile acidity
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: citric acid
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: residual sugar
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: chlorides
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: free sulfur dioxide
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: total sulfur dioxide
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: density
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: pH
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: sulphates
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
- name: alcohol
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
|
||||
output_features:
|
||||
- name: quality
|
||||
type: binary
|
||||
|
||||
trainer:
|
||||
epochs: 20
|
||||
|
||||
# NOTE: The Optuna executor requires PR #4090 to be merged, or Ludwig >= 0.14.
|
||||
# Install: pip install ludwig optuna
|
||||
hyperopt:
|
||||
executor:
|
||||
type: optuna
|
||||
num_samples: 20
|
||||
sampler: auto # auto selects the best sampler; also: tpe, gp, cmaes, random
|
||||
pruner: hyperband # stop unpromising trials early (MedianPruner also supported)
|
||||
storage: sqlite:///optuna_results.db # enables resumability across runs
|
||||
parameters:
|
||||
trainer.learning_rate:
|
||||
space: loguniform
|
||||
lower: 1.0e-5
|
||||
upper: 1.0e-2
|
||||
trainer.batch_size:
|
||||
space: int
|
||||
lower: 16
|
||||
upper: 256
|
||||
trainer.optimizer.type:
|
||||
space: choice
|
||||
categories:
|
||||
- adam
|
||||
- adamw
|
||||
- radam
|
||||
- schedule_free_adamw
|
||||
goal: minimize
|
||||
metric: validation.combined.loss
|
||||
split: validation
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,453 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "7fb27b941602401d91542211134fc71a",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Hyperparameter Optimization with Native Optuna\n",
|
||||
"\n",
|
||||
"This notebook shows how to run Ludwig hyperparameter optimization using the\n",
|
||||
"**native Optuna executor** introduced in PR #4090.\n",
|
||||
"\n",
|
||||
"> **Note:** Requires PR #4090 to be merged, or `pip install ludwig` >= 0.14.\n",
|
||||
"\n",
|
||||
"[](https://colab.research.google.com/github/ludwig-ai/ludwig/blob/main/examples/hyperopt/optuna_executor.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "acae54e37e7d407bbb7b55eff062a284",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!pip install ludwig optuna --quiet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "9a63283cbaf04dbcab1f6479b197f3a8",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"> **Dependency note:** The `optuna` executor type (`hyperopt.executor.type: optuna`) is\n",
|
||||
"> available from **Ludwig >= 0.14** (merged in PR #4090). Earlier versions only ship the\n",
|
||||
"> Ray Tune executor. To use this notebook with the development branch:\n",
|
||||
">\n",
|
||||
"> ```bash\n",
|
||||
"> pip install git+https://github.com/ludwig-ai/ludwig.git@data-pipeline-hyperopt-modernization\n",
|
||||
"> ```"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "8dd0d8092fe74a7c96281538738b07e2",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Dataset\n",
|
||||
"\n",
|
||||
"We use the [UCI Wine Quality dataset](https://archive.ics.uci.edu/ml/datasets/wine+quality).\n",
|
||||
"It contains physicochemical measurements for red and white wines. We combine both files\n",
|
||||
"and create a **binary classification** target: `quality >= 7` → `1` (high quality),\n",
|
||||
"otherwise `0`."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "72eea5119410473aa328ad9291626812",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import pathlib\n",
|
||||
"import urllib.request\n",
|
||||
"\n",
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"DATA_DIR = pathlib.Path(\"data\")\n",
|
||||
"DATA_DIR.mkdir(exist_ok=True)\n",
|
||||
"\n",
|
||||
"WHITE_URL = \"https://archive.ics.uci.edu/ml/machine-learning-databases/wine-quality/winequality-white.csv\"\n",
|
||||
"RED_URL = \"https://archive.ics.uci.edu/ml/machine-learning-databases/wine-quality/winequality-red.csv\"\n",
|
||||
"\n",
|
||||
"combined_path = DATA_DIR / \"wine_quality.csv\"\n",
|
||||
"\n",
|
||||
"if not combined_path.exists():\n",
|
||||
" print(\"Downloading …\")\n",
|
||||
" urllib.request.urlretrieve(WHITE_URL, DATA_DIR / \"winequality-white.csv\")\n",
|
||||
" urllib.request.urlretrieve(RED_URL, DATA_DIR / \"winequality-red.csv\")\n",
|
||||
"\n",
|
||||
" white = pd.read_csv(DATA_DIR / \"winequality-white.csv\", sep=\";\")\n",
|
||||
" red = pd.read_csv(DATA_DIR / \"winequality-red.csv\", sep=\";\")\n",
|
||||
" df = pd.concat([white, red], ignore_index=True)\n",
|
||||
" df[\"quality\"] = (df[\"quality\"] >= 7).astype(int)\n",
|
||||
" df.to_csv(combined_path, index=False)\n",
|
||||
"else:\n",
|
||||
" df = pd.read_csv(combined_path)\n",
|
||||
"\n",
|
||||
"print(f\"{len(df)} rows | {df['quality'].mean():.1%} positive (quality >= 7)\")\n",
|
||||
"df.head()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "8edb47106e1a46a883d545849b8ab81b",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Define search space\n",
|
||||
"\n",
|
||||
"The `hyperopt` section of the Ludwig config specifies:\n",
|
||||
"\n",
|
||||
"- **executor** — which HPO backend to use and how many trials to run\n",
|
||||
"- **parameters** — the search space for each hyperparameter\n",
|
||||
"- **goal / metric** — what to optimise\n",
|
||||
"\n",
|
||||
"The Optuna executor supports the following `space` types:\n",
|
||||
"\n",
|
||||
"| Space | Ludwig key | Description |\n",
|
||||
"|---|---|---|\n",
|
||||
"| Log-uniform float | `loguniform` | Continuous on log scale — ideal for learning rates |\n",
|
||||
"| Uniform float | `float` | Continuous on linear scale — ideal for dropout |\n",
|
||||
"| Integer | `int` | Integer range, linear scale |\n",
|
||||
"| Categorical | `choice` | Discrete set of values |\n",
|
||||
"| Grid | `grid_search` | Exhaustive grid over a list of values |"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "10185d26023b46108eb7d9f57d49d2b3",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Build feature list dynamically from the dataframe\n",
|
||||
"feature_cols = [c for c in df.columns if c != \"quality\"]\n",
|
||||
"\n",
|
||||
"config = {\n",
|
||||
" \"model_type\": \"ecd\",\n",
|
||||
" \"input_features\": [\n",
|
||||
" {\"name\": col, \"type\": \"number\", \"preprocessing\": {\"normalization\": \"zscore\"}} for col in feature_cols\n",
|
||||
" ],\n",
|
||||
" \"output_features\": [\n",
|
||||
" {\"name\": \"quality\", \"type\": \"binary\"},\n",
|
||||
" ],\n",
|
||||
" \"trainer\": {\n",
|
||||
" \"epochs\": 20,\n",
|
||||
" },\n",
|
||||
" # NOTE: type: optuna requires Ludwig >= 0.14 (PR #4090)\n",
|
||||
" \"hyperopt\": {\n",
|
||||
" \"executor\": {\n",
|
||||
" \"type\": \"optuna\",\n",
|
||||
" \"num_samples\": 20,\n",
|
||||
" \"sampler\": \"auto\", # auto, tpe, gp, cmaes, random\n",
|
||||
" \"pruner\": \"hyperband\", # stop bad trials early\n",
|
||||
" },\n",
|
||||
" \"parameters\": {\n",
|
||||
" \"trainer.learning_rate\": {\n",
|
||||
" \"space\": \"loguniform\",\n",
|
||||
" \"lower\": 1e-5,\n",
|
||||
" \"upper\": 1e-2,\n",
|
||||
" },\n",
|
||||
" \"trainer.batch_size\": {\n",
|
||||
" \"space\": \"int\",\n",
|
||||
" \"lower\": 16,\n",
|
||||
" \"upper\": 256,\n",
|
||||
" },\n",
|
||||
" \"trainer.optimizer.type\": {\n",
|
||||
" \"space\": \"choice\",\n",
|
||||
" \"categories\": [\"adam\", \"adamw\", \"radam\", \"schedule_free_adamw\"],\n",
|
||||
" },\n",
|
||||
" \"combiner.dropout\": {\n",
|
||||
" \"space\": \"float\",\n",
|
||||
" \"lower\": 0.0,\n",
|
||||
" \"upper\": 0.5,\n",
|
||||
" },\n",
|
||||
" },\n",
|
||||
" \"goal\": \"minimize\",\n",
|
||||
" \"metric\": \"validation.combined.loss\",\n",
|
||||
" \"split\": \"validation\",\n",
|
||||
" },\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"import json\n",
|
||||
"\n",
|
||||
"print(json.dumps(config[\"hyperopt\"], indent=2))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "8763a12b2bbd4a93a75aff182afb95dc",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Run HPO\n",
|
||||
"\n",
|
||||
"`LudwigModel.hyperopt()` runs the full HPO loop and returns a list of trial results."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "7623eae2785240b9bd12b16a66d81610",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from ludwig.api import LudwigModel\n",
|
||||
"\n",
|
||||
"model = LudwigModel(config=config, logging_level=20)\n",
|
||||
"\n",
|
||||
"hyperopt_results, output_dir, _ = model.hyperopt(\n",
|
||||
" dataset=str(combined_path),\n",
|
||||
" output_directory=\"hyperopt_output\",\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"print(f\"\\nCompleted {len(hyperopt_results)} trials. Output: {output_dir}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "7cdc8c89c7104fffa095e18ddfef8986",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Show top-5 trials sorted by metric\n",
|
||||
"sorted_results = sorted(hyperopt_results, key=lambda r: r.get(\"metric_score\", float(\"inf\")))\n",
|
||||
"\n",
|
||||
"rows = []\n",
|
||||
"for i, r in enumerate(sorted_results[:5]):\n",
|
||||
" row = {\"rank\": i + 1, \"loss\": round(r.get(\"metric_score\", float(\"nan\")), 5)}\n",
|
||||
" row.update(r.get(\"parameters\", {}))\n",
|
||||
" rows.append(row)\n",
|
||||
"\n",
|
||||
"pd.DataFrame(rows)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "b118ea5561624da68c537baed56e602f",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Sampler comparison\n",
|
||||
"\n",
|
||||
"Ludwig's Optuna executor exposes all of Optuna's built-in samplers via the `sampler` key.\n",
|
||||
"\n",
|
||||
"| Sampler | Key | Best for |\n",
|
||||
"|---|---|---|\n",
|
||||
"| **Auto** | `auto` | Default — Optuna selects the best sampler based on search space type |\n",
|
||||
"| **TPE** | `tpe` | General purpose; efficient with < 100 trials; the classic Optuna default |\n",
|
||||
"| **CMA-ES** | `cmaes` | Continuous spaces with many parameters; covariance matrix adaptation |\n",
|
||||
"| **GP (BoTorch)** | `gp` | Sample-efficient Bayesian optimisation; requires `pip install botorch` |\n",
|
||||
"| **Random** | `random` | Baseline; useful for ablations or very large search spaces |\n",
|
||||
"\n",
|
||||
"Change the sampler by editing the executor block:\n",
|
||||
"\n",
|
||||
"```python\n",
|
||||
"\"executor\": {\n",
|
||||
" \"type\": \"optuna\",\n",
|
||||
" \"num_samples\": 50,\n",
|
||||
" \"sampler\": \"tpe\", # <-- change this\n",
|
||||
"}\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"For GP, install the optional dependency first:\n",
|
||||
"\n",
|
||||
"```bash\n",
|
||||
"pip install botorch\n",
|
||||
"```"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "938c804e27f84196a10c8828c723f798",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Resumable HPO with SQLite\n",
|
||||
"\n",
|
||||
"If your HPO run is interrupted (Colab runtime reset, preempted spot instance, etc.) you can\n",
|
||||
"resume from where you left off by pointing Optuna at a **persistent storage** backend.\n",
|
||||
"\n",
|
||||
"Add a `storage` key to the executor:\n",
|
||||
"\n",
|
||||
"```python\n",
|
||||
"\"executor\": {\n",
|
||||
" \"type\": \"optuna\",\n",
|
||||
" \"num_samples\": 50,\n",
|
||||
" \"sampler\": \"auto\",\n",
|
||||
" \"storage\": \"sqlite:///optuna_results.db\", # <-- persist to disk\n",
|
||||
"}\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"Re-running `model.hyperopt()` with the same storage path will **continue the existing\n",
|
||||
"study** rather than starting a new one. Optuna automatically detects how many trials\n",
|
||||
"have already been completed and runs only the remaining ones.\n",
|
||||
"\n",
|
||||
"For distributed or cloud setups you can also use a PostgreSQL URL:\n",
|
||||
"\n",
|
||||
"```python\n",
|
||||
"\"storage\": \"postgresql://user:pass@host/dbname\"\n",
|
||||
"```"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "504fb2a444614c0babb325280ed9130a",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Example: run with SQLite storage for resumability\n",
|
||||
"import copy\n",
|
||||
"\n",
|
||||
"config_resumable = copy.deepcopy(config)\n",
|
||||
"config_resumable[\"hyperopt\"][\"executor\"][\"storage\"] = \"sqlite:///optuna_results.db\"\n",
|
||||
"config_resumable[\"hyperopt\"][\"executor\"][\"num_samples\"] = 10 # fewer trials for demo\n",
|
||||
"\n",
|
||||
"print(\"Executor config:\")\n",
|
||||
"print(json.dumps(config_resumable[\"hyperopt\"][\"executor\"], indent=2))\n",
|
||||
"print(\"\\nRe-running with storage enabled — existing trials will be reused.\")\n",
|
||||
"\n",
|
||||
"model2 = LudwigModel(config=config_resumable, logging_level=20)\n",
|
||||
"results2, _, _ = model2.hyperopt(\n",
|
||||
" dataset=str(combined_path),\n",
|
||||
" output_directory=\"hyperopt_output_resumable\",\n",
|
||||
")\n",
|
||||
"print(f\"Done. {len(results2)} trials.\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "59bbdb311c014d738909a11f9e486628",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Pruner: stop bad trials early\n",
|
||||
"\n",
|
||||
"A **pruner** monitors intermediate results reported during training and stops trials that\n",
|
||||
"are unlikely to beat the current best. This can dramatically reduce total compute when\n",
|
||||
"combined with epoch-level reporting.\n",
|
||||
"\n",
|
||||
"Ludwig's Optuna executor supports:\n",
|
||||
"\n",
|
||||
"| Pruner | Key | Description |\n",
|
||||
"|---|---|---|\n",
|
||||
"| **Hyperband** | `hyperband` | Successive halving over training steps; efficient for deep learning |\n",
|
||||
"| **Median** | `median` | Stops trials below the median performance at a given step |\n",
|
||||
"| **None** | *(omit key)* | No pruning; every trial runs to completion |\n",
|
||||
"\n",
|
||||
"```python\n",
|
||||
"\"executor\": {\n",
|
||||
" \"type\": \"optuna\",\n",
|
||||
" \"num_samples\": 50,\n",
|
||||
" \"sampler\": \"auto\",\n",
|
||||
" \"pruner\": \"hyperband\", # <-- add this\n",
|
||||
"}\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"Hyperband is the recommended default for neural network HPO. It requires at least\n",
|
||||
"`min_resource` epochs (default 1) to have completed before making pruning decisions,\n",
|
||||
"so short-running models (< 5 epochs) may see limited benefit."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "b43b363d81ae4b689946ece5c682cd59",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Results\n",
|
||||
"\n",
|
||||
"The cells below plot a **parallel coordinates** chart — each line is one trial,\n",
|
||||
"colour-coded by the validation loss. Narrow bundles indicate which regions of\n",
|
||||
"the search space consistently produce good results."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "8a65eabff63a45729fe45fb5ade58bdc",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Build a dataframe of all trial results\n",
|
||||
"records = []\n",
|
||||
"for r in hyperopt_results:\n",
|
||||
" row = {\"loss\": r.get(\"metric_score\", float(\"nan\"))}\n",
|
||||
" row.update(r.get(\"parameters\", {}))\n",
|
||||
" records.append(row)\n",
|
||||
"\n",
|
||||
"results_df = pd.DataFrame(records)\n",
|
||||
"print(f\"{len(results_df)} trials\")\n",
|
||||
"results_df.describe()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "c3933fab20d04ec698c2621248eb3be0",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import plotly.express as px\n",
|
||||
"\n",
|
||||
"# Map categorical optimizer to numeric for colour scale\n",
|
||||
"opt_map = {v: i for i, v in enumerate(results_df[\"trainer.optimizer.type\"].unique())}\n",
|
||||
"results_df[\"optimizer_idx\"] = results_df[\"trainer.optimizer.type\"].map(opt_map)\n",
|
||||
"\n",
|
||||
"dims = [\n",
|
||||
" dict(label=\"learning_rate\", values=results_df[\"trainer.learning_rate\"], type=\"log\"),\n",
|
||||
" dict(label=\"batch_size\", values=results_df[\"trainer.batch_size\"]),\n",
|
||||
" dict(\n",
|
||||
" label=\"optimizer\",\n",
|
||||
" values=results_df[\"optimizer_idx\"],\n",
|
||||
" tickvals=list(opt_map.values()),\n",
|
||||
" ticktext=list(opt_map.keys()),\n",
|
||||
" ),\n",
|
||||
" dict(label=\"dropout\", values=results_df[\"combiner.dropout\"]),\n",
|
||||
" dict(label=\"val loss\", values=results_df[\"loss\"]),\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"fig = px.parallel_coordinates(\n",
|
||||
" results_df,\n",
|
||||
" dimensions=[\"trainer.learning_rate\", \"trainer.batch_size\", \"optimizer_idx\", \"combiner.dropout\", \"loss\"],\n",
|
||||
" color=\"loss\",\n",
|
||||
" color_continuous_scale=px.colors.sequential.Viridis_r,\n",
|
||||
" labels={\n",
|
||||
" \"trainer.learning_rate\": \"learning rate\",\n",
|
||||
" \"trainer.batch_size\": \"batch size\",\n",
|
||||
" \"optimizer_idx\": \"optimizer\",\n",
|
||||
" \"combiner.dropout\": \"dropout\",\n",
|
||||
" \"loss\": \"val loss\",\n",
|
||||
" },\n",
|
||||
" title=\"HPO trials — parallel coordinates (lower loss is better)\",\n",
|
||||
")\n",
|
||||
"fig.show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "4dd4641cc4064e0191573fe9c69df29b",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Print best configuration\n",
|
||||
"best = sorted_results[0]\n",
|
||||
"print(f\"Best validation loss : {best['metric_score']:.5f}\")\n",
|
||||
"print(\"\\nBest hyperparameters:\")\n",
|
||||
"for k, v in best[\"parameters\"].items():\n",
|
||||
" print(f\" {k:35s} = {v}\")"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.12.0"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Hyperparameter Optimization with Native Optuna Executor
|
||||
========================================================
|
||||
|
||||
NOTE: Requires PR #4090 to be merged, or Ludwig >= 0.14.
|
||||
Install dependencies: pip install ludwig optuna
|
||||
|
||||
Usage:
|
||||
python optuna_executor.py
|
||||
|
||||
The script downloads the UCI Wine Quality dataset, binarises the target
|
||||
(quality >= 7), and runs Ludwig HPO using the native Optuna executor.
|
||||
Results are persisted in `optuna_results.db` so interrupted runs can
|
||||
be resumed by simply re-running the script.
|
||||
"""
|
||||
|
||||
# Colab: !pip install ludwig optuna --quiet
|
||||
|
||||
import pathlib
|
||||
import urllib.request
|
||||
|
||||
import pandas as pd
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Download dataset
|
||||
# ---------------------------------------------------------------------------
|
||||
DATA_DIR = pathlib.Path("data")
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
WHITE_URL = "https://archive.ics.uci.edu/ml/machine-learning-databases/wine-quality/winequality-white.csv"
|
||||
RED_URL = "https://archive.ics.uci.edu/ml/machine-learning-databases/wine-quality/winequality-red.csv"
|
||||
|
||||
white_path = DATA_DIR / "winequality-white.csv"
|
||||
red_path = DATA_DIR / "winequality-red.csv"
|
||||
combined_path = DATA_DIR / "wine_quality.csv"
|
||||
|
||||
if not combined_path.exists():
|
||||
print("Downloading Wine Quality dataset from UCI …")
|
||||
urllib.request.urlretrieve(WHITE_URL, white_path)
|
||||
urllib.request.urlretrieve(RED_URL, red_path)
|
||||
|
||||
white = pd.read_csv(white_path, sep=";")
|
||||
red = pd.read_csv(red_path, sep=";")
|
||||
df = pd.concat([white, red], ignore_index=True)
|
||||
|
||||
# Binary target: 1 if quality >= 7, else 0
|
||||
df["quality"] = (df["quality"] >= 7).astype(int)
|
||||
|
||||
df.to_csv(combined_path, index=False)
|
||||
print(f"Dataset saved to {combined_path} ({len(df)} rows)")
|
||||
else:
|
||||
print(f"Dataset already present at {combined_path}")
|
||||
df = pd.read_csv(combined_path)
|
||||
print(f" {len(df)} rows, class balance: {df['quality'].mean():.1%} positive")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Ludwig config
|
||||
# ---------------------------------------------------------------------------
|
||||
config = {
|
||||
"model_type": "ecd",
|
||||
"input_features": [
|
||||
{"name": col, "type": "number", "preprocessing": {"normalization": "zscore"}}
|
||||
for col in df.columns
|
||||
if col != "quality"
|
||||
],
|
||||
"output_features": [
|
||||
{"name": "quality", "type": "binary"},
|
||||
],
|
||||
"trainer": {
|
||||
"epochs": 20,
|
||||
},
|
||||
# NOTE: Optuna executor is available from Ludwig >= 0.14 (PR #4090).
|
||||
"hyperopt": {
|
||||
"executor": {
|
||||
"type": "optuna",
|
||||
"num_samples": 20,
|
||||
# 'auto' lets Optuna pick the best sampler given the search space.
|
||||
# Alternatives: 'tpe', 'gp', 'cmaes', 'random'
|
||||
"sampler": "auto",
|
||||
# Hyperband pruner stops unpromising trials early.
|
||||
"pruner": "hyperband",
|
||||
# SQLite storage makes runs resumable: re-run the script and
|
||||
# Optuna will continue from where it left off.
|
||||
"storage": "sqlite:///optuna_results.db",
|
||||
},
|
||||
"parameters": {
|
||||
"trainer.learning_rate": {
|
||||
"space": "loguniform",
|
||||
"lower": 1e-5,
|
||||
"upper": 1e-2,
|
||||
},
|
||||
"trainer.batch_size": {
|
||||
"space": "int",
|
||||
"lower": 16,
|
||||
"upper": 256,
|
||||
},
|
||||
"trainer.optimizer.type": {
|
||||
"space": "choice",
|
||||
"categories": ["adam", "adamw", "radam", "schedule_free_adamw"],
|
||||
},
|
||||
"combiner.dropout": {
|
||||
"space": "float",
|
||||
"lower": 0.0,
|
||||
"upper": 0.5,
|
||||
},
|
||||
},
|
||||
"goal": "minimize",
|
||||
"metric": "validation.combined.loss",
|
||||
"split": "validation",
|
||||
},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Run HPO
|
||||
# ---------------------------------------------------------------------------
|
||||
try:
|
||||
from ludwig.api import LudwigModel
|
||||
except ImportError:
|
||||
raise SystemExit("Ludwig is not installed. Run: pip install ludwig optuna")
|
||||
|
||||
print("\nStarting hyperparameter optimisation with Optuna …")
|
||||
print("Results are persisted in optuna_results.db — re-run to resume.\n")
|
||||
|
||||
model = LudwigModel(config=config, logging_level=20) # INFO
|
||||
|
||||
hyperopt_results, _, _ = model.hyperopt(
|
||||
dataset=str(combined_path),
|
||||
output_directory="hyperopt_output",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Report results
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n" + "=" * 60)
|
||||
print("HPO complete")
|
||||
print("=" * 60)
|
||||
|
||||
if hyperopt_results:
|
||||
best = min(hyperopt_results, key=lambda r: r.get("metric_score", float("inf")))
|
||||
print(f"\nBest metric (validation.combined.loss): {best.get('metric_score', 'n/a'):.4f}")
|
||||
print("\nBest hyperparameters:")
|
||||
for param, value in best.get("parameters", {}).items():
|
||||
print(f" {param}: {value}")
|
||||
else:
|
||||
print("No results returned — check logs above for errors.")
|
||||
@@ -0,0 +1,78 @@
|
||||
# Pretrained Image Encoders: CLIP, DINOv2, and SigLIP
|
||||
|
||||
[](https://colab.research.google.com/github/ludwig-ai/ludwig/blob/main/examples/image_encoders/image_encoders.ipynb)
|
||||
|
||||
## Overview
|
||||
|
||||
Pretrained image encoders are neural networks trained on large datasets (ImageNet-21k, LAION-5B, or proprietary corpora) whose learned weights can be directly transferred to new tasks. Instead of training a convolutional network from scratch on your small dataset, you can use a pretrained encoder as a frozen feature extractor and only train a lightweight classification head on top—this is called **linear probing**.
|
||||
|
||||
### Why pretrained encoders matter for few-shot learning
|
||||
|
||||
When you have limited labeled data (e.g., 5–100 examples per class), training from scratch typically leads to overfitting. Pretrained encoders solve this by:
|
||||
|
||||
- Providing rich, general-purpose visual features learned from millions of images
|
||||
- Allowing the model to converge in far fewer epochs
|
||||
- Requiring only a small head to be trained, which needs very little data
|
||||
|
||||
Ludwig supports three HuggingFace-backed pretrained image encoders alongside the traditional `stacked_cnn` approach.
|
||||
|
||||
## Encoder comparison
|
||||
|
||||
| Encoder | Pretrained | Trainable by default | Best for |
|
||||
| ------------- | ---------- | -------------------- | --------------------------------------------------------------------------- |
|
||||
| `stacked_cnn` | No | Yes | Full control, small images, custom architectures |
|
||||
| `dinov2` | Yes | Yes | General image classification, dense prediction, linear probing |
|
||||
| `clip` | Yes | Yes | Image-text tasks, zero-shot classification, multimodal fusion |
|
||||
| `siglip` | Yes | Yes | CLIP-like tasks with better scaling, Google's improved contrastive training |
|
||||
|
||||
All three pretrained encoders (`dinov2`, `clip`, `siglip`) support:
|
||||
|
||||
- `use_pretrained: true` — load weights from HuggingFace Hub
|
||||
- `trainable: false` — freeze the encoder for fast linear probing
|
||||
- `trainable: true` — fine-tune the full encoder end-to-end
|
||||
|
||||
## Quick start
|
||||
|
||||
Install Ludwig with vision support:
|
||||
|
||||
```bash
|
||||
pip install ludwig[vision]
|
||||
```
|
||||
|
||||
Train with a pretrained DINOv2 encoder (linear probe — fast, works well with limited data):
|
||||
|
||||
```bash
|
||||
ludwig train \
|
||||
--config examples/image_encoders/config_dinov2_linear_probe.yaml \
|
||||
--dataset my_images.csv
|
||||
```
|
||||
|
||||
Your CSV needs two columns: `image_path` (absolute or relative paths to image files) and `label` (the class name).
|
||||
|
||||
## Available configs
|
||||
|
||||
| Config file | Description |
|
||||
| --------------------------------- | --------------------------------------------- |
|
||||
| `config_stacked_cnn.yaml` | CNN trained from scratch (20 epochs) |
|
||||
| `config_dinov2_linear_probe.yaml` | DINOv2 frozen backbone, head only (10 epochs) |
|
||||
| `config_dinov2_finetuned.yaml` | DINOv2 full fine-tune (5 epochs, lower LR) |
|
||||
| `config_clip.yaml` | CLIP frozen backbone (10 epochs) |
|
||||
| `config_siglip.yaml` | SigLIP frozen backbone (10 epochs) |
|
||||
|
||||
## Running all configs and comparing results
|
||||
|
||||
```bash
|
||||
python examples/image_encoders/compare_encoders.py --dataset my_images.csv
|
||||
```
|
||||
|
||||
## Full walkthrough
|
||||
|
||||
See the [notebook](image_encoders.ipynb) for a complete step-by-step example using the `beans` plant disease dataset (3 classes, ~1000 images) from HuggingFace Datasets, including a few-shot experiment with only 15 training examples.
|
||||
|
||||
## Hardware requirements
|
||||
|
||||
- `stacked_cnn`: CPU or GPU
|
||||
- `dinov2` (linear probe): GPU recommended, runs on CPU for small datasets
|
||||
- `dinov2` (fine-tune), `clip`, `siglip`: GPU required (T4 or better)
|
||||
|
||||
The linear probe is especially well-suited for Google Colab free tier (T4 GPU).
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Compare pretrained image encoders: stacked_cnn, DINOv2, CLIP, and SigLIP.
|
||||
|
||||
Runs all encoder configs on a dataset and prints a comparison table of
|
||||
accuracy, training time, and approximate GPU memory usage.
|
||||
|
||||
Usage:
|
||||
python compare_encoders.py --dataset /path/to/data.csv
|
||||
|
||||
The dataset CSV must have columns: image_path, label
|
||||
|
||||
Example (using the beans dataset — see the notebook for download instructions):
|
||||
python compare_encoders.py --dataset /tmp/beans/train.csv \
|
||||
--val_dataset /tmp/beans/validation.csv \
|
||||
--test_dataset /tmp/beans/test.csv
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def get_gpu_memory_mb() -> float:
|
||||
"""Return current GPU memory usage in MB, or 0 if no GPU available."""
|
||||
try:
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
return torch.cuda.max_memory_allocated() / 1024**2
|
||||
except ImportError:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def reset_gpu_memory_stats() -> None:
|
||||
try:
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.reset_peak_memory_stats()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
def run_experiment(
|
||||
config_path: str,
|
||||
dataset: str,
|
||||
val_dataset: str | None,
|
||||
test_dataset: str | None,
|
||||
output_dir: str,
|
||||
) -> dict:
|
||||
"""Train and evaluate one config.
|
||||
|
||||
Returns a dict with result metrics.
|
||||
"""
|
||||
from ludwig.api import LudwigModel
|
||||
|
||||
reset_gpu_memory_stats()
|
||||
start = time.time()
|
||||
|
||||
model = LudwigModel(config=config_path, logging_level=30) # WARNING level
|
||||
|
||||
train_kwargs = dict(dataset=dataset, output_directory=output_dir, skip_save_processed_input=True)
|
||||
if val_dataset:
|
||||
train_kwargs["validation_set"] = val_dataset
|
||||
|
||||
_, _, output_directory = model.train(**train_kwargs)
|
||||
train_time = time.time() - start
|
||||
peak_mem = get_gpu_memory_mb()
|
||||
|
||||
# Evaluate on test set
|
||||
eval_dataset = test_dataset or dataset
|
||||
eval_stats, _, _ = model.evaluate(dataset=eval_dataset, collect_overall_stats=True)
|
||||
|
||||
accuracy = 0.0
|
||||
if "label" in eval_stats:
|
||||
accuracy = eval_stats["label"].get("accuracy", 0.0)
|
||||
|
||||
return {
|
||||
"train_time_s": train_time,
|
||||
"peak_gpu_mb": peak_mem,
|
||||
"accuracy": accuracy,
|
||||
"output_directory": output_directory,
|
||||
}
|
||||
|
||||
|
||||
CONFIGS = [
|
||||
("stacked_cnn", "config_stacked_cnn.yaml"),
|
||||
("dinov2_linear_probe", "config_dinov2_linear_probe.yaml"),
|
||||
("dinov2_finetuned", "config_dinov2_finetuned.yaml"),
|
||||
("clip", "config_clip.yaml"),
|
||||
("siglip", "config_siglip.yaml"),
|
||||
]
|
||||
|
||||
|
||||
def print_table(results: list[dict]) -> None:
|
||||
header = f"{'Encoder':<25} {'Accuracy':>10} {'Train time':>12} {'Peak GPU (MB)':>15}"
|
||||
sep = "-" * len(header)
|
||||
print("\n" + sep)
|
||||
print(header)
|
||||
print(sep)
|
||||
for r in results:
|
||||
name = r["name"]
|
||||
acc = r.get("accuracy", float("nan"))
|
||||
t = r.get("train_time_s", float("nan"))
|
||||
mem = r.get("peak_gpu_mb", 0.0)
|
||||
mem_str = f"{mem:>15.0f}" if mem > 0 else f"{'N/A':>15}"
|
||||
print(f"{name:<25} {acc:>10.4f} {t:>11.1f}s {mem_str}")
|
||||
print(sep + "\n")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Compare Ludwig image encoder configs.")
|
||||
parser.add_argument("--dataset", required=True, help="Path to training CSV (image_path, label columns).")
|
||||
parser.add_argument("--val_dataset", default=None, help="Path to validation CSV.")
|
||||
parser.add_argument("--test_dataset", default=None, help="Path to test CSV (used for evaluation).")
|
||||
parser.add_argument(
|
||||
"--output_dir",
|
||||
default="/tmp/image_encoder_results",
|
||||
help="Base output directory for Ludwig experiment results.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--encoders",
|
||||
nargs="+",
|
||||
default=None,
|
||||
help="Subset of encoders to run (e.g. --encoders stacked_cnn dinov2_linear_probe). Defaults to all encoders.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
configs_to_run = CONFIGS
|
||||
if args.encoders:
|
||||
valid = {name for name, _ in CONFIGS}
|
||||
for e in args.encoders:
|
||||
if e not in valid:
|
||||
parser.error(f"Unknown encoder '{e}'. Valid: {sorted(valid)}")
|
||||
configs_to_run = [(name, cfg) for name, cfg in CONFIGS if name in args.encoders]
|
||||
|
||||
results = []
|
||||
for name, config_file in configs_to_run:
|
||||
config_path = str(SCRIPT_DIR / config_file)
|
||||
if not os.path.exists(config_path):
|
||||
print(f"[SKIP] Config not found: {config_path}")
|
||||
continue
|
||||
|
||||
output_dir = os.path.join(args.output_dir, name)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Running: {name}")
|
||||
print(f"Config: {config_path}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
try:
|
||||
metrics = run_experiment(
|
||||
config_path=config_path,
|
||||
dataset=args.dataset,
|
||||
val_dataset=args.val_dataset,
|
||||
test_dataset=args.test_dataset,
|
||||
output_dir=output_dir,
|
||||
)
|
||||
results.append({"name": name, **metrics})
|
||||
print(
|
||||
f"Done: accuracy={metrics['accuracy']:.4f}, "
|
||||
f"time={metrics['train_time_s']:.1f}s, "
|
||||
f"peak_gpu={metrics['peak_gpu_mb']:.0f}MB"
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"[ERROR] {name} failed: {exc}")
|
||||
results.append({"name": name, "error": str(exc)})
|
||||
|
||||
print_table([r for r in results if "error" not in r])
|
||||
|
||||
failed = [r for r in results if "error" in r]
|
||||
if failed:
|
||||
print("Failed experiments:")
|
||||
for r in failed:
|
||||
print(f" {r['name']}: {r['error']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,14 @@
|
||||
model_type: ecd
|
||||
input_features:
|
||||
- name: image_path
|
||||
type: image
|
||||
encoder:
|
||||
type: clip
|
||||
use_pretrained: true
|
||||
trainable: false
|
||||
output_features:
|
||||
- name: label
|
||||
type: category
|
||||
trainer:
|
||||
epochs: 10
|
||||
learning_rate: 0.001
|
||||
@@ -0,0 +1,14 @@
|
||||
model_type: ecd
|
||||
input_features:
|
||||
- name: image_path
|
||||
type: image
|
||||
encoder:
|
||||
type: dinov2
|
||||
use_pretrained: true
|
||||
trainable: true
|
||||
output_features:
|
||||
- name: label
|
||||
type: category
|
||||
trainer:
|
||||
epochs: 5
|
||||
learning_rate: 0.0001
|
||||
@@ -0,0 +1,14 @@
|
||||
model_type: ecd
|
||||
input_features:
|
||||
- name: image_path
|
||||
type: image
|
||||
encoder:
|
||||
type: dinov2
|
||||
use_pretrained: true
|
||||
trainable: false
|
||||
output_features:
|
||||
- name: label
|
||||
type: category
|
||||
trainer:
|
||||
epochs: 10
|
||||
learning_rate: 0.001
|
||||
@@ -0,0 +1,14 @@
|
||||
model_type: ecd
|
||||
input_features:
|
||||
- name: image_path
|
||||
type: image
|
||||
encoder:
|
||||
type: siglip
|
||||
use_pretrained: true
|
||||
trainable: false
|
||||
output_features:
|
||||
- name: label
|
||||
type: category
|
||||
trainer:
|
||||
epochs: 10
|
||||
learning_rate: 0.001
|
||||
@@ -0,0 +1,13 @@
|
||||
model_type: ecd
|
||||
input_features:
|
||||
- name: image_path
|
||||
type: image
|
||||
encoder:
|
||||
type: stacked_cnn
|
||||
use_pretrained: false
|
||||
output_features:
|
||||
- name: label
|
||||
type: category
|
||||
trainer:
|
||||
epochs: 20
|
||||
learning_rate: 0.001
|
||||
@@ -0,0 +1,741 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a1b2c3d4",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Pretrained Image Encoders: CLIP, DINOv2, and SigLIP\n",
|
||||
"\n",
|
||||
"This notebook compares four approaches to image classification in Ludwig:\n",
|
||||
"\n",
|
||||
"1. **stacked_cnn** — a convolutional network trained from scratch\n",
|
||||
"2. **DINOv2 linear probe** — pretrained DINOv2 backbone, frozen; only the head is trained\n",
|
||||
"3. **DINOv2 fine-tuned** — pretrained DINOv2 backbone, full fine-tuning\n",
|
||||
"4. **CLIP** — pretrained CLIP vision encoder, frozen\n",
|
||||
"5. **SigLIP** — pretrained SigLIP vision encoder, frozen\n",
|
||||
"\n",
|
||||
"We use the [`beans`](https://huggingface.co/datasets/beans) dataset: a plant disease dataset with 3 classes (~1000 images total), which makes it a realistic small-data scenario where pretrained encoders shine.\n",
|
||||
"\n",
|
||||
"**Runtime**: GPU is required for DINOv2/CLIP/SigLIP. In Colab, go to **Runtime → Change runtime type → T4 GPU**."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "b2c3d4e5",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Setup"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "c3d4e5f6",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!pip install ludwig[vision] datasets --quiet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "d4e5f6a7",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import time\n",
|
||||
"import warnings\n",
|
||||
"\n",
|
||||
"import torch\n",
|
||||
"\n",
|
||||
"warnings.filterwarnings(\"ignore\")\n",
|
||||
"\n",
|
||||
"print(f\"PyTorch version: {torch.__version__}\")\n",
|
||||
"print(f\"CUDA available: {torch.cuda.is_available()}\")\n",
|
||||
"if torch.cuda.is_available():\n",
|
||||
" print(f\"GPU: {torch.cuda.get_device_name(0)}\")\n",
|
||||
" print(f\"GPU memory: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f} GB\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "e5f6a7b8",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Dataset\n",
|
||||
"\n",
|
||||
"The `beans` dataset contains RGB images of bean leaves classified into three categories:\n",
|
||||
"- `angular_leaf_spot` — fungal disease\n",
|
||||
"- `bean_rust` — rust disease \n",
|
||||
"- `healthy` — healthy leaf\n",
|
||||
"\n",
|
||||
"It has ~1034 training images, 133 validation images, and 128 test images. This makes it a realistic small-data scenario."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "f6a7b8c9",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import csv\n",
|
||||
"from pathlib import Path\n",
|
||||
"\n",
|
||||
"from datasets import load_dataset\n",
|
||||
"\n",
|
||||
"# Download the beans dataset from HuggingFace\n",
|
||||
"ds = load_dataset(\"beans\")\n",
|
||||
"print(ds)\n",
|
||||
"\n",
|
||||
"# Save images to disk and create CSV files\n",
|
||||
"DATA_DIR = Path(\"/tmp/beans\")\n",
|
||||
"DATA_DIR.mkdir(parents=True, exist_ok=True)\n",
|
||||
"\n",
|
||||
"label_names = ds[\"train\"].features[\"labels\"].names\n",
|
||||
"print(f\"Classes: {label_names}\")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def save_split(split_name: str) -> str:\n",
|
||||
" \"\"\"Save images to disk and return path to a CSV with image_path and label columns.\"\"\"\n",
|
||||
" split_dir = DATA_DIR / split_name\n",
|
||||
" split_dir.mkdir(parents=True, exist_ok=True)\n",
|
||||
" csv_path = DATA_DIR / f\"{split_name}.csv\"\n",
|
||||
"\n",
|
||||
" rows = []\n",
|
||||
" for i, example in enumerate(ds[split_name]):\n",
|
||||
" img = example[\"image\"]\n",
|
||||
" label_idx = example[\"labels\"]\n",
|
||||
" label = label_names[label_idx]\n",
|
||||
"\n",
|
||||
" img_path = split_dir / f\"{i:04d}_{label}.jpg\"\n",
|
||||
" if not img_path.exists():\n",
|
||||
" img.save(str(img_path))\n",
|
||||
"\n",
|
||||
" rows.append({\"image_path\": str(img_path), \"label\": label})\n",
|
||||
"\n",
|
||||
" with open(csv_path, \"w\", newline=\"\") as f:\n",
|
||||
" writer = csv.DictWriter(f, fieldnames=[\"image_path\", \"label\"])\n",
|
||||
" writer.writeheader()\n",
|
||||
" writer.writerows(rows)\n",
|
||||
"\n",
|
||||
" print(f\"Saved {len(rows)} examples to {csv_path}\")\n",
|
||||
" return str(csv_path)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"train_csv = save_split(\"train\")\n",
|
||||
"val_csv = save_split(\"validation\")\n",
|
||||
"test_csv = save_split(\"test\")\n",
|
||||
"\n",
|
||||
"print(f\"\\nTrain: {train_csv}\")\n",
|
||||
"print(f\"Val: {val_csv}\")\n",
|
||||
"print(f\"Test: {test_csv}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "a7b8c9d0",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Preview a few examples\n",
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"train_df = pd.read_csv(train_csv)\n",
|
||||
"print(train_df.head())\n",
|
||||
"print(f\"\\nClass distribution:\\n{train_df['label'].value_counts()}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "b8c9d0e1",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Visualize a few images\n",
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"from PIL import Image\n",
|
||||
"\n",
|
||||
"fig, axes = plt.subplots(1, 3, figsize=(12, 4))\n",
|
||||
"for i, (label, group) in enumerate(train_df.groupby(\"label\")):\n",
|
||||
" sample = group.sample(1).iloc[0]\n",
|
||||
" img = Image.open(sample[\"image_path\"])\n",
|
||||
" axes[i].imshow(img)\n",
|
||||
" axes[i].set_title(label)\n",
|
||||
" axes[i].axis(\"off\")\n",
|
||||
"plt.suptitle(\"Beans dataset — one example per class\", fontsize=13)\n",
|
||||
"plt.tight_layout()\n",
|
||||
"plt.show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "c9d0e1f2",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Helper: timing and memory tracking"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "d0e1f2a3",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import gc\n",
|
||||
"\n",
|
||||
"results = {} # Will accumulate {encoder_name: {accuracy, train_time_s, peak_gpu_mb}}\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def reset_gpu():\n",
|
||||
" gc.collect()\n",
|
||||
" if torch.cuda.is_available():\n",
|
||||
" torch.cuda.empty_cache()\n",
|
||||
" torch.cuda.reset_peak_memory_stats()\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def peak_gpu_mb() -> float:\n",
|
||||
" if torch.cuda.is_available():\n",
|
||||
" return torch.cuda.max_memory_allocated() / 1024**2\n",
|
||||
" return 0.0\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def evaluate_model(model, test_csv: str) -> float:\n",
|
||||
" \"\"\"Return accuracy on the test set.\"\"\"\n",
|
||||
" eval_stats, _, _ = model.evaluate(dataset=test_csv, collect_overall_stats=True)\n",
|
||||
" return eval_stats.get(\"label\", {}).get(\"accuracy\", float(\"nan\"))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "e1f2a3b4",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Approach 1: Stacked CNN (trained from scratch)\n",
|
||||
"\n",
|
||||
"The `stacked_cnn` encoder is Ludwig's default: a stack of 2D convolutional layers followed by fully connected layers. There are no pretrained weights — the model learns everything from the 1034 training images.\n",
|
||||
"\n",
|
||||
"This is the baseline. Expect lower accuracy and longer training time compared to pretrained encoders on this small dataset."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "f2a3b4c5",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from ludwig.api import LudwigModel\n",
|
||||
"\n",
|
||||
"config_stacked_cnn = {\n",
|
||||
" \"model_type\": \"ecd\",\n",
|
||||
" \"input_features\": [\n",
|
||||
" {\n",
|
||||
" \"name\": \"image_path\",\n",
|
||||
" \"type\": \"image\",\n",
|
||||
" \"encoder\": {\n",
|
||||
" \"type\": \"stacked_cnn\",\n",
|
||||
" \"use_pretrained\": False,\n",
|
||||
" },\n",
|
||||
" }\n",
|
||||
" ],\n",
|
||||
" \"output_features\": [{\"name\": \"label\", \"type\": \"category\"}],\n",
|
||||
" \"trainer\": {\"epochs\": 20, \"learning_rate\": 0.001},\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"reset_gpu()\n",
|
||||
"t0 = time.time()\n",
|
||||
"\n",
|
||||
"model_cnn = LudwigModel(config=config_stacked_cnn, logging_level=30)\n",
|
||||
"_, _, output_dir = model_cnn.train(\n",
|
||||
" dataset=train_csv,\n",
|
||||
" validation_set=val_csv,\n",
|
||||
" output_directory=\"/tmp/results/stacked_cnn\",\n",
|
||||
" skip_save_processed_input=True,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"train_time = time.time() - t0\n",
|
||||
"gpu_mem = peak_gpu_mb()\n",
|
||||
"accuracy = evaluate_model(model_cnn, test_csv)\n",
|
||||
"\n",
|
||||
"results[\"stacked_cnn\"] = {\"accuracy\": accuracy, \"train_time_s\": train_time, \"peak_gpu_mb\": gpu_mem}\n",
|
||||
"print(f\"stacked_cnn — accuracy: {accuracy:.4f}, time: {train_time:.1f}s, peak GPU: {gpu_mem:.0f}MB\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a3b4c5d6",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Approach 2: DINOv2 linear probe\n",
|
||||
"\n",
|
||||
"DINOv2 (Oquab et al., TMLR 2024) is a self-supervised vision transformer from Meta trained on 142M images. It produces rich general-purpose features without requiring image-text pairs.\n",
|
||||
"\n",
|
||||
"In **linear probe** mode (`trainable: false`), the DINOv2 backbone is frozen — only the Ludwig output head (a small linear layer) is trained. This is:\n",
|
||||
"- **Fast**: no backprop through the large backbone\n",
|
||||
"- **Data-efficient**: works well with very few labeled examples\n",
|
||||
"- **Memory-efficient**: no gradients stored for the backbone\n",
|
||||
"\n",
|
||||
"Default model: `facebook/dinov2-base` (86M parameters, 768-dim output)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "b4c5d6e7",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"config_dinov2_probe = {\n",
|
||||
" \"model_type\": \"ecd\",\n",
|
||||
" \"input_features\": [\n",
|
||||
" {\n",
|
||||
" \"name\": \"image_path\",\n",
|
||||
" \"type\": \"image\",\n",
|
||||
" \"encoder\": {\n",
|
||||
" \"type\": \"dinov2\",\n",
|
||||
" \"use_pretrained\": True,\n",
|
||||
" \"trainable\": False, # freeze the backbone\n",
|
||||
" },\n",
|
||||
" }\n",
|
||||
" ],\n",
|
||||
" \"output_features\": [{\"name\": \"label\", \"type\": \"category\"}],\n",
|
||||
" \"trainer\": {\"epochs\": 10, \"learning_rate\": 0.001},\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"reset_gpu()\n",
|
||||
"t0 = time.time()\n",
|
||||
"\n",
|
||||
"model_dinov2_probe = LudwigModel(config=config_dinov2_probe, logging_level=30)\n",
|
||||
"_, _, output_dir = model_dinov2_probe.train(\n",
|
||||
" dataset=train_csv,\n",
|
||||
" validation_set=val_csv,\n",
|
||||
" output_directory=\"/tmp/results/dinov2_linear_probe\",\n",
|
||||
" skip_save_processed_input=True,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"train_time = time.time() - t0\n",
|
||||
"gpu_mem = peak_gpu_mb()\n",
|
||||
"accuracy = evaluate_model(model_dinov2_probe, test_csv)\n",
|
||||
"\n",
|
||||
"results[\"dinov2_linear_probe\"] = {\"accuracy\": accuracy, \"train_time_s\": train_time, \"peak_gpu_mb\": gpu_mem}\n",
|
||||
"print(f\"DINOv2 linear probe — accuracy: {accuracy:.4f}, time: {train_time:.1f}s, peak GPU: {gpu_mem:.0f}MB\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "c5d6e7f8",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Approach 3: DINOv2 fine-tuned\n",
|
||||
"\n",
|
||||
"In **fine-tuning** mode (`trainable: true`), gradients flow through the entire DINOv2 backbone. This can improve accuracy over linear probing but requires:\n",
|
||||
"- More GPU memory (stores activations for backprop through 86M parameters)\n",
|
||||
"- A lower learning rate (to avoid destroying pretrained features)\n",
|
||||
"- Fewer epochs (the model has a strong starting point)\n",
|
||||
"\n",
|
||||
"Use fine-tuning when you have enough data and GPU memory and want maximum accuracy."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "d6e7f8a9",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"config_dinov2_finetune = {\n",
|
||||
" \"model_type\": \"ecd\",\n",
|
||||
" \"input_features\": [\n",
|
||||
" {\n",
|
||||
" \"name\": \"image_path\",\n",
|
||||
" \"type\": \"image\",\n",
|
||||
" \"encoder\": {\n",
|
||||
" \"type\": \"dinov2\",\n",
|
||||
" \"use_pretrained\": True,\n",
|
||||
" \"trainable\": True, # full fine-tuning\n",
|
||||
" },\n",
|
||||
" }\n",
|
||||
" ],\n",
|
||||
" \"output_features\": [{\"name\": \"label\", \"type\": \"category\"}],\n",
|
||||
" \"trainer\": {\n",
|
||||
" \"epochs\": 5,\n",
|
||||
" \"learning_rate\": 0.0001, # lower LR for fine-tuning\n",
|
||||
" },\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"reset_gpu()\n",
|
||||
"t0 = time.time()\n",
|
||||
"\n",
|
||||
"model_dinov2_ft = LudwigModel(config=config_dinov2_finetune, logging_level=30)\n",
|
||||
"_, _, output_dir = model_dinov2_ft.train(\n",
|
||||
" dataset=train_csv,\n",
|
||||
" validation_set=val_csv,\n",
|
||||
" output_directory=\"/tmp/results/dinov2_finetuned\",\n",
|
||||
" skip_save_processed_input=True,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"train_time = time.time() - t0\n",
|
||||
"gpu_mem = peak_gpu_mb()\n",
|
||||
"accuracy = evaluate_model(model_dinov2_ft, test_csv)\n",
|
||||
"\n",
|
||||
"results[\"dinov2_finetuned\"] = {\"accuracy\": accuracy, \"train_time_s\": train_time, \"peak_gpu_mb\": gpu_mem}\n",
|
||||
"print(f\"DINOv2 fine-tuned — accuracy: {accuracy:.4f}, time: {train_time:.1f}s, peak GPU: {gpu_mem:.0f}MB\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "e7f8a9b0",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Approach 4: CLIP\n",
|
||||
"\n",
|
||||
"CLIP (Radford et al., ICML 2021) from OpenAI is trained on 400M image-text pairs using contrastive learning. Its visual encoder produces embeddings that are aligned with text in a shared latent space.\n",
|
||||
"\n",
|
||||
"CLIP features are particularly useful for:\n",
|
||||
"- Zero-shot image classification (not shown here)\n",
|
||||
"- Tasks where visual-semantic alignment matters\n",
|
||||
"- Multimodal applications\n",
|
||||
"\n",
|
||||
"Default model: `openai/clip-vit-base-patch32` (86M parameters, 768-dim output)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "f8a9b0c1",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"config_clip = {\n",
|
||||
" \"model_type\": \"ecd\",\n",
|
||||
" \"input_features\": [\n",
|
||||
" {\n",
|
||||
" \"name\": \"image_path\",\n",
|
||||
" \"type\": \"image\",\n",
|
||||
" \"encoder\": {\n",
|
||||
" \"type\": \"clip\",\n",
|
||||
" \"use_pretrained\": True,\n",
|
||||
" \"trainable\": False,\n",
|
||||
" },\n",
|
||||
" }\n",
|
||||
" ],\n",
|
||||
" \"output_features\": [{\"name\": \"label\", \"type\": \"category\"}],\n",
|
||||
" \"trainer\": {\"epochs\": 10, \"learning_rate\": 0.001},\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"reset_gpu()\n",
|
||||
"t0 = time.time()\n",
|
||||
"\n",
|
||||
"model_clip = LudwigModel(config=config_clip, logging_level=30)\n",
|
||||
"_, _, output_dir = model_clip.train(\n",
|
||||
" dataset=train_csv,\n",
|
||||
" validation_set=val_csv,\n",
|
||||
" output_directory=\"/tmp/results/clip\",\n",
|
||||
" skip_save_processed_input=True,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"train_time = time.time() - t0\n",
|
||||
"gpu_mem = peak_gpu_mb()\n",
|
||||
"accuracy = evaluate_model(model_clip, test_csv)\n",
|
||||
"\n",
|
||||
"results[\"clip\"] = {\"accuracy\": accuracy, \"train_time_s\": train_time, \"peak_gpu_mb\": gpu_mem}\n",
|
||||
"print(f\"CLIP linear probe — accuracy: {accuracy:.4f}, time: {train_time:.1f}s, peak GPU: {gpu_mem:.0f}MB\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a9b0c1d2",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Approach 5: SigLIP\n",
|
||||
"\n",
|
||||
"SigLIP (Zhai et al., ICCV 2023) from Google uses sigmoid loss instead of softmax contrastive loss for image-text pre-training. This removes the dependency on global batch statistics and enables better scaling.\n",
|
||||
"\n",
|
||||
"SigLIP features are similar to CLIP in nature but often outperform CLIP on downstream classification tasks, especially with smaller models.\n",
|
||||
"\n",
|
||||
"Default model: `google/siglip-base-patch16-224` (86M parameters, 768-dim output)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "b0c1d2e3",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"config_siglip = {\n",
|
||||
" \"model_type\": \"ecd\",\n",
|
||||
" \"input_features\": [\n",
|
||||
" {\n",
|
||||
" \"name\": \"image_path\",\n",
|
||||
" \"type\": \"image\",\n",
|
||||
" \"encoder\": {\n",
|
||||
" \"type\": \"siglip\",\n",
|
||||
" \"use_pretrained\": True,\n",
|
||||
" \"trainable\": False,\n",
|
||||
" },\n",
|
||||
" }\n",
|
||||
" ],\n",
|
||||
" \"output_features\": [{\"name\": \"label\", \"type\": \"category\"}],\n",
|
||||
" \"trainer\": {\"epochs\": 10, \"learning_rate\": 0.001},\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"reset_gpu()\n",
|
||||
"t0 = time.time()\n",
|
||||
"\n",
|
||||
"model_siglip = LudwigModel(config=config_siglip, logging_level=30)\n",
|
||||
"_, _, output_dir = model_siglip.train(\n",
|
||||
" dataset=train_csv,\n",
|
||||
" validation_set=val_csv,\n",
|
||||
" output_directory=\"/tmp/results/siglip\",\n",
|
||||
" skip_save_processed_input=True,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"train_time = time.time() - t0\n",
|
||||
"gpu_mem = peak_gpu_mb()\n",
|
||||
"accuracy = evaluate_model(model_siglip, test_csv)\n",
|
||||
"\n",
|
||||
"results[\"siglip\"] = {\"accuracy\": accuracy, \"train_time_s\": train_time, \"peak_gpu_mb\": gpu_mem}\n",
|
||||
"print(f\"SigLIP linear probe — accuracy: {accuracy:.4f}, time: {train_time:.1f}s, peak GPU: {gpu_mem:.0f}MB\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "c1d2e3f4",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Results comparison"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "d2e3f4a5",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"rows = []\n",
|
||||
"for name, metrics in results.items():\n",
|
||||
" rows.append(\n",
|
||||
" {\n",
|
||||
" \"Encoder\": name,\n",
|
||||
" \"Accuracy\": f\"{metrics['accuracy']:.4f}\",\n",
|
||||
" \"Train time (s)\": f\"{metrics['train_time_s']:.1f}\",\n",
|
||||
" \"Peak GPU (MB)\": f\"{metrics['peak_gpu_mb']:.0f}\" if metrics[\"peak_gpu_mb\"] > 0 else \"N/A\",\n",
|
||||
" }\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
"df_results = pd.DataFrame(rows)\n",
|
||||
"print(df_results.to_string(index=False))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "e3f4a5b6",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Plot accuracy comparison\n",
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"\n",
|
||||
"names = list(results.keys())\n",
|
||||
"accuracies = [results[n][\"accuracy\"] for n in names]\n",
|
||||
"times = [results[n][\"train_time_s\"] for n in names]\n",
|
||||
"\n",
|
||||
"fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))\n",
|
||||
"\n",
|
||||
"colors = [\"#4e79a7\", \"#f28e2b\", \"#e15759\", \"#76b7b2\", \"#59a14f\"]\n",
|
||||
"\n",
|
||||
"bars = ax1.bar(names, accuracies, color=colors[: len(names)])\n",
|
||||
"ax1.set_ylim(0, 1.05)\n",
|
||||
"ax1.set_ylabel(\"Test Accuracy\")\n",
|
||||
"ax1.set_title(\"Accuracy by encoder\")\n",
|
||||
"ax1.tick_params(axis=\"x\", rotation=20)\n",
|
||||
"for bar, acc in zip(bars, accuracies):\n",
|
||||
" ax1.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01, f\"{acc:.3f}\", ha=\"center\", va=\"bottom\")\n",
|
||||
"\n",
|
||||
"bars2 = ax2.bar(names, times, color=colors[: len(names)])\n",
|
||||
"ax2.set_ylabel(\"Training time (s)\")\n",
|
||||
"ax2.set_title(\"Training time by encoder\")\n",
|
||||
"ax2.tick_params(axis=\"x\", rotation=20)\n",
|
||||
"for bar, t in zip(bars2, times):\n",
|
||||
" ax2.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 1, f\"{t:.0f}s\", ha=\"center\", va=\"bottom\")\n",
|
||||
"\n",
|
||||
"plt.tight_layout()\n",
|
||||
"plt.show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "f4a5b6c7",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Few-shot: 5 examples per class\n",
|
||||
"\n",
|
||||
"One of the biggest advantages of pretrained encoders is their ability to generalize from very few labeled examples. In this section, we compare `stacked_cnn` vs `dinov2` (linear probe) when trained on only **5 examples per class** (15 examples total).\n",
|
||||
"\n",
|
||||
"This simulates a realistic scenario where annotation is expensive."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "a5b6c7d8",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from pathlib import Path\n",
|
||||
"\n",
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"# Build a 5-shot training set (5 examples per class)\n",
|
||||
"train_df = pd.read_csv(train_csv)\n",
|
||||
"fewshot_df = train_df.groupby(\"label\").sample(n=5, random_state=42).reset_index(drop=True)\n",
|
||||
"\n",
|
||||
"fewshot_csv = \"/tmp/beans/fewshot_5shot.csv\"\n",
|
||||
"fewshot_df.to_csv(fewshot_csv, index=False)\n",
|
||||
"\n",
|
||||
"print(f\"Few-shot training set: {len(fewshot_df)} examples\")\n",
|
||||
"print(fewshot_df[\"label\"].value_counts())"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "b6c7d8e9",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"fewshot_results = {}\n",
|
||||
"\n",
|
||||
"# Few-shot with stacked_cnn\n",
|
||||
"reset_gpu()\n",
|
||||
"t0 = time.time()\n",
|
||||
"model_cnn_fs = LudwigModel(config=config_stacked_cnn, logging_level=30)\n",
|
||||
"model_cnn_fs.train(\n",
|
||||
" dataset=fewshot_csv,\n",
|
||||
" output_directory=\"/tmp/results/fewshot_cnn\",\n",
|
||||
" skip_save_processed_input=True,\n",
|
||||
")\n",
|
||||
"train_time = time.time() - t0\n",
|
||||
"accuracy = evaluate_model(model_cnn_fs, test_csv)\n",
|
||||
"fewshot_results[\"stacked_cnn (5-shot)\"] = {\"accuracy\": accuracy, \"train_time_s\": train_time}\n",
|
||||
"print(f\"stacked_cnn (5-shot) — accuracy: {accuracy:.4f}, time: {train_time:.1f}s\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "c7d8e9f0",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Few-shot with DINOv2 linear probe\n",
|
||||
"reset_gpu()\n",
|
||||
"t0 = time.time()\n",
|
||||
"model_dinov2_fs = LudwigModel(config=config_dinov2_probe, logging_level=30)\n",
|
||||
"model_dinov2_fs.train(\n",
|
||||
" dataset=fewshot_csv,\n",
|
||||
" output_directory=\"/tmp/results/fewshot_dinov2\",\n",
|
||||
" skip_save_processed_input=True,\n",
|
||||
")\n",
|
||||
"train_time = time.time() - t0\n",
|
||||
"accuracy = evaluate_model(model_dinov2_fs, test_csv)\n",
|
||||
"fewshot_results[\"dinov2_linear_probe (5-shot)\"] = {\"accuracy\": accuracy, \"train_time_s\": train_time}\n",
|
||||
"print(f\"DINOv2 linear probe (5-shot) — accuracy: {accuracy:.4f}, time: {train_time:.1f}s\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "d8e9f0a1",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"print(\"\\nFew-shot results (5 examples per class):\")\n",
|
||||
"print(\"-\" * 55)\n",
|
||||
"print(f\"{'Encoder':<35} {'Accuracy':>10} {'Time':>8}\")\n",
|
||||
"print(\"-\" * 55)\n",
|
||||
"for name, m in fewshot_results.items():\n",
|
||||
" print(f\"{name:<35} {m['accuracy']:>10.4f} {m['train_time_s']:>7.1f}s\")\n",
|
||||
"print(\"-\" * 55)\n",
|
||||
"\n",
|
||||
"print(\"\\nFull dataset results (for reference):\")\n",
|
||||
"print(\"-\" * 55)\n",
|
||||
"print(f\"{'Encoder':<35} {'Accuracy':>10} {'Time':>8}\")\n",
|
||||
"print(\"-\" * 55)\n",
|
||||
"for name in [\"stacked_cnn\", \"dinov2_linear_probe\"]:\n",
|
||||
" m = results[name]\n",
|
||||
" print(f\"{name:<35} {m['accuracy']:>10.4f} {m['train_time_s']:>7.1f}s\")\n",
|
||||
"print(\"-\" * 55)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "e9f0a1b2",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Summary and recommendations\n",
|
||||
"\n",
|
||||
"### When to use each encoder\n",
|
||||
"\n",
|
||||
"| Scenario | Recommended encoder |\n",
|
||||
"|---|---|\n",
|
||||
"| Large dataset (>10k images), custom domain | `stacked_cnn` or fine-tuned pretrained |\n",
|
||||
"| Small dataset (<1k images), natural images | `dinov2` linear probe |\n",
|
||||
"| Few-shot (<50 examples total) | `dinov2` or `siglip` linear probe |\n",
|
||||
"| Multimodal or image-text retrieval | `clip` or `siglip` |\n",
|
||||
"| Best possible accuracy, GPU available | `dinov2` fine-tuned |\n",
|
||||
"\n",
|
||||
"### Key takeaways\n",
|
||||
"\n",
|
||||
"- **Linear probing is surprisingly effective**: freezing the pretrained backbone and training only the head takes a fraction of the time and memory while achieving strong accuracy on small datasets.\n",
|
||||
"- **DINOv2 is the most versatile**: its self-supervised training (no text labels needed) makes it robust across diverse image domains.\n",
|
||||
"- **CLIP and SigLIP are better for semantically rich tasks**: their image-text alignment gives them an edge when classes have meaningful visual-semantic structure.\n",
|
||||
"- **Few-shot gap is dramatic**: with only 5 examples per class, pretrained encoders maintain high accuracy while the CNN from scratch struggles.\n",
|
||||
"\n",
|
||||
"### Changing the pretrained model\n",
|
||||
"\n",
|
||||
"You can use any HuggingFace-compatible model by overriding `pretrained_model_name_or_path`:\n",
|
||||
"\n",
|
||||
"```python\n",
|
||||
"{\n",
|
||||
" \"type\": \"dinov2\",\n",
|
||||
" \"use_pretrained\": True,\n",
|
||||
" \"trainable\": False,\n",
|
||||
" \"pretrained_model_name_or_path\": \"facebook/dinov2-large\", # larger model\n",
|
||||
"}\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"Available DINOv2 variants: `facebook/dinov2-small`, `facebook/dinov2-base`, `facebook/dinov2-large`, `facebook/dinov2-giant`\n",
|
||||
"\n",
|
||||
"Available CLIP variants: `openai/clip-vit-base-patch16`, `openai/clip-vit-large-patch14`\n",
|
||||
"\n",
|
||||
"Available SigLIP variants: `google/siglip-base-patch16-224`, `google/siglip-large-patch16-256`, `google/siglip-so400m-patch14-384`"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"accelerator": "GPU",
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.12.0"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
input_features:
|
||||
- name: image_path
|
||||
type: image
|
||||
preprocessing:
|
||||
height: 224
|
||||
width: 224
|
||||
in_memory: true
|
||||
num_channels: 3
|
||||
encoder: vit
|
||||
img_height: 224
|
||||
in_channels: 3
|
||||
use_pretrained: true
|
||||
hidden_size: 768
|
||||
num_hidden_layers: 12
|
||||
num_attention_heads: 12
|
||||
intermediate_size: 3072
|
||||
- name: insurance_company
|
||||
type: category
|
||||
preprocessing:
|
||||
missing_value_strategy: fill_with_const
|
||||
fill_value: UNKNOWN
|
||||
- name: cost_of_vehicle
|
||||
type: number
|
||||
preprocessing:
|
||||
missing_value_strategy: fill_with_mean
|
||||
normalization: zscore
|
||||
- name: expiry_date
|
||||
type: date
|
||||
preprocessing:
|
||||
missing_value_strategy: fill_with_const
|
||||
fill_value: ""
|
||||
datetime_format: "%Y-%m-%d"
|
||||
- name: min_coverage
|
||||
type: number
|
||||
preprocessing:
|
||||
missing_value_strategy: fill_with_mean
|
||||
normalization: zscore
|
||||
- name: max_coverage
|
||||
type: number
|
||||
preprocessing:
|
||||
missing_value_strategy: fill_with_mean
|
||||
normalization: zscore
|
||||
- name: condition
|
||||
type: category
|
||||
preprocessing:
|
||||
missing_value_strategy: fill_with_const
|
||||
fill_value: UNKNOWN
|
||||
combiner:
|
||||
type: concat
|
||||
num_fc_layers: 3
|
||||
output_size: 256
|
||||
output_features:
|
||||
- name: amount
|
||||
type: number
|
||||
preprocessing:
|
||||
normalization: zscore
|
||||
trainer:
|
||||
epochs: 10
|
||||
early_stop: 0
|
||||
batch_size: 8
|
||||
preprocessing:
|
||||
split:
|
||||
type: random
|
||||
probabilities: [0.7, 0.1, 0.2]
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# # Simple Model Training Example on multi-modal data.
|
||||
|
||||
# Import required libraries
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from ludwig.api import LudwigModel
|
||||
from ludwig.datasets import insurance_lite
|
||||
|
||||
# clean out prior results
|
||||
shutil.rmtree("./results", ignore_errors=True)
|
||||
|
||||
# Download and prepare the dataset
|
||||
dataset = insurance_lite.load()
|
||||
|
||||
# Define Ludwig model object that drive model training
|
||||
model = LudwigModel(config="./config.yaml", logging_level=logging.INFO, backend="local")
|
||||
|
||||
|
||||
# initiate model training
|
||||
(
|
||||
train_stats, # dictionary containing training statistics
|
||||
preprocessed_data, # tuple Ludwig Dataset objects of pre-processed training data
|
||||
output_directory, # location of training results stored on disk
|
||||
) = model.train(dataset=dataset, experiment_name="simple_experiment", model_name="simple_model")
|
||||
|
||||
# list contents of output directory
|
||||
print("contents of output directory:", output_directory)
|
||||
for item in os.listdir(output_directory):
|
||||
print("\t", item)
|
||||
@@ -0,0 +1,74 @@
|
||||
# K-Ffold Cross Validation Example
|
||||
|
||||
This directory contains two examples of performing a k-fold cross validation analysis with Ludwig.
|
||||
|
||||
## Classification Example
|
||||
|
||||
This example illustrates running the k-fold cv with the `ludwig experiment` cli.
|
||||
|
||||
To run this example execute this bash script:
|
||||
|
||||
```
|
||||
./k-fold_cv_classification.sh
|
||||
```
|
||||
|
||||
This bash script performs these steps:
|
||||
|
||||
- Download and prepare data for training and create a Ludwig config file
|
||||
- Execute `ludwig experiment` to run the 5-fold cross validation
|
||||
- Display results from the 5-fold cross validation analysis
|
||||
|
||||
Sample output:
|
||||
|
||||
```
|
||||
Cleaning out old results
|
||||
Downloading data set
|
||||
Preparing data for training
|
||||
Saving training and test data sets
|
||||
Preparing Ludwig config
|
||||
Completed data preparation
|
||||
Training: 100%|████████████████████████████████████████████████████████████████████████████████| 12/12 [00:00<00:00, 23.14it/s]
|
||||
Evaluation train: 100%|████████████████████████████████████████████████████████████████████████| 12/12 [00:00<00:00, 98.62it/s]
|
||||
Evaluation test : 100%|█████████████████████████████████████████████████████████████████████████| 3/3 [00:00<00:00, 321.03it/s]
|
||||
Training: 100%|███████████████████████████████████████████████████████████████████████████████| 12/12 [00:00<00:00, 190.18it/s]
|
||||
Evaluation train: 100%|███████████████████████████████████████████████████████████████████████| 12/12 [00:00<00:00, 331.68it/s]
|
||||
Evaluation test : 100%|█████████████████████████████████████████████████████████████████████████| 3/3 [00:00<00:00, 298.08it/s]
|
||||
<<<< DELETED LINES >>>>>
|
||||
Training: 100%|███████████████████████████████████████████████████████████████████████████████| 12/12 [00:00<00:00, 248.00it/s]
|
||||
Evaluation train: 100%|███████████████████████████████████████████████████████████████████████| 12/12 [00:00<00:00, 400.31it/s]
|
||||
Evaluation test : 100%|█████████████████████████████████████████████████████████████████████████| 3/3 [00:00<00:00, 340.35it/s]
|
||||
Evaluation: 100%|████████████████████████████████████████████████████████████████████████████████| 3/3 [00:00<00:00, 27.87it/s]
|
||||
retrieving results from results
|
||||
#
|
||||
# K-fold Cross Validation Results
|
||||
#
|
||||
{'combined': {'accuracy_mean': 0.9736263736263737,
|
||||
'accuracy_std': 0.011206636293610508,
|
||||
'loss_mean': 0.06359774886251807,
|
||||
'loss_std': 0.011785678840394689},
|
||||
'diagnosis': {'accuracy_mean': 0.9736263736263737,
|
||||
'accuracy_std': 0.011206636293610508,
|
||||
'average_precision_macro_mean': 0.995842104045726,
|
||||
'average_precision_macro_std': 0.002339014329647542,
|
||||
'average_precision_micro_mean': 0.995842104045726,
|
||||
'average_precision_micro_std': 0.002339014329647542,
|
||||
'average_precision_samples_mean': 0.995842104045726,
|
||||
'average_precision_samples_std': 0.002339014329647542,
|
||||
'loss_mean': 0.06359774886251807,
|
||||
'loss_std': 0.011785678840394689,
|
||||
'roc_auc_macro_mean': 0.9973999160508542,
|
||||
'roc_auc_macro_std': 0.0011259319854886507,
|
||||
'roc_auc_micro_mean': 0.9973999160508542,
|
||||
'roc_auc_micro_std': 0.0011259319854886507}}
|
||||
```
|
||||
|
||||
## Regression Example
|
||||
|
||||
This illustrates using the Ludwig API to run the K-fold cross validation analysis. To run the example, open the jupyter notebook `regression_example.ipynb`. Following steps are performed:
|
||||
|
||||
- Download and prepare data for training and create a Ludwig config data structure from a pandas dataframe structure
|
||||
- Use `ludwig.api.kfold_cross_validate()` function to run the 5-fold cross validation
|
||||
- Display results from the 5-fold cross validation analysis
|
||||
|
||||
Expected output from running the example:
|
||||

|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
|
||||
import argparse
|
||||
import os.path
|
||||
import pprint
|
||||
import sys
|
||||
|
||||
from ludwig.utils.data_utils import load_json
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Display K-fold cross validation results",
|
||||
prog="display_kfold_cv_results",
|
||||
usage="%(prog)s [options]",
|
||||
)
|
||||
|
||||
# ----------------------------
|
||||
# Experiment naming parameters
|
||||
# ----------------------------
|
||||
parser.add_argument(
|
||||
"--results_directory", type=str, default="results", help="directory that contains the K-fold cv results"
|
||||
)
|
||||
|
||||
args = parser.parse_args(sys.argv[1:])
|
||||
results_directory = args.results_directory
|
||||
|
||||
print("Retrieving results from ", results_directory)
|
||||
|
||||
kfold_cv_stats = load_json(os.path.join(results_directory, "kfold_training_statistics.json"))
|
||||
|
||||
print("#\n# K-fold Cross Validation Results\n#")
|
||||
pprint.pprint(kfold_cv_stats["overall"])
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
|
||||
#
|
||||
# Download and prepare training data
|
||||
#
|
||||
python prepare_classification_data_set.py
|
||||
|
||||
#
|
||||
# Run 5-fold cross validation
|
||||
#
|
||||
ludwig experiment \
|
||||
--config config.yaml \
|
||||
--dataset data/train.csv \
|
||||
--output_directory results \
|
||||
--logging_level 'error' \
|
||||
-kf 5
|
||||
|
||||
#
|
||||
# Display results from K-fold cv
|
||||
#
|
||||
python display_kfold_cv_results.py --results_directory results
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
|
||||
# Download and prepare training data set
|
||||
# Create Ludwig config file
|
||||
#
|
||||
# Based on the
|
||||
# [UCI Wisconsin Breast Cancer data set](https://archive.ics.uci.edu/ml/datasets/breast+cancer+wisconsin+(original))
|
||||
#
|
||||
import os.path
|
||||
import shutil
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
import yaml
|
||||
from sklearn.model_selection import train_test_split
|
||||
|
||||
from ludwig.constants import TRAINER
|
||||
|
||||
# Constants
|
||||
DATA_SET_URL = "https://archive.ics.uci.edu/ml/machine-learning-databases/breast-cancer-wisconsin/wdbc.data"
|
||||
DATA_SET = "wdbc.data"
|
||||
DATA_DIR = "./data"
|
||||
RESULTS_DIR = "results"
|
||||
|
||||
# Clean out previous results
|
||||
print("Cleaning out old results")
|
||||
if os.path.isfile(DATA_SET):
|
||||
os.remove(DATA_SET)
|
||||
if os.path.isfile("config.yaml"):
|
||||
os.remove("config.yaml")
|
||||
|
||||
shutil.rmtree(RESULTS_DIR, ignore_errors=True)
|
||||
shutil.rmtree(DATA_DIR, ignore_errors=True)
|
||||
|
||||
# Retrieve data from UCI Machine Learning Repository
|
||||
# Download required data
|
||||
print("Downloading data set")
|
||||
r = requests.get(DATA_SET_URL)
|
||||
if r.status_code == 200:
|
||||
with open(DATA_SET, "w") as f:
|
||||
f.write(r.content.decode("utf-8"))
|
||||
|
||||
# create pandas dataframe from downloaded data
|
||||
print("Preparing data for training")
|
||||
raw_df = pd.read_csv(DATA_SET, header=None, sep=",", skipinitialspace=True)
|
||||
raw_df.columns = ["ID", "diagnosis"] + ["X" + str(i) for i in range(1, 31)]
|
||||
|
||||
# convert diagnosis attribute to binary format
|
||||
raw_df["diagnosis"] = raw_df["diagnosis"].map({"M": 1, "B": 0})
|
||||
|
||||
# Create train/test split
|
||||
print("Saving training and test data sets")
|
||||
train_df, test_df = train_test_split(raw_df, train_size=0.8, random_state=17)
|
||||
os.mkdir(DATA_DIR)
|
||||
train_df.to_csv(os.path.join(DATA_DIR, "train.csv"), index=False)
|
||||
test_df.to_csv(os.path.join(DATA_DIR, "test.csv"), index=False)
|
||||
|
||||
print("Preparing Ludwig config")
|
||||
# Create ludwig input_features
|
||||
num_features = ["X" + str(i) for i in range(1, 31)]
|
||||
input_features = []
|
||||
|
||||
# setup input features for number variables
|
||||
for p in num_features:
|
||||
a_feature = {
|
||||
"name": p,
|
||||
"type": "number",
|
||||
"preprocessing": {"missing_value_strategy": "fill_with_mean", "normalization": "zscore"},
|
||||
}
|
||||
input_features.append(a_feature)
|
||||
|
||||
# Create ludwig output features
|
||||
output_features = [{"name": "diagnosis", "type": "binary", "num_fc_layers": 2, "output_size": 64}]
|
||||
|
||||
# setup ludwig config
|
||||
config = {
|
||||
"input_features": input_features,
|
||||
"output_features": output_features,
|
||||
TRAINER: {"epochs": 20, "batch_size": 32},
|
||||
}
|
||||
|
||||
with open("config.yaml", "w") as f:
|
||||
yaml.dump(config, f)
|
||||
|
||||
print("Completed data preparation")
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,40 @@
|
||||
input_features:
|
||||
- name: RESOURCE
|
||||
type: category
|
||||
- name: MGR_ID
|
||||
type: category
|
||||
- name: ROLE_ROLLUP_1
|
||||
type: category
|
||||
- name: ROLE_ROLLUP_2
|
||||
type: category
|
||||
- name: ROLE_DEPTNAME
|
||||
type: category
|
||||
- name: ROLE_TITLE
|
||||
type: category
|
||||
- name: ROLE_FAMILY_DESC
|
||||
type: category
|
||||
- name: ROLE_FAMILY
|
||||
type: category
|
||||
- name: ROLE_CODE
|
||||
type: category
|
||||
output_features:
|
||||
- name: ACTION
|
||||
type: binary
|
||||
preprocessing:
|
||||
split:
|
||||
type: fixed
|
||||
defaults:
|
||||
category:
|
||||
encoder:
|
||||
type: sparse
|
||||
trainer:
|
||||
batch_size: 32769 # entire training set
|
||||
train_steps: 1
|
||||
steps_per_checkpoint: 1
|
||||
learning_rate: 1
|
||||
regularization_lambda: 0.0000057
|
||||
optimizer:
|
||||
type: lbfgs
|
||||
max_iter: 100
|
||||
tolerance_grad: 0.0001
|
||||
history_size: 10
|
||||
@@ -0,0 +1,32 @@
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from ludwig.api import LudwigModel
|
||||
from ludwig.datasets import amazon_employee_access_challenge
|
||||
|
||||
df = amazon_employee_access_challenge.load()
|
||||
|
||||
model = LudwigModel(config="config.yaml", logging_level=logging.INFO)
|
||||
|
||||
training_statistics, preprocessed_data, output_directory = model.train(
|
||||
df,
|
||||
skip_save_processed_input=True,
|
||||
skip_save_log=True,
|
||||
skip_save_progress=True,
|
||||
skip_save_training_description=True,
|
||||
skip_save_training_statistics=True,
|
||||
)
|
||||
|
||||
# Predict on unlabeled test
|
||||
config = model.config
|
||||
config["preprocessing"] = {}
|
||||
model.config = config
|
||||
unlabeled_test = df[df.split == 2].reset_index(drop=True)
|
||||
preds, _ = model.predict(unlabeled_test)
|
||||
|
||||
# Save predictions to csv
|
||||
action = preds.ACTION_probabilities_True
|
||||
submission = pd.merge(unlabeled_test.reset_index(drop=True).id.astype(int), action, left_index=True, right_index=True)
|
||||
submission.rename(columns={"ACTION_probabilities_True": "Action", "id": "Id"}, inplace=True)
|
||||
submission.to_csv("submission.csv", index=False)
|
||||
@@ -0,0 +1,43 @@
|
||||
# Llama2-7b Fine-Tuning 4bit (QLoRA)
|
||||
|
||||
[](https://colab.research.google.com/drive/1c3AO8l_H6V_x37RwQ8V7M6A-RmcBf2tG?usp=sharing)
|
||||
|
||||
This example shows how to fine-tune [Llama2-7b](https://huggingface.co/meta-llama/Llama-2-7b-hf) to follow instructions.
|
||||
Instruction tuning is the first step in adapting a general purpose Large Language Model into a chatbot.
|
||||
|
||||
This example uses no distributed training or big data functionality. It is designed to run locally on any machine
|
||||
with GPU availability.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [HuggingFace API Token](https://huggingface.co/docs/hub/security-tokens)
|
||||
- Access approval to [Llama2-7b-hf](https://huggingface.co/meta-llama/Llama-2-7b-hf)
|
||||
- GPU with at least 12 GiB of VRAM (in our tests, we used an Nvidia T4)
|
||||
|
||||
## Running
|
||||
|
||||
### Command Line
|
||||
|
||||
Set your token environment variable from the terminal, then run the API script:
|
||||
|
||||
```bash
|
||||
export HUGGING_FACE_HUB_TOKEN="<api_token>"
|
||||
./run_train.sh
|
||||
```
|
||||
|
||||
### Python API
|
||||
|
||||
Set your token environment variable from the terminal, then run the API script:
|
||||
|
||||
```bash
|
||||
export HUGGING_FACE_HUB_TOKEN="<api_token>"
|
||||
python train_alpaca.py
|
||||
```
|
||||
|
||||
## Upload to HuggingFace
|
||||
|
||||
You can upload to the HuggingFace Hub from the command line:
|
||||
|
||||
```bash
|
||||
ludwig upload hf_hub -r <your_org>/<model_name> -m <path/to/model>
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
model_type: llm
|
||||
base_model: meta-llama/Llama-2-7b-hf
|
||||
|
||||
quantization:
|
||||
bits: 4
|
||||
|
||||
adapter:
|
||||
type: lora
|
||||
|
||||
input_features:
|
||||
- name: instruction
|
||||
type: text
|
||||
|
||||
output_features:
|
||||
- name: output
|
||||
type: text
|
||||
|
||||
trainer:
|
||||
type: finetune
|
||||
learning_rate: 0.0003
|
||||
batch_size: 2
|
||||
gradient_accumulation_steps: 8
|
||||
epochs: 3
|
||||
learning_rate_scheduler:
|
||||
warmup_fraction: 0.01
|
||||
|
||||
backend:
|
||||
type: local
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Fail fast if an error occurs
|
||||
set -e
|
||||
|
||||
# Get the directory of this script, which contains the config file
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
|
||||
# Train
|
||||
ludwig train --config ${SCRIPT_DIR}/llama2_7b_4bit.yaml --dataset ludwig://alpaca
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user