468 lines
16 KiB
Python
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()
|