Skip to content

IPEDS_StudentToFacultyRatio: Adding provisional data mapping, updated download/preprocess scripts, and golden validation - #2117

Open
smarthg-gi wants to merge 1 commit into
datacommonsorg:masterfrom
smarthg-gi:ipeds_student_faculty_ratio_fix
Open

IPEDS_StudentToFacultyRatio: Adding provisional data mapping, updated download/preprocess scripts, and golden validation#2117
smarthg-gi wants to merge 1 commit into
datacommonsorg:masterfrom
smarthg-gi:ipeds_student_faculty_ratio_fix

Conversation

@smarthg-gi

@smarthg-gi smarthg-gi commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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)

  • Updated download script with provisional data download logic where revised (_rv) datasets are downloaded first and downloads available provisional dataset if no revised file exists.

2. Preprocessing Logic (preprocess.py)

  • Added provisional dataset logic to append measurementMethod : "NCES_ProvisionalEstimate" for provisional files while omitting it for revised releases.

3. Schema & Mapping

  • Added mapping for provisional data in the pvmap
  • Included measurementMethod in output_columns in metadata.csv.

4. Import Manifest (manifest.json)

  • Configured manifest with fortnightly cron schedule and vallidation_config.json

5. Validation & Goldens (validation_config.json & golden_data/)

  • Created validation_config.json with deletion percentage threshold checks and golden data verification rules.
  • Added golden_data/golden_observations.csv and updated golden_data/golden_summary_report.csv.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +61 to +99
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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:
                pass
References
  1. 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.

Comment on lines +102 to +168
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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 False
References
  1. 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.
  2. 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.

Comment on lines +100 to 109
# 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}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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}")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant