Files
2026-07-13 13:35:10 +08:00

468 lines
16 KiB
Python

#!/usr/bin/env python3
"""
Data utilities for Text-to-SQL evaluation with Ragas.
This module provides CLI tools to download and prepare datasets for
text-to-SQL evaluation workflows.
"""
import argparse
import json
import logging
import sys
from pathlib import Path
from typing import Any, Dict, List
# Load environment variables from ragas root
try:
from dotenv import load_dotenv
# Load .env from ragas root directory (3 levels up from this file)
ragas_root = Path(__file__).parent.parent.parent.parent
env_path = ragas_root / ".env"
load_dotenv(env_path)
except ImportError:
# dotenv is optional, continue without it
pass
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(levelname)s: %(message)s'
)
logger = logging.getLogger(__name__)
try:
from huggingface_hub import snapshot_download
from huggingface_hub.errors import GatedRepoError, RepositoryNotFoundError
except ImportError:
logger.error("huggingface_hub is required. Install with: pip install huggingface_hub")
sys.exit(1)
try:
import pandas as pd
from pandas import DataFrame
except ImportError:
logger.error("pandas is required. Install with: pip install pandas")
sys.exit(1)
# Import validation functions from validate_sql_dataset.py
try:
from .validate_sql_dataset import execute_and_validate_query
except ImportError:
logger.error("validate_sql_dataset.py not found in the same directory")
sys.exit(1)
def download_booksql_dataset() -> bool:
"""
Download the BookSQL dataset from Hugging Face Hub to ./BookSQL-files directory.
Returns:
bool: True if download successful, False otherwise
Note:
This dataset is gated and requires accepting terms on the Hugging Face Hub.
You need to:
1. Visit https://huggingface.co/datasets/Exploration-Lab/BookSQL
2. Accept the terms and conditions
3. Authenticate with: huggingface-cli login
"""
repo_id = "Exploration-Lab/BookSQL"
local_dir = "BookSQL-files"
# Create local directory if it doesn't exist
Path(local_dir).mkdir(parents=True, exist_ok=True)
logger.info(f"Downloading BookSQL dataset to {local_dir}")
logger.info(f"Repository: {repo_id}")
try:
# Download the entire repository
downloaded_path = snapshot_download(
repo_id=repo_id,
repo_type="dataset",
local_dir=local_dir,
local_dir_use_symlinks=False # Create actual files, not symlinks
)
logger.info(f"Successfully downloaded dataset to: {downloaded_path}")
# List downloaded files
dataset_path = Path(local_dir)
files = list(dataset_path.rglob("*"))
logger.info(f"Downloaded {len(files)} files")
for file in sorted(files)[:5]: # Show first 5 files
if file.is_file():
logger.info(f" {file.relative_to(dataset_path)}")
if len(files) > 5:
logger.info(f" ... and {len(files) - 5} more files")
return True
except GatedRepoError:
logger.error("This dataset is gated and requires authentication")
logger.error("Please follow these steps:")
logger.error("1. Visit: https://huggingface.co/datasets/Exploration-Lab/BookSQL")
logger.error("2. Accept the terms and conditions")
logger.error("3. Run: huggingface-cli login")
logger.error("4. Try downloading again")
return False
except RepositoryNotFoundError:
logger.error(f"Repository '{repo_id}' not found")
return False
except Exception as e:
logger.error(f"Error downloading dataset: {e}")
return False
def validate_query_data(query_data: Dict[str, Any], require_data: bool = False) -> bool:
"""
Validate a single query by executing it against the database.
Args:
query_data: Dictionary containing query information (query, sql, level, split)
require_data: If True, only accept queries that return actual data
Returns:
bool: True if query is valid (and optionally returns data), False otherwise
"""
try:
result = execute_and_validate_query(query_data)
if not result['execution_success']:
return False
if require_data:
# Only accept queries that return actual data (not empty or null values)
return result.get('result_type') == 'has_data'
else:
# Accept any successful query execution
return True
except Exception as e:
logger.warning(f"Error validating query: {e}")
return False
def load_and_clean_data(input_file: str) -> DataFrame:
"""
Load JSON data and remove duplicates.
Args:
input_file: Path to the BookSQL train.json file
Returns:
DataFrame: Cleaned train data with duplicates removed
Raises:
FileNotFoundError: If input file doesn't exist
json.JSONDecodeError: If JSON is invalid
"""
input_path = Path(input_file)
if not input_path.exists():
raise FileNotFoundError(f"Input file '{input_file}' not found")
logger.info(f"Loading data from {input_file}")
# Load JSON data
with open(input_path, 'r', encoding='utf-8') as f:
data = json.load(f)
logger.info(f"Loaded {len(data)} total records")
# Convert to DataFrame and filter for train split
df = pd.DataFrame(data)
train_df = df[df['split'] == 'train'].copy()
logger.info(f"Found {len(train_df)} train records")
# Remove duplicates based on Query + SQL combination
original_count = len(train_df)
train_df = train_df.drop_duplicates(subset=['Query', 'SQL'], keep='first')
duplicate_count = original_count - len(train_df)
if duplicate_count > 0:
logger.info(f"Removed {duplicate_count} duplicate records")
logger.info(f"{len(train_df)} unique records remaining")
# Show difficulty distribution
level_counts = train_df['Levels'].value_counts()
logger.info("Difficulty distribution after deduplication:")
for level, count in level_counts.items():
logger.info(f" {level}: {count} records")
return train_df
def sample_by_difficulty(data: DataFrame, level: str, samples_per_level: int, random_seed: int) -> DataFrame:
"""
Sample data for a specific difficulty level.
Args:
data: DataFrame containing the data
level: Difficulty level ('easy', 'medium', 'hard')
samples_per_level: Number of samples to take
random_seed: Random seed for reproducible sampling
Returns:
DataFrame: Sampled data for the specified level
"""
level_data = data[data['Levels'] == level]
if len(level_data) == 0:
logger.warning(f"No '{level}' records found, skipping")
return pd.DataFrame()
if len(level_data) < samples_per_level:
logger.warning(f"Only {len(level_data)} '{level}' records available, using all")
return level_data
else:
sampled = level_data.sample(n=samples_per_level, random_state=random_seed)
logger.info(f"Sampled {len(sampled)} '{level}' records")
return sampled
def validate_samples(data: DataFrame, level: str, samples_per_level: int,
random_seed: int, require_data: bool = False) -> DataFrame:
"""
Sample and validate data for a specific difficulty level.
Args:
data: DataFrame containing the data
level: Difficulty level ('easy', 'medium', 'hard')
samples_per_level: Number of samples to find
random_seed: Random seed for reproducible sampling
require_data: If True, only include queries that return data
Returns:
DataFrame: Validated samples for the specified level
"""
level_data = data[data['Levels'] == level]
if len(level_data) == 0:
logger.warning(f"No '{level}' records found, skipping")
return pd.DataFrame()
logger.info(f"Validating '{level}' queries to find {samples_per_level} valid samples")
# Shuffle data for random sampling during validation
shuffled_data = level_data.sample(frac=1, random_state=random_seed).reset_index(drop=True)
valid_samples = []
checked_count = 0
for idx, row in shuffled_data.iterrows():
checked_count += 1
# Prepare query data for validation
query_data = {
'index': idx,
'query': row['Query'],
'sql': row['SQL'],
'level': row['Levels'],
'split': row['split']
}
if validate_query_data(query_data, require_data):
valid_samples.append(row)
# Stop if we have enough samples
if len(valid_samples) >= samples_per_level:
break
if len(valid_samples) == 0:
logger.warning(f"No valid '{level}' queries found, skipping this level")
return pd.DataFrame()
elif len(valid_samples) < samples_per_level:
logger.warning(f"Only found {len(valid_samples)} valid '{level}' queries out of {samples_per_level} requested")
else:
logger.info(f"Found {len(valid_samples)} valid '{level}' queries")
return pd.DataFrame(valid_samples) if valid_samples else pd.DataFrame()
def save_results(data: DataFrame, output_dir: str, output_filename: str, random_seed: int) -> bool:
"""
Save final dataset to CSV.
Args:
data: Final dataset to save
output_dir: Directory to save the output CSV
output_filename: Name of the output CSV file
random_seed: Random seed for final shuffle
Returns:
bool: True if successful, False otherwise
"""
if data.empty:
logger.error("No data to save")
return False
# Create output directory
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
# Final duplicate check
pre_final_count = len(data)
data = data.drop_duplicates(subset=['Query', 'SQL'], keep='first')
final_duplicate_count = pre_final_count - len(data)
if final_duplicate_count > 0:
logger.warning(f"Removed {final_duplicate_count} duplicates from final sample")
# Shuffle the final dataset
data = data.sample(frac=1, random_state=random_seed).reset_index(drop=True)
# Save to CSV
output_file_path = output_path / output_filename
data.to_csv(output_file_path, index=False)
logger.info(f"Saved {len(data)} records to {output_file_path}")
logger.info("Final distribution:")
for level, count in data['Levels'].value_counts().items():
logger.info(f" {level}: {count} records")
return True
def create_sample_dataset(
input_file: str = "BookSQL-files/BookSQL/train.json",
output_dir: str = "datasets",
output_filename: str = "booksql_sample.csv",
samples_per_level: int = 10,
random_seed: int = 42,
validate_queries: bool = False,
require_data: bool = False
) -> bool:
"""
Create a balanced sample dataset from BookSQL train.json.
This function orchestrates the data loading, sampling, validation, and saving process.
Args:
input_file: Path to the BookSQL train.json file
output_dir: Directory to save the output CSV
output_filename: Name of the output CSV file
samples_per_level: Number of samples per difficulty level (easy, medium, hard)
random_seed: Random seed for reproducible sampling
validate_queries: If True, validate SQL queries before including them
require_data: If True (and validate_queries=True), only include queries that return data
Returns:
bool: True if successful, False otherwise
"""
try:
# Step 1: Load and clean data
train_df = load_and_clean_data(input_file)
# Step 2: Sample data for each difficulty level
sampled_dfs = []
if validate_queries:
logger.info("Validation enabled - testing SQL queries before including them in sample")
if require_data:
logger.info("Only including queries that return actual data")
for level in ['easy', 'medium', 'hard']:
if validate_queries:
sampled = validate_samples(train_df, level, samples_per_level, random_seed, require_data)
else:
sampled = sample_by_difficulty(train_df, level, samples_per_level, random_seed)
if not sampled.empty:
sampled_dfs.append(sampled)
if not sampled_dfs:
logger.error("No data could be sampled")
return False
# Step 3: Combine all sampled data
final_df = pd.concat(sampled_dfs, ignore_index=True)
# Step 4: Save results
return save_results(final_df, output_dir, output_filename, random_seed)
except FileNotFoundError:
logger.error(f"Input file '{input_file}' not found")
logger.error("Tip: Run with --download-data first to download the BookSQL dataset")
return False
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in {input_file}: {e}")
return False
except Exception as e:
logger.error(f"Error processing data: {e}")
return False
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
description="Data utilities for Text-to-SQL evaluation",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --download-data # Download BookSQL dataset
%(prog)s --create-sample # Create sample CSV (15 per level)
%(prog)s --create-sample --samples 5 # Create sample with 5 per level
%(prog)s --create-sample --validate # Create sample with SQL validation
%(prog)s --create-sample --validate --require-data # Only queries that return data
"""
)
parser.add_argument(
"--download-data",
action="store_true",
help="Download the BookSQL dataset to ./BookSQL-files directory"
)
parser.add_argument(
"--create-sample",
action="store_true",
help="Create a balanced sample CSV from BookSQL train.json"
)
parser.add_argument(
"--samples",
type=int,
default=15,
help="Number of samples per difficulty level (default: 15)"
)
parser.add_argument(
"--validate",
action="store_true",
help="Validate SQL queries before including them in the sample"
)
parser.add_argument(
"--require-data",
action="store_true",
help="Only include queries that return actual data (requires --validate)"
)
args = parser.parse_args()
if args.download_data:
success = download_booksql_dataset()
sys.exit(0 if success else 1)
elif args.create_sample:
# Validate argument combinations
if args.require_data and not args.validate:
logger.error("--require-data requires --validate to be enabled")
sys.exit(1)
success = create_sample_dataset(
samples_per_level=args.samples,
validate_queries=args.validate,
require_data=args.require_data
)
sys.exit(0 if success else 1)
else:
parser.print_help()
if __name__ == "__main__":
main()