Current section

Files

Jump to
crucible_datasets lib dataset_manager sampler.ex
Raw

lib/dataset_manager/sampler.ex

defmodule CrucibleDatasets.Sampler do
@moduledoc """
Create representative subsets of datasets for experimentation.
Supports:
- Random sampling
- Stratified sampling (maintain distribution)
- K-fold cross-validation
"""
alias CrucibleDatasets.Dataset
@doc """
Create random sample from dataset.
## Options
* `:size` - Number of items to sample (default: 100)
* `:seed` - Random seed for reproducibility (default: random)
## Examples
{:ok, dataset} = CrucibleDatasets.Loader.load(:mmlu)
{:ok, sample} = CrucibleDatasets.Sampler.random(dataset, size: 200)
length(sample.items)
# => 200
"""
@spec random(Dataset.t(), keyword()) :: {:ok, Dataset.t()}
def random(%Dataset{} = dataset, opts \\ []) do
size = Keyword.get(opts, :size, 100)
seed = Keyword.get(opts, :seed, :rand.uniform(1_000_000))
:rand.seed(:exsss, {seed, seed, seed})
sampled_items = Enum.take_random(dataset.items, min(size, length(dataset.items)))
sampled_dataset = %{
dataset
| name: "#{dataset.name}_random_#{size}",
items: sampled_items,
metadata:
Map.merge(dataset.metadata, %{
sample_method: :random,
sample_size: length(sampled_items),
sample_seed: seed,
original_size: length(dataset.items)
})
}
{:ok, sampled_dataset}
end
@doc """
Create stratified sample maintaining distribution of a field.
## Options
* `:size` - Total number of items to sample (required)
* `:strata_field` - Field path to stratify by (required)
Can be atom or list of keys for nested access
## Examples
# Sample 200 items, maintaining subject distribution
{:ok, sample} = CrucibleDatasets.Sampler.stratified(dataset,
size: 200,
strata_field: [:metadata, :subject]
)
# If original has 30% science, 40% humanities, 30% other
# Sample will have same proportions
"""
@spec stratified(Dataset.t(), keyword()) :: {:ok, Dataset.t()} | {:error, term()}
def stratified(%Dataset{} = dataset, opts \\ []) do
with {:ok, size} <- Keyword.fetch(opts, :size),
{:ok, strata_field} <- Keyword.fetch(opts, :strata_field) do
# Normalize strata_field to list
field_path = if is_list(strata_field), do: strata_field, else: [strata_field]
# Group by strata
groups = Enum.group_by(dataset.items, &get_in(&1, field_path))
# Calculate samples per stratum
total_items = length(dataset.items)
samples_per_stratum =
groups
|> Enum.map(fn {stratum, items} ->
proportion = length(items) / total_items
sample_count = round(proportion * size)
{stratum, sample_count}
end)
|> Map.new()
# Adjust if total exceeds requested size due to rounding
total_allocated = samples_per_stratum |> Map.values() |> Enum.sum()
samples_per_stratum = adjust_strata_allocation(samples_per_stratum, total_allocated, size)
# Sample from each stratum
sampled_items =
groups
|> Enum.flat_map(fn {stratum, items} ->
n = Map.get(samples_per_stratum, stratum, 0)
Enum.take_random(items, min(n, length(items)))
end)
sampled_dataset = %{
dataset
| name: "#{dataset.name}_stratified_#{size}",
items: sampled_items,
metadata:
Map.merge(dataset.metadata, %{
sample_method: :stratified,
sample_size: length(sampled_items),
strata_field: field_path,
strata_distribution: samples_per_stratum,
original_size: total_items
})
}
{:ok, sampled_dataset}
else
:error -> {:error, :missing_required_option}
end
end
@doc """
Create k-fold cross-validation splits.
## Options
* `:k` - Number of folds (default: 5)
* `:shuffle` - Shuffle before splitting (default: true)
* `:seed` - Random seed for shuffling (default: random)
## Examples
{:ok, folds} = CrucibleDatasets.Sampler.k_fold(dataset, k: 5)
# Returns 5 train/test splits
Enum.each(folds, fn {train, test} ->
# Train on 80%, test on 20%
evaluate_model(train, test)
end)
"""
@spec k_fold(Dataset.t(), keyword()) :: {:ok, [{Dataset.t(), Dataset.t()}]}
def k_fold(%Dataset{} = dataset, opts \\ []) do
k = Keyword.get(opts, :k, 5)
shuffle = Keyword.get(opts, :shuffle, true)
seed = Keyword.get(opts, :seed, :rand.uniform(1_000_000))
items =
if shuffle do
:rand.seed(:exsss, {seed, seed, seed})
Enum.shuffle(dataset.items)
else
dataset.items
end
fold_size = div(length(items), k)
folds =
0..(k - 1)
|> Enum.map(fn i ->
test_start = i * fold_size
test_end = min((i + 1) * fold_size, length(items))
test_items = Enum.slice(items, test_start, test_end - test_start)
train_items = Enum.take(items, test_start) ++ Enum.drop(items, test_end)
train_dataset = %{
dataset
| name: "#{dataset.name}_fold#{i}_train",
items: train_items,
metadata:
Map.merge(dataset.metadata, %{
fold: i,
split: :train,
k_folds: k
})
}
test_dataset = %{
dataset
| name: "#{dataset.name}_fold#{i}_test",
items: test_items,
metadata:
Map.merge(dataset.metadata, %{
fold: i,
split: :test,
k_folds: k
})
}
{train_dataset, test_dataset}
end)
{:ok, folds}
end
@doc """
Split dataset into train and test sets.
## Options
* `:test_size` - Proportion for test set (0.0 to 1.0, default: 0.2)
* `:shuffle` - Shuffle before splitting (default: true)
* `:seed` - Random seed for shuffling (default: random)
## Examples
{:ok, {train, test}} = CrucibleDatasets.Sampler.train_test_split(
dataset,
test_size: 0.2,
shuffle: true
)
length(train.items) + length(test.items) == length(dataset.items)
# => true
"""
@spec train_test_split(Dataset.t(), keyword()) :: {:ok, {Dataset.t(), Dataset.t()}}
def train_test_split(%Dataset{} = dataset, opts \\ []) do
test_size = Keyword.get(opts, :test_size, 0.2)
shuffle = Keyword.get(opts, :shuffle, true)
seed = Keyword.get(opts, :seed, :rand.uniform(1_000_000))
items =
if shuffle do
:rand.seed(:exsss, {seed, seed, seed})
Enum.shuffle(dataset.items)
else
dataset.items
end
total = length(items)
test_count = round(total * test_size)
train_count = total - test_count
train_items = Enum.take(items, train_count)
test_items = Enum.drop(items, train_count)
train_dataset = %{
dataset
| name: "#{dataset.name}_train",
items: train_items,
metadata: Map.merge(dataset.metadata, %{split: :train, split_ratio: 1.0 - test_size})
}
test_dataset = %{
dataset
| name: "#{dataset.name}_test",
items: test_items,
metadata: Map.merge(dataset.metadata, %{split: :test, split_ratio: test_size})
}
{:ok, {train_dataset, test_dataset}}
end
# Adjust strata allocation if rounding caused over-allocation
defp adjust_strata_allocation(samples_per_stratum, total_allocated, size)
when total_allocated <= size do
samples_per_stratum
end
defp adjust_strata_allocation(samples_per_stratum, total_allocated, size) do
excess = total_allocated - size
samples_per_stratum
|> Enum.sort_by(fn {_stratum, count} -> -count end)
|> Enum.reduce({%{}, excess}, &reduce_stratum_allocation/2)
|> elem(0)
end
# Reduce allocation for a single stratum if there's remaining excess
defp reduce_stratum_allocation({stratum, count}, {acc_map, 0}) do
{Map.put(acc_map, stratum, count), 0}
end
defp reduce_stratum_allocation({stratum, count}, {acc_map, remaining}) do
reduction = min(remaining, count)
{Map.put(acc_map, stratum, count - reduction), remaining - reduction}
end
end