IPEDS_StudentToFacultyRatio: Adding provisional data mapping, updated download/preprocess scripts, and golden validation - #2117
Conversation
There was a problem hiding this comment.
Code Review
This pull request updates the IPEDS student-to-faculty ratio import to support downloading and preprocessing both revised and provisional datasets, adding a measurementMethod column to handle provisional estimates. Feedback on these changes suggests simplifying the download logic in download.py to prevent redundant zip file downloads by trying candidate URLs in order of preference and extracting files in a single pass. Additionally, the reviewer notes that dropping the measurementMethod column from revised files in preprocess.py would cause schema inconsistencies across years, recommending instead to keep the column and populate it with empty strings for revised datasets.
| def process_and_filter_zip(zip_path: str, output_dir: str, require_rv: bool) -> bool: | ||
| """ | ||
| Handles the custom unzipping, RV-pattern filtering, and cleanup. | ||
| If extraction fails unexpectedly, it is treated as a fatal error. | ||
| Unzips and filters contents of zip_path. | ||
| If require_rv is True, extracts only files matching RV_PATTERN. | ||
| If require_rv is False, extracts files matching PROVISIONAL_PATTERN. | ||
| Returns True if matching file(s) were found and extracted, False otherwise. | ||
| """ | ||
| zip_filename = os.path.basename(zip_path) | ||
| logging.info(" Unzipping and filtering contents (keeping only files matching pattern: %s)...", filter_pattern.pattern) | ||
|
|
||
| extraction_successful = False | ||
|
|
||
| try: | ||
| # 1. Unzip and filter | ||
| with zipfile.ZipFile(zip_path, 'r') as zip_ref: | ||
| all_files = zip_ref.namelist() | ||
| files_to_extract = [] | ||
| target_pattern = RV_PATTERN if require_rv else PROVISIONAL_PATTERN | ||
|
|
||
| for file_name in all_files: | ||
| base_name = os.path.basename(file_name) | ||
| # Check if the file is at the root or within a single folder. | ||
| if filter_pattern.search(base_name): | ||
| files_to_extract.append(file_name) | ||
| files_to_extract = [ | ||
| f for f in all_files if target_pattern.search(os.path.basename(f)) | ||
| ] | ||
|
|
||
| if not files_to_extract: | ||
| # This is a warning, not a failure, as the process was clean. | ||
| logging.fatal(" Warning: No files matching the pattern found in %s. Skipping extraction.", zip_filename) | ||
| extraction_successful = True | ||
| return False | ||
|
|
||
| # Extract the filtered files | ||
| for file_name in files_to_extract: | ||
| zip_ref.extract(file_name, output_dir) | ||
| logging.info(" Extracted: %s", file_name) | ||
| logging.info(" Extracted (%s): %s", "REVISED" if require_rv else "PROVISIONAL", file_name) | ||
|
|
||
| if files_to_extract: | ||
| logging.info(" Extraction successful.") | ||
| extraction_successful = True | ||
| return True | ||
|
|
||
| except zipfile.BadZipFile: | ||
| # FATAL: Corrupted zip file means data is unavailable, and we must stop processing this file. | ||
| logging.fatal(" FATAL ERROR: %s is a corrupted or empty zip file. Cannot proceed.", zip_filename) | ||
| raise RuntimeError(f"Corrupted or empty zip file encountered: {zip_filename}") | ||
| except Exception as e: | ||
| # FATAL: Any unexpected extraction error means partial data, which must be avoided. | ||
| logging.fatal(" FATAL ERROR: An unexpected error occurred during unzipping/extraction of %s: %s", zip_filename, e) | ||
| # Raise an error to stop the script from proceeding with potentially partial unzipped files | ||
| raise RuntimeError(f"Extraction failed for {zip_filename}: {e}") | ||
| finally: | ||
| # 2. Clean up the downloaded zip file manually, regardless of success or failure | ||
| if os.path.exists(zip_path): | ||
| try: | ||
| os.remove(zip_path) | ||
| logging.info(" Removed zip file: %s", zip_path) | ||
| except OSError as e: | ||
| # Use info for non-critical file removal errors | ||
| logging.info(" Warning: Failed to remove zip file %s: %s", zip_path, e) | ||
|
|
||
| return extraction_successful | ||
| except OSError: | ||
| pass |
There was a problem hiding this comment.
The current implementation of process_and_filter_zip and download_for_year splits the download process into two separate phases (Phase 1 for revised data, Phase 2 for provisional data). This causes the script to download the same large zip files multiple times (up to 8 times per year if only provisional data is available), which is highly inefficient and risks rate-limiting or IP bans from the NCES servers.
We can simplify this significantly by trying the candidate URLs in order of preference (with BASE_URL_RV first) and extracting whatever matches either the revised or provisional pattern. Since preprocess.py already handles selecting the revised file and deleting redundant files, we only need to download the first working zip file per year.
def process_and_filter_zip(zip_path: str, output_dir: str) -> bool:
"""
Unzips and filters contents of zip_path.
Extracts files matching RV_PATTERN or PROVISIONAL_PATTERN.
Returns True if matching file(s) were found and extracted, False otherwise.
"""
zip_filename = os.path.basename(zip_path)
try:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
all_files = zip_ref.namelist()
files_to_extract = [
f for f in all_files if RV_PATTERN.search(os.path.basename(f))
]
if not files_to_extract:
files_to_extract = [
f for f in all_files if PROVISIONAL_PATTERN.search(os.path.basename(f))
]
if not files_to_extract:
return False
for file_name in files_to_extract:
zip_ref.extract(file_name, output_dir)
logging.info(" Extracted: %s", file_name)
return True
except zipfile.BadZipFile:
logging.fatal(" FATAL ERROR: %s is a corrupted or empty zip file. Cannot proceed.", zip_filename)
raise RuntimeError(f"Corrupted or empty zip file encountered: {zip_filename}")
except Exception as e:
logging.fatal(" FATAL ERROR: An unexpected error occurred during unzipping/extraction of %s: %s", zip_filename, e)
raise RuntimeError(f"Extraction failed for {zip_filename}: {e}")
finally:
if os.path.exists(zip_path):
try:
os.remove(zip_path)
except OSError:
passReferences
- When downloading and processing ZIP files, ensure that exception handling covers both network-related errors (e.g., requests.exceptions.RequestException) and ZIP-specific errors (e.g., zipfile.BadZipFile) to prevent the script from crashing on corrupted or invalid downloads.
| def download_for_year(year: int) -> bool: | ||
| """ | ||
| Downloads IPEDS zip files using the utility, then handles custom unzipping/filtering locally. | ||
| Download failure is logged as a warning, allowing the script to proceed to the next year. | ||
| Extraction failure is logged as FATAL, which will stop the script for that year's file. | ||
| Downloads data for a given year following strict precedence: | ||
| 1. Checks candidate URLs to see if a REVISED dataset (_rv) is available. | ||
| 2. ONLY IF no revised dataset is available across any candidate URL, | ||
| checks candidate URLs to download a PROVISIONAL dataset. | ||
| """ | ||
| logging.info("\nProcessing year %d...", year) | ||
|
|
||
| # 1. Create the target directory if it doesn't exist | ||
| if not os.path.exists(DOWNLOAD_DIR): | ||
| try: | ||
| os.makedirs(DOWNLOAD_DIR) | ||
| logging.info("Created directory: %s", DOWNLOAD_DIR) | ||
| except OSError as e: | ||
| # Use logging.fatal for critical directory creation errors | ||
| logging.fatal("FATAL ERROR: Could not create directory %s: %s", DOWNLOAD_DIR, e) | ||
| raise RuntimeError(f"FATAL: Directory creation failed for {DOWNLOAD_DIR}: {e}") | ||
| # --- Phase 1: Try to download REVISED dataset across candidate URLs --- | ||
| for url_template in BASE_URL_TEMPLATES: | ||
| if "{year}" in url_template: | ||
| url = url_template.format(year=year) | ||
| else: | ||
| url = url_template.format(year) | ||
|
|
||
| # 2. Iterate through the required year range | ||
| for year in range(START_YEAR, END_YEAR + 1): | ||
| url = BASE_URL.format(year) | ||
| zip_filename = f"EF{year}D.zip" | ||
| download_path = os.path.join(DOWNLOAD_DIR, zip_filename) | ||
|
|
||
| logging.info("\nProcessing year %d...", year) | ||
|
|
||
| try: | ||
| # 3. Call the utility function to DOWNLOAD ONLY (unzip=False) | ||
| download_success = download_file( | ||
| url=url, | ||
| output_folder=DOWNLOAD_DIR, | ||
| unzip=False, # <-- CRITICAL: Do not let the utility unzip the file | ||
| tries=3, | ||
| delay=5, | ||
| backoff=2 | ||
| unzip=False, # <-- CRITICAL: Do not let utility unzip file | ||
| tries=1, | ||
| delay=1, | ||
| backoff=1 | ||
| ) | ||
|
|
||
| if download_success: | ||
| # 4. Handle custom processing (unzip, filter, and cleanup) locally | ||
| # If process_and_filter_zip encounters a fatal error, it will raise an exception | ||
| process_and_filter_zip(download_path, DOWNLOAD_DIR, RV_PATTERN) | ||
| if download_success and os.path.exists(download_path) and zipfile.is_zipfile(download_path): | ||
| if process_and_filter_zip(download_path, DOWNLOAD_DIR, require_rv=True): | ||
| logging.info(" Successfully fetched REVISED dataset for year %d.", year) | ||
| return True | ||
| except Exception as e: | ||
| logging.info(" Candidate URL %s (REVISED check) failed: %s", url, e) | ||
|
|
||
| logging.info(" No REVISED dataset available for year %d. Checking for PROVISIONAL dataset...", year) | ||
|
|
||
| else: | ||
| # Not available/download failed: Log as a warning and skip, as requested | ||
| logging.info("Warning: Download failed for year %d. Skipping processing for this year.", year) | ||
| # --- Phase 2: ONLY if REVISED is not available, try to download PROVISIONAL dataset --- | ||
| for url_template in BASE_URL_TEMPLATES: | ||
| if "{year}" in url_template: | ||
| url = url_template.format(year=year) | ||
| else: | ||
| url = url_template.format(year) | ||
|
|
||
| zip_filename = f"EF{year}D.zip" | ||
| download_path = os.path.join(DOWNLOAD_DIR, zip_filename) | ||
|
|
||
| try: | ||
| download_success = download_file( | ||
| url=url, | ||
| output_folder=DOWNLOAD_DIR, | ||
| unzip=False, | ||
| tries=1, | ||
| delay=1, | ||
| backoff=1 | ||
| ) | ||
|
|
||
| if download_success and os.path.exists(download_path) and zipfile.is_zipfile(download_path): | ||
| if process_and_filter_zip(download_path, DOWNLOAD_DIR, require_rv=False): | ||
| logging.info(" Successfully fetched PROVISIONAL dataset for year %d.", year) | ||
| return True | ||
| except Exception as e: | ||
| # Catch errors that process_and_filter_zip explicitly raises (BadZipFile, Extraction Error) | ||
| # or any other unexpected error during the loop. The error is already logged as FATAL. | ||
| logging.info("Execution failed for year %d, continuing to next year (if possible). Error: %s", year, e) | ||
| # The script will now proceed to the next iteration unless the error is outside the loop | ||
| logging.info(" Candidate URL %s (PROVISIONAL check) failed: %s", url, e) | ||
|
|
||
| logging.info("Warning: Neither REVISED nor PROVISIONAL dataset found for year %d across candidate URLs.", year) | ||
| return False |
There was a problem hiding this comment.
Simplify download_for_year to try candidate URLs in order of preference, downloading and extracting the first successful match. This completely eliminates the redundant downloads and the complex two-phase logic. Also, configure a higher number of retries (e.g., tries=10) to ensure download success from unstable external sources, and handle network-related exceptions.
def download_for_year(year: int) -> bool:
"""
Downloads data for a given year by trying candidate URLs in order of preference.
"""
logging.info("\nProcessing year %d...", year)
urls_to_try = [
BASE_URL_RV,
BASE_URL_LEGACY,
BASE_URL_COMPLETE,
BASE_URL_PROV,
]
for url_template in urls_to_try:
if "{year}" in url_template:
url = url_template.format(year=year)
else:
url = url_template.format(year)
zip_filename = f"EF{year}D.zip"
download_path = os.path.join(DOWNLOAD_DIR, zip_filename)
try:
download_success = download_file(
url=url,
output_folder=DOWNLOAD_DIR,
unzip=False, # <-- CRITICAL: Do not let utility unzip file
tries=10,
delay=1,
backoff=1
)
if download_success and os.path.exists(download_path) and zipfile.is_zipfile(download_path):
if process_and_filter_zip(download_path, DOWNLOAD_DIR):
logging.info(" Successfully fetched dataset for year %d.", year)
return True
except requests.exceptions.RequestException as e:
logging.info(" Network error for candidate URL %s: %s", url, e)
except Exception as e:
logging.info(" Candidate URL %s failed: %s", url, e)
logging.info("Warning: No dataset found for year %d across candidate URLs.", year)
return FalseReferences
- When downloading data from unstable or unreliable external sources, configure a higher number of retries (e.g., tries=10) to ensure download success, even if it results in a long cumulative wait time.
- When downloading and processing ZIP files, ensure that exception handling covers both network-related errors (e.g., requests.exceptions.RequestException) and ZIP-specific errors (e.g., zipfile.BadZipFile) to prevent the script from crashing on corrupted or invalid downloads.
| # Check if the source dataset is provisional (lacks '_rv' suffix) | ||
| is_provisional = "_rv" not in selected_filename.lower() | ||
| if is_provisional: | ||
| df["measurementMethod"] = "NCES_ProvisionalEstimate" | ||
| logging.info(f"Added 'measurementMethod' = 'NCES_ProvisionalEstimate' to provisional file {new_filename}") | ||
| else: | ||
| if "measurementMethod" in df.columns: | ||
| df.drop(columns=["measurementMethod"], inplace=True) | ||
| logging.info(f"Removed 'measurementMethod' column from revised file {new_filename}") | ||
|
|
There was a problem hiding this comment.
Dropping the measurementMethod column from revised files will cause the CSV schema to be inconsistent across different years. Since all CSV files in this import share the same student_faculty_ratio_metadata.csv and student_faculty_ratio.tmcf, they must all contain the exact same columns. If a revised CSV is missing the measurementMethod column, the import tool will fail with a 'column not found' error.
Instead of dropping the column, keep it in the revised CSV but set its values to an empty string.
# Check if the source dataset is provisional (lacks '_rv' suffix)
is_provisional = "_rv" not in selected_filename.lower()
if is_provisional:
df["measurementMethod"] = "NCES_ProvisionalEstimate"
logging.info(f"Added 'measurementMethod' = 'NCES_ProvisionalEstimate' to provisional file {new_filename}")
else:
df["measurementMethod"] = ""
logging.info(f"Set empty 'measurementMethod' for revised file {new_filename}")
Import: IPEDS_StudentToFacultyRatio
Summary of Changes
This PR updates the IPEDS_StudentToFacultyRatio import pipeline to support provisional data releases, handle NCES URL structure changes, add validation configs and golden data verification.
Key changes
1. Download Script (
download.py)_rv) datasets are downloaded first and downloads available provisional dataset if no revised file exists.2. Preprocessing Logic (
preprocess.py)measurementMethod : "NCES_ProvisionalEstimate"for provisional files while omitting it for revised releases.3. Schema & Mapping
measurementMethodinoutput_columnsinmetadata.csv.4. Import Manifest (
manifest.json)5. Validation & Goldens (
validation_config.json&golden_data/)validation_config.jsonwith deletion percentage threshold checks and golden data verification rules.golden_data/golden_observations.csvand updatedgolden_data/golden_summary_report.csv.