diff --git a/notebooks/use_cases/regression/linear_regression_template.yaml b/notebooks/use_cases/regression/linear_regression_template.yaml new file mode 100644 index 000000000..caa42bc6e --- /dev/null +++ b/notebooks/use_cases/regression/linear_regression_template.yaml @@ -0,0 +1,318 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +- id: model_overview + title: Model Overview + index_only: true + sections: + - id: purpose_and_use + title: Purpose and Intended Use + parent_section: model_overview + guidelines: + - State what the model predicts, for whom, and which business decisions + rely on it. + - Explain which errors matter most for that use (for example, whether + under-prediction is more costly than over-prediction). + - Describe the population and time period the model is meant to cover. + contents: + - content_id: purpose_and_use + content_type: text + - id: methodology + title: Methodology + parent_section: model_overview + guidelines: + - Describe the model form (ordinary least squares regression), the + target, and the candidate explanatory variables. + - Justify a linear model for this problem and state its known + limitations, such as a target bounded at zero. + - Describe any challenger models and why they were considered. + contents: + - content_id: methodology + content_type: text + +- id: data + title: Data + index_only: true + sections: + - id: data_description + title: Data Description + parent_section: data + guidelines: + - Describe the data sources, the observation period, and each variable. + - Explain how the development and out-of-time samples were defined. + contents: + - content_id: data_description + content_type: text + - content_id: validmind.data_validation.DatasetDescription:raw_data + content_type: test + - content_id: validmind.data_validation.DescriptiveStatistics:raw_data + content_type: test + - id: data_quality + title: Data Quality + parent_section: data + guidelines: + - Report missing values, duplicates, implausible values and outliers. + - Describe every correction made to the data and its justification. + contents: + - content_id: data_quality + content_type: text + - content_id: validmind.data_validation.MissingValues:raw_data + content_type: test + - content_id: validmind.data_validation.Duplicates:raw_data + content_type: test + - content_id: validmind.data_validation.IQROutliersTable:raw_data + content_type: test + - content_id: validmind.data_validation.Skewness:raw_data + content_type: test + - id: correlations + title: Correlations + parent_section: data + guidelines: + - Identify strongly correlated candidate variables and their + implications for the model specification. + contents: + - content_id: validmind.data_validation.PearsonCorrelationMatrix:raw_data + content_type: test + - content_id: validmind.data_validation.HighPearsonCorrelation:raw_data + content_type: test + - id: data_split + title: Development and Out-of-Time Samples + parent_section: data + contents: + - content_id: validmind.data_validation.DatasetSplit + content_type: test + +- id: model_specification + title: Model Specification + index_only: true + sections: + - id: influential_observations + title: Influential Observations + parent_section: model_specification + guidelines: + - Identify observations that individually drive the estimates, and + explain how each was investigated and treated. + contents: + - content_id: validmind.model_validation.statsmodels.InfluentialObservations:candidate_model + content_type: test + - id: multicollinearity + title: Multicollinearity and Variable Selection + parent_section: model_specification + guidelines: + - Report multicollinearity among candidate variables and justify which + variables were kept or removed. + - Show the final model is free of harmful multicollinearity. + contents: + - content_id: variable_selection + content_type: text + - content_id: validmind.model_validation.statsmodels.VarianceInflationFactor:candidate_model + content_type: test + - content_id: validmind.model_validation.statsmodels.VarianceInflationFactor:champion + content_type: test + - id: coefficients + title: Coefficients + parent_section: model_specification + guidelines: + - Present the estimated coefficients with their uncertainty. + - Confirm each effect has the expected direction and that inference + holds without the constant-variance assumption. + - Show the coefficients are stable under resampling. + contents: + - content_id: validmind.model_validation.statsmodels.RegressionCoeffs:champion + content_type: test + - content_id: validmind.model_validation.statsmodels.ExpectedCoefficientSigns:champion + content_type: test + - content_id: validmind.model_validation.statsmodels.RobustStandardErrors:champion + content_type: test + - content_id: validmind.model_validation.statsmodels.CoefficientStability:champion + content_type: test + - id: functional_form + title: Functional Form + parent_section: model_specification + guidelines: + - Test whether each variable's effect is adequately captured by a + straight line, and describe any transformation considered. + contents: + - content_id: validmind.model_validation.statsmodels.LinearitySpecification:champion + content_type: test + +- id: assumptions + title: Model Assumptions + index_only: true + sections: + - id: residual_diagnostics + title: Residual Diagnostics + parent_section: assumptions + guidelines: + - Assess whether the residuals are normally distributed and have + constant variance. + - Distinguish statistically significant departures from material ones, + and state their consequences for inference and prediction intervals. + contents: + - content_id: assumption_assessment + content_type: text + - content_id: validmind.model_validation.ResidualNormality:champion + content_type: test + - content_id: validmind.model_validation.Heteroscedasticity:champion + content_type: test + +- id: performance + title: Performance and Uncertainty + index_only: true + sections: + - id: predictive_performance + title: Predictive Performance + parent_section: performance + contents: + - content_id: validmind.model_validation.sklearn.RegressionR2Square:champion_test + content_type: test + - content_id: validmind.model_validation.sklearn.RegressionPerformance:champion_test + content_type: test + - id: prediction_uncertainty + title: Prediction Uncertainty + parent_section: performance + guidelines: + - Backtest the coverage of the model's prediction intervals on held-out + data. + contents: + - content_id: validmind.model_validation.statsmodels.PredictionIntervals:test + content_type: test + - id: prediction_soundness + title: Prediction Soundness and Calibration + parent_section: performance + guidelines: + - Check that predictions stay within the feasible range of the target. + - Check calibration across the prediction range and accuracy in the + tails, where exposure concentrates. + contents: + - content_id: validmind.model_validation.PredictionRange:champion_test + content_type: test + - content_id: validmind.model_validation.CalibrationByDecile:champion_test + content_type: test + - content_id: validmind.model_validation.TailPerformance:champion_test + content_type: test + +- id: benchmarking + title: Champion and Challenger + index_only: true + sections: + - id: challenger_comparison + title: Comparison with the Challenger + parent_section: benchmarking + guidelines: + - Compare the champion with the challenger on the same data. + - State whether any difference is statistically significant, and weigh + it against the challenger's added complexity. + contents: + - content_id: challenger_assessment + content_type: text + - content_id: validmind.model_validation.sklearn.RegressionErrorsComparison:champion_vs_challenger + content_type: test + - content_id: validmind.model_validation.PerformanceConfidenceIntervals:champion_vs_challenger + content_type: test + - content_id: validmind.model_validation.PredictionRange:challenger_test + content_type: test + +- id: sensitivity + title: Sensitivity and Stress Testing + index_only: true + sections: + - id: feature_sensitivity + title: Feature Sensitivity + parent_section: sensitivity + guidelines: + - Show how the portfolio-level prediction responds to each variable, and + confirm the size and direction are plausible. + contents: + - content_id: validmind.model_validation.FeatureSensitivity:champion + content_type: test + - content_id: validmind.model_validation.FeatureSensitivity:challenger + content_type: test + - id: stress_scenarios + title: Stress Scenarios + parent_section: sensitivity + guidelines: + - Define the stress scenarios and justify their severity. + - Report the projected impact and whether it is plausible. + contents: + - content_id: stress_scenarios + content_type: text + - content_id: validmind.model_validation.ScenarioAnalysis:champion + content_type: test + - content_id: validmind.model_validation.ScenarioAnalysis:challenger + content_type: test + +- id: segment_analysis + title: Segment Analysis + index_only: true + sections: + - id: segment_bias + title: Bias and Accuracy by Segment + parent_section: segment_analysis + guidelines: + - Look for segments, including along variables the model does not use, + in which the model is systematically biased or materially less + accurate. + - Assess the materiality of each finding for the model's intended use. + contents: + - content_id: segment_findings + content_type: text + - content_id: validmind.model_validation.ResidualBiasBySegment:champion_test + content_type: test + - content_id: validmind.model_validation.RegressionWeakspots:champion_test + content_type: test + - content_id: validmind.model_validation.ResidualBiasBySegment:challenger_test + content_type: test + +- id: stability + title: Stability Over Time + index_only: true + sections: + - id: out_of_time + title: Out-of-Time Performance + parent_section: stability + guidelines: + - Compare performance on the development and out-of-time samples. + - Show when any change began, and whether prediction intervals remain + reliable out of time. + contents: + - content_id: validmind.model_validation.sklearn.RegressionErrorsComparison:in_time_vs_out_of_time + content_type: test + - content_id: validmind.model_validation.PerformanceOverTime:champion + content_type: test + - content_id: validmind.model_validation.statsmodels.PredictionIntervals:oot + content_type: test + - id: population_drift + title: Population Drift + parent_section: stability + guidelines: + - Measure how the distribution of each variable changed between the + development and out-of-time samples, and explain the drivers. + contents: + - content_id: validmind.ongoing_monitoring.FeatureDrift:development_vs_out_of_time + content_type: test + +- id: conclusions + title: Conclusions + index_only: true + sections: + - id: limitations + title: Limitations and Findings + parent_section: conclusions + guidelines: + - Summarize every finding with its severity and the evidence for it. + - State the model's known limitations and the conditions under which it + should not be relied on. + contents: + - content_id: limitations + content_type: text + - id: monitoring_plan + title: Ongoing Monitoring + parent_section: conclusions + guidelines: + - Define the metrics, thresholds and frequency for monitoring the model + in use, and the actions triggered by a breach. + contents: + - content_id: monitoring_plan + content_type: text diff --git a/notebooks/use_cases/regression/quickstart_linear_regression.ipynb b/notebooks/use_cases/regression/quickstart_linear_regression.ipynb new file mode 100644 index 000000000..c436baf42 --- /dev/null +++ b/notebooks/use_cases/regression/quickstart_linear_regression.ipynb @@ -0,0 +1,1004 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "2d10c886", + "metadata": {}, + "source": [ + "# Document and validate a linear regression model\n", + "\n", + "Develop, document and validate a linear regression model end to end with the ValidMind Library: a credit card balance model, a challenger model, and a validation that goes beyond averages — model assumptions, uncertainty, sensitivity, segments and stability over time.\n", + "\n", + "The model predicts each cardholder's **average monthly revolving balance**, the amount carried from month to month. A bank uses a model like this to forecast interest income and card exposure, so under-predicting balances understates both.\n", + "\n", + "
The data in this notebook is synthetic.\n", + "

\n", + "It was generated by ValidMind so that a linear regression validation has realistic issues to find. The script that generates it is included in the library (validmind/datasets/regression/generate_credit_card_balance_csv.py), so you can see exactly how each issue was built.
" + ] + }, + { + "cell_type": "markdown", + "id": "bb67308c", + "metadata": {}, + "source": [ + "::: {.content-hidden when-format=\"html\"}\n", + "## Contents\n", + "- [About ValidMind](#toc1__)\n", + "- [Before you begin](#toc2__)\n", + "- [Setting up](#toc3__)\n", + " - [Install the ValidMind Library](#toc3_1__)\n", + " - [Initialize the ValidMind Library](#toc3_2__)\n", + " - [Register sample model](#toc3_2_1__)\n", + " - [Apply documentation template](#toc3_2_2__)\n", + " - [Get your code snippet](#toc3_2_3__)\n", + " - [Initialize the Python environment](#toc3_3__)\n", + "- [Load and inspect the data](#toc4__)\n", + "- [Develop the champion model](#toc5__)\n", + " - [Fit a candidate model](#toc5_1__)\n", + " - [Investigate influential observations](#toc5_2__)\n", + " - [Select the variables](#toc5_3__)\n", + "- [Build a challenger model](#toc6__)\n", + "- [Initialize the ValidMind inputs](#toc7__)\n", + " - [Initialize the models](#toc7_1__)\n", + " - [Initialize the datasets](#toc7_2__)\n", + " - [Assign predictions](#toc7_3__)\n", + "- [What the validation covers](#toc8__)\n", + " - [Configure and run the tests](#toc8_1__)\n", + "- [Read the results](#toc9__)\n", + "- [Next steps](#toc10__)\n", + " - [Work with your documentation](#toc10_1__)\n", + " - [Discover more learning resources](#toc10_2__)\n", + "- [Upgrade ValidMind](#toc11__)\n", + "\n", + ":::" + ] + }, + { + "cell_type": "markdown", + "id": "82834ce1", + "metadata": {}, + "source": [ + "\n", + "\n", + "## About ValidMind\n", + "\n", + "ValidMind is a suite of tools for managing risk, including risk associated with AI and statistical models.\n", + "\n", + "You use the ValidMind Library to automate documentation and validation tests, and then use the ValidMind Platform to collaborate on documentation. Together, these products simplify risk management, facilitate compliance with regulations and institutional standards, and enhance collaboration between yourself and validators.\n", + "\n", + "If this is your first time trying out ValidMind, you can make use of the following resources alongside this notebook:\n", + "\n", + "- [Get started](https://docs.validmind.ai/get-started/get-started.html) — The basics, including key concepts, and how our products work\n", + "- [ValidMind Library](https://docs.validmind.ai/developer/validmind-library.html) — The path for developers, more code samples, and our developer reference" + ] + }, + { + "cell_type": "markdown", + "id": "a5b8bcf2", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Before you begin\n", + "\n", + "
For access to all features available in this notebook, you'll need access to a ValidMind account.\n", + "

\n", + "Register with ValidMind
\n", + "\n", + "If you encounter errors due to missing modules in your Python environment, install the modules with `pip install`, and then re-run the notebook. For more help, refer to [Installing Python Modules](https://docs.python.org/3/installing/index.html)." + ] + }, + { + "cell_type": "markdown", + "id": "72b967bd", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Setting up" + ] + }, + { + "cell_type": "markdown", + "id": "fb4b0549", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Install the ValidMind Library\n", + "\n", + "The statistical tests in this notebook need the `stats` extra, which installs `statsmodels`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5c2a47f4", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install -q \"validmind[stats]\"" + ] + }, + { + "cell_type": "markdown", + "id": "0a065915", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Initialize the ValidMind Library" + ] + }, + { + "cell_type": "markdown", + "id": "aadb4519", + "metadata": {}, + "source": [ + "\n", + "\n", + "#### Register sample model\n", + "\n", + "Let's first register a sample record (model) for use with this notebook:\n", + "\n", + "1. In a browser, [log in to ValidMind](https://docs.validmind.ai/guide/access/log-in-to-validmind.html).\n", + "\n", + "2. In the left sidebar, select **Inventory**.\n", + "\n", + "3. Select `Model` by clicking on **{Record} Inventory**, where `{Record}` is the currently active type of record. (Learn more: [Register records in the inventory](https://docs.validmind.ai/guide/inventory/register-records-in-inventory.html))\n", + "4. Click **+ Register Model**.\n", + "\n", + "5. Enter the model details and click **Next >** to continue to assignment of inventory record stakeholders.\n", + "\n", + "6. Select your own name under the **Record Owner** drop-down.\n", + "\n", + "7. Click **Register Model** to add the model to your inventory." + ] + }, + { + "cell_type": "markdown", + "id": "dcaa0565", + "metadata": {}, + "source": [ + "\n", + "\n", + "#### Apply documentation template\n", + "\n", + "Once you've registered your model, let's select a documentation template. A template predefines sections for your documentation and provides a general outline to follow, making the documentation process much easier.\n", + "\n", + "1. In the left sidebar that appears for your model, click **Documents** and select **Development**.\n", + "\n", + " If you cannot locate your Development document, make sure Development type documents are enabled for model records and create a new document. (**Learn more:** [Manage documents](https://docs.validmind.ai/guide/templates/manage-documents.html#add-record-documents))\n", + "\n", + "2. Under **Template**, select `Linear Regression`.\n", + "\n", + "3. Click **Use Template** to apply the template.\n", + "\n", + "
Can't select this template?\n", + "

\n", + "Your organization administrators may need to add it to your template library:\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "8da8f5ae", + "metadata": {}, + "source": [ + "\n", + "\n", + "#### Get your code snippet\n", + "\n", + "Initialize the ValidMind Library with the *code snippet* unique to each record per document, ensuring your test results are uploaded to the correct record and automatically populated in the right document in the ValidMind Platform when you run the Library.\n", + "\n", + "1. On the left sidebar that appears for your model, select **Getting Started** and select `Development` from the **Document** drop-down menu.\n", + "\n", + "2. Click **Copy snippet to clipboard**.\n", + "\n", + "3. Next, [load your model identifier credentials from an `.env` file](https://docs.validmind.ai/developer/quickstart/store-credentials-in-env-file.html) or replace the placeholder with your own code snippet:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a35a7a37", + "metadata": {}, + "outputs": [], + "source": [ + "# Load your model identifier credentials from an `.env` file\n", + "\n", + "%load_ext dotenv\n", + "%dotenv .env\n", + "\n", + "# Or replace with your code snippet\n", + "\n", + "import validmind as vm\n", + "\n", + "vm.init(\n", + " # api_host=\"...\",\n", + " # api_key=\"...\",\n", + " # api_secret=\"...\",\n", + " # model=\"...\",\n", + " document=\"documentation\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "283414a1", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Initialize the Python environment\n", + "\n", + "Import the libraries used in this notebook, and preview the template to confirm it is applied to your model:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c04dd223", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "import statsmodels.formula.api as smf\n", + "\n", + "from validmind.datasets.regression import credit_card_balance as ccb\n", + "\n", + "vm.preview_template()" + ] + }, + { + "cell_type": "markdown", + "id": "747240ec", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Load and inspect the data\n", + "\n", + "Each row is a cardholder observed in one quarter. The target, `Balance`, is the average monthly revolving balance in dollars; customers who pay in full each month have a balance of zero.\n", + "\n", + "| Column | Description |\n", + "|---|---|\n", + "| `Quarter` | Quarter in which the customer was observed |\n", + "| `Income` | Annual income, in thousands of dollars |\n", + "| `DebtToIncome` | Monthly debt payments as a share of income |\n", + "| `CreditScore` | Bureau-style credit score, 300–850 |\n", + "| `CreditLimit` | Credit limit on the card, in dollars |\n", + "| `RecentInquiries` | Credit inquiries in the last six months |\n", + "| `NumCards` | Number of credit cards held |\n", + "| `TenureYears` | Years as a customer |\n", + "| `IsStudent` | 1 for students, 0 otherwise |\n", + "| `Region` | Region of residence |\n", + "| `Balance` | Average monthly revolving balance, in dollars — **the target** |\n", + "\n", + "The most recent quarters — from `ccb.oot_start` onwards — are held back as an **out-of-time** sample: data the model never sees during development, used to check that it still works on newer customers. The cell below prints the period each sample covers. Protected characteristics such as age, gender and ethnicity are deliberately absent, as they would be from a credit model in practice." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "783561f6", + "metadata": {}, + "outputs": [], + "source": [ + "raw_df = ccb.load_data()\n", + "\n", + "development_df = raw_df[raw_df[ccb.time_column] < ccb.oot_start]\n", + "oot_quarters = raw_df[ccb.time_column] >= ccb.oot_start\n", + "print(f\"Development: {development_df[ccb.time_column].min()} to {development_df[ccb.time_column].max()}, {len(development_df):,} rows\")\n", + "print(f\"Out of time: {raw_df.loc[oot_quarters, ccb.time_column].min()} to {raw_df.loc[oot_quarters, ccb.time_column].max()}, {oot_quarters.sum():,} rows\")\n", + "print(f\"Customers with a zero balance: {(raw_df[ccb.target_column] == 0).mean():.0%}\")\n", + "\n", + "raw_df.head()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f95726c3", + "metadata": {}, + "outputs": [], + "source": [ + "raw_df.describe().round(2)" + ] + }, + { + "cell_type": "markdown", + "id": "8d1ea762", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Develop the champion model\n", + "\n", + "The champion is an ordinary least squares (OLS) regression fitted with `statsmodels`. We use the formula API, which keeps the variable names and handles categorical variables such as `Region`." + ] + }, + { + "cell_type": "markdown", + "id": "d692a28c", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Fit a candidate model\n", + "\n", + "Start with every candidate variable, fitted on the development data as received:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "51431f84", + "metadata": {}, + "outputs": [], + "source": [ + "CANDIDATE_FORMULA = (\n", + " \"Balance ~ Income + DebtToIncome + CreditScore + CreditLimit + RecentInquiries\"\n", + " \" + NumCards + TenureYears + IsStudent + C(Region)\"\n", + ")\n", + "\n", + "candidate_fit = smf.ols(CANDIDATE_FORMULA, data=development_df).fit()\n", + "candidate_fit.params.round(3)" + ] + }, + { + "cell_type": "markdown", + "id": "d29cc8bb", + "metadata": {}, + "source": [ + "Before running any test, read the coefficients against business sense. A higher credit limit should mean a higher balance, and with the limit held fixed, a higher income a lower one. Here `CreditLimit` and `Income` do not behave that way — a sign that something in the data is distorting the fit. Let's find out what." + ] + }, + { + "cell_type": "markdown", + "id": "80d9fcfa", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Investigate influential observations\n", + "\n", + "Initialize the candidate as a ValidMind model and run the `InfluentialObservations` test, which flags rows that individually move the estimates:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "600dc854", + "metadata": {}, + "outputs": [], + "source": [ + "vm_candidate_model = vm.init_model(candidate_fit, input_id=\"candidate_model\")\n", + "\n", + "vm.tests.run_test(\n", + " \"validmind.model_validation.statsmodels.InfluentialObservations\",\n", + " inputs={\"model\": vm_candidate_model},\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "8616da83", + "metadata": {}, + "source": [ + "Look at the observations above the severe Cook's distance threshold, and at the coefficient table beside them: each of those rows, on its own, moves the estimates materially. The test assesses observations one at a time, and one extreme row can mask others like it — so inspect the flagged rows, then search the data for the same pattern. The source system records incomes up to a fixed cap, so any income above it is an error:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ee62e097", + "metadata": {}, + "outputs": [], + "source": [ + "INCOME_CAP = 200 # $000s: the source system records incomes up to $200k\n", + "\n", + "suspicious = raw_df[raw_df[\"Income\"] > INCOME_CAP]\n", + "suspicious" + ] + }, + { + "cell_type": "markdown", + "id": "eff63532", + "metadata": {}, + "source": [ + "These incomes are about ten times a plausible value, while the same customers' balances, limits and scores are typical: they are data entry errors (a misplaced decimal point), not genuine high earners. We remove them **before** splitting the data, so that no error reaches the test or out-of-time samples either." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ac3dd13e", + "metadata": {}, + "outputs": [], + "source": [ + "clean_df = raw_df[raw_df[\"Income\"] <= INCOME_CAP]\n", + "train_df, test_df, oot_df = ccb.preprocess(clean_df)\n", + "\n", + "print(f\"Train {len(train_df):,} | Test {len(test_df):,} | Out of time {len(oot_df):,}\")" + ] + }, + { + "cell_type": "markdown", + "id": "a5d2fcc9", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Select the variables\n", + "\n", + "Refit the candidate on the clean training data and check multicollinearity with the `VarianceInflationFactor` test:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2ec51a74", + "metadata": {}, + "outputs": [], + "source": [ + "clean_candidate = vm.init_model(\n", + " smf.ols(CANDIDATE_FORMULA, data=train_df).fit(), input_id=\"clean_candidate\"\n", + ")\n", + "\n", + "vm.tests.run_test(\n", + " \"validmind.model_validation.statsmodels.VarianceInflationFactor\",\n", + " inputs={\"model\": clean_candidate},\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "f3bc27f1", + "metadata": {}, + "source": [ + "`CreditScore` and `CreditLimit` are flagged as severely collinear: they carry almost the same information, because card limits are set largely from the credit score. We keep `CreditLimit`, which drives balances directly, and drop `CreditScore`.\n", + "\n", + "Next, check which variables add nothing — coefficients not statistically different from zero. Look at `TenureYears` and the `Region` dummies:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ca122ff2", + "metadata": {}, + "outputs": [], + "source": [ + "clean_candidate.model.pvalues.drop(\"Intercept\").round(3).sort_values(ascending=False)" + ] + }, + { + "cell_type": "markdown", + "id": "1dfa939c", + "metadata": {}, + "source": [ + "The champion keeps the variables with a clear, significant effect:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ceb63114", + "metadata": {}, + "outputs": [], + "source": [ + "FEATURES = [\"Income\", \"DebtToIncome\", \"CreditLimit\", \"RecentInquiries\", \"NumCards\", \"IsStudent\"]\n", + "CHAMPION_FORMULA = \"Balance ~ \" + \" + \".join(FEATURES)\n", + "\n", + "champion_fit = smf.ols(CHAMPION_FORMULA, data=train_df).fit()\n", + "champion_fit.summary()" + ] + }, + { + "cell_type": "markdown", + "id": "8df1acca", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Build a challenger model\n", + "\n", + "A linear model of a balance has a structural weakness: balances cannot be negative, and many customers have none at all, but a straight line will predict negative values for low-risk customers. The challenger addresses this with a **two-part model**, a standard approach for targets with many zeros:\n", + "\n", + "1. a logistic regression for whether the customer carries a balance, and\n", + "2. an OLS regression for how much, fitted on customers who do.\n", + "\n", + "Its prediction is the probability of carrying a balance times the expected amount. ValidMind passes either a DataFrame or an array to `predict`, so the class accepts both:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a265e38e", + "metadata": {}, + "outputs": [], + "source": [ + "class TwoPartModel:\n", + " \"\"\"P(balance > 0) from a logit, times the expected balance when positive from OLS.\"\"\"\n", + "\n", + " def __init__(self, formula_rhs, features, train):\n", + " self.features = features\n", + " has_balance = train.assign(HasBalance=(train[\"Balance\"] > 0).astype(int))\n", + " self.probability = smf.logit(f\"HasBalance ~ {formula_rhs}\", data=has_balance).fit(disp=0)\n", + " self.amount = smf.ols(f\"Balance ~ {formula_rhs}\", data=train[train[\"Balance\"] > 0]).fit()\n", + "\n", + " def predict(self, X):\n", + " X = pd.DataFrame(X, columns=self.features)\n", + " expected_amount = self.amount.predict(X).clip(lower=0)\n", + " return np.asarray(self.probability.predict(X) * expected_amount)\n", + "\n", + "\n", + "challenger = TwoPartModel(\" + \".join(FEATURES), FEATURES, train_df)" + ] + }, + { + "cell_type": "markdown", + "id": "b455ae06", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Initialize the ValidMind inputs\n", + "\n", + "Before running tests, register the models and datasets with ValidMind. Each gets an `input_id`, which is how test results — and the template's configuration — refer to it." + ] + }, + { + "cell_type": "markdown", + "id": "f1b91e7d", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Initialize the models\n", + "\n", + "`vm.init_model` wraps a trained model so tests can use it. It recognizes the library the model comes from — here, a fitted `statsmodels` result for the champion, and the custom `TwoPartModel` class for the challenger, which ValidMind treats like a scikit-learn model with a `predict` method:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c43fd289", + "metadata": {}, + "outputs": [], + "source": [ + "vm_champion = vm.init_model(champion_fit, input_id=\"champion\")\n", + "vm_challenger = vm.init_model(challenger, input_id=\"challenger\")" + ] + }, + { + "cell_type": "markdown", + "id": "fd243fdc", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Initialize the datasets\n", + "\n", + "`vm.init_dataset` wraps a DataFrame and tells ValidMind how to read it:\n", + "\n", + "- `target_column` — the column the model predicts,\n", + "- `feature_columns` — the columns the model uses,\n", + "- `extra_columns` — columns the model does *not* use but tests can still look at. Here, `CreditScore`, `TenureYears`, `Region` and `Quarter` are attached this way so the validation can segment results by them. Looking for bias along variables the model does not use is one of the most revealing checks a validator can run.\n", + "\n", + "We create one dataset per sample, plus the raw data as received for the data-quality tests, and the test and out-of-time samples together for tracking performance quarter by quarter. Each gets a copy of its DataFrame, because ValidMind adds prediction columns to the data it is given:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2a4de0e6", + "metadata": {}, + "outputs": [], + "source": [ + "EXTRA_COLUMNS = {column: None for column in [\"CreditScore\", \"TenureYears\", \"Region\", \"Quarter\"]}\n", + "\n", + "vm_raw = vm.init_dataset(\n", + " raw_df.copy(),\n", + " input_id=\"raw_data\",\n", + " target_column=ccb.target_column,\n", + " feature_columns=ccb.feature_columns,\n", + " extra_columns={ccb.time_column: None},\n", + ")\n", + "\n", + "vm_train = vm.init_dataset(\n", + " train_df.copy(),\n", + " input_id=\"train_dataset\",\n", + " target_column=ccb.target_column,\n", + " feature_columns=FEATURES,\n", + " extra_columns=EXTRA_COLUMNS,\n", + ")\n", + "\n", + "vm_test = vm.init_dataset(\n", + " test_df.copy(),\n", + " input_id=\"test_dataset\",\n", + " target_column=ccb.target_column,\n", + " feature_columns=FEATURES,\n", + " extra_columns=EXTRA_COLUMNS,\n", + ")\n", + "\n", + "vm_oot = vm.init_dataset(\n", + " oot_df.copy(),\n", + " input_id=\"oot_dataset\",\n", + " target_column=ccb.target_column,\n", + " feature_columns=FEATURES,\n", + " extra_columns=EXTRA_COLUMNS,\n", + ")\n", + "\n", + "test_and_oot_df = pd.concat([test_df, oot_df])\n", + "vm_test_and_oot = vm.init_dataset(\n", + " test_and_oot_df.copy(),\n", + " input_id=\"test_and_oot_dataset\",\n", + " target_column=ccb.target_column,\n", + " feature_columns=FEATURES,\n", + " extra_columns=EXTRA_COLUMNS,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "a67be3cd", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Assign predictions\n", + "\n", + "`assign_predictions` links a model's predictions to a dataset. Tests then compare actual and predicted values directly, without calling the model again, and each dataset can hold the predictions of several models side by side.\n", + "\n", + "There are two ways to assign them:\n", + "\n", + "- **Let ValidMind call the model.** Pass only the model, and ValidMind calls its `predict` method on the dataset's features. The challenger works this way.\n", + "- **Pass the predictions yourself** with `prediction_values`. Use this when the model needs its input in a particular form, or when predictions were computed elsewhere. ValidMind passes features as an array, and a `statsmodels` formula model needs named columns, so for the champion we compute the predictions with `champion_fit.predict` and pass them in.\n", + "\n", + "Each dataset gets the predictions of the models that are tested on it:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ec4c3c08", + "metadata": {}, + "outputs": [], + "source": [ + "# Champion: predictions computed by statsmodels and passed in\n", + "vm_train.assign_predictions(vm_champion, prediction_values=champion_fit.predict(train_df).values)\n", + "vm_test.assign_predictions(vm_champion, prediction_values=champion_fit.predict(test_df).values)\n", + "vm_oot.assign_predictions(vm_champion, prediction_values=champion_fit.predict(oot_df).values)\n", + "vm_test_and_oot.assign_predictions(\n", + " vm_champion, prediction_values=champion_fit.predict(test_and_oot_df).values\n", + ")\n", + "\n", + "# Challenger: ValidMind calls its predict method — compared with the champion on the test sample\n", + "vm_test.assign_predictions(vm_challenger)" + ] + }, + { + "cell_type": "markdown", + "id": "ac5793fa", + "metadata": {}, + "source": [ + "\n", + "\n", + "## What the validation covers\n", + "\n", + "This quickstart runs the whole validation in one go. The template groups its tests into eight areas, each answering one question a validator asks of a linear regression model:\n", + "\n", + "| Area | The question | Tests |\n", + "|---|---|---|\n", + "| **Data** | Is the data fit to build and validate the model on? | Data quality, outliers, skewness and correlations, on the data as received |\n", + "| **Model specification** | Are the right variables in the model, with sensible and stable effects? | Influential observations, multicollinearity (VIF), coefficients and their expected signs, robust standard errors, bootstrap stability, linearity |\n", + "| **Model assumptions** | Do the errors behave as ordinary least squares assumes? | Normality and constant variance of the residuals |\n", + "| **Performance and uncertainty** | How accurate is the model on unseen data, and is it honest about its uncertainty? | Accuracy, prediction-interval coverage, impossible predictions, calibration, the tails |\n", + "| **Champion and challenger** | Is there a better model, and is the difference real? | Side-by-side errors, and confidence intervals for the difference |\n", + "| **Sensitivity and stress testing** | Does the output respond plausibly when the inputs move? | One-at-a-time shocks and joint stress scenarios |\n", + "| **Segment analysis** | Where in the portfolio is the model wrong? | Bias and weak spots by segment, including variables the model does not use |\n", + "| **Stability over time** | Does the model still work on newer customers? | Out-of-time performance, performance quarter by quarter, population drift |\n", + "\n", + "The tests are of four kinds, and they are read differently:\n", + "\n", + "- **Formal statistical tests** give a p-value and a pass/fail. In large samples they also detect tiny, immaterial effects.\n", + "- **Threshold checks** compare a measure with a conventional limit — read the margin, not just the verdict.\n", + "- **Diagnostics** are tables and plots that show *where* and *how much* — this is where materiality is judged.\n", + "- **Scenarios** project outcomes under assumed conditions, to check the response is plausible." + ] + }, + { + "cell_type": "markdown", + "id": "6829e548", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Configure and run the tests\n", + "\n", + "The template lists every test with the input it applies to, using a suffix after the test ID — for example `PredictionIntervals:test` and `PredictionIntervals:oot`. The configuration below gives each test its inputs, and the parameters only a validator can supply: the **expected sign** of each coefficient, the **feasible range** of a balance (it cannot be negative), and the **stress scenarios** to project.\n", + "\n", + "Durbin-Watson is deliberately not used: it tests autocorrelation in row order, which has no meaning for customers shuffled into a training sample. Whether errors drift over time is tested directly instead, in the stability area." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "47495cc8", + "metadata": {}, + "outputs": [], + "source": [ + "T = \"validmind.model_validation\"\n", + "\n", + "EXPECTED_SIGNS = {\n", + " \"Income\": \"-\", # with the limit held fixed, higher earners pay down more\n", + " \"DebtToIncome\": \"+\",\n", + " \"CreditLimit\": \"+\",\n", + " \"RecentInquiries\": \"+\",\n", + " \"NumCards\": \"+\",\n", + " \"IsStudent\": \"+\",\n", + "}\n", + "SCENARIOS = {\n", + " \"Recession\": {\"Income\": {\"multiply\": 0.8}, \"DebtToIncome\": {\"add\": 0.05}},\n", + " \"Credit line increase\": {\"CreditLimit\": {\"multiply\": 1.1}},\n", + " \"Severe stress\": {\n", + " \"Income\": {\"multiply\": 0.7},\n", + " \"DebtToIncome\": {\"add\": 0.10},\n", + " \"RecentInquiries\": {\"add\": 2},\n", + " },\n", + "}\n", + "\n", + "raw = {\"inputs\": {\"dataset\": vm_raw}}\n", + "champion_on_test = {\"inputs\": {\"dataset\": vm_test, \"model\": vm_champion}}\n", + "challenger_on_test = {\"inputs\": {\"dataset\": vm_test, \"model\": vm_challenger}}\n", + "\n", + "config = {\n", + " # Data\n", + " \"validmind.data_validation.DatasetDescription:raw_data\": raw,\n", + " \"validmind.data_validation.DescriptiveStatistics:raw_data\": raw,\n", + " \"validmind.data_validation.MissingValues:raw_data\": raw,\n", + " \"validmind.data_validation.Duplicates:raw_data\": raw,\n", + " \"validmind.data_validation.IQROutliersTable:raw_data\": raw,\n", + " \"validmind.data_validation.Skewness:raw_data\": raw,\n", + " \"validmind.data_validation.PearsonCorrelationMatrix:raw_data\": raw,\n", + " \"validmind.data_validation.HighPearsonCorrelation:raw_data\": raw,\n", + " \"validmind.data_validation.DatasetSplit\": {\n", + " \"inputs\": {\"datasets\": [vm_train, vm_test, vm_oot]}\n", + " },\n", + " # Model specification\n", + " f\"{T}.statsmodels.InfluentialObservations:candidate_model\": {\n", + " \"inputs\": {\"model\": vm_candidate_model}\n", + " },\n", + " f\"{T}.statsmodels.VarianceInflationFactor:candidate_model\": {\n", + " \"inputs\": {\"model\": clean_candidate}\n", + " },\n", + " f\"{T}.statsmodels.VarianceInflationFactor:champion\": {\"inputs\": {\"model\": vm_champion}},\n", + " f\"{T}.statsmodels.RegressionCoeffs:champion\": {\"inputs\": {\"model\": vm_champion}},\n", + " f\"{T}.statsmodels.ExpectedCoefficientSigns:champion\": {\n", + " \"inputs\": {\"model\": vm_champion},\n", + " \"params\": {\"expected_signs\": EXPECTED_SIGNS},\n", + " },\n", + " f\"{T}.statsmodels.RobustStandardErrors:champion\": {\"inputs\": {\"model\": vm_champion}},\n", + " f\"{T}.statsmodels.CoefficientStability:champion\": {\"inputs\": {\"model\": vm_champion}},\n", + " f\"{T}.statsmodels.LinearitySpecification:champion\": {\"inputs\": {\"model\": vm_champion}},\n", + " # Assumptions, on the data the model was fitted on\n", + " f\"{T}.ResidualNormality:champion\": {\"inputs\": {\"dataset\": vm_train, \"model\": vm_champion}},\n", + " f\"{T}.Heteroscedasticity:champion\": {\"inputs\": {\"dataset\": vm_train, \"model\": vm_champion}},\n", + " # Performance and uncertainty, on held-out data\n", + " f\"{T}.sklearn.RegressionR2Square:champion_test\": champion_on_test,\n", + " f\"{T}.sklearn.RegressionPerformance:champion_test\": champion_on_test,\n", + " f\"{T}.statsmodels.PredictionIntervals:test\": champion_on_test,\n", + " f\"{T}.PredictionRange:champion_test\": {**champion_on_test, \"params\": {\"lower_bound\": 0}},\n", + " f\"{T}.CalibrationByDecile:champion_test\": champion_on_test,\n", + " f\"{T}.TailPerformance:champion_test\": champion_on_test,\n", + " # Champion and challenger\n", + " f\"{T}.sklearn.RegressionErrorsComparison:champion_vs_challenger\": {\n", + " \"inputs\": {\"datasets\": [vm_test], \"models\": [vm_champion, vm_challenger]}\n", + " },\n", + " f\"{T}.PerformanceConfidenceIntervals:champion_vs_challenger\": {\n", + " \"inputs\": {\"dataset\": vm_test, \"models\": [vm_champion, vm_challenger]}\n", + " },\n", + " f\"{T}.PredictionRange:challenger_test\": {**challenger_on_test, \"params\": {\"lower_bound\": 0}},\n", + " # Sensitivity and stress\n", + " f\"{T}.FeatureSensitivity:champion\": champion_on_test,\n", + " f\"{T}.FeatureSensitivity:challenger\": challenger_on_test,\n", + " f\"{T}.ScenarioAnalysis:champion\": {\n", + " **champion_on_test,\n", + " \"params\": {\"scenarios\": SCENARIOS, \"lower_bound\": 0},\n", + " },\n", + " f\"{T}.ScenarioAnalysis:challenger\": {\n", + " **challenger_on_test,\n", + " \"params\": {\"scenarios\": SCENARIOS, \"lower_bound\": 0},\n", + " },\n", + " # Segments — every column by default, including the extra columns\n", + " f\"{T}.ResidualBiasBySegment:champion_test\": champion_on_test,\n", + " f\"{T}.RegressionWeakspots:champion_test\": champion_on_test,\n", + " f\"{T}.ResidualBiasBySegment:challenger_test\": challenger_on_test,\n", + " # Stability over time\n", + " f\"{T}.sklearn.RegressionErrorsComparison:in_time_vs_out_of_time\": {\n", + " \"inputs\": {\"datasets\": [vm_test, vm_oot], \"models\": [vm_champion]}\n", + " },\n", + " f\"{T}.PerformanceOverTime:champion\": {\n", + " \"inputs\": {\"dataset\": vm_test_and_oot, \"model\": vm_champion},\n", + " \"params\": {\"time_column\": \"Quarter\"},\n", + " },\n", + " f\"{T}.statsmodels.PredictionIntervals:oot\": {\n", + " \"inputs\": {\"dataset\": vm_oot, \"model\": vm_champion}\n", + " },\n", + " # PSI between 0.1 and 0.25 is conventionally a moderate shift worth investigating\n", + " \"validmind.ongoing_monitoring.FeatureDrift:development_vs_out_of_time\": {\n", + " \"inputs\": {\"datasets\": [vm_train, vm_oot]},\n", + " \"params\": {\"psi_threshold\": 0.1},\n", + " },\n", + "}\n", + "\n", + "full_suite = vm.run_documentation_tests(config=config)" + ] + }, + { + "cell_type": "markdown", + "id": "8db69abd", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Read the results\n", + "\n", + "The results are now in your model's documentation on the ValidMind Platform, section by section. The synthetic data was built so that a validation has issues to find — here is what to look for in each area, and why it matters:\n", + "\n", + "- **Data.** The tests run on the data *as received*: look for the implausible incomes among the outliers, the strong correlation between `CreditScore` and `CreditLimit`, and the many zero balances that make the target bounded and skewed.\n", + "- **Model specification.** Compare the candidate with the champion — the influence and multicollinearity results on the candidate are the evidence for the decisions that produced the champion. In the linearity test, the partial-residual plot for `RecentInquiries` flattens out: a diminishing effect that a straight line overstates.\n", + "- **Model assumptions.** With thousands of rows, the formal tests reject over small departures; judge materiality from the residual shape and spread. The zero balances are the usual cause.\n", + "- **Performance and uncertainty.** Headline accuracy is solid, but look for predictions of negative balances, and for a calibration curve that bends away from the diagonal at the ends.\n", + "- **Champion and challenger.** A difference matters only if its confidence interval excludes zero — and even then it must be weighed against the challenger's added complexity.\n", + "- **Sensitivity and stress testing.** Check the direction and size of every effect. The severe-stress projection extrapolates `RecentInquiries` along a straight line.\n", + "- **Segment analysis.** `Region` and `TenureYears` have no real effect, so they should show no bias — evidence the test is not raising false alarms. Then look at `CreditScore`, a variable the champion no longer uses: dropping it was sound by every average-based test, and segmenting on it shows what that decision cost.\n", + "- **Stability over time.** Look for when bias begins, whether prediction intervals miss on one side, and which inputs drifted — together they separate a change in *who* the customers are from a change in *how* they behave.\n", + "\n", + "### What the validator would conclude\n", + "\n", + "Check each finding against your own run's output before drawing a conclusion.\n", + "\n", + "| Finding | Evidence | Severity | Recommendation |\n", + "|---|---|---|---|\n", + "| Under-prediction out of time | Stability over time | High | Recalibrate on recent quarters before use, and monitor bias quarterly |\n", + "| Lowest credit scores under-predicted | Segment analysis | High | Bring credit risk back into the model in a form that avoids the collinearity with the limit, such as a subprime indicator |\n", + "| Negative predictions | Performance and uncertainty; champion and challenger | Medium | Adopt the two-part challenger, or floor predictions at zero with the limitation documented |\n", + "| Diminishing effect of inquiries | Model specification; sensitivity and stress testing | Low | Use a transformed term, such as `np.log1p(RecentInquiries)` |\n", + "| Non-normal, non-constant errors | Model assumptions | Low | Rely on robust standard errors and backtested intervals rather than analytical ones |\n", + "\n", + "None of these findings is visible in the champion's headline accuracy. They come from interrogating the model by segment, over time, and at the edges of its range." + ] + }, + { + "cell_type": "markdown", + "id": "9b0b7571", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Next steps\n", + "\n", + "You can look at the output produced by the ValidMind Library right in the notebook where you ran the code, as you would expect. But there is a better way — use the ValidMind Platform to work with your documentation.\n", + "\n", + "\n", + "\n", + "### Work with your documentation\n", + "\n", + "1. From the **Inventory** in the ValidMind Platform, go to the model you registered earlier. (Learn more: [Working with the inventory](https://docs.validmind.ai/guide/inventory/working-with-the-inventory.html))\n", + "\n", + "2. In the left sidebar that appears for your model, click **Development** under Documents.\n", + "\n", + "3. Expand the following sections and take a look around:\n", + "\n", + " - **Segment Analysis**\n", + " - **Stability Over Time**\n", + "\n", + "What you can see now is a much more easily consumable version of the documentation, including the results of the tests you just performed, along with other parts of your model documentation that still need to be completed. There is a wealth of information that gets uploaded when you run the full test suite, so take a closer look around, especially at test results that might need attention (hint: start with the flagged segments and the out-of-time results). From here, you can make qualitative edits to model documentation, view guidelines, collaborate with validators, and submit your documentation for approval when it's ready. (**Learn more:** [Working with documentation](https://docs.validmind.ai/guide/documentation/working-with-documentation.html))\n", + "\n", + "\n", + "\n", + "### Discover more learning resources\n", + "\n", + "We also offer many interactive notebooks to help you use the ValidMind Library to streamline your work:\n", + "\n", + "- [Run tests & test suites](https://docs.validmind.ai/developer/how-to/testing-overview.html)\n", + "- [Use ValidMind Library features](https://docs.validmind.ai/developer/how-to/feature-overview.html)\n", + "- [Code samples by use case](https://docs.validmind.ai/developer/samples-jupyter-notebooks.html)\n", + "\n", + "Or, visit our [documentation](https://docs.validmind.ai/) to learn more about ValidMind." + ] + }, + { + "cell_type": "markdown", + "id": "edf85234", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Upgrade ValidMind\n", + "\n", + "
After installing ValidMind, you’ll want to periodically make sure you are on the latest version to access any new features and other enhancements.
\n", + "\n", + "Retrieve the information for the currently installed version of ValidMind:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21d38d77", + "metadata": {}, + "outputs": [], + "source": [ + "%pip show validmind" + ] + }, + { + "cell_type": "markdown", + "id": "67a8c381", + "metadata": {}, + "source": [ + "If the version returned is lower than the version indicated in our [production open-source code](https://github.com/validmind/validmind-library/blob/prod/validmind/__version__.py), restart your notebook and run:\n", + "\n", + "```bash\n", + "%pip install --upgrade validmind\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "fdfd1fe7", + "metadata": {}, + "source": [ + "You may need to restart your kernel after running the upgrade package for changes to be applied." + ] + }, + { + "cell_type": "markdown", + "id": "copyright-4969298aae484bfc84ec4b5094d06f2c", + "metadata": {}, + "source": [ + "\n", + "\n", + "\n", + "\n", + "***\n", + "\n", + "Copyright © 2023-2026 ValidMind Inc. All rights reserved.
\n", + "Refer to [LICENSE](https://github.com/validmind/validmind-library/blob/main/LICENSE) for details.
\n", + "SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial
" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "gpuClass": "standard", + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/use_cases/regression/quickstart_regression_full_suite.ipynb b/notebooks/use_cases/regression/quickstart_regression_full_suite.ipynb index 9f082cda1..b6d9a33c6 100644 --- a/notebooks/use_cases/regression/quickstart_regression_full_suite.ipynb +++ b/notebooks/use_cases/regression/quickstart_regression_full_suite.ipynb @@ -164,7 +164,7 @@ "\n", " If you cannot locate your Development document, make sure Development type documents are enabled for model records and create a new document. (**Learn more:** [Manage documents](https://docs.validmind.ai/guide/templates/manage-documents.html#add-record-documents))\n", "\n", - "2. Under **Template**, select `Binary classification`.\n", + "2. Under **Template**, select `Regression`.\n", "\n", "3. Click **Use Template** to apply the template." ] diff --git a/pyproject.toml b/pyproject.toml index a885eb3af..6b588e1c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "validmind" -version = "2.14.0" +version = "2.15.0" description = "ValidMind Library" readme = "README.pypi.md" requires-python = ">=3.9,<3.15" diff --git a/r/validmind/DESCRIPTION b/r/validmind/DESCRIPTION index a3dc50c55..205ccb605 100644 --- a/r/validmind/DESCRIPTION +++ b/r/validmind/DESCRIPTION @@ -1,7 +1,7 @@ Package: validmind Type: Package Title: Interface to the 'ValidMind' Platform -Version: 2.14.0 +Version: 2.15.0 Authors@R: c(person("Andres", "Rodriguez", role = c("aut", "cre","cph"), email = "andres@validmind.ai")) Maintainer: Andres Rodriguez diff --git a/tests/test_test_descriptions.py b/tests/test_test_descriptions.py index a8c6ae10e..33f0f2a45 100644 --- a/tests/test_test_descriptions.py +++ b/tests/test_test_descriptions.py @@ -86,6 +86,45 @@ def test_truncate_text_simple_with_truncation(self): self.assertTrue(result.endswith("a")) +class TestBackgroundDescriptionFigures(unittest.TestCase): + """Plotly figures must be rendered before descriptions run in the thread pool.""" + + @patch("validmind.api_client.generate_test_result_description") + def test_figures_are_rendered_on_the_calling_thread(self, mock_generate): + import threading + + import plotly.graph_objects as go + from plotly.basedatatypes import BaseFigure + + from validmind.ai.test_descriptions import background_generate_description + from validmind.vm_models.figure import Figure + + mock_generate.return_value = {"content": "ok"} + render_threads = [] + + def fake_to_image(fig, *args, **kwargs): + render_threads.append(threading.current_thread()) + return b"png" + + # kaleido deadlocks when several workers render at once, so no figure may be + # rendered inside the pool. Figure wraps plotly figures in a FigureWidget, so + # patch the base class both share. + with patch.object(BaseFigure, "to_image", fake_to_image): + figures = [ + Figure(key=f"fig_{i}", figure=go.Figure(), ref_id="ref") for i in range(3) + ] + future = background_generate_description( + test_id="validmind.model_validation.ResidualNormality", + test_description="desc", + figures=figures, + ) + description, generated = future._future.result() + + self.assertTrue(generated) + self.assertEqual(len(render_threads), 3) + self.assertTrue(all(t is threading.current_thread() for t in render_threads)) + + class TestTruncateSummary(unittest.TestCase): """Test the _truncate_summary function.""" diff --git a/tests/unit_tests/model_validation/statsmodels/test_CoefficientStability.py b/tests/unit_tests/model_validation/statsmodels/test_CoefficientStability.py new file mode 100644 index 000000000..677658efb --- /dev/null +++ b/tests/unit_tests/model_validation/statsmodels/test_CoefficientStability.py @@ -0,0 +1,79 @@ +import unittest + +import numpy as np +import pandas as pd +import statsmodels.api as sm +import statsmodels.formula.api as smf + +import validmind as vm +from validmind.tests.model_validation.statsmodels.CoefficientStability import ( + CoefficientStability, +) + + +def _model(fit): + return vm.init_model(fit, input_id="m", __log=False) + + +class TestCoefficientStability(unittest.TestCase): + def setUp(self): + rng = np.random.default_rng(0) + n = 400 + self.df = pd.DataFrame({"x": rng.normal(0, 1, n), "noise": rng.normal(0, 1, n)}) + self.df["y"] = 2 * self.df.x + rng.normal(0, 1, n) # "noise" has no effect + + def test_strong_effect_is_stable(self): + table, passed, _ = CoefficientStability( + _model(smf.ols("y ~ x", self.df).fit()), n_bootstrap=200 + ) + row = table.set_index("Coefficient").loc["x"] + + self.assertTrue(passed) + self.assertEqual(row["Sign Stability"], 1.0) + self.assertLess(row["Bootstrap 95% CI Lower"], 2) + self.assertGreater(row["Bootstrap 95% CI Upper"], 2) + + def test_no_effect_is_unstable(self): + table, passed, _ = CoefficientStability( + _model(smf.ols("y ~ x + noise", self.df).fit()), n_bootstrap=200 + ) + rows = table.set_index("Coefficient") + + self.assertFalse(passed) + self.assertEqual(rows.loc["noise", "Pass/Fail"], "Fail") + self.assertEqual(rows.loc["x", "Pass/Fail"], "Pass") + self.assertEqual(rows.loc["Intercept", "Pass/Fail"], "N/A") + + def test_deterministic_for_a_seed(self): + model = _model(smf.ols("y ~ x + noise", self.df).fit()) + a = CoefficientStability(model, n_bootstrap=50, seed=7)[0] + b = CoefficientStability(model, n_bootstrap=50, seed=7)[0] + pd.testing.assert_frame_equal(a, b) + + def test_array_api(self): + X = sm.add_constant(self.df[["x", "noise"]]) + table, passed, raw = CoefficientStability( + _model(sm.OLS(self.df.y, X).fit()), n_bootstrap=100 + ) + self.assertFalse(passed) + self.assertEqual( + table.set_index("Coefficient").loc["const", "Pass/Fail"], "N/A" + ) + self.assertEqual(raw.bootstrap_coefficients.shape, (100, 3)) + + def test_rare_dummy_is_not_read_as_a_sign_flip(self): + df = self.df.assign(rare=0) + df.loc[df.index[:2], "rare"] = 1 # 2 of 400 rows + df["y"] += 5 * df.rare + table, _, raw = CoefficientStability( + _model(smf.ols("y ~ x + rare", df).fit()), n_bootstrap=200 + ) + + self.assertEqual( + table.set_index("Coefficient").loc["rare", "Sign Stability"], 1.0 + ) + self.assertGreater(raw.rank_deficient_draws, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/statsmodels/test_ExpectedCoefficientSigns.py b/tests/unit_tests/model_validation/statsmodels/test_ExpectedCoefficientSigns.py new file mode 100644 index 000000000..cb7536c93 --- /dev/null +++ b/tests/unit_tests/model_validation/statsmodels/test_ExpectedCoefficientSigns.py @@ -0,0 +1,84 @@ +import unittest + +import numpy as np +import pandas as pd +import statsmodels.api as sm +import statsmodels.formula.api as smf + +import validmind as vm +from validmind.tests.model_validation.statsmodels.ExpectedCoefficientSigns import ( + ExpectedCoefficientSigns, +) + + +class TestExpectedCoefficientSigns(unittest.TestCase): + def setUp(self): + rng = np.random.default_rng(0) + n = 400 + df = pd.DataFrame( + { + "up": rng.normal(0, 1, n), + "down": rng.normal(0, 1, n), + "none": rng.normal(0, 1, n), + "group": rng.choice(["a", "b"], n), + } + ) + df["y"] = 2 * df.up - 3 * df.down + rng.normal(0, 1, n) + self.fit = smf.ols("y ~ up + down + none + C(group)", df).fit() + self.model = vm.init_model(self.fit, input_id="m", __log=False) + + def test_passes_matching_signs(self): + group_sign = 1 if self.fit.params["C(group)[T.b]"] > 0 else -1 + table, passed, _ = ExpectedCoefficientSigns( + self.model, + expected_signs={"up": "+", "down": "negative", "C(group)[T.b]": group_sign}, + ) + self.assertTrue(passed) + self.assertTrue((table["Result"] == "Pass").all()) + + def test_fails_significant_wrong_sign(self): + table, passed, _ = ExpectedCoefficientSigns( + self.model, expected_signs={"up": "-", "down": "-"} + ) + results = table.set_index("Coefficient")["Result"] + self.assertFalse(passed) + self.assertEqual(results["up"], "Fail") + self.assertEqual(results["down"], "Pass") + + def test_distinguishes_unsupported_from_contradicted(self): + wrong = "-" if self.fit.params["none"] > 0 else "+" + table, passed, _ = ExpectedCoefficientSigns( + self.model, expected_signs={"none": wrong} + ) + self.assertFalse(passed) + self.assertEqual(table["Result"].iloc[0], "Fail (not significant)") + + def test_array_api_on_numpy_arrays(self): + rng = np.random.default_rng(1) + X = rng.normal(0, 1, (300, 2)) + y = 2 * X[:, 0] - 3 * X[:, 1] + rng.normal(0, 1, 300) + model = vm.init_model( + sm.OLS(y, sm.add_constant(X)).fit(), input_id="a", __log=False + ) + table, passed, _ = ExpectedCoefficientSigns( + model, expected_signs={"x1": "+", "x2": "+"} + ) + + self.assertFalse(passed) + self.assertEqual(list(table["Result"]), ["Pass", "Fail"]) + + def test_unknown_coefficient_raises(self): + with self.assertRaisesRegex(ValueError, "not in the model"): + ExpectedCoefficientSigns(self.model, expected_signs={"missing": "+"}) + + def test_invalid_sign_raises(self): + with self.assertRaisesRegex(ValueError, "must be"): + ExpectedCoefficientSigns(self.model, expected_signs={"up": "up"}) + + def test_empty_expected_signs_raises(self): + with self.assertRaisesRegex(ValueError, "at least one"): + ExpectedCoefficientSigns(self.model, expected_signs={}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/statsmodels/test_InfluentialObservations.py b/tests/unit_tests/model_validation/statsmodels/test_InfluentialObservations.py new file mode 100644 index 000000000..038c4f98d --- /dev/null +++ b/tests/unit_tests/model_validation/statsmodels/test_InfluentialObservations.py @@ -0,0 +1,93 @@ +import unittest + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +import statsmodels.api as sm +import statsmodels.formula.api as smf + +import validmind as vm +from validmind.tests.model_validation.statsmodels.InfluentialObservations import ( + InfluentialObservations, +) + + +def _model(fit): + return vm.init_model(fit, input_id="m", __log=False) + + +class TestInfluentialObservations(unittest.TestCase): + def setUp(self): + rng = np.random.default_rng(0) + n = 300 + self.df = pd.DataFrame({"x": rng.normal(0, 1, n), "z": rng.normal(0, 1, n)}) + self.df["y"] = 3 * self.df.x + self.df.z + rng.normal(0, 1, n) + self.df.index = [f"row{i}" for i in range(n)] + + self.bad = self.df.copy() + self.bad.loc["row7", "x"] = 40.0 # data entry error: extreme x, ordinary y + + def test_passes_clean_data(self): + tables, fig, passed, _ = InfluentialObservations( + _model(smf.ols("y ~ x + z", self.df).fit()) + ) + self.assertTrue(passed) + self.assertIn("Result", tables["Coefficient Impact of Severe Observations"]) + self.assertIsInstance(fig, go.Figure) + + def test_flags_the_planted_observation_by_its_label(self): + tables, _, passed, _ = InfluentialObservations( + _model(smf.ols("y ~ x + z", self.bad).fit()), top_n=5 + ) + top = tables["Top 5 Observations by Cook's Distance"] + + self.assertFalse(passed) + self.assertEqual(top["Observation"].iloc[0], "row7") + self.assertGreater(top["Cook's Distance"].iloc[0], 1) + + def test_reports_coefficient_impact(self): + tables, _, _, _ = InfluentialObservations( + _model(smf.ols("y ~ x + z", self.bad).fit()) + ) + impact = tables["Coefficient Impact of Severe Observations"].set_index( + "Coefficient" + ) + # without the error the slope recovers towards the true value of 3 + self.assertAlmostEqual(impact.loc["x", "Without 1 Severe"], 3, delta=0.3) + self.assertGreater(abs(impact.loc["x", "Change"]), 1) + + def test_default_thresholds_follow_conventions(self): + fit = smf.ols("y ~ x + z", self.df).fit() + summary = InfluentialObservations(_model(fit))[0]["Influence Summary"] + n, p = int(fit.nobs), len(fit.params) + + self.assertAlmostEqual(summary["Threshold"].iloc[0], round(4 / n, 6)) + self.assertAlmostEqual(summary["Threshold"].iloc[2], round(2 * p / n, 6)) + + def test_array_api(self): + X = sm.add_constant(self.bad[["x", "z"]]) + _, _, passed, _ = InfluentialObservations(_model(sm.OLS(self.bad.y, X).fit())) + self.assertFalse(passed) + + +class TestExternallyStudentizedResiduals(unittest.TestCase): + def test_matches_statsmodels(self): + from validmind.tests.model_validation.statsmodels.InfluentialObservations import ( + _externally_studentized, + ) + + rng = np.random.default_rng(1) + df = pd.DataFrame({"x": rng.normal(0, 1, 120), "z": rng.normal(0, 1, 120)}) + df["y"] = df.x - 2 * df.z + rng.standard_t(3, 120) + fit = smf.ols("y ~ x + z", df).fit() + influence = fit.get_influence() + + np.testing.assert_allclose( + _externally_studentized(fit, influence.hat_matrix_diag), + influence.resid_studentized_external, + rtol=1e-10, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/statsmodels/test_LinearitySpecification.py b/tests/unit_tests/model_validation/statsmodels/test_LinearitySpecification.py new file mode 100644 index 000000000..ea878fe81 --- /dev/null +++ b/tests/unit_tests/model_validation/statsmodels/test_LinearitySpecification.py @@ -0,0 +1,82 @@ +import unittest +import warnings + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +import statsmodels.api as sm +import statsmodels.formula.api as smf + +import validmind as vm +from validmind.tests.model_validation.statsmodels.LinearitySpecification import ( + LinearitySpecification, +) + + +def _model(fit): + return vm.init_model(fit, input_id="m", __log=False) + + +class TestLinearitySpecification(unittest.TestCase): + def setUp(self): + rng = np.random.default_rng(0) + n = 500 + self.df = pd.DataFrame( + {"x": rng.uniform(0, 3, n), "z": rng.normal(0, 1, n), "flag": rng.integers(0, 2, n)} + ) + noise = rng.normal(0, 0.5, n) + self.df["y_linear"] = 1 + 2 * self.df.x + self.df.z + self.df.flag + noise + self.df["y_curved"] = 1 + 2 * self.df.x**2 + self.df.z + self.df.flag + noise + + def test_detects_missing_curvature(self): + model = _model(smf.ols("y_curved ~ x + z + flag", self.df).fit()) + table, fig, passed, raw = LinearitySpecification(model) + + self.assertFalse(passed) + self.assertEqual(table["Pass/Fail"].iloc[0], "Fail") + self.assertIsInstance(fig, go.Figure) + + def test_passes_correct_specification(self): + model = _model(smf.ols("y_linear ~ x + z + flag", self.df).fit()) + table, _, passed, _ = LinearitySpecification(model) + + self.assertTrue(passed) + self.assertGreaterEqual(table["p-value"].iloc[0], 0.05) + + def test_plots_continuous_regressors_only(self): + model = _model(smf.ols("y_curved ~ x + z + flag", self.df).fit()) + _, fig, _, raw = LinearitySpecification(model) + + titles = [a.text for a in fig.layout.annotations] + self.assertEqual(titles, ["x", "z"]) # no intercept, no binary flag + self.assertEqual(set(raw.lowess), {"x", "z"}) + + def test_count_regressor_with_few_levels_is_smoothed_quietly(self): + rng = np.random.default_rng(0) + df = pd.DataFrame({"x": rng.normal(size=1000), "count": rng.poisson(1, 1000)}) + df["y"] = df.x + df["count"] + rng.normal(size=1000) + model = _model(smf.ols("y ~ x + count", df).fit()) + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + raw = LinearitySpecification(model)[-1] + + self.assertTrue(np.isfinite(raw.lowess["count"]).all()) + + def test_array_api(self): + X = sm.add_constant(self.df[["x", "z", "flag"]]) + model = _model(sm.OLS(self.df.y_curved, X).fit()) + _, fig, passed, _ = LinearitySpecification(model) + + self.assertFalse(passed) + self.assertEqual([a.text for a in fig.layout.annotations], ["x", "z"]) + + def test_samples_points_but_not_smoother(self): + model = _model(smf.ols("y_linear ~ x + z + flag", self.df).fit()) + _, fig, _, raw = LinearitySpecification(model, max_points=100) + + self.assertEqual(len(fig.data[0].x), 100) + self.assertEqual(len(raw.lowess["x"]), len(self.df)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/statsmodels/test_PredictionIntervals.py b/tests/unit_tests/model_validation/statsmodels/test_PredictionIntervals.py new file mode 100644 index 000000000..b7c0b3617 --- /dev/null +++ b/tests/unit_tests/model_validation/statsmodels/test_PredictionIntervals.py @@ -0,0 +1,181 @@ +import unittest + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +import statsmodels.api as sm +import statsmodels.formula.api as smf +from sklearn.linear_model import LinearRegression + +import validmind as vm +from validmind.errors import SkipTestError +from validmind.tests.model_validation.statsmodels.PredictionIntervals import ( + PredictionIntervals, +) + + +def _frame(n, seed, shift=0.0): + rng = np.random.default_rng(seed) + df = pd.DataFrame({"x": rng.normal(0, 1, n), "z": rng.normal(0, 1, n)}) + df["y"] = 1 + 2 * df.x - df.z + rng.normal(0, 1, n) + shift + return df + + +def _dataset(df, model, fit): + dataset = vm.init_dataset(df, input_id="d", target_column="y", __log=False) + dataset.assign_predictions(model, prediction_values=np.asarray(fit.predict(df))) + return dataset + + +class TestPredictionIntervals(unittest.TestCase): + def setUp(self): + self.train = _frame(1000, seed=0) + self.fit = smf.ols("y ~ x + z", self.train).fit() + self.model = vm.init_model(self.fit, input_id="m", __log=False) + + def test_well_specified_model_covers_new_data(self): + holdout = _frame(2000, seed=1) + tables, coverage_fig, band_fig, passed, _ = PredictionIntervals( + _dataset(holdout, self.model, self.fit), self.model + ) + coverage = tables["Prediction Interval Coverage"]["Coverage"].iloc[0] + + self.assertTrue(passed) + self.assertAlmostEqual(coverage, 0.95, delta=0.015) + self.assertIsInstance(coverage_fig, go.Figure) + self.assertIsInstance(band_fig, go.Figure) + + def test_detects_shifted_data_and_its_direction(self): + shifted = _frame(2000, seed=1, shift=1.5) + tables, _, _, passed, _ = PredictionIntervals( + _dataset(shifted, self.model, self.fit), self.model + ) + row = tables["Prediction Interval Coverage"].iloc[0] + + self.assertFalse(passed) + self.assertGreater(row["Above Interval"], 10 * row["Below Interval"]) + + def test_confidence_level_is_a_parameter(self): + holdout = _frame(2000, seed=1) + tables = PredictionIntervals( + _dataset(holdout, self.model, self.fit), self.model, confidence_level=0.8 + )[0] + coverage = tables["Prediction Interval Coverage"]["Coverage"].iloc[0] + self.assertAlmostEqual(coverage, 0.8, delta=0.03) + + def test_array_api(self): + X = sm.add_constant(self.train[["x", "z"]]) + fit = sm.OLS(self.train.y, X).fit() + model = vm.init_model(fit, input_id="a", __log=False) + holdout = _frame(2000, seed=1) + dataset = vm.init_dataset(holdout, input_id="d", target_column="y", __log=False) + dataset.assign_predictions( + model, + prediction_values=np.asarray( + fit.predict(sm.add_constant(holdout[["x", "z"]])) + ), + ) + + formula_tables = PredictionIntervals( + _dataset(holdout, self.model, self.fit), self.model + )[0] + array_tables = PredictionIntervals(dataset, model)[0] + pd.testing.assert_frame_equal( + formula_tables["Prediction Interval Coverage"], + array_tables["Prediction Interval Coverage"], + ) + + def test_skips_non_statsmodels_models(self): + sk = LinearRegression().fit(self.train[["x", "z"]], self.train.y) + model = vm.init_model(sk, input_id="sk", __log=False) + dataset = vm.init_dataset( + self.train, input_id="d", target_column="y", __log=False + ) + dataset.assign_predictions(model) + with self.assertRaises(SkipTestError): + PredictionIntervals(dataset, model) + + def test_array_api_on_numpy_arrays(self): + X = sm.add_constant(self.train[["x", "z"]].to_numpy()) + fit = sm.OLS(self.train.y.to_numpy(), X).fit() + model = vm.init_model(fit, input_id="np", __log=False) + holdout = _frame(2000, seed=1) + dataset = vm.init_dataset( + holdout, + input_id="d", + target_column="y", + feature_columns=["x", "z"], + __log=False, + ) + dataset.assign_predictions(model, prediction_values=np.zeros(len(holdout))) + + formula_tables = PredictionIntervals( + _dataset(holdout, self.model, self.fit), self.model + )[0] + array_tables = PredictionIntervals(dataset, model)[0] + pd.testing.assert_frame_equal( + formula_tables["Prediction Interval Coverage"], + array_tables["Prediction Interval Coverage"], + ) + + def test_numpy_model_with_unmatched_feature_count_skips(self): + fit = sm.OLS( + self.train.y.to_numpy(), sm.add_constant(self.train[["x"]].to_numpy()) + ).fit() + model = vm.init_model(fit, input_id="np", __log=False) + dataset = vm.init_dataset( + self.train, + input_id="d", + target_column="y", + feature_columns=["x", "z"], + __log=False, + ) + dataset.assign_predictions(model, prediction_values=np.zeros(len(self.train))) + with self.assertRaisesRegex(SkipTestError, "by position"): + PredictionIntervals(dataset, model) + + def test_rows_with_a_missing_regressor_are_excluded(self): + X = sm.add_constant(self.train[["x", "z"]]) + array = vm.init_model(sm.OLS(self.train.y, X).fit(), input_id="a", __log=False) + base = _frame(500, seed=1) + base.loc[base.index[:200], "x"] = np.nan + # coverage computed by hand on the complete rows only + complete = base.dropna() + bounds = self.fit.get_prediction(complete).summary_frame(alpha=0.05) + expected = float( + ( + (complete.y.to_numpy() >= bounds.obs_ci_lower.to_numpy()) + & (complete.y.to_numpy() <= bounds.obs_ci_upper.to_numpy()) + ).mean() + ) + indexes = { + "default": base.index, + "shuffled": np.random.default_rng(0).permutation(base.index), + "duplicated": np.repeat(np.arange(250), 2), + } + for (label, index), model in ( + (item, model) for item in indexes.items() for model in (self.model, array) + ): + with self.subTest(index=label, model=model.input_id): + holdout = base.set_axis(index) + dataset = vm.init_dataset( + holdout, + input_id="d", + target_column="y", + feature_columns=["x", "z"], + __log=False, + ) + dataset.assign_predictions( + model, prediction_values=np.zeros(len(holdout)) + ) + coverage = PredictionIntervals(dataset, model)[0][ + "Prediction Interval Coverage" + ].iloc[0] + + self.assertEqual(coverage["Observations"], 300) + self.assertEqual(coverage["Excluded (Missing)"], 200) + self.assertAlmostEqual(coverage["Coverage"], expected, places=4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/statsmodels/test_RegressionCoeffs.py b/tests/unit_tests/model_validation/statsmodels/test_RegressionCoeffs.py index da9581e78..1fa52252a 100644 --- a/tests/unit_tests/model_validation/statsmodels/test_RegressionCoeffs.py +++ b/tests/unit_tests/model_validation/statsmodels/test_RegressionCoeffs.py @@ -69,3 +69,28 @@ def test_plot_properties(self): # Check if error bars are present self.assertTrue(hasattr(fig.data[0], "error_y")) self.assertTrue(fig.data[0].error_y.visible) + + def test_error_bars_are_distances_to_the_model_confidence_interval(self): + fig, _, _ = RegressionCoeffs(self.vm_model) + bar = fig.data[0] + intervals = np.asarray(self.model.conf_int(alpha=0.05)) + coef = np.asarray(self.model.params) + + np.testing.assert_allclose(bar.error_y.array, intervals[:, 1] - coef) + np.testing.assert_allclose(bar.error_y.arrayminus, coef - intervals[:, 0]) + + def test_confidence_level_is_a_parameter(self): + wide = RegressionCoeffs(self.vm_model, confidence_level=0.99)[0].data[0] + narrow = RegressionCoeffs(self.vm_model, confidence_level=0.90)[0].data[0] + + self.assertTrue( + (np.asarray(wide.error_y.array) > np.asarray(narrow.error_y.array)).all() + ) + + def test_table_shows_the_chart_confidence_level(self): + _, table, _ = RegressionCoeffs(self.vm_model, confidence_level=0.90) + intervals = np.asarray(self.model.conf_int(alpha=0.10)) + + self.assertEqual(list(table.columns[-2:]), ["[0.05", "0.95]"]) + np.testing.assert_allclose(table["[0.05"].astype(float), intervals[:, 0], atol=5e-4) + np.testing.assert_allclose(table["0.95]"].astype(float), intervals[:, 1], atol=5e-4) diff --git a/tests/unit_tests/model_validation/statsmodels/test_RegressionModelSummary.py b/tests/unit_tests/model_validation/statsmodels/test_RegressionModelSummary.py new file mode 100644 index 000000000..b01d94776 --- /dev/null +++ b/tests/unit_tests/model_validation/statsmodels/test_RegressionModelSummary.py @@ -0,0 +1,33 @@ +import unittest + +import numpy as np +import pandas as pd +import statsmodels.formula.api as smf + +import validmind as vm +from validmind.tests.model_validation.statsmodels.RegressionModelSummary import ( + RegressionModelSummary, +) + + +class TestRegressionModelSummary(unittest.TestCase): + def setUp(self): + rng = np.random.default_rng(0) + df = pd.DataFrame({"x": rng.normal(0, 1, 200)}) + df["y"] = 2 * df.x + rng.normal(0, 1, 200) + fit = smf.ols("y ~ x", df).fit() + + self.model = vm.init_model(fit, input_id="m", __log=False) + self.dataset = vm.init_dataset(df, input_id="d", target_column="y", __log=False) + self.dataset.assign_predictions(self.model, prediction_values=fit.predict(df).values) + + def test_rmse_is_the_root_of_mse(self): + # `mean_squared_error(squared=False)` was removed in scikit-learn 1.6 + rows, _ = RegressionModelSummary(self.dataset, self.model) + + self.assertAlmostEqual(rows[0]["RMSE"], np.sqrt(rows[0]["MSE"])) + self.assertGreater(rows[0]["R-Squared"], 0.7) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/statsmodels/test_RobustStandardErrors.py b/tests/unit_tests/model_validation/statsmodels/test_RobustStandardErrors.py new file mode 100644 index 000000000..75d05b12b --- /dev/null +++ b/tests/unit_tests/model_validation/statsmodels/test_RobustStandardErrors.py @@ -0,0 +1,71 @@ +import unittest + +import numpy as np +import pandas as pd +import statsmodels.api as sm +import statsmodels.formula.api as smf + +import validmind as vm +from validmind.tests.model_validation.statsmodels.RobustStandardErrors import ( + RobustStandardErrors, +) + + +def _model(fit): + return vm.init_model(fit, input_id="m", __log=False) + + +def _data(heteroscedastic, seed=1, n=200): + rng = np.random.default_rng(seed) + x = rng.normal(0, 1, n) + noise = rng.normal(0, 1, n) + if heteroscedastic: + noise = noise * (0.2 + 2 * x**2) # variance grows at the extremes of x + return pd.DataFrame({"x": x, "y": 0.25 * x + noise}) + + +class TestRobustStandardErrors(unittest.TestCase): + def test_flags_significance_that_depends_on_constant_variance(self): + table, passed, _ = RobustStandardErrors( + _model(smf.ols("y ~ x", _data(heteroscedastic=True)).fit()) + ) + row = table.set_index("Coefficient").loc["x"] + + self.assertFalse(passed) + self.assertTrue(row["Significant (OLS)"]) + self.assertFalse(row["Significant (HC3)"]) + self.assertGreater(row["Ratio (Robust / OLS)"], 1.5) + + def test_passes_with_constant_variance(self): + table, passed, _ = RobustStandardErrors( + _model(smf.ols("y ~ x", _data(heteroscedastic=False)).fit()) + ) + self.assertTrue(passed) + self.assertTrue(table["Ratio (Robust / OLS)"].between(0.8, 1.25).all()) + + def test_cov_type_is_a_parameter(self): + table, _, _ = RobustStandardErrors( + _model(smf.ols("y ~ x", _data(heteroscedastic=True)).fit()), cov_type="HC1" + ) + self.assertIn("HC1 Std. Error", table.columns) + + def test_array_api(self): + df = _data(heteroscedastic=True) + _, passed, _ = RobustStandardErrors( + _model(sm.OLS(df.y, sm.add_constant(df[["x"]])).fit()) + ) + self.assertFalse(passed) + + def test_model_fitted_with_robust_errors_is_still_compared_to_ols(self): + df = _data(heteroscedastic=True) + plain = RobustStandardErrors(_model(smf.ols("y ~ x", df).fit())) + robust_fit = RobustStandardErrors( + _model(smf.ols("y ~ x", df).fit(cov_type="HC3")) + ) + + self.assertFalse(robust_fit[1]) + pd.testing.assert_frame_equal(plain[0], robust_fit[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/statsmodels/test_VarianceInflationFactor.py b/tests/unit_tests/model_validation/statsmodels/test_VarianceInflationFactor.py new file mode 100644 index 000000000..224343895 --- /dev/null +++ b/tests/unit_tests/model_validation/statsmodels/test_VarianceInflationFactor.py @@ -0,0 +1,105 @@ +import unittest + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +import statsmodels.api as sm +import statsmodels.formula.api as smf +from sklearn.linear_model import LinearRegression +from statsmodels.stats.outliers_influence import variance_inflation_factor + +import validmind as vm +from validmind.errors import SkipTestError +from validmind.tests.model_validation.statsmodels.VarianceInflationFactor import ( + VarianceInflationFactor, +) + + +def _data(seed=0, n=500): + rng = np.random.default_rng(seed) + x1 = rng.normal(0, 1, n) + df = pd.DataFrame( + { + "x1": x1, + "x2": x1 + rng.normal(0, 0.05, n), # near-duplicate of x1 + "x3": rng.normal(0, 1, n), + } + ) + df["y"] = 2 * df.x1 + df.x3 + rng.normal(0, 1, n) + return df + + +def _model(fit, input_id="m"): + return vm.init_model(fit, input_id=input_id, __log=False) + + +class TestVarianceInflationFactor(unittest.TestCase): + def setUp(self): + self.df = _data() + + def test_flags_collinear_regressors(self): + model = _model(smf.ols("y ~ x1 + x2 + x3", self.df).fit()) + tables, fig, passed, _ = VarianceInflationFactor(model) + vif = tables["Variance Inflation Factor"].set_index("Feature") + + self.assertFalse(passed) + self.assertEqual(vif.loc["x1", "Status"], "Severe") + self.assertEqual(vif.loc["x2", "Status"], "Severe") + self.assertEqual(vif.loc["x3", "Status"], "OK") + self.assertNotIn("Intercept", vif.index) + self.assertIsInstance(fig, go.Figure) + + def test_passes_without_collinearity(self): + model = _model(smf.ols("y ~ x1 + x3", self.df).fit()) + tables, _, passed, _ = VarianceInflationFactor(model) + + self.assertTrue(passed) + self.assertTrue((tables["Variance Inflation Factor"]["VIF"] < 2).all()) + self.assertEqual(tables["Condition Number"]["Pass/Fail"].iloc[0], "Pass") + + def test_thresholds_are_parameters(self): + model = _model(smf.ols("y ~ x1 + x3", self.df).fit()) + _, _, passed, _ = VarianceInflationFactor( + model, vif_threshold=1.0, moderate_vif_threshold=0.5 + ) + self.assertFalse(passed) + + def test_array_api_matches_formula_api(self): + formula = _model(smf.ols("y ~ x1 + x2 + x3", self.df).fit()) + array = _model( + sm.OLS(self.df.y, sm.add_constant(self.df[["x1", "x2", "x3"]])).fit() + ) + vif_formula = VarianceInflationFactor(formula)[0]["Variance Inflation Factor"] + vif_array = VarianceInflationFactor(array)[0]["Variance Inflation Factor"] + + self.assertNotIn("const", vif_array["Feature"].tolist()) + np.testing.assert_allclose(vif_formula["VIF"], vif_array["VIF"]) + + def test_skips_non_statsmodels_models(self): + sk = LinearRegression().fit(self.df[["x1", "x3"]], self.df.y) + with self.assertRaises(SkipTestError): + VarianceInflationFactor(_model(sk)) + + def test_status_and_pass_fail_use_the_value_shown(self): + fit = smf.ols("y ~ x1 + x2 + x3", self.df).fit() + raw = { + name: variance_inflation_factor(fit.model.exog, i) + for i, name in enumerate(fit.model.exog_names) + if name != "Intercept" + } + # a threshold the raw VIF is just below and the shown (rounded) VIF reaches + name, threshold = next( + (n, round(v, 2)) for n, v in raw.items() if round(v, 2) > v + ) + tables, _, passed, _ = VarianceInflationFactor( + _model(fit), vif_threshold=threshold, moderate_vif_threshold=threshold / 2 + ) + row = tables["Variance Inflation Factor"].set_index("Feature").loc[name] + + self.assertEqual(row["VIF"], threshold) + self.assertEqual(row["Status"], "Severe") + self.assertFalse(passed) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/test_CalibrationByDecile.py b/tests/unit_tests/model_validation/test_CalibrationByDecile.py new file mode 100644 index 000000000..46843fd17 --- /dev/null +++ b/tests/unit_tests/model_validation/test_CalibrationByDecile.py @@ -0,0 +1,44 @@ +import unittest + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +import validmind as vm +from validmind.tests.model_validation.CalibrationByDecile import CalibrationByDecile + + +def _dataset(prediction_fn, n=5000, seed=0): + rng = np.random.default_rng(seed) + df = pd.DataFrame({"x": rng.uniform(0, 10, n)}) + df["y"] = df.x + rng.normal(0, 1, n) + model = vm.init_model(input_id="m", predict_fn=lambda row: 0.0, __log=False) + dataset = vm.init_dataset(df, input_id="d", target_column="y", __log=False) + dataset.assign_predictions(model, prediction_values=prediction_fn(df.x).to_numpy()) + return dataset, model + + +class TestCalibrationByDecile(unittest.TestCase): + def test_calibrated_model_passes(self): + table, fig, passed, _ = CalibrationByDecile(*_dataset(lambda x: x)) + self.assertTrue(passed) + self.assertEqual(len(table), 10) + self.assertIsInstance(fig, go.Figure) + + def test_too_flat_model_fails_at_both_ends(self): + # predictions shrunk towards the mean: under-predicts the top, over-predicts the bottom + table, _, passed, _ = CalibrationByDecile(*_dataset(lambda x: 5 + 0.6 * (x - 5))) + self.assertFalse(passed) + self.assertEqual(table["Result"].iloc[0], "Miscalibrated") + self.assertEqual(table["Result"].iloc[-1], "Miscalibrated") + self.assertLess(table["Actual − Predicted"].iloc[0], 0) + self.assertGreater(table["Actual − Predicted"].iloc[-1], 0) + + def test_number_of_bins_is_a_parameter(self): + table = CalibrationByDecile(*_dataset(lambda x: x), n_bins=5)[0] + self.assertEqual(len(table), 5) + self.assertEqual(table["Count"].sum(), 5000) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/test_FeatureSensitivity.py b/tests/unit_tests/model_validation/test_FeatureSensitivity.py new file mode 100644 index 000000000..76a0744d8 --- /dev/null +++ b/tests/unit_tests/model_validation/test_FeatureSensitivity.py @@ -0,0 +1,160 @@ +import unittest + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +import statsmodels.api as sm +import statsmodels.formula.api as smf +from sklearn.ensemble import HistGradientBoostingRegressor +from sklearn.linear_model import LinearRegression + +import validmind as vm +from validmind.tests.model_validation.FeatureSensitivity import FeatureSensitivity + + +def _data(n=1000, seed=0): + rng = np.random.default_rng(seed) + df = pd.DataFrame( + { + "big": rng.normal(10, 2, n), + "small": rng.normal(0, 1, n), + "flag": (rng.random(n) < 0.25).astype(int), + } + ) + df["y"] = 3 * df.big + 0.5 * df.small + 4 * df.flag + rng.normal(0, 0.1, n) + return df + + +def _sklearn(df): + fit = LinearRegression().fit(df[["big", "small", "flag"]], df.y) + model = vm.init_model(fit, input_id="m", __log=False) + dataset = vm.init_dataset(df, input_id="d", target_column="y", __log=False) + dataset.assign_predictions(model) + return dataset, model + + +class TestFeatureSensitivity(unittest.TestCase): + def setUp(self): + self.df = _data() + + def test_linear_effects_in_target_units(self): + dataset, model = _sklearn(self.df) + table, fig, _ = FeatureSensitivity(dataset, model, clip_to_range=False) + rows = table.set_index("Feature") + std = self.df.big.std() + + self.assertAlmostEqual(rows.loc["big", "Change (Up)"], 3 * std, delta=0.05) + self.assertAlmostEqual(rows.loc["big", "Change (Down)"], -3 * std, delta=0.05) + self.assertEqual(table["Feature"].iloc[0], "big") # ranked by effect + self.assertIsInstance(fig, go.Figure) + + def test_binary_features_are_flipped(self): + dataset, model = _sklearn(self.df) + rows = FeatureSensitivity(dataset, model)[0].set_index("Feature") + share = self.df.flag.mean() + + self.assertEqual(rows.loc["flag", "Shock"], "0 → 1") + self.assertAlmostEqual( + rows.loc["flag", "Change (Up)"], 4 * (1 - share), delta=0.05 + ) + self.assertAlmostEqual( + rows.loc["flag", "Change (Down)"], -4 * share, delta=0.05 + ) + + def test_custom_shock_in_feature_units(self): + dataset, model = _sklearn(self.df) + rows = FeatureSensitivity( + dataset, + model, + features=["small"], + shocks={"small": 0.1}, + clip_to_range=False, + )[0].set_index("Feature") + + self.assertEqual(list(rows.index), ["small"]) + self.assertAlmostEqual(rows.loc["small", "Change (Up)"], 0.05, delta=0.005) + + def test_shocks_are_clipped_to_the_observed_range(self): + dataset, model = _sklearn(self.df) + huge = {"big": 100.0} + clipped = FeatureSensitivity(dataset, model, features=["big"], shocks=huge)[0] + unclipped = FeatureSensitivity( + dataset, model, features=["big"], shocks=huge, clip_to_range=False + )[0] + max_up = 3 * (self.df.big.max() - self.df.big.mean()) + + self.assertAlmostEqual(clipped["Change (Up)"].iloc[0], max_up, delta=0.1) + self.assertGreater(unclipped["Change (Up)"].iloc[0], 299) + + def test_statsmodels_formula_and_array_apis(self): + formula = smf.ols("y ~ big + small + flag", self.df).fit() + array = sm.OLS( + self.df.y, sm.add_constant(self.df[["big", "small", "flag"]]) + ).fit() + tables = [] + for fit, name in [(formula, "f"), (array, "a")]: + model = vm.init_model(fit, input_id=name, __log=False) + dataset = vm.init_dataset( + self.df.copy(), + input_id="d", + target_column="y", + feature_columns=["big", "small", "flag"], + __log=False, + ) + dataset.assign_predictions(model, prediction_values=np.zeros(len(self.df))) + tables.append(FeatureSensitivity(dataset, model)[0]) + + pd.testing.assert_frame_equal(tables[0], tables[1]) + + def test_numeric_column_wrapped_in_c_is_not_shocked(self): + df = self.df.assign(cards=np.resize([1, 2, 3, 4], len(self.df))) + fit = smf.ols("y ~ big + small + flag + C(cards)", df).fit() + model = vm.init_model(fit, input_id="f", __log=False) + dataset = vm.init_dataset( + df, + input_id="d", + target_column="y", + feature_columns=["big", "small", "flag", "cards"], + __log=False, + ) + dataset.assign_predictions(model, prediction_values=np.zeros(len(df))) + table = FeatureSensitivity(dataset, model)[0] + + self.assertEqual(set(table["Feature"]), {"big", "small", "flag"}) + + def test_non_linear_model_is_asymmetric(self): + df = self.df.assign(y=np.exp(self.df.big / 4)) + model = vm.init_model( + input_id="exp", predict_fn=lambda row: np.exp(row["big"] / 4), __log=False + ) + dataset = vm.init_dataset(df, input_id="d", target_column="y", __log=False) + dataset.assign_predictions(model, prediction_values=df.y.to_numpy()) + row = FeatureSensitivity(dataset, model, features=["big"], clip_to_range=False)[ + 0 + ] + + self.assertGreater(row["Change (Up)"].iloc[0], -row["Change (Down)"].iloc[0]) + + def test_missing_values_are_left_unshocked(self): + df = self.df.copy() + df.loc[df.index[:20], ["big", "flag"]] = np.nan + fit = HistGradientBoostingRegressor(random_state=0).fit( + df[["big", "small", "flag"]], df.y + ) + model = vm.init_model(fit, input_id="hgb", __log=False) + dataset = vm.init_dataset(df, input_id="d", target_column="y", __log=False) + dataset.assign_predictions(model) + rows = FeatureSensitivity(dataset, model, clip_to_range=False)[0].set_index( + "Feature" + ) + + self.assertEqual(rows.loc["flag", "Shock"], "0 → 1") + self.assertAlmostEqual( + float(rows.loc["big", "Shock"].lstrip("± ")), df.big.std(), delta=0.001 + ) + self.assertGreater(rows.loc["big", "Change (Up)"], 0) + self.assertLess(rows.loc["big", "Change (Down)"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/test_Heteroscedasticity.py b/tests/unit_tests/model_validation/test_Heteroscedasticity.py new file mode 100644 index 000000000..5eb83e904 --- /dev/null +++ b/tests/unit_tests/model_validation/test_Heteroscedasticity.py @@ -0,0 +1,105 @@ +import unittest + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +import statsmodels.formula.api as smf +from sklearn.linear_model import LinearRegression + +import validmind as vm +from validmind.errors import SkipTestError +from validmind.tests.model_validation.Heteroscedasticity import Heteroscedasticity + + +def _setup(heteroscedastic, n=1000, seed=0): + rng = np.random.default_rng(seed) + df = pd.DataFrame( + { + "x": rng.uniform(1, 10, n), + "z": rng.normal(0, 1, n), + "group": rng.choice(["a", "b"], n), + } + ) + scale = df.x if heteroscedastic else 3.0 + df["y"] = 2 * df.x + df.z + rng.normal(0, 1, n) * scale + return df + + +def _sklearn(df): + fit = LinearRegression().fit(df[["x", "z"]], df.y) + model = vm.init_model(fit, input_id="m", __log=False) + dataset = vm.init_dataset( + df, + input_id="d", + target_column="y", + feature_columns=["x", "z", "group"], + __log=False, + ) + # the model uses x and z only; "group" is a categorical feature of the dataset + dataset.assign_predictions(model, prediction_values=fit.predict(df[["x", "z"]])) + return dataset, model + + +class TestHeteroscedasticity(unittest.TestCase): + def test_detects_variance_growing_with_a_feature(self): + tables, fig, passed, _ = Heteroscedasticity(*_sklearn(_setup(True))) + spread = tables["Residual Spread by Prediction Bin"]["Residual Std. Deviation"] + + self.assertFalse(passed) + self.assertTrue( + (tables["Heteroscedasticity Tests"]["Pass/Fail"] == "Fail").all() + ) + self.assertGreater(spread.iloc[-1], 2 * spread.iloc[0]) + self.assertIsInstance(fig, go.Figure) + + def test_passes_constant_variance(self): + tables, _, passed, _ = Heteroscedasticity(*_sklearn(_setup(False))) + self.assertTrue(passed) + + def test_works_with_statsmodels_predictions(self): + df = _setup(True) + fit = smf.ols("y ~ x + z", df).fit() + model = vm.init_model(fit, input_id="sm", __log=False) + dataset = vm.init_dataset( + df, + input_id="d", + target_column="y", + feature_columns=["x", "z", "group"], + __log=False, + ) + dataset.assign_predictions(model, prediction_values=fit.predict(df).values) + + self.assertFalse(Heteroscedasticity(dataset, model)[2]) + + def test_skips_without_numeric_features(self): + df = _setup(False) + model = vm.init_model(input_id="f", predict_fn=lambda row: 0.0, __log=False) + dataset = vm.init_dataset( + df[["group", "y"]], input_id="d", target_column="y", __log=False + ) + dataset.assign_predictions(model, prediction_values=np.zeros(len(df))) + + with self.assertRaises(SkipTestError): + Heteroscedasticity(dataset, model) + + def test_rows_with_missing_features_are_excluded(self): + df = _setup(True) + df.loc[df.index[:10], "x"] = np.nan + fit = LinearRegression().fit(df[["x", "z"]].fillna(0), df.y) + model = vm.init_model(fit, input_id="m", __log=False) + dataset = vm.init_dataset( + df, input_id="d", target_column="y", feature_columns=["x", "z"], __log=False + ) + dataset.assign_predictions( + model, prediction_values=fit.predict(df[["x", "z"]].fillna(0)) + ) + tables, _, passed, _ = Heteroscedasticity(dataset, model) + + self.assertFalse(passed) + self.assertTrue( + (tables["Heteroscedasticity Tests"]["Observations"] == 990).all() + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/test_PerformanceConfidenceIntervals.py b/tests/unit_tests/model_validation/test_PerformanceConfidenceIntervals.py new file mode 100644 index 000000000..a7f822d7e --- /dev/null +++ b/tests/unit_tests/model_validation/test_PerformanceConfidenceIntervals.py @@ -0,0 +1,73 @@ +import unittest + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +import validmind as vm +from validmind.tests.model_validation.PerformanceConfidenceIntervals import ( + PerformanceConfidenceIntervals, +) + + +class TestPerformanceConfidenceIntervals(unittest.TestCase): + def setUp(self): + rng = np.random.default_rng(0) + n = 1000 + df = pd.DataFrame({"x": rng.normal(0, 1, n)}) + df["y"] = 2 * df.x + rng.normal(0, 1, n) + self.dataset = vm.init_dataset(df, input_id="d", target_column="y", __log=False) + + shared = rng.normal(0, 1, n) + self.models = {} + for name, noise in [ + ("good", 0.3 * shared), + ("bad", 0.3 * shared + rng.normal(0, 1.0, n)), # clearly worse + ("twin", 0.3 * shared + rng.normal(0, 0.01, n)), # same as good + ]: + model = vm.init_model(input_id=name, predict_fn=lambda row: 0.0, __log=False) + self.dataset.assign_predictions( + model, prediction_values=(2 * df.x + noise).to_numpy() + ) + self.models[name] = model + + def test_single_model_gets_intervals(self): + tables, fig, _ = PerformanceConfidenceIntervals( + self.dataset, [self.models["good"]], n_bootstrap=200 + ) + perf = tables["Performance with Confidence Intervals"].set_index("Metric") + + self.assertEqual(list(tables), ["Performance with Confidence Intervals"]) + for metric in ["R-Squared", "RMSE", "MAE", "Mean Bias"]: + row = perf.loc[metric] + self.assertLessEqual(row["95% CI Lower"], row["Estimate"]) + self.assertGreaterEqual(row["95% CI Upper"], row["Estimate"]) + self.assertIsInstance(fig, go.Figure) + + def test_difference_identifies_the_better_model(self): + tables, _, _ = PerformanceConfidenceIntervals( + self.dataset, [self.models["bad"], self.models["good"]], n_bootstrap=300 + ) + diff = tables["Difference vs bad"].set_index("Metric") + + self.assertTrue(diff["CI Excludes Zero"].all()) + self.assertTrue((diff["Better Model"] == "good").all()) + self.assertLess(diff.loc["MAE", "Difference"], 0) + + def test_no_difference_between_equivalent_models(self): + tables, _, _ = PerformanceConfidenceIntervals( + self.dataset, [self.models["good"], self.models["twin"]], n_bootstrap=300 + ) + diff = tables["Difference vs good"] + self.assertTrue((diff["Better Model"] == "No significant difference").all()) + + def test_deterministic_for_a_seed(self): + args = (self.dataset, [self.models["good"], self.models["bad"]]) + a = PerformanceConfidenceIntervals(*args, n_bootstrap=50, seed=3)[0] + b = PerformanceConfidenceIntervals(*args, n_bootstrap=50, seed=3)[0] + for key in a: + pd.testing.assert_frame_equal(a[key], b[key]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/test_PerformanceOverTime.py b/tests/unit_tests/model_validation/test_PerformanceOverTime.py new file mode 100644 index 000000000..620581d4e --- /dev/null +++ b/tests/unit_tests/model_validation/test_PerformanceOverTime.py @@ -0,0 +1,58 @@ +import unittest + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +import validmind as vm +from validmind.tests.model_validation.PerformanceOverTime import PerformanceOverTime + + +def _dataset(break_size, n_per_period=300, seed=0, missing_periods=0): + rng = np.random.default_rng(seed) + periods = [f"2024-{m:02d}" for m in range(1, 13)] + df = pd.DataFrame({"period": np.repeat(periods, n_per_period)}) + df["x"] = rng.normal(0, 1, len(df)) + prediction = 2 * df.x + after_break = df.period >= "2024-10" + df["y"] = prediction + rng.normal(0, 1, len(df)) + break_size * after_break + df = df.sample(frac=1, random_state=0) # periods arrive unsorted + df.loc[df.index[:missing_periods], "period"] = None + model = vm.init_model(input_id="m", predict_fn=lambda row: 0.0, __log=False) + dataset = vm.init_dataset( + df, input_id="d", target_column="y", feature_columns=["x"], + extra_columns={"period": None}, __log=False, + ) + dataset.assign_predictions(model, prediction_values=(2 * df.x).to_numpy()) + return dataset, model + + +class TestPerformanceOverTime(unittest.TestCase): + def test_flags_periods_after_a_break(self): + table, fig, passed, _ = PerformanceOverTime(*_dataset(break_size=0.5), time_column="period") + + self.assertFalse(passed) + self.assertEqual(list(table["Period"]), sorted(table["Period"])) + self.assertEqual(list(table[table.Result == "Biased"]["Period"]), ["2024-10", "2024-11", "2024-12"]) + self.assertIsInstance(fig, go.Figure) + + def test_stable_model_passes(self): + _, _, passed, _ = PerformanceOverTime(*_dataset(break_size=0.0), time_column="period") + self.assertTrue(passed) + + def test_rows_without_a_period_are_reported_last(self): + table = PerformanceOverTime( + *_dataset(break_size=0.0, missing_periods=20), time_column="period" + )[0] + + self.assertEqual(table["Period"].iloc[-1], "Missing") + self.assertEqual(table["Count"].iloc[-1], 20) + self.assertEqual(table["Count"].sum(), 3600) + + def test_time_column_must_exist(self): + with self.assertRaisesRegex(ValueError, "not in the dataset"): + PerformanceOverTime(*_dataset(break_size=0.0), time_column="missing") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/test_PredictionRange.py b/tests/unit_tests/model_validation/test_PredictionRange.py new file mode 100644 index 000000000..74dba93cf --- /dev/null +++ b/tests/unit_tests/model_validation/test_PredictionRange.py @@ -0,0 +1,52 @@ +import unittest + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +import validmind as vm +from validmind.tests.model_validation.PredictionRange import PredictionRange + + +class TestPredictionRange(unittest.TestCase): + def setUp(self): + df = pd.DataFrame({"x": np.arange(10.0), "y": np.arange(10.0)}) + self.model = vm.init_model(input_id="m", predict_fn=lambda row: 0.0, __log=False) + self.dataset = vm.init_dataset(df, input_id="d", target_column="y", __log=False) + # predictions -2 .. 7 + self.dataset.assign_predictions(self.model, prediction_values=np.arange(10.0) - 2) + + def test_counts_predictions_below_the_lower_bound(self): + table, fig, passed, _ = PredictionRange(self.dataset, self.model, lower_bound=0) + lower = table.set_index("Bound").loc["Lower"] + + self.assertFalse(passed) + self.assertEqual(lower["Predictions Beyond"], 2) + self.assertAlmostEqual(lower["Share Beyond"], 0.2) + self.assertEqual(lower["Most Extreme Prediction"], -2) + self.assertIsInstance(fig, go.Figure) + + def test_combines_both_bounds(self): + table, _, _, _ = PredictionRange( + self.dataset, self.model, lower_bound=0, upper_bound=6 + ) + total = table.set_index("Bound").loc["Total outside range"] + self.assertEqual(total["Predictions Beyond"], 3) + + def test_bound_of_zero_is_respected(self): + table, _, _, _ = PredictionRange(self.dataset, self.model, upper_bound=0) + self.assertEqual(table.set_index("Bound").loc["Upper", "Predictions Beyond"], 7) + + def test_tolerance_is_a_parameter(self): + _, _, passed, _ = PredictionRange( + self.dataset, self.model, lower_bound=0, max_share_outside=0.2 + ) + self.assertTrue(passed) + + def test_requires_a_bound(self): + with self.assertRaisesRegex(ValueError, "requires"): + PredictionRange(self.dataset, self.model) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/test_RegressionWeakspots.py b/tests/unit_tests/model_validation/test_RegressionWeakspots.py new file mode 100644 index 000000000..695145c77 --- /dev/null +++ b/tests/unit_tests/model_validation/test_RegressionWeakspots.py @@ -0,0 +1,57 @@ +import unittest + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +import validmind as vm +from validmind.tests.model_validation.RegressionWeakspots import RegressionWeakspots + + +def _dataset(noisy_scale, n=4000, seed=0): + """Noise is `noisy_scale` times larger for group == 'noisy'; no bias anywhere.""" + rng = np.random.default_rng(seed) + df = pd.DataFrame({"x": rng.normal(0, 1, n), "group": rng.choice(["noisy", "a", "b", "c"], n)}) + scale = np.where(df.group == "noisy", noisy_scale, 1.0) + prediction = 2 * df.x + df["y"] = prediction + rng.normal(0, 1, n) * scale + model = vm.init_model(input_id="m", predict_fn=lambda row: 0.0, __log=False) + dataset = vm.init_dataset( + df, input_id="d", target_column="y", feature_columns=["x"], + extra_columns={"group": None}, __log=False, + ) + dataset.assign_predictions(model, prediction_values=prediction.to_numpy()) + return dataset, model + + +class TestRegressionWeakspots(unittest.TestCase): + def test_flags_the_noisy_segment(self): + table, fig, passed, _ = RegressionWeakspots(*_dataset(noisy_scale=3.0)) + weak = table[table.Result == "Weak spot"] + + self.assertFalse(passed) + self.assertEqual(list(weak["Segment"]), ["noisy"]) + self.assertGreater(weak["MAE Ratio to Overall"].iloc[0], 1.25) + self.assertIsInstance(fig, go.Figure) + + def test_quiet_with_uniform_error(self): + _, _, passed, _ = RegressionWeakspots(*_dataset(noisy_scale=1.0)) + self.assertTrue(passed) + + def test_missing_values_in_a_binned_column_do_not_crash(self): + dataset, model = _dataset(noisy_scale=1.0) + dataset.add_extra_column("score", np.r_[np.full(10, np.nan), np.arange(3990.0)]) + table = RegressionWeakspots(dataset, model, segment_columns=["score"])[0] + + self.assertEqual(table["Segment"].iloc[-1], "Missing") + + def test_requires_a_material_difference(self): + # 1.15x noise is statistically clear on 1,000 rows but below the 1.25x ratio + table, _, passed, _ = RegressionWeakspots(*_dataset(noisy_scale=1.15)) + self.assertTrue(passed) + _, _, passed_strict, _ = RegressionWeakspots(*_dataset(noisy_scale=1.15), max_error_ratio=1.05) + self.assertFalse(passed_strict) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/test_ResidualBiasBySegment.py b/tests/unit_tests/model_validation/test_ResidualBiasBySegment.py new file mode 100644 index 000000000..32346d126 --- /dev/null +++ b/tests/unit_tests/model_validation/test_ResidualBiasBySegment.py @@ -0,0 +1,117 @@ +import unittest + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +import validmind as vm +from validmind.tests.model_validation.ResidualBiasBySegment import ResidualBiasBySegment + + +def _dataset(n=4000, seed=0, bias=0.0, missing_scores=0): + """`bias` is added to y for group == 'hit'; predictions are unbiased overall, as a + fitted model's would be (its intercept absorbs the average bias).""" + rng = np.random.default_rng(seed) + df = pd.DataFrame( + { + "x": rng.normal(0, 1, n), + "group": rng.choice(["hit", "a", "b", "c"], n), + "score": rng.uniform(300, 850, n), + "flag": rng.integers(0, 2, n), + } + ) + df["y"] = 2 * df.x + rng.normal(0, 1, n) + bias * (df.group == "hit") + df.loc[df.index[:missing_scores], "score"] = np.nan + prediction = 2 * df.x + bias * (df.group == "hit").mean() + model = vm.init_model(input_id="m", predict_fn=lambda row: 0.0, __log=False) + dataset = vm.init_dataset( + df, + input_id="d", + target_column="y", + feature_columns=["x"], + extra_columns={"group": None, "score": None, "flag": None}, + __log=False, + ) + dataset.assign_predictions(model, prediction_values=prediction.to_numpy()) + return dataset, model + + +class TestResidualBiasBySegment(unittest.TestCase): + def test_flags_only_the_column_carrying_the_bias(self): + dataset, model = _dataset(bias=1.0) + table, fig, passed, _ = ResidualBiasBySegment(dataset, model) + biased = table[table.Result == "Biased"].set_index("Segment") + + self.assertFalse(passed) + self.assertEqual(set(biased["Segment Column"]), {"group"}) + # 'hit' is under-predicted; the rest are over-predicted, averaging to zero + self.assertAlmostEqual(biased.loc["hit", "Mean Residual"], 0.75, delta=0.15) + self.assertTrue((biased.drop("hit")["Mean Residual"] < 0).all()) + self.assertIsInstance(fig, go.Figure) + + def test_quiet_without_bias_across_many_segments(self): + dataset, model = _dataset(bias=0.0) + table, _, passed, _ = ResidualBiasBySegment(dataset, model, n_bins=10) + self.assertTrue(passed) + self.assertGreater(len(table), 15) + + def test_default_segments_include_columns_the_model_does_not_use(self): + dataset, model = _dataset() + columns = set(ResidualBiasBySegment(dataset, model)[0]["Segment Column"]) + self.assertEqual(columns, {"x", "group", "score", "flag"}) + + def test_numeric_columns_are_binned_from_their_real_minimum(self): + dataset, model = _dataset() + table = ResidualBiasBySegment( + dataset, model, segment_columns=["score"], n_bins=4 + )[0] + self.assertEqual(len(table), 4) + first_edge = float(table["Segment"].iloc[0].split(" – ")[0].replace(",", "")) + self.assertAlmostEqual(first_edge, dataset.df.score.min(), delta=1) + + def test_missing_values_are_their_own_segment_listed_last(self): + dataset, model = _dataset(missing_scores=10) + table = ResidualBiasBySegment( + dataset, model, segment_columns=["score"], n_bins=4 + )[0] + + self.assertEqual(table["Segment"].iloc[-1], "Missing") + self.assertEqual(table["Count"].iloc[-1], 10) + + def test_column_pairs_segment_on_combinations(self): + dataset, model = _dataset() + table = ResidualBiasBySegment( + dataset, model, segment_columns=[["group", "flag"]] + )[0] + self.assertEqual(set(table["Segment Column"]), {"group × flag"}) + self.assertEqual(len(table), 8) + + def test_small_segments_are_reported_not_assessed(self): + dataset, model = _dataset(n=200, bias=5.0) + table = ResidualBiasBySegment( + dataset, model, segment_columns=["group"], min_segment_size=1000 + )[0] + self.assertTrue((table.Result == "Too small to assess").all()) + + def test_unknown_column_explains_extra_columns(self): + dataset, model = _dataset() + with self.assertRaisesRegex(ValueError, "add_extra_column"): + ResidualBiasBySegment(dataset, model, segment_columns=["missing"]) + + def test_large_values_keep_one_segment_per_bin(self): + n = 1000 + df = pd.DataFrame({"x": np.zeros(n), "date": 20240101 + np.arange(n) % 365}) + df["y"] = np.random.default_rng(0).normal(0, 1, n) + model = vm.init_model(input_id="m", predict_fn=lambda row: 0.0, __log=False) + dataset = vm.init_dataset(df, input_id="d", target_column="y", __log=False) + dataset.assign_predictions(model, prediction_values=np.zeros(n)) + table = ResidualBiasBySegment( + dataset, model, segment_columns=["date"], n_bins=4 + )[0] + + self.assertEqual(len(table), 4) + self.assertEqual(table["Segment"].nunique(), 4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/test_ResidualNormality.py b/tests/unit_tests/model_validation/test_ResidualNormality.py new file mode 100644 index 000000000..4855080b5 --- /dev/null +++ b/tests/unit_tests/model_validation/test_ResidualNormality.py @@ -0,0 +1,71 @@ +import unittest +import warnings + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from sklearn.linear_model import LinearRegression + +import validmind as vm +from validmind.tests.model_validation.ResidualNormality import ResidualNormality + + +def _setup(noise, n=1000, seed=0): + rng = np.random.default_rng(seed) + df = pd.DataFrame({"x": rng.normal(0, 1, n)}) + df["y"] = 2 * df.x + noise(rng, n) + model = vm.init_model( + LinearRegression().fit(df[["x"]], df.y), input_id="m", __log=False + ) + dataset = vm.init_dataset(df, input_id="d", target_column="y", __log=False) + dataset.assign_predictions(model) + return dataset, model + + +class TestResidualNormality(unittest.TestCase): + def test_passes_normal_errors(self): + dataset, model = _setup(lambda rng, n: rng.normal(0, 1, n)) + tables, fig, passed, _ = ResidualNormality(dataset, model) + + self.assertTrue(passed) + self.assertTrue((tables["Normality Tests"]["Pass/Fail"] == "Pass").all()) + self.assertIsInstance(fig, go.Figure) + + def test_anderson_darling_uses_no_deprecated_scipy_api(self): + dataset, model = _setup(lambda rng, n: rng.normal(0, 1, n)) + with warnings.catch_warnings(): + warnings.simplefilter("error", FutureWarning) + tables = ResidualNormality(dataset, model)[0] + row = tables["Normality Tests"].set_index("Test").loc["Anderson-Darling"] + + self.assertEqual(row["Pass/Fail"], "Pass") + + def test_fails_skewed_errors(self): + dataset, model = _setup(lambda rng, n: rng.exponential(1, n)) + tables, _, passed, _ = ResidualNormality(dataset, model) + + self.assertFalse(passed) + self.assertTrue((tables["Normality Tests"]["Pass/Fail"] == "Fail").all()) + self.assertGreater(tables["Residual Shape"]["Skewness"].iloc[0], 1) + + def test_tests_residuals_not_features(self): + # skewed feature, normal errors: feature normality is not an assumption + rng = np.random.default_rng(0) + df = pd.DataFrame({"x": rng.exponential(1, 1000)}) + df["y"] = 2 * df.x + rng.normal(0, 1, 1000) + model = vm.init_model( + LinearRegression().fit(df[["x"]], df.y), input_id="m", __log=False + ) + dataset = vm.init_dataset(df, input_id="d", target_column="y", __log=False) + dataset.assign_predictions(model) + + self.assertTrue(ResidualNormality(dataset, model)[2]) + + def test_samples_large_datasets_for_shapiro(self): + dataset, model = _setup(lambda rng, n: rng.normal(0, 1, n), n=6000) + tests = ResidualNormality(dataset, model)[0]["Normality Tests"] + self.assertEqual(tests["Test"].iloc[1], "Shapiro-Wilk (n=5000)") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/test_ScenarioAnalysis.py b/tests/unit_tests/model_validation/test_ScenarioAnalysis.py new file mode 100644 index 000000000..63b0511e9 --- /dev/null +++ b/tests/unit_tests/model_validation/test_ScenarioAnalysis.py @@ -0,0 +1,71 @@ +import unittest + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from sklearn.linear_model import LinearRegression + +import validmind as vm +from validmind.tests.model_validation.ScenarioAnalysis import ScenarioAnalysis + + +class TestScenarioAnalysis(unittest.TestCase): + def setUp(self): + rng = np.random.default_rng(0) + n = 500 + self.df = pd.DataFrame({"a": rng.uniform(1, 2, n), "b": rng.uniform(0, 1, n)}) + self.df["y"] = 10 + 2 * self.df.a - 5 * self.df.b + fit = LinearRegression().fit(self.df[["a", "b"]], self.df.y) + self.model = vm.init_model(fit, input_id="m", __log=False) + self.dataset = vm.init_dataset(self.df, input_id="d", target_column="y", __log=False) + self.dataset.assign_predictions(self.model) + + def _run(self, scenarios, **kwargs): + table, fig, _ = ScenarioAnalysis(self.dataset, self.model, scenarios, **kwargs) + return table.set_index("Scenario"), fig + + def test_operations_have_exact_linear_effects(self): + n = len(self.df) + table, fig = self._run( + { + "double a": {"a": {"multiply": 2}}, + "b up": {"b": {"add": 0.1}}, + "b zero": {"b": {"set": 0}}, + } + ) + + self.assertAlmostEqual(table.loc["double a", "Change in Total"], 2 * self.df.a.sum(), places=4) + self.assertAlmostEqual(table.loc["b up", "Change in Total"], -5 * 0.1 * n, places=4) + self.assertAlmostEqual(table.loc["b zero", "Change in Total"], 5 * self.df.b.sum(), places=4) + self.assertEqual(table.loc["Baseline", "Change in Total"], 0) + self.assertIsInstance(fig, go.Figure) + + def test_joint_changes_in_one_scenario(self): + table, _ = self._run({"both": {"a": {"add": 1}, "b": {"add": 1}}}) + self.assertAlmostEqual(table.loc["both", "Mean Prediction"] - table.loc["Baseline", "Mean Prediction"], -3, places=4) + self.assertEqual(table.loc["both", "Changes"], "a add 1; b add 1") + + def test_reports_share_below_lower_bound(self): + table, _ = self._run({"crash": {"b": {"set": 10}}}, lower_bound=0) + self.assertEqual(table.loc["Baseline", "Share Below 0"], 0) + self.assertEqual(table.loc["crash", "Share Below 0"], 1) + + def test_does_not_modify_the_dataset(self): + before = self.dataset.df.copy() + self._run({"double a": {"a": {"multiply": 2}}}) + pd.testing.assert_frame_equal(self.dataset.df, before) + + def test_invalid_scenarios_raise(self): + for scenarios, message in [ + ({"s": {"missing": {"add": 1}}}, "unknown feature"), + ({"s": {"a": {"divide": 2}}}, "unknown operation"), + ({"s": {"a": 2}}, "exactly one of"), + ({"s": {"a": {"add": 1, "set": 2}}}, "exactly one of"), + ({"Baseline": {"a": {"add": 1}}}, "reserved"), + ]: + with self.assertRaisesRegex(ValueError, message): + self._run(scenarios) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit_tests/model_validation/test_TailPerformance.py b/tests/unit_tests/model_validation/test_TailPerformance.py new file mode 100644 index 000000000..c8dbabea3 --- /dev/null +++ b/tests/unit_tests/model_validation/test_TailPerformance.py @@ -0,0 +1,64 @@ +import unittest + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +import validmind as vm +from validmind.tests.model_validation.TailPerformance import TailPerformance + + +class TestTailPerformance(unittest.TestCase): + def setUp(self): + y = np.arange(1, 1001, dtype=float) + df = pd.DataFrame({"x": y, "y": y}) + self.model = vm.init_model( + input_id="m", predict_fn=lambda row: 0.0, __log=False + ) + self.dataset = vm.init_dataset(df, input_id="d", target_column="y", __log=False) + # perfect except the top 10% of actual values, under-predicted by 100 + prediction = np.where(y > 900, y - 100, y) + self.dataset.assign_predictions(self.model, prediction_values=prediction) + + def test_isolates_error_in_the_top_tail(self): + table, fig, _ = TailPerformance(self.dataset, self.model) + rows = table.set_index("Group") + + self.assertEqual(rows.loc["Top 10% by actual", "Mean Residual"], 100) + self.assertEqual(rows.loc["Bottom 10% by actual", "MAE"], 0) + self.assertAlmostEqual(rows.loc["All observations", "MAE"], 10) + self.assertEqual(rows.loc["Top 10% by actual", "MAE Ratio to Overall"], 10) + self.assertIsInstance(fig, go.Figure) + + def test_tail_share_is_a_parameter(self): + table = TailPerformance(self.dataset, self.model, tail_share=0.05)[0] + rows = table.set_index("Group") + self.assertEqual(rows.loc["Top 5% by actual", "Count"], 50) + + def test_ties_do_not_widen_a_tail(self): + y = np.r_[np.zeros(300), np.arange(1, 701, dtype=float)] # 30% zeros + dataset = vm.init_dataset( + pd.DataFrame({"x": y, "y": y}), input_id="z", target_column="y", __log=False + ) + dataset.assign_predictions(self.model, prediction_values=y) + rows = TailPerformance(dataset, self.model)[0].set_index("Group") + + self.assertEqual(rows.loc["Bottom 10% by actual", "Count"], 100) + self.assertEqual(rows.loc["Bottom 10% by prediction", "Count"], 100) + + def test_tails_have_the_same_size(self): + y = np.arange(1, 101, dtype=float) + dataset = vm.init_dataset( + pd.DataFrame({"x": y, "y": y}), input_id="h", target_column="y", __log=False + ) + dataset.assign_predictions(self.model, prediction_values=y) + rows = TailPerformance(dataset, self.model, tail_share=0.29)[0].set_index( + "Group" + ) + + self.assertEqual(rows.loc["Top 29% by actual", "Count"], 29) + self.assertEqual(rows.loc["Bottom 29% by actual", "Count"], 29) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 04191c530..b0971c632 100644 --- a/uv.lock +++ b/uv.lock @@ -11288,7 +11288,7 @@ wheels = [ [[package]] name = "validmind" -version = "2.14.0" +version = "2.15.0" source = { editable = "." } dependencies = [ { name = "aiohttp", extra = ["speedups"] }, diff --git a/validmind/__version__.py b/validmind/__version__.py index d0979fd03..90c1ae3a0 100644 --- a/validmind/__version__.py +++ b/validmind/__version__.py @@ -1 +1 @@ -__version__ = "2.14.0" +__version__ = "2.15.0" diff --git a/validmind/ai/test_descriptions.py b/validmind/ai/test_descriptions.py index 568db9b98..e7e62a4e7 100644 --- a/validmind/ai/test_descriptions.py +++ b/validmind/ai/test_descriptions.py @@ -220,6 +220,14 @@ def wrapped(): return test_description, False + # Render figures to PNG here, on the calling thread: Plotly's renderer (kaleido) + # deadlocks when several description workers start it at the same time. + for figure in figures or []: + try: + figure.pre_serialize() + except Exception: + pass # the worker hits the same error and falls back to the default + return DescriptionFuture(__executor.submit(wrapped)) diff --git a/validmind/datasets/regression/__init__.py b/validmind/datasets/regression/__init__.py index dc911490a..6a0e9e8a3 100644 --- a/validmind/datasets/regression/__init__.py +++ b/validmind/datasets/regression/__init__.py @@ -10,6 +10,8 @@ import pandas as pd __all__: List[str] = [ + "california_housing", + "credit_card_balance", "fred", "lending_club", ] diff --git a/validmind/datasets/regression/credit_card_balance.py b/validmind/datasets/regression/credit_card_balance.py new file mode 100644 index 000000000..8885c890e --- /dev/null +++ b/validmind/datasets/regression/credit_card_balance.py @@ -0,0 +1,57 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +""" +Synthetic credit card balance dataset for linear regression. + +Each row is a cardholder observed in one quarter; the target is their average monthly +revolving balance. The data is synthetic — see `generate_credit_card_balance_csv.py` +for how it was made. +""" + +import os + +import pandas as pd +from sklearn.model_selection import train_test_split + +current_path = os.path.dirname(os.path.abspath(__file__)) +dataset_path = os.path.join(current_path, "datasets") + +feature_columns = [ + "Income", + "DebtToIncome", + "CreditScore", + "CreditLimit", + "RecentInquiries", + "NumCards", + "TenureYears", + "IsStudent", + "Region", +] +target_column = "Balance" +time_column = "Quarter" +oot_start = "2025Q3" + +bundled_data_file = os.path.join(dataset_path, "credit_card_balance.csv") + + +def load_data(): + """Load the bundled synthetic credit card balance dataset.""" + return pd.read_csv(bundled_data_file) + + +def preprocess(df, oot_start=oot_start, test_size=0.2, random_state=42): + """ + Split into train and test sets from the development period, and an out-of-time + set from `oot_start` onwards. + + Returns: + tuple: (train_df, test_df, oot_df) + """ + development = df[df[time_column] < oot_start] + oot_df = df[df[time_column] >= oot_start].copy() + train_df, test_df = train_test_split( + development.copy(), test_size=test_size, random_state=random_state + ) + return train_df, test_df, oot_df diff --git a/validmind/datasets/regression/datasets/credit_card_balance.csv b/validmind/datasets/regression/datasets/credit_card_balance.csv new file mode 100644 index 000000000..dee1e8f09 --- /dev/null +++ b/validmind/datasets/regression/datasets/credit_card_balance.csv @@ -0,0 +1,5001 @@ +Quarter,Income,DebtToIncome,CreditScore,CreditLimit,RecentInquiries,NumCards,TenureYears,IsStudent,Region,Balance +2023Q1,54.8,0.192,561,4720,1,4,26,0,Northeast,0 +2023Q1,85.0,0.175,673,6500,3,4,13,0,West,79 +2023Q1,18.0,0.37,649,5500,2,4,1,0,South,372 +2023Q1,75.2,0.298,589,5230,1,3,12,0,West,0 +2023Q1,38.7,0.279,728,6720,4,6,15,0,West,788 +2023Q1,20.2,0.352,641,5350,0,1,13,0,West,273 +2023Q1,19.7,0.417,729,6750,1,4,9,0,West,647 +2023Q1,65.6,0.237,697,6390,2,2,26,0,West,50 +2023Q1,46.4,0.481,474,3050,2,6,11,0,Northeast,252 +2023Q1,30.4,0.474,598,4720,2,6,15,0,Northeast,516 +2023Q1,10.8,0.502,572,4310,2,2,6,0,Midwest,708 +2023Q1,38.2,0.327,640,5410,1,2,3,0,West,275 +2023Q1,55.0,0.378,753,7330,2,4,23,0,West,656 +2023Q1,20.8,0.364,807,7730,0,5,2,0,Northeast,855 +2023Q1,28.0,0.41,499,3030,2,5,9,0,Midwest,150 +2023Q1,22.2,0.538,672,5600,0,1,21,0,Midwest,117 +2023Q1,76.1,0.174,564,5000,1,6,22,0,Northeast,111 +2023Q1,40.3,0.241,627,5530,1,7,15,0,Northeast,313 +2023Q1,31.1,0.41,710,6260,2,3,17,0,Northeast,722 +2023Q1,20.0,0.482,707,6270,3,5,5,0,South,840 +2023Q1,120.9,0.0,772,8370,3,3,21,0,Northeast,160 +2023Q1,26.8,0.331,546,4120,1,1,28,0,West,7 +2023Q1,66.8,0.157,570,4530,0,7,13,0,Northeast,0 +2023Q1,71.6,0.209,549,4520,0,4,9,0,Midwest,0 +2023Q1,33.0,0.137,761,7110,2,4,18,0,South,641 +2023Q1,57.3,0.308,706,6600,1,1,8,0,West,319 +2023Q1,39.0,0.421,662,6010,3,2,17,0,Midwest,656 +2023Q1,22.9,0.482,618,5200,1,2,23,0,South,149 +2023Q1,24.7,0.355,707,6350,1,2,25,0,Northeast,439 +2023Q1,37.2,0.35,656,5820,1,1,2,0,South,287 +2023Q1,82.6,0.087,663,6340,1,5,14,0,Northeast,116 +2023Q1,32.6,0.455,714,6490,1,2,30,0,West,549 +2023Q1,42.9,0.417,696,6500,1,5,29,1,Midwest,983 +2023Q1,69.9,0.186,668,6060,1,2,6,0,Midwest,0 +2023Q1,76.2,0.081,773,7810,1,7,6,0,Northeast,364 +2023Q1,19.3,0.435,748,7030,1,1,6,0,Northeast,941 +2023Q1,200.0,0.0,594,6430,3,2,19,0,Northeast,0 +2023Q1,72.4,0.209,727,7150,2,3,2,0,West,475 +2023Q1,33.2,0.422,624,5100,1,7,11,0,South,503 +2023Q1,51.3,0.214,570,4770,0,2,22,0,Northeast,0 +2023Q1,90.7,0.205,784,8130,2,7,18,0,Midwest,505 +2023Q1,25.7,0.441,508,3390,1,4,19,0,South,481 +2023Q1,34.5,0.331,779,7480,2,5,15,0,South,756 +2023Q1,28.8,0.412,679,6060,6,4,16,0,West,832 +2023Q1,17.4,0.43,551,3960,0,4,28,0,South,338 +2023Q1,32.0,0.424,569,4350,0,6,28,0,Northeast,311 +2023Q1,35.2,0.353,552,4340,2,4,27,0,Midwest,445 +2023Q1,19.6,0.385,709,6400,2,1,2,0,Midwest,912 +2023Q1,38.1,0.324,529,3970,3,3,21,0,West,269 +2023Q1,41.9,0.172,705,6510,3,6,28,1,West,1098 +2023Q1,32.6,0.545,567,4290,2,1,7,0,West,358 +2023Q1,58.7,0.458,676,6050,0,1,28,0,Midwest,106 +2023Q1,20.1,0.208,686,5990,3,7,17,0,Midwest,797 +2023Q1,55.0,0.292,548,4330,2,7,9,1,West,549 +2023Q1,10.0,0.333,715,6260,0,1,28,0,West,559 +2023Q1,151.0,0.0,606,5960,3,4,22,0,Northeast,0 +2023Q1,39.8,0.258,665,5710,1,1,4,0,West,208 +2023Q1,29.2,0.093,578,4450,2,2,2,0,West,268 +2023Q1,57.8,0.532,499,3620,1,5,15,0,Northeast,124 +2023Q1,36.1,0.377,581,4820,2,1,23,0,Northeast,285 +2023Q1,34.7,0.325,752,7020,1,7,28,0,Midwest,677 +2023Q1,39.3,0.326,620,5130,1,1,4,0,West,162 +2023Q1,57.2,0.376,596,5190,2,3,18,0,South,448 +2023Q1,20.0,0.429,597,4600,0,3,11,0,Northeast,69 +2023Q1,61.7,0.28,711,6600,1,4,6,1,South,663 +2023Q1,30.4,0.429,477,2960,0,5,4,1,West,380 +2023Q1,56.6,0.082,760,7430,5,1,13,0,Midwest,695 +2023Q1,23.8,0.088,650,5560,2,5,2,1,South,695 +2023Q1,20.2,0.319,765,7320,0,6,15,0,South,624 +2023Q1,37.1,0.249,727,6720,2,7,25,0,Northeast,643 +2023Q1,20.8,0.283,683,5980,0,5,26,0,West,343 +2023Q1,27.5,0.403,433,2270,1,5,25,1,Northeast,314 +2023Q1,63.7,0.189,517,3750,2,1,24,0,Midwest,0 +2023Q1,61.1,0.288,604,5290,1,5,29,0,South,86 +2023Q1,28.1,0.413,829,8230,1,5,16,0,Northeast,1070 +2023Q1,34.2,0.344,699,6470,0,4,7,1,Northeast,828 +2023Q1,50.7,0.315,552,4330,6,5,15,0,West,559 +2023Q1,24.7,0.422,547,4020,3,6,19,0,South,667 +2023Q1,14.2,0.349,618,4920,3,6,18,0,South,492 +2023Q1,21.6,0.212,638,5320,2,5,20,0,Midwest,655 +2023Q1,75.3,0.164,706,6630,1,6,6,0,South,206 +2023Q1,109.9,0.305,613,5830,2,6,10,0,South,0 +2023Q1,50.2,0.361,534,3850,2,2,20,0,West,162 +2023Q1,18.3,0.469,731,6780,0,5,9,0,Midwest,723 +2023Q1,49.2,0.392,593,5120,0,7,1,0,Northeast,0 +2023Q1,50.8,0.399,571,4550,2,6,4,0,Northeast,247 +2023Q1,52.1,0.404,583,4730,4,1,24,0,Northeast,56 +2023Q1,113.7,0.246,652,6400,0,4,29,1,Midwest,196 +2023Q1,75.9,0.258,665,6210,4,4,9,0,Midwest,432 +2023Q1,86.1,0.1,645,6150,1,3,4,0,Midwest,0 +2023Q1,26.6,0.46,518,3480,0,2,18,0,West,0 +2023Q1,33.6,0.325,628,5350,1,4,1,0,Midwest,84 +2023Q1,41.1,0.307,706,6470,2,7,7,0,West,741 +2023Q1,66.1,0.145,635,5630,1,6,1,0,Midwest,0 +2023Q1,39.9,0.501,669,5920,3,5,12,0,Northeast,518 +2023Q1,63.0,0.206,554,4400,0,1,17,0,South,0 +2023Q1,75.5,0.103,657,6180,3,1,25,0,Northeast,51 +2023Q1,51.5,0.364,742,7220,3,5,18,0,West,731 +2023Q1,124.3,0.196,760,8290,0,4,16,0,Midwest,0 +2023Q1,35.7,0.253,646,5610,0,7,7,0,Midwest,289 +2023Q1,61.6,0.37,718,6940,3,4,27,0,Northeast,696 +2023Q1,24.9,0.205,630,5100,0,5,17,0,South,98 +2023Q1,41.2,0.261,560,4210,1,7,5,0,West,26 +2023Q1,67.9,0.152,604,5130,0,6,0,0,Midwest,0 +2023Q1,108.0,0.244,746,7510,4,3,3,0,Northeast,381 +2023Q1,47.6,0.143,813,8310,3,1,29,0,Northeast,861 +2023Q1,90.2,0.325,578,5050,0,5,1,0,Northeast,0 +2023Q1,119.5,0.224,745,8240,0,5,25,0,Midwest,31 +2023Q1,40.7,0.157,536,3950,4,5,1,0,Midwest,138 +2023Q1,87.9,0.388,681,6640,3,3,13,0,Northeast,207 +2023Q1,100.2,0.169,654,6050,0,7,16,0,South,0 +2023Q1,36.2,0.228,597,4720,1,5,0,0,Midwest,205 +2023Q1,64.4,0.265,620,5290,2,3,21,0,Northeast,23 +2023Q1,13.6,0.417,501,3210,2,5,8,0,Midwest,287 +2023Q1,12.6,0.29,526,3560,2,4,7,0,Midwest,181 +2023Q1,19.6,0.484,561,4150,0,6,15,0,South,338 +2023Q1,48.6,0.188,732,7060,1,7,21,0,Northeast,447 +2023Q1,61.1,0.128,649,5980,0,2,7,0,West,20 +2023Q1,13.7,0.317,635,5230,3,7,11,0,West,851 +2023Q1,35.0,0.311,531,3900,4,6,17,0,Northeast,391 +2023Q1,36.6,0.349,649,5690,3,1,21,0,Midwest,615 +2023Q1,18.8,0.295,510,3410,0,3,10,1,Midwest,418 +2023Q1,32.9,0.431,676,6060,2,5,19,0,Northeast,580 +2023Q1,73.7,0.029,850,8890,3,7,20,0,South,782 +2023Q1,40.2,0.327,517,3860,2,5,6,0,Midwest,168 +2023Q1,72.7,0.151,669,6530,2,3,17,0,Midwest,218 +2023Q1,65.8,0.08,572,4710,1,5,30,0,West,89 +2023Q1,56.8,0.404,533,4060,2,5,0,0,Midwest,38 +2023Q1,37.5,0.204,793,7530,2,4,15,1,Midwest,1186 +2023Q1,48.8,0.316,463,3020,5,4,28,1,Midwest,595 +2023Q1,21.6,0.204,564,4270,0,3,8,0,Northeast,178 +2023Q1,85.3,0.284,596,5370,4,1,18,0,West,107 +2023Q1,119.8,0.005,679,7000,2,6,25,1,South,127 +2023Q1,19.1,0.447,467,2480,4,1,19,0,Midwest,167 +2023Q1,20.3,0.303,744,6680,4,5,29,1,Northeast,1541 +2023Q1,39.4,0.373,670,6190,1,1,2,0,South,434 +2023Q1,18.4,0.324,799,7820,2,6,22,0,South,1320 +2023Q1,56.3,0.309,692,6470,1,1,24,0,West,205 +2023Q1,18.9,0.427,660,5610,3,4,24,0,Northeast,806 +2023Q1,162.5,0.086,745,8130,3,1,23,0,South,0 +2023Q1,129.4,0.009,632,6090,1,1,2,0,West,0 +2023Q1,61.3,0.0,578,4590,0,2,25,0,Northeast,0 +2023Q1,29.8,0.223,642,5490,2,3,4,0,South,413 +2023Q1,82.9,0.287,541,4450,0,6,23,0,West,0 +2023Q1,31.4,0.296,573,4370,2,4,1,1,Midwest,914 +2023Q1,22.8,0.299,510,3400,1,4,4,0,Midwest,137 +2023Q1,12.2,0.268,602,4590,3,3,4,0,South,518 +2023Q1,23.6,0.58,561,4060,0,3,18,1,Northeast,779 +2023Q1,101.3,0.228,679,6640,2,3,29,0,Midwest,74 +2023Q1,96.9,0.135,683,6520,1,5,2,0,Northeast,0 +2023Q1,57.6,0.094,557,4460,2,1,2,1,Northeast,330 +2023Q1,39.4,0.381,828,8280,3,4,1,0,Midwest,897 +2023Q1,16.4,0.286,556,3900,0,3,23,0,West,114 +2023Q1,27.8,0.361,707,6380,1,2,27,0,Northeast,572 +2023Q1,29.5,0.173,513,3340,2,6,22,1,Northeast,460 +2023Q1,35.8,0.011,704,6440,2,7,11,1,Midwest,942 +2023Q1,22.8,0.356,578,4360,7,5,15,0,Northeast,809 +2023Q1,61.3,0.354,605,5200,0,4,2,0,South,0 +2023Q1,26.1,0.321,649,5440,5,4,4,0,South,516 +2023Q1,28.0,0.305,595,4720,2,4,1,0,Northeast,371 +2023Q1,57.2,0.268,850,8840,5,6,12,0,Midwest,1114 +2023Q1,52.4,0.202,776,7630,3,1,3,0,South,521 +2023Q1,77.6,0.212,612,5530,2,4,18,0,West,0 +2023Q1,25.0,0.419,573,4270,3,1,13,0,Northeast,572 +2023Q1,67.2,0.209,703,6630,3,4,18,0,Northeast,431 +2023Q1,14.4,0.359,536,3660,4,4,27,0,South,643 +2023Q1,34.0,0.348,598,4710,1,3,30,0,Midwest,35 +2023Q1,20.9,0.315,583,4480,0,5,15,0,Northeast,124 +2023Q1,36.0,0.148,611,5040,1,4,28,1,South,677 +2023Q1,88.3,0.287,748,7520,3,2,14,0,South,547 +2023Q1,10.1,0.373,577,3990,0,4,2,0,Northeast,0 +2023Q1,88.0,0.19,732,7410,0,1,20,0,West,0 +2023Q1,83.7,0.204,644,6000,1,3,26,0,Midwest,0 +2023Q1,30.3,0.369,531,3830,2,1,22,0,Northeast,273 +2023Q1,23.9,0.32,413,1950,1,2,7,0,Northeast,0 +2023Q1,47.3,0.244,613,5040,1,5,25,0,Northeast,55 +2023Q1,12.7,0.289,706,6160,1,5,10,1,West,1034 +2023Q1,89.3,0.34,656,6130,2,3,17,0,Midwest,283 +2023Q1,121.1,0.173,734,7610,3,7,6,0,Midwest,335 +2023Q1,24.7,0.237,679,5980,2,4,30,0,South,357 +2023Q1,28.1,0.344,512,3380,0,7,30,0,South,5 +2023Q1,57.4,0.387,653,5910,0,6,26,0,West,48 +2023Q1,24.4,0.63,683,6000,3,1,28,0,West,851 +2023Q1,26.3,0.204,677,5760,3,7,26,0,West,598 +2023Q1,62.4,0.056,821,8500,3,6,8,1,West,1335 +2023Q1,39.0,0.401,628,5330,3,7,23,1,South,801 +2023Q1,59.8,0.281,568,4520,3,4,19,1,Northeast,712 +2023Q1,96.9,0.052,762,7790,0,1,23,0,South,0 +2023Q1,23.4,0.443,600,4900,1,3,17,0,West,342 +2023Q1,45.3,0.27,633,5440,3,3,12,0,Northeast,404 +2023Q1,19.6,0.451,567,4380,2,6,12,0,West,572 +2023Q1,72.8,0.321,842,8920,2,3,5,0,West,940 +2023Q1,36.8,0.377,632,5260,0,1,14,0,West,0 +2023Q1,44.6,0.22,595,4810,2,3,23,0,Northeast,17 +2023Q1,93.2,0.156,737,7480,2,3,19,1,West,676 +2023Q1,16.2,0.62,489,3150,0,3,10,0,Northeast,296 +2023Q1,49.3,0.406,739,7210,2,1,27,0,Northeast,617 +2023Q1,20.3,0.244,674,5540,0,2,20,0,South,104 +2023Q1,53.7,0.191,659,5730,1,1,27,0,Northeast,0 +2023Q1,12.8,0.356,593,4660,0,7,7,0,Northeast,215 +2023Q1,30.0,0.24,634,5510,0,2,18,0,West,0 +2023Q1,30.4,0.387,640,5240,0,2,14,1,West,257 +2023Q1,82.4,0.19,774,7830,2,6,10,0,Northeast,559 +2023Q1,50.7,0.326,666,6000,4,2,1,0,South,489 +2023Q1,19.2,0.458,594,4620,3,1,11,0,Midwest,418 +2023Q1,26.1,0.405,786,7470,0,2,24,0,Northeast,773 +2023Q1,76.1,0.082,666,6320,4,1,29,0,Northeast,143 +2023Q1,32.5,0.318,589,4610,2,5,26,0,West,333 +2023Q1,44.7,0.353,676,6090,0,3,24,0,West,177 +2023Q1,29.5,0.358,526,3640,2,3,23,0,Northeast,265 +2023Q1,67.3,0.226,689,6600,0,1,24,0,West,0 +2023Q1,75.8,0.273,690,6650,4,3,16,0,South,498 +2023Q1,151.6,0.0,611,6080,1,3,20,0,Midwest,0 +2023Q1,26.7,0.264,603,4830,2,2,18,1,Northeast,604 +2023Q1,26.8,0.236,542,3920,1,3,15,0,South,0 +2023Q1,43.6,0.191,488,3330,1,3,20,0,Midwest,0 +2023Q1,31.4,0.42,662,5860,2,2,12,0,South,690 +2023Q1,15.3,0.312,623,5210,2,6,6,0,West,796 +2023Q1,68.8,0.382,690,6620,2,7,12,1,West,1077 +2023Q1,77.2,0.07,638,5650,0,5,28,0,Midwest,0 +2023Q1,16.5,0.304,613,4800,2,5,26,0,West,207 +2023Q1,29.8,0.366,672,5670,1,7,30,0,Midwest,214 +2023Q1,79.8,0.119,603,5270,3,3,4,0,West,29 +2023Q1,10.7,0.58,668,5620,0,7,15,0,Midwest,517 +2023Q1,43.2,0.289,755,7060,1,7,0,0,South,723 +2023Q1,44.5,0.383,531,4010,0,7,24,0,Midwest,0 +2023Q1,27.5,0.335,763,7170,1,2,24,0,Northeast,771 +2023Q1,48.6,0.407,656,5910,4,6,13,0,Northeast,608 +2023Q1,52.3,0.372,554,4390,2,4,29,0,Northeast,390 +2023Q1,16.7,0.343,705,6070,2,6,0,1,Northeast,1114 +2023Q1,74.1,0.272,709,6970,0,4,9,0,Midwest,209 +2023Q1,34.9,0.449,550,4090,1,2,23,0,Midwest,217 +2023Q1,98.4,0.22,663,6370,1,3,25,0,West,14 +2023Q1,17.1,0.55,756,6830,3,2,11,0,Northeast,901 +2023Q1,29.6,0.375,682,5950,2,3,17,0,Midwest,714 +2023Q1,24.8,0.29,733,6870,0,6,6,1,Northeast,1051 +2023Q1,27.8,0.422,711,6430,2,5,13,0,South,769 +2023Q1,148.6,0.085,850,9630,1,4,18,1,West,739 +2023Q1,30.6,0.201,747,6970,0,1,25,1,West,803 +2023Q1,39.4,0.298,622,5150,0,3,22,0,Northeast,0 +2023Q1,36.5,0.347,701,6310,1,6,5,1,Midwest,918 +2023Q1,29.1,0.301,635,5280,0,7,13,0,Midwest,272 +2023Q1,26.9,0.36,593,4740,2,1,19,1,Northeast,806 +2023Q1,59.7,0.251,661,6070,0,4,29,0,South,0 +2023Q1,25.2,0.336,702,6200,1,6,23,0,South,501 +2023Q1,49.9,0.247,602,5030,5,2,17,0,West,77 +2023Q1,58.4,0.115,719,7070,0,4,16,0,West,64 +2023Q1,34.4,0.49,503,3350,1,5,7,0,South,134 +2023Q1,47.4,0.413,618,5150,2,5,2,0,South,283 +2023Q1,24.7,0.359,537,3740,1,6,8,0,West,271 +2023Q1,36.3,0.379,582,4650,3,4,2,0,South,267 +2023Q1,14.6,0.32,565,4020,1,7,26,0,West,56 +2023Q1,23.4,0.309,489,3270,2,2,20,0,West,225 +2023Q1,46.2,0.35,689,6240,2,3,8,0,Midwest,517 +2023Q1,92.1,0.349,466,3270,1,7,0,1,Midwest,118 +2023Q1,54.3,0.192,600,5040,3,5,0,0,Northeast,0 +2023Q1,52.0,0.26,595,4810,0,4,13,0,West,0 +2023Q1,62.7,0.313,687,6620,4,3,15,0,Midwest,431 +2023Q1,24.4,0.289,694,6430,1,7,21,0,West,592 +2023Q1,33.5,0.253,718,6430,0,3,21,0,Northeast,368 +2023Q1,30.9,0.448,568,4420,2,1,14,0,West,483 +2023Q1,13.9,0.373,556,3970,0,4,22,0,Northeast,373 +2023Q1,17.3,0.356,527,3660,2,4,25,0,Midwest,355 +2023Q1,49.8,0.255,625,5410,0,1,6,0,West,89 +2023Q1,80.5,0.429,612,5320,1,4,7,0,Midwest,0 +2023Q1,48.8,0.187,517,3670,2,5,25,0,Northeast,0 +2023Q1,20.3,0.344,588,4470,1,6,6,0,Northeast,298 +2023Q1,71.8,0.057,650,5840,1,2,9,1,South,537 +2023Q1,29.2,0.518,663,5750,4,6,28,0,South,634 +2023Q1,17.7,0.243,591,4480,0,4,28,0,Midwest,0 +2023Q1,58.2,0.092,620,5370,2,2,30,0,Northeast,127 +2023Q1,60.5,0.272,778,7570,1,6,17,0,West,440 +2023Q1,118.7,0.287,475,3770,1,6,6,0,West,0 +2023Q1,31.3,0.391,692,6050,0,2,8,0,Midwest,0 +2023Q1,87.2,0.341,679,6610,3,4,30,0,Midwest,352 +2023Q1,35.9,0.344,615,5260,0,5,1,0,South,344 +2023Q1,55.6,0.137,619,5590,0,2,9,0,Northeast,0 +2023Q1,107.1,0.219,775,8190,1,1,14,0,Northeast,549 +2023Q1,51.5,0.27,508,3530,3,7,11,0,South,442 +2023Q1,68.4,0.115,664,6160,6,1,28,0,South,323 +2023Q1,86.5,0.199,797,8200,0,7,8,0,South,303 +2023Q1,83.3,0.23,498,3780,1,2,12,0,Midwest,0 +2023Q1,28.4,0.319,744,7100,2,6,4,0,Northeast,1040 +2023Q1,104.9,0.178,658,6490,4,3,22,0,Midwest,135 +2023Q1,37.1,0.437,661,5870,0,7,10,0,Northeast,256 +2023Q1,27.2,0.524,562,4260,1,2,14,1,South,762 +2023Q1,41.4,0.152,769,7350,1,5,29,0,Midwest,742 +2023Q1,123.3,0.06,677,6840,2,7,23,1,West,431 +2023Q1,27.8,0.373,479,3030,3,5,5,0,South,214 +2023Q1,67.9,0.169,544,4150,0,5,10,0,West,0 +2023Q1,33.9,0.313,752,7150,2,7,2,0,Midwest,983 +2023Q1,158.2,0.056,672,7360,0,7,1,0,Northeast,0 +2023Q1,17.1,0.469,684,5970,1,6,9,0,West,724 +2023Q1,17.8,0.347,732,6700,1,1,19,0,Northeast,614 +2023Q1,16.5,0.288,688,5960,4,4,4,0,South,809 +2023Q1,35.2,0.23,769,7390,3,1,15,0,Northeast,720 +2023Q1,50.2,0.151,773,7780,3,4,3,1,South,1269 +2023Q1,17.5,0.286,654,5580,2,6,14,0,West,545 +2023Q1,11.5,0.511,653,5200,2,3,22,0,South,608 +2023Q1,29.1,0.295,618,5180,2,2,8,0,Midwest,185 +2023Q1,80.3,0.214,730,7460,0,6,15,0,Midwest,359 +2023Q1,12.4,0.466,676,5800,0,5,18,0,South,486 +2023Q1,23.0,0.238,749,6990,2,6,12,0,Northeast,846 +2023Q1,71.9,0.307,774,7680,3,3,7,0,Midwest,607 +2023Q1,34.8,0.3,698,6300,1,5,21,0,West,684 +2023Q1,41.5,0.265,592,4800,1,5,27,0,South,0 +2023Q1,17.3,0.454,603,4630,0,2,29,0,West,137 +2023Q1,47.6,0.286,579,4630,1,3,2,0,West,82 +2023Q1,48.1,0.096,516,3840,3,4,23,0,South,214 +2023Q1,66.7,0.517,567,4690,2,2,3,0,Midwest,259 +2023Q1,66.2,0.369,487,3410,2,5,12,0,Northeast,0 +2023Q1,37.4,0.338,654,5970,0,2,19,0,West,257 +2023Q1,59.0,0.064,800,7950,2,2,16,0,Midwest,684 +2023Q1,18.6,0.31,702,6270,4,2,26,0,Northeast,631 +2023Q1,30.2,0.416,731,6930,4,1,18,0,Midwest,1153 +2023Q1,61.1,0.21,621,5200,3,5,4,0,Midwest,320 +2023Q1,13.1,0.25,672,5690,3,1,10,0,West,322 +2023Q1,18.7,0.435,660,5610,1,7,14,0,West,636 +2023Q1,26.4,0.381,559,4420,1,5,27,0,West,466 +2023Q1,55.2,0.177,585,4670,3,7,26,0,Midwest,137 +2023Q1,51.2,0.414,782,7730,0,1,22,0,South,555 +2023Q1,20.7,0.442,635,5150,0,2,13,0,Midwest,100 +2023Q1,43.4,0.404,688,6550,1,2,5,0,South,504 +2023Q1,84.3,0.0,718,7160,2,6,6,0,Midwest,213 +2023Q1,85.6,0.345,601,5480,4,6,17,0,West,440 +2023Q1,13.5,0.224,747,6740,3,7,11,0,Northeast,1091 +2023Q1,30.4,0.346,770,7430,1,6,12,0,Midwest,896 +2023Q1,71.1,0.202,641,5820,2,4,20,0,Midwest,118 +2023Q1,80.0,0.254,603,5510,1,1,2,1,Midwest,155 +2023Q1,31.7,0.366,624,5240,1,5,16,0,Northeast,320 +2023Q1,32.9,0.346,632,5270,0,7,5,0,Midwest,201 +2023Q1,65.0,0.358,606,5320,0,3,18,0,Midwest,0 +2023Q1,105.1,0.155,646,6240,0,3,3,0,West,0 +2023Q1,30.0,0.354,692,6080,0,5,17,0,Northeast,361 +2023Q1,16.3,0.36,722,6570,0,6,25,0,Northeast,551 +2023Q1,31.8,0.157,579,4530,4,1,0,0,Midwest,517 +2023Q1,63.3,0.04,655,6100,0,5,7,0,Midwest,0 +2023Q1,10.0,0.478,411,1800,5,7,16,0,South,203 +2023Q1,21.9,0.415,620,5080,2,1,13,0,Midwest,360 +2023Q1,50.4,0.284,719,6730,1,4,27,0,Northeast,521 +2023Q1,24.3,0.29,648,5530,1,1,28,0,Northeast,306 +2023Q1,33.6,0.306,775,7240,0,5,19,0,South,789 +2023Q1,45.7,0.324,601,4920,2,7,12,0,Midwest,213 +2023Q1,39.3,0.172,564,4410,2,2,23,0,Midwest,137 +2023Q1,33.3,0.208,796,7950,3,5,13,0,Midwest,1078 +2023Q1,26.8,0.411,750,7010,0,7,22,0,Midwest,576 +2023Q1,75.4,0.16,655,6020,4,4,11,0,Northeast,231 +2023Q1,84.5,0.224,603,5320,1,4,23,0,Northeast,0 +2023Q1,82.6,0.203,600,5240,4,6,4,0,Northeast,0 +2023Q1,78.4,0.243,613,5690,1,1,2,0,South,0 +2023Q1,76.8,0.351,731,7040,0,3,8,0,South,84 +2023Q1,25.3,0.262,528,3740,0,7,4,0,Northeast,14 +2023Q1,39.2,0.254,670,5920,1,3,14,1,Northeast,856 +2023Q1,131.3,0.044,726,7590,0,2,20,0,Midwest,0 +2023Q1,17.9,0.288,575,4390,0,1,17,0,Northeast,73 +2023Q1,22.9,0.15,788,7760,1,4,2,0,Northeast,759 +2023Q1,36.8,0.351,781,7770,2,2,3,0,Northeast,655 +2023Q1,28.3,0.191,829,8330,1,7,23,0,South,1138 +2023Q1,82.8,0.317,608,5270,1,6,5,0,South,0 +2023Q1,31.0,0.133,680,5810,3,5,24,0,Northeast,512 +2023Q1,27.4,0.16,850,8650,3,7,26,0,Northeast,1293 +2023Q1,22.3,0.353,591,4600,1,7,26,0,Midwest,203 +2023Q1,34.7,0.26,684,6110,1,1,18,1,West,787 +2023Q1,30.9,0.347,612,4980,2,6,4,0,Northeast,484 +2023Q1,34.6,0.285,617,5200,3,4,5,0,Midwest,330 +2023Q1,36.3,0.193,680,6100,1,3,26,0,West,169 +2023Q1,38.1,0.154,680,5970,2,7,30,0,West,473 +2023Q1,52.3,0.2,652,5970,2,3,15,0,Northeast,241 +2023Q1,31.1,0.202,681,6170,1,6,6,0,West,318 +2023Q1,96.1,0.484,596,5410,2,4,12,0,West,120 +2023Q1,24.9,0.377,618,4760,2,7,10,1,Midwest,792 +2023Q1,72.3,0.214,635,5680,0,5,2,0,South,0 +2023Q1,53.2,0.341,676,6290,0,6,13,0,Northeast,220 +2023Q1,40.6,0.332,620,5070,3,1,3,1,South,538 +2023Q1,31.0,0.457,547,4040,0,3,26,0,Midwest,167 +2023Q1,32.9,0.209,606,4890,3,2,18,1,West,664 +2023Q1,42.4,0.352,549,4180,1,1,27,0,Northeast,114 +2023Q1,113.4,0.213,850,9500,3,5,30,0,Midwest,957 +2023Q1,86.7,0.214,685,6840,1,3,12,0,South,203 +2023Q1,20.7,0.2,682,5880,2,6,29,0,Midwest,537 +2023Q1,66.5,0.363,653,5880,3,4,24,0,Northeast,241 +2023Q1,30.8,0.277,647,5610,1,2,20,0,Northeast,211 +2023Q1,40.8,0.393,671,5840,2,6,9,0,Northeast,464 +2023Q1,80.7,0.115,597,5360,0,6,16,0,Northeast,0 +2023Q1,33.1,0.251,712,6570,3,7,5,0,Northeast,847 +2023Q1,52.1,0.264,545,4030,2,6,22,1,South,524 +2023Q1,64.7,0.184,629,5660,1,6,26,0,Northeast,210 +2023Q1,32.0,0.174,589,4510,2,1,8,0,South,257 +2023Q1,22.0,0.418,602,4690,0,1,20,1,South,311 +2023Q1,46.3,0.394,597,4840,4,2,0,0,South,335 +2023Q1,37.6,0.365,661,5690,3,3,22,0,Northeast,408 +2023Q1,21.5,0.295,649,5800,0,2,14,0,West,354 +2023Q1,99.2,0.027,703,7120,1,6,9,0,West,137 +2023Q1,136.3,0.159,626,6390,0,7,6,0,Midwest,0 +2023Q1,70.1,0.489,570,4580,1,6,0,0,Northeast,61 +2023Q1,19.2,0.297,649,5380,1,6,21,0,Northeast,497 +2023Q1,89.9,0.185,626,5640,2,3,5,1,Northeast,403 +2023Q1,37.3,0.37,639,5430,3,4,12,0,South,611 +2023Q1,26.4,0.455,647,5490,1,5,25,0,West,428 +2023Q1,21.6,0.247,670,5670,5,7,22,0,South,819 +2023Q1,55.9,0.19,755,7080,1,3,2,1,South,990 +2023Q1,80.3,0.099,699,6710,2,5,22,0,Northeast,209 +2023Q1,35.0,0.371,643,5480,0,5,21,1,South,582 +2023Q1,112.0,0.107,657,6200,0,4,25,0,Midwest,0 +2023Q1,16.9,0.235,655,5530,1,3,1,1,Midwest,886 +2023Q1,17.7,0.477,623,5100,2,3,3,0,Northeast,661 +2023Q1,38.7,0.254,669,5940,1,5,28,0,Midwest,487 +2023Q1,91.4,0.021,723,7430,2,6,6,0,Northeast,363 +2023Q1,36.2,0.032,678,5890,2,4,16,0,Northeast,384 +2023Q1,29.8,0.241,747,7060,4,2,0,0,South,572 +2023Q1,52.1,0.317,757,7430,0,2,11,0,Northeast,426 +2023Q1,56.3,0.175,772,7890,1,3,0,1,West,940 +2023Q1,24.2,0.397,699,6400,1,6,27,0,Northeast,840 +2023Q1,22.9,0.239,629,5090,1,2,26,0,Northeast,70 +2023Q1,32.8,0.378,575,4500,0,6,11,0,Northeast,269 +2023Q1,63.4,0.237,576,4550,2,6,8,0,West,107 +2023Q1,24.6,0.433,678,5880,0,3,4,0,West,290 +2023Q1,58.6,0.295,666,6120,2,7,1,0,West,460 +2023Q1,52.4,0.302,681,6380,2,5,24,0,Midwest,494 +2023Q1,80.2,0.227,709,6840,1,7,28,0,Midwest,76 +2023Q1,35.8,0.182,707,6630,2,5,16,0,South,548 +2023Q1,58.6,0.368,595,4990,2,1,15,0,Northeast,73 +2023Q1,31.9,0.211,843,8440,1,6,10,0,West,1110 +2023Q1,23.4,0.342,695,6200,2,6,19,0,Northeast,837 +2023Q1,36.3,0.272,605,5000,0,1,10,1,West,451 +2023Q1,133.8,0.272,746,8080,0,6,16,1,Midwest,281 +2023Q1,17.5,0.526,701,6330,1,7,29,0,Midwest,802 +2023Q1,35.5,0.435,611,5110,5,5,21,0,Northeast,461 +2023Q1,41.5,0.38,549,4060,1,3,30,0,South,132 +2023Q1,47.0,0.443,729,6910,1,6,17,0,Midwest,702 +2023Q1,83.4,0.292,555,4850,1,3,7,0,West,166 +2023Q1,45.0,0.432,687,6230,2,1,20,0,Midwest,584 +2023Q1,42.1,0.308,580,4770,1,5,3,0,South,184 +2023Q1,53.1,0.144,796,8140,1,5,21,1,Midwest,1119 +2023Q1,43.4,0.589,497,3430,3,4,20,0,West,244 +2023Q1,65.9,0.356,788,7960,1,7,13,0,West,774 +2023Q1,57.3,0.347,587,4940,0,5,7,0,West,0 +2023Q1,39.9,0.257,644,5620,0,1,23,0,South,44 +2023Q1,79.9,0.215,771,7550,2,3,5,1,South,825 +2023Q1,145.2,0.126,497,4570,2,4,29,1,West,0 +2023Q1,31.4,0.349,650,5630,4,2,22,1,Northeast,917 +2023Q1,33.8,0.084,633,5470,2,3,26,0,South,267 +2023Q1,32.2,0.291,770,7360,1,2,4,0,Midwest,760 +2023Q1,15.8,0.134,697,6100,0,6,11,0,West,286 +2023Q1,54.8,0.272,677,6250,3,3,17,1,Northeast,826 +2023Q1,24.8,0.328,663,5440,1,3,16,1,Midwest,800 +2023Q1,32.8,0.155,734,6840,1,2,9,1,Northeast,987 +2023Q1,16.3,0.203,711,6230,0,6,12,0,Midwest,566 +2023Q2,15.0,0.427,540,3540,1,1,25,0,West,0 +2023Q2,14.9,0.405,502,3170,5,6,11,0,South,579 +2023Q2,38.5,0.246,626,5180,2,4,19,0,Midwest,251 +2023Q2,157.6,0.0,479,4390,2,2,18,0,Northeast,0 +2023Q2,46.6,0.368,708,6550,1,5,23,1,South,964 +2023Q2,36.7,0.335,693,6260,1,6,22,0,Northeast,664 +2023Q2,29.6,0.249,616,4990,0,2,21,0,Midwest,0 +2023Q2,39.1,0.277,613,5020,0,3,22,0,Midwest,0 +2023Q2,32.5,0.306,644,5530,0,1,2,0,South,0 +2023Q2,79.9,0.135,703,6810,2,7,24,0,West,266 +2023Q2,62.5,0.074,806,8120,2,3,1,0,South,768 +2023Q2,61.9,0.426,573,4650,1,6,26,0,West,186 +2023Q2,69.3,0.166,656,6090,0,7,7,0,South,23 +2023Q2,14.1,0.4,754,6950,2,6,27,0,West,844 +2023Q2,30.7,0.352,612,5020,2,7,30,0,Midwest,275 +2023Q2,72.4,0.138,702,6790,2,4,7,0,Midwest,400 +2023Q2,32.3,0.404,652,5570,0,6,25,0,South,396 +2023Q2,21.2,0.444,523,3610,0,7,18,0,West,144 +2023Q2,33.7,0.401,603,4920,1,6,30,0,West,278 +2023Q2,30.8,0.429,627,5280,0,2,18,1,South,375 +2023Q2,68.7,0.17,636,5610,2,5,20,0,West,35 +2023Q2,54.5,0.305,627,5630,1,2,25,0,Midwest,164 +2023Q2,41.8,0.314,669,5960,0,7,29,0,South,276 +2023Q2,30.8,0.24,703,6300,3,6,30,0,Midwest,562 +2023Q2,70.3,0.261,604,5240,2,3,22,0,West,0 +2023Q2,39.6,0.242,557,4350,0,3,7,0,West,0 +2023Q2,35.5,0.298,607,4760,3,5,3,0,Northeast,569 +2023Q2,40.2,0.457,672,5990,2,3,14,0,Northeast,455 +2023Q2,27.0,0.381,658,5970,0,1,25,0,Midwest,239 +2023Q2,64.9,0.134,616,5450,0,6,16,0,West,0 +2023Q2,66.8,0.23,709,6570,1,3,23,1,South,586 +2023Q2,21.8,0.312,629,5140,2,3,15,0,South,300 +2023Q2,73.0,0.387,659,6320,1,3,12,0,Northeast,292 +2023Q2,53.4,0.314,569,4550,3,3,26,0,Midwest,445 +2023Q2,46.2,0.347,588,4730,0,2,0,0,Midwest,0 +2023Q2,46.2,0.265,713,6520,1,7,22,0,West,378 +2023Q2,29.5,0.325,612,4780,1,3,2,0,South,120 +2023Q2,41.5,0.203,695,6240,2,6,5,0,Northeast,443 +2023Q2,19.3,0.478,574,4460,1,4,8,0,West,443 +2023Q2,44.3,0.15,608,5020,2,6,16,0,South,14 +2023Q2,42.4,0.334,625,5370,3,2,28,0,Northeast,424 +2023Q2,24.2,0.334,593,4610,4,1,17,0,Northeast,298 +2023Q2,38.6,0.334,641,5390,2,1,1,1,West,822 +2023Q2,20.9,0.27,643,5340,3,3,26,0,South,354 +2023Q2,45.3,0.403,594,4730,3,5,11,0,West,186 +2023Q2,46.1,0.212,648,5720,1,5,9,0,Northeast,147 +2023Q2,91.7,0.293,582,5150,4,3,10,0,Midwest,0 +2023Q2,67.1,0.258,706,6750,1,7,8,0,Midwest,338 +2023Q2,39.4,0.358,475,3000,1,2,17,0,South,0 +2023Q2,16.9,0.602,740,6880,0,7,7,0,Midwest,914 +2023Q2,131.2,0.012,794,8850,2,4,9,0,Midwest,436 +2023Q2,82.9,0.215,778,7620,2,2,16,0,West,191 +2023Q2,34.2,0.261,660,5720,0,6,23,0,West,146 +2023Q2,35.2,0.171,674,5990,1,1,19,1,West,591 +2023Q2,21.4,0.199,641,5340,1,5,28,0,Northeast,316 +2023Q2,24.7,0.386,501,3440,1,5,14,0,West,277 +2023Q2,20.8,0.3,706,6310,1,2,28,1,West,607 +2023Q2,22.1,0.277,518,3370,2,7,2,0,West,353 +2023Q2,12.8,0.414,476,2940,1,4,12,1,West,456 +2023Q2,62.4,0.201,709,6700,2,5,9,0,Northeast,708 +2023Q2,65.6,0.172,658,6000,4,6,17,0,Midwest,200 +2023Q2,97.8,0.0,649,6140,0,3,20,0,Midwest,0 +2023Q2,37.6,0.172,782,7650,1,2,27,0,Northeast,425 +2023Q2,19.3,0.387,533,3780,3,4,30,0,Midwest,748 +2023Q2,73.4,0.098,734,7100,3,3,28,0,South,289 +2023Q2,88.8,0.174,695,6960,0,4,18,0,Northeast,0 +2023Q2,17.6,0.423,615,4890,1,1,7,0,Northeast,308 +2023Q2,15.0,0.438,651,5500,0,4,8,0,Midwest,171 +2023Q2,16.8,0.336,602,4740,2,2,29,0,Northeast,470 +2023Q2,33.3,0.525,738,6830,0,4,11,0,South,636 +2023Q2,17.8,0.392,487,3020,2,7,26,0,Northeast,323 +2023Q2,88.2,0.153,668,6530,1,4,28,0,Midwest,76 +2023Q2,28.2,0.297,708,6380,2,6,0,0,Midwest,683 +2023Q2,26.0,0.226,696,6250,2,4,1,0,South,564 +2023Q2,88.2,0.187,772,8020,2,2,27,1,South,835 +2023Q2,23.4,0.123,677,5920,0,2,14,1,South,352 +2023Q2,60.6,0.068,720,6830,1,7,5,0,South,184 +2023Q2,28.3,0.292,633,5290,1,2,7,0,South,427 +2023Q2,33.3,0.257,600,4790,0,4,13,0,West,0 +2023Q2,11.6,0.31,703,6160,3,2,19,0,Northeast,1097 +2023Q2,26.7,0.242,536,3920,1,4,18,0,West,26 +2023Q2,61.0,0.232,738,7270,2,2,17,0,West,591 +2023Q2,57.1,0.429,522,3840,1,3,25,0,South,100 +2023Q2,40.7,0.265,632,5250,2,2,3,0,South,255 +2023Q2,61.4,0.242,655,5580,1,3,10,0,South,83 +2023Q2,85.6,0.063,641,5730,0,4,7,0,Midwest,0 +2023Q2,28.9,0.253,627,5130,1,4,15,0,Midwest,330 +2023Q2,106.8,0.329,660,6650,2,1,28,1,Northeast,543 +2023Q2,26.1,0.105,791,7470,3,6,23,0,Northeast,1239 +2023Q2,13.6,0.277,743,6840,1,4,24,0,West,775 +2023Q2,29.6,0.217,726,6690,4,2,26,0,Midwest,826 +2023Q2,31.7,0.41,629,5430,2,5,9,0,West,539 +2023Q2,37.9,0.369,716,6740,0,4,19,0,West,539 +2023Q2,50.7,0.334,813,8300,1,2,19,0,Midwest,912 +2023Q2,46.2,0.381,679,6050,1,5,23,0,West,440 +2023Q2,13.4,0.243,632,5370,3,3,18,0,West,649 +2023Q2,38.9,0.193,740,6930,3,7,10,0,South,724 +2023Q2,17.2,0.435,563,3970,2,3,25,0,Midwest,355 +2023Q2,51.1,0.087,740,7130,1,2,24,0,West,378 +2023Q2,47.7,0.235,790,7700,4,6,8,0,South,1077 +2023Q2,87.4,0.176,728,7350,1,5,11,0,Midwest,358 +2023Q2,17.8,0.398,577,4600,4,5,12,1,South,1245 +2023Q2,125.6,0.249,549,4950,1,3,10,0,South,0 +2023Q2,57.0,0.298,627,5280,2,4,11,0,South,211 +2023Q2,18.2,0.393,716,6610,1,7,6,0,Northeast,733 +2023Q2,40.5,0.274,714,6770,1,4,13,0,Midwest,424 +2023Q2,59.9,0.093,644,5750,1,4,20,0,Northeast,0 +2023Q2,83.6,0.216,651,6140,0,4,24,0,South,0 +2023Q2,60.6,0.366,634,5820,4,1,8,0,West,535 +2023Q2,37.4,0.358,637,5520,2,5,0,1,Midwest,744 +2023Q2,134.9,0.342,483,4270,1,4,19,1,Northeast,0 +2023Q2,38.6,0.227,772,7680,1,1,1,0,Midwest,612 +2023Q2,14.3,0.411,575,4170,3,6,0,0,West,593 +2023Q2,28.3,0.262,651,5630,0,5,30,0,West,281 +2023Q2,75.3,0.425,651,6120,3,1,4,0,South,298 +2023Q2,45.9,0.337,655,5780,1,7,15,0,South,392 +2023Q2,36.5,0.352,603,4820,2,7,18,0,Northeast,286 +2023Q2,26.6,0.415,538,3900,0,3,30,0,Northeast,56 +2023Q2,45.8,0.412,649,5750,2,3,26,0,West,465 +2023Q2,34.7,0.449,690,6380,2,4,13,0,West,894 +2023Q2,19.5,0.31,658,5380,3,5,2,0,Northeast,605 +2023Q2,26.6,0.27,639,5570,0,6,19,0,Midwest,127 +2023Q2,40.5,0.275,741,6970,0,3,22,0,West,108 +2023Q2,77.3,0.177,608,5160,0,5,11,0,Northeast,0 +2023Q2,69.6,0.417,680,6430,1,6,9,0,South,338 +2023Q2,63.5,0.19,705,6540,0,3,22,0,West,87 +2023Q2,51.8,0.184,659,5860,1,6,24,0,Northeast,228 +2023Q2,35.1,0.359,729,6780,0,6,5,0,Midwest,595 +2023Q2,69.8,0.259,646,5980,3,7,21,0,South,351 +2023Q2,26.6,0.303,656,5720,2,7,10,0,South,544 +2023Q2,21.1,0.452,466,2780,0,5,24,1,West,334 +2023Q2,33.3,0.203,673,5920,1,3,6,0,Northeast,0 +2023Q2,78.9,0.119,730,7420,3,6,19,0,Midwest,555 +2023Q2,23.1,0.17,707,6390,0,2,22,1,Midwest,721 +2023Q2,27.7,0.352,694,6200,0,1,16,1,South,769 +2023Q2,88.6,0.308,568,4980,0,2,13,0,Midwest,0 +2023Q2,50.6,0.191,705,6450,0,2,8,0,Northeast,0 +2023Q2,60.4,0.258,529,3900,1,4,0,0,West,0 +2023Q2,34.8,0.199,672,6010,1,3,22,0,West,219 +2023Q2,122.6,0.067,761,8160,0,2,22,0,Midwest,0 +2023Q2,51.0,0.084,657,5820,4,5,2,0,Northeast,495 +2023Q2,76.3,0.239,716,7240,0,5,26,0,Midwest,201 +2023Q2,38.0,0.29,693,6480,2,1,25,0,West,461 +2023Q2,25.1,0.232,732,6780,1,1,17,0,West,479 +2023Q2,33.9,0.275,611,4900,2,3,26,0,South,285 +2023Q2,36.3,0.336,781,7700,2,6,15,0,South,907 +2023Q2,49.0,0.152,579,4670,1,6,17,0,South,452 +2023Q2,16.4,0.333,689,6000,1,5,26,0,Northeast,504 +2023Q2,19.0,0.354,584,4600,1,2,1,0,West,136 +2023Q2,88.9,0.0,544,4770,0,2,16,0,Midwest,0 +2023Q2,31.4,0.35,731,7070,4,1,0,0,Northeast,646 +2023Q2,26.7,0.303,597,4530,2,2,18,0,West,61 +2023Q2,14.9,0.451,601,4600,0,6,23,0,West,345 +2023Q2,92.2,0.261,597,5450,0,6,24,0,West,0 +2023Q2,53.7,0.348,660,5780,1,1,6,0,West,216 +2023Q2,36.7,0.229,548,4270,1,2,3,0,Northeast,24 +2023Q2,44.0,0.401,561,4370,1,6,7,0,Midwest,146 +2023Q2,164.5,0.0,633,6530,4,5,24,0,South,0 +2023Q2,46.8,0.429,619,5340,2,5,2,0,Midwest,274 +2023Q2,17.1,0.314,518,3520,0,1,30,0,Midwest,64 +2023Q2,32.7,0.074,716,6600,2,3,18,0,Northeast,435 +2023Q2,59.7,0.298,632,5470,0,2,29,1,Northeast,379 +2023Q2,52.1,0.312,640,5580,1,7,8,0,West,485 +2023Q2,11.6,0.398,613,4710,0,2,29,0,Midwest,0 +2023Q2,49.7,0.229,598,4760,0,4,26,0,West,0 +2023Q2,34.7,0.355,606,4870,2,6,30,0,Northeast,403 +2023Q2,28.5,0.342,650,5550,2,4,13,0,West,484 +2023Q2,35.1,0.305,659,5540,1,6,21,0,Midwest,337 +2023Q2,37.7,0.28,727,6910,1,1,18,0,Midwest,592 +2023Q2,24.8,0.43,658,5530,3,1,22,0,South,760 +2023Q2,25.6,0.354,661,5790,2,6,25,0,West,674 +2023Q2,63.9,0.188,712,6550,0,1,6,0,South,0 +2023Q2,37.3,0.244,582,4490,4,7,17,0,West,345 +2023Q2,51.8,0.341,741,7150,3,2,1,0,Northeast,539 +2023Q2,36.3,0.282,579,4410,2,1,23,0,Midwest,259 +2023Q2,10.0,0.329,695,6010,0,5,27,0,Northeast,528 +2023Q2,72.0,0.336,677,6450,1,5,8,0,Midwest,216 +2023Q2,34.1,0.236,703,6410,1,7,20,0,Northeast,430 +2023Q2,32.7,0.235,665,5770,0,2,6,1,West,564 +2023Q2,52.9,0.306,644,5810,1,7,1,0,South,339 +2023Q2,24.5,0.332,631,5310,1,7,14,0,South,370 +2023Q2,37.9,0.285,783,7760,1,2,0,0,South,548 +2023Q2,34.5,0.403,682,6020,0,5,10,0,South,433 +2023Q2,53.0,0.218,632,5590,1,6,25,0,South,81 +2023Q2,17.1,0.207,711,6420,3,5,24,0,West,723 +2023Q2,25.6,0.32,654,5680,0,3,9,0,West,501 +2023Q2,76.8,0.181,605,5350,1,7,24,0,South,0 +2023Q2,200.0,0.098,766,8660,3,1,3,0,South,0 +2023Q2,15.4,0.414,739,6800,0,1,24,0,Northeast,685 +2023Q2,10.0,0.352,670,5520,1,5,14,0,Midwest,743 +2023Q2,132.5,0.065,837,9630,1,6,3,0,Midwest,495 +2023Q2,31.5,0.362,480,3120,1,3,16,0,South,106 +2023Q2,32.7,0.272,678,6030,2,2,17,0,West,363 +2023Q2,51.3,0.269,674,6340,3,3,16,1,West,877 +2023Q2,69.5,0.282,468,3200,0,4,17,0,South,0 +2023Q2,22.8,0.344,561,3880,2,1,23,0,South,108 +2023Q2,16.1,0.323,610,5020,0,7,10,0,South,0 +2023Q2,16.4,0.232,576,4270,1,6,29,0,Midwest,353 +2023Q2,24.6,0.443,647,5350,4,4,30,1,West,1047 +2023Q2,22.5,0.398,564,4190,0,3,24,0,Northeast,59 +2023Q2,30.2,0.215,622,5270,1,5,29,0,South,170 +2023Q2,34.1,0.307,794,7820,1,2,21,1,Midwest,1134 +2023Q2,50.9,0.18,566,4420,0,5,22,0,Midwest,15 +2023Q2,43.0,0.362,634,5470,1,4,28,0,Midwest,336 +2023Q2,19.7,0.275,656,5590,1,5,29,0,Northeast,353 +2023Q2,50.6,0.283,649,5550,3,4,18,0,West,369 +2023Q2,18.9,0.308,743,6720,1,5,0,0,West,800 +2023Q2,65.5,0.435,725,7110,2,2,27,0,Midwest,442 +2023Q2,90.0,0.234,628,5830,1,7,3,0,South,0 +2023Q2,173.9,0.143,777,8930,3,2,19,0,West,113 +2023Q2,71.9,0.225,584,5000,1,3,8,0,West,0 +2023Q2,97.3,0.216,689,6820,1,2,25,0,Northeast,85 +2023Q2,67.7,0.329,602,5070,3,1,1,0,Midwest,0 +2023Q2,69.0,0.199,802,8210,0,4,9,0,West,556 +2023Q2,64.3,0.211,737,7190,1,3,10,0,Midwest,581 +2023Q2,26.3,0.558,610,4820,1,5,25,0,Northeast,387 +2023Q2,26.9,0.262,569,4290,2,6,25,0,South,356 +2023Q2,16.4,0.168,579,4090,3,7,20,0,Northeast,462 +2023Q2,27.4,0.284,490,3140,2,5,7,0,Northeast,63 +2023Q2,58.8,0.186,566,4470,1,4,21,0,South,0 +2023Q2,177.4,0.095,665,7220,1,6,10,0,West,0 +2023Q2,63.3,0.21,656,5910,1,2,8,0,West,209 +2023Q2,55.0,0.106,717,6740,1,3,20,0,South,136 +2023Q2,44.9,0.04,664,5990,0,4,11,0,South,212 +2023Q2,87.3,0.355,560,4500,1,1,16,0,Midwest,0 +2023Q2,40.0,0.327,640,5290,4,5,5,0,South,418 +2023Q2,32.0,0.388,736,6830,1,5,19,0,Northeast,568 +2023Q2,34.7,0.304,635,5470,0,4,15,0,West,0 +2023Q2,22.5,0.691,699,6180,0,6,14,0,West,608 +2023Q2,37.4,0.333,582,4630,2,7,0,0,Midwest,163 +2023Q2,53.8,0.282,729,6890,1,4,6,0,Midwest,330 +2023Q2,50.5,0.319,644,5670,0,7,22,0,West,0 +2023Q2,43.6,0.29,729,6920,1,4,23,0,West,497 +2023Q2,30.6,0.362,650,5500,2,4,22,0,Midwest,600 +2023Q2,39.0,0.391,793,7960,3,1,11,0,Northeast,1072 +2023Q2,51.9,0.145,671,6030,1,3,17,0,West,45 +2023Q2,30.7,0.266,597,4790,2,3,17,0,Northeast,95 +2023Q2,23.2,0.168,723,6380,3,1,29,0,Midwest,652 +2023Q2,47.1,0.218,768,7480,3,2,26,1,South,1185 +2023Q2,76.0,0.178,763,7580,2,7,13,0,Northeast,683 +2023Q2,46.1,0.388,610,4960,2,5,12,0,West,45 +2023Q2,89.1,0.447,668,6270,0,7,12,0,Northeast,0 +2023Q2,38.3,0.26,591,4940,1,7,16,0,Midwest,228 +2023Q2,78.5,0.235,647,6110,3,7,15,0,Midwest,404 +2023Q2,113.4,0.301,644,6360,0,7,22,0,West,0 +2023Q2,32.5,0.334,666,5820,1,3,27,0,West,563 +2023Q2,31.5,0.362,553,3980,0,5,11,0,Northeast,0 +2023Q2,44.2,0.252,804,7890,1,6,27,0,West,756 +2023Q2,16.6,0.207,600,4800,1,3,23,0,West,307 +2023Q2,15.5,0.238,606,4470,1,3,5,0,West,60 +2023Q2,79.4,0.02,846,8820,1,2,5,0,Northeast,555 +2023Q2,39.3,0.265,649,5650,1,7,8,0,West,322 +2023Q2,85.7,0.12,650,6210,0,6,26,0,South,0 +2023Q2,22.3,0.41,648,5530,1,1,6,0,Midwest,336 +2023Q2,16.6,0.406,561,3990,2,1,22,0,South,478 +2023Q2,69.8,0.25,642,5530,1,3,18,0,Midwest,221 +2023Q2,66.8,0.213,657,5980,3,5,16,0,South,110 +2023Q2,22.3,0.523,653,5520,1,1,27,0,Northeast,327 +2023Q2,53.2,0.61,620,5460,4,5,9,0,West,524 +2023Q2,67.2,0.285,765,7600,2,7,15,0,Northeast,706 +2023Q2,63.4,0.102,580,4860,1,7,13,0,Midwest,0 +2023Q2,38.0,0.216,833,8490,4,4,8,0,South,1379 +2023Q2,34.9,0.414,638,5490,4,1,20,0,Midwest,554 +2023Q2,19.9,0.36,592,4570,0,1,6,0,West,0 +2023Q2,53.3,0.255,746,7200,0,2,11,0,South,604 +2023Q2,53.4,0.262,632,5780,5,1,4,0,South,400 +2023Q2,25.2,0.421,647,5640,2,6,1,0,West,660 +2023Q2,48.2,0.218,641,5440,1,3,30,1,West,626 +2023Q2,24.2,0.453,594,4550,2,6,6,0,South,0 +2023Q2,32.0,0.325,695,6150,1,4,29,1,Northeast,777 +2023Q2,114.1,0.056,765,8020,0,2,24,0,Northeast,0 +2023Q2,25.8,0.5,535,3790,1,2,26,1,Northeast,746 +2023Q2,35.5,0.256,652,5710,1,1,14,0,West,393 +2023Q2,53.3,0.087,709,6870,0,1,25,0,Northeast,154 +2023Q2,24.9,0.319,762,6890,0,7,3,0,Midwest,298 +2023Q2,65.5,0.439,523,4060,2,7,21,0,West,169 +2023Q2,16.4,0.259,656,5570,1,7,18,0,Midwest,600 +2023Q2,32.2,0.239,672,5670,0,2,27,0,West,154 +2023Q2,59.5,0.3,712,6970,0,6,0,0,West,307 +2023Q2,81.5,0.144,703,7050,4,2,29,1,South,970 +2023Q2,99.1,0.261,691,6580,4,5,22,0,Northeast,206 +2023Q2,52.0,0.19,617,5300,0,6,5,0,Northeast,0 +2023Q2,50.5,0.213,540,4150,2,3,9,0,South,185 +2023Q2,83.1,0.168,820,8460,1,4,8,0,Midwest,777 +2023Q2,51.8,0.326,626,5440,2,4,12,1,West,465 +2023Q2,98.6,0.523,615,5630,1,1,15,0,South,0 +2023Q2,28.8,0.251,607,4890,3,3,18,0,Northeast,496 +2023Q2,20.8,0.266,685,5930,0,6,8,0,South,463 +2023Q2,28.9,0.352,608,4990,2,5,29,0,West,423 +2023Q2,63.1,0.229,514,3890,1,2,24,1,South,224 +2023Q2,23.0,0.422,689,6080,2,7,11,0,Midwest,811 +2023Q2,34.3,0.312,557,4170,1,2,4,0,West,329 +2023Q2,43.6,0.205,608,4800,2,5,20,0,Northeast,160 +2023Q2,24.9,0.218,675,5690,2,3,12,0,Midwest,275 +2023Q2,18.5,0.25,646,5390,0,4,23,0,Midwest,237 +2023Q2,18.1,0.461,527,3640,3,6,15,0,West,619 +2023Q2,44.0,0.451,498,3350,1,3,22,0,Midwest,0 +2023Q2,28.0,0.339,576,4380,0,5,19,0,Midwest,185 +2023Q2,43.1,0.134,723,6720,1,7,13,0,South,409 +2023Q2,28.0,0.188,522,3470,1,3,2,0,Northeast,0 +2023Q2,45.9,0.283,661,5770,3,1,29,0,West,391 +2023Q2,38.0,0.451,635,5520,3,3,11,0,Midwest,497 +2023Q2,58.8,0.097,651,5760,0,4,13,0,Northeast,0 +2023Q2,102.2,0.236,431,3090,3,1,1,0,Midwest,0 +2023Q2,23.1,0.346,688,6050,1,1,4,0,West,496 +2023Q2,19.4,0.6,638,5210,3,5,5,0,West,679 +2023Q2,64.8,0.3,518,4080,1,5,14,0,Midwest,0 +2023Q2,49.3,0.104,699,6580,2,1,4,0,Northeast,296 +2023Q2,137.6,0.0,684,6850,0,2,16,0,South,0 +2023Q2,17.7,0.426,611,4860,3,3,7,0,South,666 +2023Q2,17.7,0.396,695,6010,3,6,4,0,Northeast,928 +2023Q2,77.5,0.144,644,5980,1,1,20,0,West,166 +2023Q2,39.6,0.394,570,4350,4,3,8,0,Northeast,330 +2023Q2,45.5,0.303,732,7210,4,6,0,0,Northeast,911 +2023Q2,26.2,0.408,749,7140,1,4,16,0,Midwest,780 +2023Q2,29.0,0.335,632,5050,2,5,22,0,West,350 +2023Q2,49.9,0.337,768,7710,1,6,13,0,South,873 +2023Q2,50.2,0.362,664,6020,2,1,27,0,South,414 +2023Q2,43.2,0.395,569,4270,1,1,23,0,Northeast,176 +2023Q2,78.5,0.208,826,8760,1,6,17,0,Midwest,747 +2023Q2,29.1,0.597,705,6330,2,4,8,0,Midwest,967 +2023Q2,45.5,0.14,591,5010,1,3,3,0,West,35 +2023Q2,54.4,0.361,591,4770,0,2,22,1,South,263 +2023Q2,31.2,0.318,644,5320,0,1,15,0,South,5 +2023Q2,16.1,0.35,575,4090,2,3,0,0,Midwest,475 +2023Q2,75.5,0.153,612,5130,0,5,15,0,West,0 +2023Q2,139.8,0.262,632,6290,1,6,16,1,West,78 +2023Q2,200.0,0.0,656,7250,3,4,1,0,West,0 +2023Q2,50.8,0.375,592,5140,1,1,13,0,Northeast,88 +2023Q2,28.3,0.185,655,5460,1,3,5,0,West,342 +2023Q2,63.4,0.247,706,6910,0,5,1,0,South,345 +2023Q2,100.6,0.248,620,5770,1,1,28,1,South,167 +2023Q2,38.4,0.191,738,6760,1,5,11,1,South,954 +2023Q2,49.9,0.341,649,5620,3,4,22,0,Midwest,534 +2023Q2,25.6,0.499,646,5410,2,5,18,0,South,658 +2023Q2,77.0,0.03,683,6480,0,2,20,0,Northeast,0 +2023Q2,39.6,0.184,555,4320,2,4,8,0,West,145 +2023Q2,21.2,0.321,707,6280,2,4,14,0,Midwest,714 +2023Q2,64.0,0.299,696,6640,3,3,1,0,Midwest,585 +2023Q2,46.4,0.146,658,5700,1,1,12,0,West,17 +2023Q2,76.3,0.148,601,5250,3,6,28,0,South,0 +2023Q2,34.9,0.109,659,5780,2,3,1,0,South,367 +2023Q2,53.2,0.345,792,7820,2,7,2,1,West,1429 +2023Q2,41.1,0.2,532,3980,1,2,16,0,West,111 +2023Q2,27.4,0.355,663,5770,2,7,21,0,South,910 +2023Q2,23.0,0.191,602,4600,0,2,5,0,Midwest,0 +2023Q2,43.2,0.442,709,6550,1,6,30,0,Northeast,604 +2023Q2,30.1,0.277,728,6670,1,4,29,0,South,374 +2023Q2,28.8,0.23,637,5270,1,4,29,0,Northeast,0 +2023Q2,55.3,0.32,658,5910,1,4,13,0,West,222 +2023Q2,16.8,0.407,607,4840,0,2,26,0,Midwest,284 +2023Q2,56.5,0.399,635,5680,0,6,27,0,West,103 +2023Q2,29.4,0.286,725,6910,3,7,10,0,Northeast,1030 +2023Q2,178.3,0.171,796,9260,1,3,5,0,West,109 +2023Q2,77.8,0.176,700,6900,1,7,3,0,West,274 +2023Q2,67.2,0.192,611,5160,2,6,4,1,Midwest,415 +2023Q2,41.6,0.154,673,6100,4,5,16,0,West,557 +2023Q2,24.4,0.23,665,5550,4,2,2,0,Northeast,672 +2023Q2,37.4,0.138,713,6700,5,7,25,0,West,893 +2023Q2,48.8,0.118,698,6340,2,6,26,0,Northeast,465 +2023Q2,71.9,0.219,629,5530,2,1,17,0,West,81 +2023Q2,45.1,0.394,675,6100,3,4,10,0,West,430 +2023Q2,33.0,0.249,717,6750,5,5,22,0,West,804 +2023Q2,31.2,0.419,518,3580,2,7,22,0,Midwest,469 +2023Q2,41.3,0.381,624,5330,3,2,14,0,South,774 +2023Q2,22.4,0.518,612,4840,1,2,29,0,South,228 +2023Q2,34.3,0.381,786,7390,3,5,9,1,South,1374 +2023Q2,84.7,0.224,723,7310,2,6,4,0,Northeast,377 +2023Q2,32.2,0.301,579,4500,1,4,17,0,Northeast,379 +2023Q2,57.4,0.329,718,7050,1,6,4,0,Northeast,588 +2023Q2,26.9,0.305,588,4500,2,5,24,0,South,166 +2023Q2,126.5,0.082,435,3280,0,7,27,0,West,0 +2023Q2,31.9,0.319,684,6340,2,6,17,0,Northeast,910 +2023Q2,35.5,0.187,553,4180,2,6,1,0,Midwest,455 +2023Q2,28.6,0.189,719,6320,1,5,3,0,Northeast,465 +2023Q2,19.9,0.335,745,6910,2,4,21,0,West,884 +2023Q2,120.4,0.165,630,6130,3,2,11,0,Midwest,0 +2023Q2,17.8,0.511,671,5660,1,2,22,1,Northeast,854 +2023Q2,106.2,0.237,696,7000,0,5,1,0,Northeast,0 +2023Q2,32.0,0.276,616,4840,1,5,6,0,West,0 +2023Q2,40.0,0.36,640,5370,4,4,30,0,South,385 +2023Q2,95.2,0.151,693,6660,1,5,13,0,Northeast,108 +2023Q2,45.9,0.38,589,4740,2,6,6,0,Northeast,41 +2023Q2,15.6,0.206,695,6020,2,7,21,1,Northeast,1109 +2023Q2,43.6,0.397,644,5690,0,2,1,0,Midwest,205 +2023Q2,57.9,0.241,687,6360,6,3,8,0,West,658 +2023Q2,24.0,0.098,720,6680,3,4,0,0,South,651 +2023Q2,25.2,0.292,566,4150,0,3,18,0,West,119 +2023Q2,26.4,0.341,661,5820,2,5,30,0,South,711 +2023Q2,20.5,0.201,689,6050,1,6,12,0,West,426 +2023Q2,22.7,0.29,842,8290,1,2,13,0,West,1183 +2023Q2,74.7,0.339,664,6070,1,1,9,0,Northeast,5 +2023Q2,27.3,0.424,666,5940,1,1,12,0,West,415 +2023Q2,116.6,0.144,655,6390,5,3,8,0,Midwest,186 +2023Q2,42.9,0.521,608,5060,1,3,16,0,Midwest,453 +2023Q2,56.7,0.322,672,6020,0,1,11,0,Midwest,46 +2023Q2,24.4,0.339,668,5830,0,4,16,0,South,373 +2023Q3,22.1,0.527,489,3040,1,6,28,0,Northeast,34 +2023Q3,20.8,0.389,587,4610,3,1,14,0,South,488 +2023Q3,30.1,0.455,637,5240,3,7,20,1,West,1013 +2023Q3,82.2,0.135,590,5090,0,3,25,0,Northeast,0 +2023Q3,54.1,0.262,535,4110,3,1,19,1,West,403 +2023Q3,86.7,0.249,640,6020,1,1,7,0,West,23 +2023Q3,43.2,0.244,659,5960,1,7,16,0,Midwest,380 +2023Q3,25.2,0.35,613,5000,2,4,3,0,South,343 +2023Q3,53.7,0.391,557,4610,1,6,18,0,West,366 +2023Q3,52.1,0.397,743,6950,1,3,2,1,Midwest,957 +2023Q3,36.5,0.315,716,6630,3,3,9,0,Midwest,618 +2023Q3,41.0,0.575,448,2560,1,5,21,1,Midwest,357 +2023Q3,69.1,0.26,515,4100,3,5,15,0,Midwest,41 +2023Q3,160.0,0.0,751,8330,1,2,16,0,Northeast,0 +2023Q3,17.4,0.288,747,6770,2,2,13,0,Midwest,787 +2023Q3,45.7,0.304,636,5390,2,2,5,1,West,655 +2023Q3,13.5,0.255,520,3430,0,7,4,0,South,0 +2023Q3,61.3,0.175,638,5800,3,1,15,0,West,63 +2023Q3,120.9,0.054,819,9170,3,4,15,1,West,891 +2023Q3,30.3,0.313,530,3600,5,2,30,0,Northeast,350 +2023Q3,50.0,0.086,766,7420,1,3,19,0,West,553 +2023Q3,45.7,0.309,506,3640,1,6,17,0,Midwest,0 +2023Q3,22.3,0.353,700,6080,0,3,15,0,South,426 +2023Q3,30.5,0.237,653,5370,2,1,4,0,West,227 +2023Q3,27.7,0.296,646,5620,3,3,25,0,Northeast,455 +2023Q3,31.3,0.248,661,5840,1,6,28,0,West,430 +2023Q3,17.0,0.177,595,4630,0,2,27,0,South,0 +2023Q3,53.9,0.342,565,4520,1,4,5,0,South,258 +2023Q3,41.1,0.234,606,5130,5,7,15,0,Northeast,395 +2023Q3,64.9,0.29,696,6670,1,7,16,0,Midwest,378 +2023Q3,89.0,0.178,646,6030,3,3,26,0,West,303 +2023Q3,59.7,0.24,562,4580,1,7,20,0,South,327 +2023Q3,17.4,0.373,723,6730,0,5,15,0,West,387 +2023Q3,54.3,0.218,667,5800,5,7,15,0,Midwest,478 +2023Q3,86.1,0.152,603,5510,2,2,21,0,Midwest,0 +2023Q3,117.2,0.129,583,5560,3,2,18,0,Northeast,0 +2023Q3,60.5,0.294,708,6610,0,3,17,0,Northeast,3 +2023Q3,41.4,0.211,647,5670,1,7,24,0,West,270 +2023Q3,13.9,0.4,750,6750,0,3,30,0,West,607 +2023Q3,77.7,0.253,628,5680,1,5,16,0,West,0 +2023Q3,48.4,0.338,724,6950,0,1,11,0,Northeast,295 +2023Q3,14.7,0.342,550,4070,1,5,26,0,Northeast,289 +2023Q3,19.5,0.386,537,3600,2,3,16,0,West,249 +2023Q3,21.8,0.368,683,5810,2,2,28,0,Midwest,584 +2023Q3,53.9,0.372,545,3980,3,7,8,0,Northeast,402 +2023Q3,21.2,0.573,642,5500,1,4,2,0,South,377 +2023Q3,42.9,0.428,651,5710,1,6,18,0,South,351 +2023Q3,47.6,0.325,768,7580,0,3,7,0,South,442 +2023Q3,49.3,0.327,647,5580,1,6,7,0,West,113 +2023Q3,42.9,0.361,674,6080,1,4,7,1,Northeast,878 +2023Q3,29.1,0.408,691,6180,4,2,18,0,Midwest,657 +2023Q3,64.7,0.304,666,6240,2,4,2,0,West,195 +2023Q3,23.3,0.284,609,4980,0,2,17,0,West,0 +2023Q3,33.3,0.443,776,7390,2,5,0,0,Northeast,932 +2023Q3,20.7,0.282,584,4550,0,4,22,0,Northeast,0 +2023Q3,81.8,0.229,741,7620,1,6,10,0,South,184 +2023Q3,19.3,0.11,616,5180,1,4,13,0,West,97 +2023Q3,80.9,0.3,761,7550,1,1,14,0,Northeast,432 +2023Q3,73.3,0.173,794,8300,2,4,8,1,West,947 +2023Q3,62.0,0.263,583,4940,1,5,12,0,South,0 +2023Q3,42.5,0.284,553,4310,1,7,13,0,Midwest,333 +2023Q3,45.0,0.311,720,6950,1,2,24,0,Midwest,611 +2023Q3,27.5,0.304,740,6960,0,6,15,0,Northeast,468 +2023Q3,11.2,0.47,738,6660,2,2,4,0,West,1051 +2023Q3,62.0,0.42,646,5770,3,1,19,0,West,272 +2023Q3,70.1,0.089,536,4170,0,5,0,0,South,0 +2023Q3,44.5,0.147,609,5090,1,7,0,0,Midwest,0 +2023Q3,34.1,0.29,637,5460,3,5,11,0,South,429 +2023Q3,12.2,0.465,723,6440,1,7,3,0,Midwest,730 +2023Q3,73.4,0.332,530,4290,3,6,20,0,Northeast,366 +2023Q3,22.8,0.373,594,4800,2,7,16,0,Northeast,170 +2023Q3,42.9,0.374,722,6760,2,7,12,0,South,883 +2023Q3,27.0,0.342,722,6770,1,5,21,0,West,834 +2023Q3,25.2,0.389,741,6820,1,6,23,0,Northeast,876 +2023Q3,84.0,0.056,475,3430,2,2,19,0,Northeast,0 +2023Q3,31.2,0.152,619,4980,1,4,6,0,Midwest,211 +2023Q3,47.0,0.29,673,6180,2,6,28,0,Midwest,643 +2023Q3,55.9,0.266,631,5640,1,7,17,0,South,271 +2023Q3,17.5,0.522,639,5260,2,1,29,0,Northeast,317 +2023Q3,29.0,0.476,585,4560,1,2,20,0,Northeast,75 +2023Q3,42.1,0.253,740,6960,0,7,16,0,Midwest,545 +2023Q3,31.6,0.186,667,6000,4,2,15,0,Northeast,588 +2023Q3,28.2,0.406,627,5380,0,7,11,0,West,203 +2023Q3,20.5,0.311,650,5390,2,6,22,0,Midwest,448 +2023Q3,31.6,0.362,561,4220,2,6,10,0,West,501 +2023Q3,31.1,0.33,690,6070,3,7,18,0,South,704 +2023Q3,49.5,0.309,644,5590,1,2,16,0,West,69 +2023Q3,83.4,0.248,662,6150,2,7,10,0,Midwest,171 +2023Q3,96.4,0.212,556,4810,2,5,15,0,South,0 +2023Q3,200.0,0.0,682,7610,1,3,26,0,Northeast,0 +2023Q3,99.8,0.189,756,7730,5,4,11,0,South,585 +2023Q3,39.2,0.36,773,7440,2,5,11,0,South,910 +2023Q3,91.8,0.222,713,7220,0,4,26,0,West,0 +2023Q3,107.4,0.194,739,7780,1,2,22,0,Midwest,158 +2023Q3,61.5,0.424,597,4870,0,2,19,0,Midwest,0 +2023Q3,24.8,0.267,583,4330,2,4,12,0,South,347 +2023Q3,69.4,0.279,640,5610,4,3,17,0,South,319 +2023Q3,42.5,0.207,684,5880,2,7,8,0,Midwest,433 +2023Q3,10.0,0.37,632,5320,0,2,4,1,Northeast,793 +2023Q3,51.8,0.449,783,7730,1,4,25,0,Midwest,791 +2023Q3,55.0,0.189,833,8580,2,2,25,0,West,1043 +2023Q3,44.6,0.394,657,5770,2,6,30,0,Northeast,412 +2023Q3,45.3,0.333,576,4530,0,4,28,0,South,87 +2023Q3,42.3,0.31,821,8130,1,2,12,0,West,767 +2023Q3,40.0,0.373,604,5010,3,2,12,0,South,290 +2023Q3,133.9,0.102,689,7050,6,6,9,0,West,318 +2023Q3,15.2,0.422,654,5550,3,4,8,0,West,570 +2023Q3,46.9,0.279,730,7010,2,5,29,0,Midwest,668 +2023Q3,47.9,0.08,717,6410,3,3,28,0,West,305 +2023Q3,43.7,0.201,822,8210,0,4,15,0,West,613 +2023Q3,61.1,0.281,692,6400,1,2,14,0,Northeast,84 +2023Q3,58.4,0.272,753,7350,3,7,28,0,South,862 +2023Q3,25.3,0.22,592,4530,1,3,30,0,South,0 +2023Q3,24.4,0.213,601,4710,2,6,9,1,West,581 +2023Q3,37.4,0.313,758,7290,1,5,12,0,South,663 +2023Q3,30.7,0.262,550,4040,1,7,5,0,Midwest,274 +2023Q3,65.4,0.097,700,6790,3,1,6,0,West,478 +2023Q3,26.2,0.347,790,7620,0,6,14,0,South,871 +2023Q3,32.4,0.366,796,7990,2,4,19,0,Northeast,1134 +2023Q3,38.7,0.563,610,5000,1,1,18,0,Midwest,197 +2023Q3,40.1,0.253,754,7110,1,3,20,0,South,579 +2023Q3,60.1,0.112,675,6310,2,5,18,0,Northeast,220 +2023Q3,16.9,0.433,615,4960,0,3,6,0,South,27 +2023Q3,34.0,0.358,552,4120,1,3,28,0,South,283 +2023Q3,19.7,0.169,575,4350,0,4,9,0,South,320 +2023Q3,70.3,0.193,680,6280,1,4,25,0,Northeast,128 +2023Q3,24.4,0.456,713,6480,4,7,14,0,South,1092 +2023Q3,64.7,0.198,677,6410,1,2,22,0,West,0 +2023Q3,38.7,0.436,792,7920,2,3,21,1,Northeast,1296 +2023Q3,67.9,0.322,605,5180,1,5,1,0,Northeast,0 +2023Q3,39.3,0.484,619,5040,2,7,26,0,South,551 +2023Q3,65.4,0.358,765,7800,3,1,23,1,Northeast,1270 +2023Q3,60.3,0.294,717,6930,1,6,10,0,West,591 +2023Q3,32.4,0.286,566,4320,1,2,30,0,West,148 +2023Q3,38.4,0.282,678,5870,1,5,6,0,West,496 +2023Q3,38.1,0.413,739,7070,1,5,12,0,Northeast,578 +2023Q3,90.6,0.244,798,8600,2,6,17,0,South,793 +2023Q3,115.8,0.06,710,7330,0,3,29,0,West,0 +2023Q3,57.6,0.197,704,6880,1,4,23,0,Northeast,311 +2023Q3,46.0,0.094,777,7520,2,3,19,1,South,1122 +2023Q3,26.2,0.358,687,6030,3,6,24,0,West,661 +2023Q3,23.7,0.415,652,5600,1,4,12,0,South,701 +2023Q3,41.5,0.285,537,4010,3,3,13,0,West,523 +2023Q3,86.0,0.215,649,6050,2,2,19,0,Northeast,0 +2023Q3,13.7,0.411,601,4700,2,1,25,0,Northeast,343 +2023Q3,71.2,0.084,705,6700,3,5,27,1,Midwest,719 +2023Q3,65.1,0.193,610,5250,3,6,19,0,West,59 +2023Q3,53.7,0.327,620,5480,4,1,9,0,Northeast,238 +2023Q3,32.2,0.35,505,3370,3,6,19,0,Northeast,463 +2023Q3,31.3,0.27,623,5320,3,6,26,0,Northeast,370 +2023Q3,27.8,0.228,644,5220,2,4,21,0,South,257 +2023Q3,10.0,0.356,602,4580,1,5,28,0,South,404 +2023Q3,46.2,0.246,682,6030,1,4,13,0,South,426 +2023Q3,107.1,0.077,747,8020,1,6,25,0,South,156 +2023Q3,48.6,0.24,784,7660,2,1,16,0,Northeast,739 +2023Q3,23.7,0.295,721,6620,3,7,18,0,South,737 +2023Q3,40.2,0.227,712,6570,2,7,17,1,Northeast,1237 +2023Q3,100.2,0.152,737,7560,2,4,19,0,Midwest,200 +2023Q3,20.7,0.268,609,4840,2,6,1,1,South,805 +2023Q3,24.7,0.268,669,5810,0,2,12,0,West,138 +2023Q3,50.5,0.365,607,5090,1,1,21,0,West,227 +2023Q3,61.5,0.236,815,8610,2,4,13,0,Midwest,959 +2023Q3,44.1,0.222,691,6310,1,5,14,0,Midwest,582 +2023Q3,17.5,0.212,695,5940,0,4,0,1,West,779 +2023Q3,28.6,0.333,644,5320,0,6,15,0,Northeast,189 +2023Q3,25.9,0.41,628,5160,2,6,22,1,South,1026 +2023Q3,18.7,0.247,621,5110,3,5,7,0,Northeast,350 +2023Q3,14.8,0.318,574,4260,0,7,1,1,Northeast,658 +2023Q3,29.9,0.379,763,7380,0,2,25,0,West,594 +2023Q3,24.0,0.387,583,4540,0,2,8,0,Northeast,12 +2023Q3,28.0,0.272,503,3190,2,4,2,0,South,83 +2023Q3,16.4,0.195,595,4530,1,5,0,0,Northeast,269 +2023Q3,42.5,0.117,679,6150,1,4,3,1,South,624 +2023Q3,40.6,0.354,704,6280,3,2,23,0,South,386 +2023Q3,20.8,0.289,589,4810,0,5,6,0,Midwest,187 +2023Q3,30.1,0.337,571,4400,3,3,4,0,South,554 +2023Q3,28.1,0.48,695,6160,2,6,8,0,South,792 +2023Q3,89.8,0.357,624,5520,2,4,8,0,West,0 +2023Q3,48.5,0.227,643,5820,1,1,10,1,West,619 +2023Q3,64.3,0.189,680,6440,1,4,9,0,West,286 +2023Q3,70.4,0.319,659,6220,3,7,7,0,Northeast,300 +2023Q3,71.9,0.35,659,6240,1,2,1,0,West,228 +2023Q3,51.6,0.188,789,7760,1,2,19,0,West,505 +2023Q3,10.0,0.355,596,4490,2,1,6,0,Northeast,87 +2023Q3,40.3,0.409,667,5980,1,3,14,0,West,278 +2023Q3,47.7,0.392,618,5100,3,7,22,0,West,333 +2023Q3,23.4,0.398,574,4420,0,5,22,0,Northeast,402 +2023Q3,70.4,0.327,744,7480,3,4,14,0,Northeast,791 +2023Q3,35.1,0.24,683,6110,2,6,22,0,Northeast,572 +2023Q3,32.2,0.4,512,3530,2,3,5,0,South,81 +2023Q3,147.1,0.239,850,9900,1,3,4,0,West,321 +2023Q3,33.4,0.408,521,3640,3,3,3,0,South,476 +2023Q3,58.4,0.296,635,5550,0,7,26,0,West,0 +2023Q3,155.2,0.0,699,7450,0,2,14,0,South,0 +2023Q3,70.9,0.292,756,7590,2,3,28,0,West,714 +2023Q3,24.1,0.356,519,3480,0,1,15,0,Northeast,0 +2023Q3,15.1,0.339,534,3830,3,2,14,0,South,342 +2023Q3,14.1,0.335,673,5840,1,3,10,0,Northeast,554 +2023Q3,52.4,0.495,601,5130,2,4,1,0,South,363 +2023Q3,52.7,0.07,689,6520,0,3,21,0,Northeast,141 +2023Q3,31.6,0.296,614,5250,1,4,30,0,Northeast,232 +2023Q3,45.6,0.433,774,7630,2,2,8,0,South,704 +2023Q3,36.6,0.259,783,7670,1,3,11,0,West,705 +2023Q3,37.9,0.261,550,3990,0,4,0,0,West,41 +2023Q3,94.0,0.396,690,6690,1,4,14,0,West,26 +2023Q3,27.8,0.304,591,4490,2,1,14,0,Northeast,166 +2023Q3,21.2,0.461,652,5610,0,6,15,0,South,407 +2023Q3,480.0,0.233,715,6860,0,7,25,0,Northeast,568 +2023Q3,136.3,0.151,691,7060,1,4,19,0,South,0 +2023Q3,24.4,0.408,737,6770,0,5,11,0,South,485 +2023Q3,36.1,0.197,718,6850,2,7,3,0,Northeast,821 +2023Q3,30.8,0.397,729,6800,1,2,8,0,Northeast,735 +2023Q3,52.1,0.306,498,3310,1,6,26,0,South,0 +2023Q3,37.1,0.412,708,6640,0,5,25,1,West,842 +2023Q3,49.3,0.293,645,5710,4,3,23,1,Northeast,741 +2023Q3,24.2,0.352,680,5850,3,7,26,0,Northeast,649 +2023Q3,36.6,0.428,608,5060,4,3,18,0,West,476 +2023Q3,14.7,0.381,682,5890,4,5,3,0,South,803 +2023Q3,11.5,0.261,724,6320,2,7,27,0,West,883 +2023Q3,31.9,0.229,714,6490,0,5,18,0,West,178 +2023Q3,46.0,0.182,635,5350,3,5,27,0,Northeast,357 +2023Q3,25.5,0.389,745,6700,1,2,12,0,Northeast,764 +2023Q3,85.2,0.361,608,5270,2,5,21,0,West,0 +2023Q3,34.8,0.323,637,5430,1,2,4,0,Northeast,138 +2023Q3,29.5,0.357,645,5420,1,4,0,0,Northeast,443 +2023Q3,55.7,0.204,533,3960,1,1,14,0,South,42 +2023Q3,45.5,0.101,774,7590,1,7,26,0,West,795 +2023Q3,21.8,0.488,698,6390,2,3,0,0,West,797 +2023Q3,19.9,0.502,684,6160,0,6,2,1,South,779 +2023Q3,200.0,0.0,850,10200,1,4,14,1,West,293 +2023Q3,77.7,0.233,792,8110,3,6,10,0,West,1125 +2023Q3,33.1,0.374,746,6780,0,6,14,0,West,563 +2023Q3,61.8,0.223,768,7650,3,1,23,0,Midwest,781 +2023Q3,46.8,0.276,800,7960,0,5,1,0,South,619 +2023Q3,120.3,0.178,718,7560,1,5,25,0,South,0 +2023Q3,45.8,0.24,579,4490,2,3,27,0,Northeast,76 +2023Q3,49.8,0.343,645,5640,2,4,6,0,Northeast,339 +2023Q3,127.1,0.107,635,6290,3,1,1,0,Northeast,0 +2023Q3,17.7,0.435,715,6660,1,1,24,0,Northeast,568 +2023Q3,86.4,0.01,620,5600,2,5,6,0,Midwest,0 +2023Q3,15.7,0.295,574,4290,1,4,29,0,West,648 +2023Q3,77.4,0.328,660,6220,4,2,24,0,Northeast,272 +2023Q3,13.4,0.358,681,5590,2,6,10,0,South,762 +2023Q3,32.5,0.288,760,7140,0,3,6,1,West,836 +2023Q3,37.2,0.172,468,3040,0,4,27,0,Northeast,0 +2023Q3,48.7,0.403,535,4010,2,5,16,0,South,234 +2023Q3,42.8,0.376,838,8610,2,3,18,1,Midwest,1422 +2023Q3,32.6,0.383,685,6120,1,3,7,0,West,330 +2023Q3,11.6,0.148,648,5280,4,1,4,0,Midwest,564 +2023Q3,99.2,0.206,635,6120,3,2,25,1,South,475 +2023Q3,33.5,0.186,644,5570,0,1,0,0,South,133 +2023Q3,42.9,0.273,625,5480,2,2,13,0,South,249 +2023Q3,21.5,0.432,614,4860,2,4,5,0,West,516 +2023Q3,67.2,0.187,650,6110,0,7,17,0,South,0 +2023Q3,18.4,0.233,664,5650,0,7,23,0,West,399 +2023Q3,35.1,0.351,611,4790,2,4,0,0,Northeast,200 +2023Q3,22.1,0.157,768,7420,2,3,20,0,West,930 +2023Q3,51.7,0.276,697,6350,1,7,28,0,South,315 +2023Q3,36.2,0.34,713,6510,0,4,1,0,Northeast,451 +2023Q3,55.3,0.292,605,5200,0,1,12,0,West,0 +2023Q3,36.0,0.352,654,5780,4,4,15,0,South,537 +2023Q3,20.3,0.222,646,5280,3,5,27,0,South,490 +2023Q3,105.1,0.083,750,7640,3,3,20,0,Northeast,309 +2023Q3,36.9,0.405,550,4300,2,6,4,0,Northeast,488 +2023Q3,49.3,0.361,426,2360,1,3,15,0,South,0 +2023Q3,38.7,0.277,638,5460,1,1,19,0,Midwest,0 +2023Q3,21.4,0.291,619,5080,1,1,4,0,West,94 +2023Q3,69.1,0.31,554,4630,1,1,12,0,Northeast,122 +2023Q3,27.0,0.27,526,3680,2,1,5,0,Northeast,0 +2023Q3,59.7,0.299,720,6910,0,7,17,0,South,486 +2023Q3,43.6,0.165,567,4400,1,6,10,0,Northeast,349 +2023Q3,48.8,0.199,762,7430,0,5,14,0,South,662 +2023Q3,25.8,0.444,477,2780,1,4,0,0,West,77 +2023Q3,10.7,0.406,623,4880,0,4,17,0,West,80 +2023Q3,46.9,0.229,603,4940,2,7,11,0,West,60 +2023Q3,34.4,0.225,688,6010,3,4,14,0,West,459 +2023Q3,21.2,0.384,750,6860,0,2,1,0,Midwest,363 +2023Q3,32.2,0.261,638,5410,1,2,26,0,Midwest,0 +2023Q3,119.4,0.055,562,5210,1,6,30,1,Midwest,121 +2023Q3,57.8,0.236,622,5250,0,7,5,0,Northeast,0 +2023Q3,53.3,0.124,682,6340,1,5,24,0,South,218 +2023Q3,47.0,0.317,563,4350,3,2,11,0,Northeast,513 +2023Q3,35.4,0.329,664,5910,0,1,29,0,South,276 +2023Q3,32.6,0.429,592,4700,1,3,30,1,Midwest,539 +2023Q3,40.2,0.292,620,5160,2,4,21,0,Northeast,253 +2023Q3,18.0,0.153,789,7700,0,1,13,0,Northeast,527 +2023Q3,49.1,0.251,586,4800,2,7,4,1,Northeast,564 +2023Q3,40.0,0.179,774,7530,2,5,4,0,West,607 +2023Q3,38.1,0.256,689,6170,0,7,5,0,Midwest,260 +2023Q3,25.2,0.379,525,3510,2,6,24,0,Northeast,344 +2023Q3,80.3,0.143,588,5120,2,2,13,0,Northeast,0 +2023Q3,74.3,0.075,648,6250,1,6,30,0,Northeast,34 +2023Q3,70.4,0.225,593,5170,1,2,18,0,Midwest,0 +2023Q3,47.3,0.269,658,5910,1,2,14,0,Midwest,337 +2023Q3,31.7,0.342,816,7820,1,1,2,0,South,1032 +2023Q3,29.0,0.338,699,6240,3,2,3,0,South,762 +2023Q3,59.6,0.226,608,5280,1,5,0,0,South,0 +2023Q3,83.5,0.166,647,6020,0,1,16,0,South,0 +2023Q3,35.3,0.282,757,7220,1,7,19,0,West,860 +2023Q3,18.4,0.331,667,5790,2,4,16,0,Northeast,749 +2023Q3,52.6,0.427,562,4350,1,7,8,0,West,389 +2023Q3,48.0,0.346,704,6560,1,2,25,0,Midwest,514 +2023Q3,42.6,0.225,611,5170,0,6,8,0,West,0 +2023Q3,16.6,0.3,544,3940,1,4,21,0,Midwest,212 +2023Q3,37.8,0.209,647,5590,1,5,9,0,West,373 +2023Q3,78.6,0.129,780,7940,3,6,24,0,West,746 +2023Q3,30.9,0.193,565,4110,2,1,1,0,Midwest,8 +2023Q3,33.0,0.397,659,5740,1,7,21,0,South,577 +2023Q3,34.1,0.31,683,5920,1,5,22,1,Midwest,845 +2023Q3,41.8,0.043,849,8700,1,3,20,0,South,989 +2023Q3,22.3,0.403,627,4870,1,2,4,0,West,90 +2023Q3,25.7,0.202,694,6330,0,3,14,1,South,983 +2023Q3,37.7,0.355,494,3250,1,6,19,0,West,0 +2023Q3,77.9,0.342,580,5120,1,1,7,1,Northeast,245 +2023Q3,83.8,0.172,705,6790,1,7,20,0,Northeast,92 +2023Q3,34.5,0.222,621,5280,1,3,16,0,Midwest,283 +2023Q3,17.8,0.339,620,4980,2,5,9,1,Midwest,784 +2023Q3,17.9,0.434,561,3910,1,5,15,0,Midwest,267 +2023Q3,33.0,0.309,850,8520,1,7,14,0,Midwest,1048 +2023Q3,14.2,0.526,612,4910,1,1,10,0,Midwest,389 +2023Q3,52.7,0.135,725,6810,4,5,20,0,Midwest,597 +2023Q3,62.4,0.146,794,8050,1,3,10,0,Northeast,697 +2023Q3,53.6,0.283,600,4900,1,3,11,0,West,0 +2023Q3,31.8,0.237,804,7850,3,1,14,0,Midwest,1089 +2023Q3,32.7,0.198,698,6080,2,4,24,0,Northeast,265 +2023Q3,90.2,0.247,608,5500,1,7,8,1,South,382 +2023Q3,73.4,0.222,597,5240,2,7,8,0,Northeast,0 +2023Q3,42.9,0.256,787,7720,0,5,4,0,Northeast,638 +2023Q3,45.3,0.36,666,6360,2,3,9,0,Midwest,678 +2023Q3,34.0,0.349,735,6760,0,7,19,0,South,458 +2023Q3,33.2,0.297,568,4420,2,2,14,0,South,465 +2023Q3,14.1,0.256,850,8460,1,1,0,0,West,1269 +2023Q3,20.2,0.312,734,6770,1,7,26,0,Midwest,818 +2023Q3,55.6,0.235,578,4690,5,5,13,0,South,360 +2023Q3,14.4,0.446,773,7130,2,5,8,0,West,1186 +2023Q3,75.6,0.273,597,5300,4,5,26,0,Northeast,13 +2023Q3,185.6,0.119,667,7230,1,1,7,0,West,0 +2023Q3,57.1,0.306,528,3850,0,1,26,0,South,0 +2023Q3,46.0,0.342,643,5640,1,2,14,0,Northeast,238 +2023Q3,71.2,0.391,438,2810,2,7,12,0,Midwest,0 +2023Q3,18.7,0.523,653,5330,3,7,25,0,Northeast,707 +2023Q3,91.2,0.0,807,8550,0,7,12,0,Midwest,460 +2023Q3,27.9,0.15,729,6870,2,4,10,0,South,645 +2023Q3,41.1,0.438,598,5040,2,5,12,0,Midwest,169 +2023Q3,29.9,0.347,841,8170,0,1,12,1,Northeast,1432 +2023Q3,25.9,0.515,596,4790,0,7,11,0,Midwest,175 +2023Q3,15.2,0.38,682,5880,2,4,11,0,Midwest,741 +2023Q3,30.6,0.393,664,5850,2,3,23,0,South,595 +2023Q3,42.3,0.405,644,5360,1,5,25,0,South,402 +2023Q3,25.1,0.382,672,5960,2,7,2,0,West,512 +2023Q3,27.1,0.432,717,6590,0,5,25,0,Midwest,536 +2023Q3,47.5,0.29,641,5670,0,1,6,0,West,66 +2023Q3,17.4,0.345,575,4340,1,2,20,0,Northeast,273 +2023Q3,33.1,0.389,547,3920,2,6,23,0,Northeast,252 +2023Q3,87.7,0.223,681,6640,0,3,9,0,Midwest,0 +2023Q3,24.5,0.282,622,4950,2,7,21,0,Northeast,421 +2023Q3,1173.0,0.055,702,7190,2,2,23,0,South,129 +2023Q3,17.1,0.422,720,6490,3,5,3,0,South,1038 +2023Q3,11.6,0.571,509,3250,0,3,13,0,Northeast,46 +2023Q3,53.1,0.385,720,6950,3,7,6,0,Midwest,711 +2023Q3,47.3,0.375,724,6910,2,7,18,0,South,675 +2023Q3,119.7,0.071,712,7520,2,2,12,1,Midwest,547 +2023Q3,13.3,0.24,545,3640,1,6,27,0,West,289 +2023Q3,34.3,0.376,666,5560,1,4,7,0,Midwest,369 +2023Q3,34.9,0.382,561,4290,2,3,25,0,Midwest,453 +2023Q3,107.5,0.046,664,6490,0,1,16,0,West,0 +2023Q3,72.0,0.152,677,6320,3,5,0,0,Midwest,130 +2023Q3,28.5,0.285,591,4480,1,4,30,0,West,84 +2023Q3,112.8,0.089,635,6340,2,7,9,0,South,0 +2023Q3,26.4,0.493,566,4170,2,5,30,1,South,1007 +2023Q3,53.7,0.323,706,6720,0,4,11,0,West,327 +2023Q3,38.4,0.316,677,6050,0,2,5,1,South,696 +2023Q3,29.1,0.177,649,5600,4,6,28,0,Northeast,602 +2023Q3,33.8,0.47,668,5730,3,1,29,0,West,477 +2023Q3,35.5,0.281,625,5210,0,4,17,0,South,120 +2023Q3,22.8,0.262,675,5600,2,7,8,0,Northeast,538 +2023Q3,30.3,0.203,681,6140,2,3,22,0,Northeast,293 +2023Q3,94.5,0.289,616,5660,1,1,28,0,West,0 +2023Q3,26.8,0.298,641,5480,0,1,14,0,West,102 +2023Q3,43.6,0.305,776,7550,1,4,0,0,Northeast,815 +2023Q3,72.5,0.154,801,7980,3,1,10,0,South,758 +2023Q3,32.7,0.293,668,5940,1,1,24,0,Midwest,537 +2023Q3,66.2,0.287,709,6790,1,7,10,0,West,196 +2023Q3,69.1,0.272,559,4470,0,4,27,0,Midwest,0 +2023Q3,34.6,0.205,526,3780,0,7,28,0,Northeast,0 +2023Q3,76.8,0.303,469,3470,1,6,6,0,Northeast,0 +2023Q3,74.0,0.173,708,6860,0,7,8,0,Midwest,320 +2023Q4,58.1,0.15,782,7660,1,3,3,1,Northeast,604 +2023Q4,71.2,0.306,679,6370,2,6,1,0,West,291 +2023Q4,35.7,0.344,676,5840,1,1,21,1,Midwest,811 +2023Q4,75.1,0.418,673,6290,1,1,6,0,South,74 +2023Q4,128.4,0.178,678,6900,2,2,1,0,Northeast,0 +2023Q4,33.0,0.27,581,4550,0,2,18,0,West,0 +2023Q4,60.7,0.343,672,6150,2,4,15,0,West,344 +2023Q4,46.5,0.209,723,6820,1,4,15,0,Northeast,472 +2023Q4,16.6,0.333,658,5570,1,1,26,0,Midwest,338 +2023Q4,99.7,0.11,643,6170,0,3,19,0,West,0 +2023Q4,23.2,0.304,526,3600,1,5,12,0,Midwest,131 +2023Q4,153.2,0.174,793,8930,1,7,22,0,Midwest,64 +2023Q4,13.8,0.273,793,7420,0,1,6,0,Midwest,530 +2023Q4,43.2,0.288,801,7820,0,6,12,0,South,667 +2023Q4,91.0,0.337,670,6520,3,4,8,0,Midwest,274 +2023Q4,54.5,0.275,647,5710,1,1,15,0,South,204 +2023Q4,41.2,0.234,736,6870,3,4,20,0,Midwest,453 +2023Q4,65.5,0.233,674,6270,3,7,8,0,Northeast,128 +2023Q4,60.0,0.16,601,5040,0,4,6,0,Midwest,0 +2023Q4,18.2,0.356,547,3890,1,3,18,0,Northeast,292 +2023Q4,10.0,0.195,619,5130,4,1,12,0,Northeast,435 +2023Q4,51.3,0.301,620,5410,2,5,11,0,South,273 +2023Q4,36.3,0.339,771,7360,1,7,17,0,Northeast,838 +2023Q4,36.9,0.255,774,7520,2,5,15,0,Midwest,874 +2023Q4,60.0,0.182,652,5820,2,3,22,0,Northeast,189 +2023Q4,102.6,0.125,675,6730,2,5,17,0,South,108 +2023Q4,65.7,0.27,781,7970,0,3,11,0,South,345 +2023Q4,19.7,0.218,562,4200,3,2,19,1,West,621 +2023Q4,44.3,0.352,503,3630,0,2,1,1,South,278 +2023Q4,51.2,0.309,640,5610,1,7,22,0,Northeast,116 +2023Q4,39.4,0.359,576,4600,1,6,6,0,Northeast,300 +2023Q4,20.7,0.369,599,4630,1,2,22,0,Northeast,47 +2023Q4,67.5,0.306,647,5730,0,5,8,0,South,31 +2023Q4,28.2,0.368,700,6350,2,5,7,1,Midwest,987 +2023Q4,31.0,0.251,584,4380,5,3,9,0,Northeast,264 +2023Q4,51.0,0.183,845,8760,3,2,17,0,Northeast,906 +2023Q4,67.1,0.101,575,4830,1,2,0,0,West,0 +2023Q4,30.6,0.339,741,7000,1,2,15,0,West,637 +2023Q4,49.5,0.255,602,5020,3,4,28,0,Midwest,22 +2023Q4,43.4,0.269,597,4760,3,2,26,0,West,185 +2023Q4,66.2,0.152,674,6260,1,3,20,0,South,91 +2023Q4,21.6,0.233,674,5870,1,1,2,1,Northeast,776 +2023Q4,18.2,0.458,664,5580,2,4,3,0,Northeast,669 +2023Q4,25.2,0.295,623,5220,1,2,23,0,South,204 +2023Q4,38.0,0.224,601,5200,1,7,17,0,Northeast,203 +2023Q4,36.3,0.394,550,4230,1,3,4,0,South,191 +2023Q4,19.0,0.257,732,6810,2,1,9,0,Northeast,784 +2023Q4,73.3,0.176,625,5410,4,7,18,0,Midwest,0 +2023Q4,15.0,0.246,548,3860,2,1,10,0,Midwest,237 +2023Q4,17.1,0.165,545,3620,2,1,23,0,Northeast,143 +2023Q4,20.9,0.499,600,4610,3,3,26,0,West,424 +2023Q4,63.3,0.401,618,5220,0,4,10,0,Midwest,0 +2023Q4,21.2,0.32,546,3950,2,4,28,0,Midwest,387 +2023Q4,35.9,0.277,565,4270,1,1,2,0,West,31 +2023Q4,14.7,0.349,694,5990,1,6,22,0,West,525 +2023Q4,41.2,0.279,701,6360,1,4,14,0,Midwest,398 +2023Q4,19.1,0.443,654,5490,2,5,11,0,Midwest,753 +2023Q4,78.9,0.0,642,5800,1,5,24,0,South,0 +2023Q4,73.7,0.258,643,5900,4,3,8,0,Midwest,259 +2023Q4,116.8,0.171,737,7690,3,1,3,0,Midwest,214 +2023Q4,35.0,0.258,597,4670,3,6,20,1,Midwest,696 +2023Q4,22.2,0.027,766,7220,0,1,10,0,South,208 +2023Q4,17.8,0.138,701,5980,4,4,5,0,South,762 +2023Q4,123.7,0.185,697,7250,2,4,4,1,Northeast,470 +2023Q4,59.1,0.125,651,5750,2,3,15,0,Northeast,285 +2023Q4,21.9,0.485,717,6640,1,2,1,0,West,577 +2023Q4,31.7,0.491,689,5960,0,7,15,0,South,403 +2023Q4,49.3,0.205,703,6620,2,6,17,0,Northeast,521 +2023Q4,31.0,0.322,663,5800,3,7,3,0,South,620 +2023Q4,92.2,0.094,667,6480,2,6,12,0,West,0 +2023Q4,91.6,0.17,577,4940,0,4,1,0,South,0 +2023Q4,14.7,0.206,672,5700,4,4,29,1,South,1062 +2023Q4,98.7,0.188,718,7210,2,5,24,0,Northeast,286 +2023Q4,62.9,0.164,655,5850,3,2,11,0,Northeast,142 +2023Q4,23.2,0.143,517,3440,2,6,27,0,Northeast,181 +2023Q4,33.4,0.285,732,6830,1,5,6,0,Northeast,624 +2023Q4,16.6,0.49,639,5240,1,7,5,0,Northeast,515 +2023Q4,38.5,0.386,714,6560,1,7,0,0,West,416 +2023Q4,23.4,0.262,689,5980,2,3,0,0,Northeast,607 +2023Q4,28.1,0.309,528,3450,0,7,15,0,West,0 +2023Q4,30.6,0.397,719,6520,0,5,28,0,South,653 +2023Q4,25.7,0.362,500,3300,3,4,3,0,Northeast,498 +2023Q4,58.6,0.26,624,5300,0,6,2,0,South,0 +2023Q4,13.6,0.347,519,3360,2,3,15,0,Northeast,285 +2023Q4,62.4,0.313,599,5030,0,5,26,0,Northeast,0 +2023Q4,42.7,0.314,519,3780,2,3,11,1,Northeast,435 +2023Q4,28.7,0.268,582,4600,4,4,29,0,Midwest,453 +2023Q4,18.0,0.47,539,3750,1,7,25,0,South,228 +2023Q4,10.0,0.253,658,5360,1,7,16,0,West,357 +2023Q4,87.8,0.213,666,6510,0,6,8,0,South,0 +2023Q4,15.6,0.322,632,5170,3,6,10,0,West,620 +2023Q4,55.2,0.384,600,4740,2,2,2,0,West,0 +2023Q4,21.6,0.454,608,4850,2,3,19,0,South,418 +2023Q4,46.2,0.473,505,3470,0,1,12,0,West,0 +2023Q4,16.1,0.412,692,5890,2,3,13,0,Midwest,623 +2023Q4,51.7,0.26,748,7170,2,3,3,0,South,645 +2023Q4,18.5,0.068,663,5640,3,7,0,0,Midwest,543 +2023Q4,61.6,0.221,633,5670,0,7,17,0,West,0 +2023Q4,49.3,0.319,820,8250,1,3,5,0,West,829 +2023Q4,10.0,0.233,700,6150,2,5,5,0,South,860 +2023Q4,20.4,0.151,598,4490,1,3,12,0,Northeast,203 +2023Q4,194.6,0.05,686,7680,2,7,8,0,South,0 +2023Q4,63.1,0.326,611,5450,1,1,24,0,Northeast,8 +2023Q4,95.7,0.052,737,7330,5,2,8,0,South,175 +2023Q4,28.6,0.422,680,5910,0,5,11,0,Midwest,380 +2023Q4,18.0,0.223,797,7740,0,4,12,0,Northeast,625 +2023Q4,17.5,0.34,624,4920,1,3,15,0,Northeast,223 +2023Q4,31.2,0.287,715,6620,0,7,27,0,West,578 +2023Q4,25.5,0.373,644,5360,1,5,8,0,South,368 +2023Q4,43.1,0.35,662,5950,3,4,20,0,Midwest,549 +2023Q4,18.7,0.361,598,4790,2,1,5,0,Northeast,342 +2023Q4,51.5,0.156,616,5260,5,2,29,0,Northeast,359 +2023Q4,15.5,0.28,736,6690,1,7,9,0,West,993 +2023Q4,21.7,0.249,708,6300,1,3,19,1,South,952 +2023Q4,50.5,0.342,497,3380,1,2,19,0,South,0 +2023Q4,83.7,0.333,736,7370,0,1,30,1,South,511 +2023Q4,82.9,0.356,670,6270,2,1,30,0,Northeast,115 +2023Q4,44.7,0.352,620,5360,0,1,13,0,South,0 +2023Q4,55.7,0.254,555,4410,2,4,5,0,South,0 +2023Q4,16.2,0.458,797,7540,2,3,3,0,South,1172 +2023Q4,66.0,0.047,632,5530,3,5,4,0,South,70 +2023Q4,54.3,0.215,615,5480,1,4,13,0,West,0 +2023Q4,32.9,0.291,688,6350,0,4,7,0,Midwest,448 +2023Q4,69.3,0.061,722,6990,5,1,26,0,Midwest,389 +2023Q4,46.8,0.284,467,3070,2,1,30,0,South,0 +2023Q4,39.9,0.254,538,3940,4,4,2,0,West,375 +2023Q4,34.8,0.361,630,5300,2,5,8,0,Midwest,528 +2023Q4,36.6,0.305,592,4850,1,1,14,0,West,86 +2023Q4,45.6,0.126,619,5150,0,1,7,0,Northeast,0 +2023Q4,10.6,0.384,546,3700,1,1,13,0,West,230 +2023Q4,57.3,0.181,675,6350,2,3,20,0,West,339 +2023Q4,83.2,0.172,752,7400,2,4,28,0,Northeast,461 +2023Q4,18.5,0.221,732,6690,0,1,0,0,Midwest,446 +2023Q4,19.7,0.34,634,5250,1,4,13,0,South,375 +2023Q4,37.5,0.235,498,3260,1,6,17,0,West,175 +2023Q4,90.9,0.054,507,4210,0,3,2,0,West,0 +2023Q4,36.8,0.239,488,3320,2,1,26,0,Midwest,0 +2023Q4,28.4,0.237,565,4580,3,4,29,0,Northeast,450 +2023Q4,23.0,0.363,686,6150,0,5,0,0,Northeast,389 +2023Q4,174.2,0.048,699,7980,3,3,15,0,South,0 +2023Q4,88.5,0.306,646,5940,0,6,27,0,Midwest,0 +2023Q4,30.4,0.106,615,5120,0,5,13,0,Northeast,73 +2023Q4,151.4,0.0,596,6030,1,1,22,0,South,0 +2023Q4,47.3,0.338,679,6360,2,3,28,0,Northeast,341 +2023Q4,15.6,0.261,604,4770,1,6,4,0,Midwest,405 +2023Q4,21.8,0.341,675,6020,1,7,8,0,Midwest,639 +2023Q4,39.0,0.315,596,4910,1,7,30,0,West,60 +2023Q4,14.0,0.36,626,5210,2,1,30,0,Midwest,215 +2023Q4,17.4,0.402,438,2140,1,1,0,0,South,0 +2023Q4,35.1,0.34,698,6270,2,7,13,0,South,423 +2023Q4,48.8,0.025,589,4700,1,2,16,0,Northeast,0 +2023Q4,32.5,0.384,778,7350,0,1,26,0,West,333 +2023Q4,89.5,0.063,617,5590,1,3,14,1,West,29 +2023Q4,29.0,0.334,618,5140,3,7,21,0,Northeast,389 +2023Q4,34.2,0.353,626,5230,2,6,26,0,South,418 +2023Q4,35.8,0.31,622,5140,0,5,23,0,South,0 +2023Q4,21.1,0.309,702,6180,2,1,14,0,Northeast,897 +2023Q4,49.3,0.14,770,7500,2,4,21,0,South,846 +2023Q4,32.8,0.489,584,4570,1,6,7,0,West,139 +2023Q4,72.4,0.216,674,6400,3,5,9,0,West,273 +2023Q4,16.8,0.316,599,4730,0,1,26,0,Northeast,0 +2023Q4,29.9,0.317,743,6730,2,6,9,0,Midwest,884 +2023Q4,14.5,0.462,496,3170,2,4,26,0,Northeast,277 +2023Q4,17.8,0.484,500,3260,1,6,28,0,West,177 +2023Q4,30.5,0.185,663,5790,0,1,22,0,Northeast,139 +2023Q4,23.2,0.291,632,5240,2,7,12,1,Midwest,550 +2023Q4,71.8,0.455,625,5740,1,3,1,0,Northeast,138 +2023Q4,86.7,0.236,736,7410,2,1,5,0,Northeast,150 +2023Q4,25.3,0.281,666,5700,2,3,23,0,Northeast,539 +2023Q4,35.7,0.251,692,6140,1,7,13,0,Midwest,508 +2023Q4,41.2,0.375,598,4830,0,4,20,0,Midwest,0 +2023Q4,42.2,0.321,716,6710,2,2,1,0,West,459 +2023Q4,32.3,0.283,561,4250,3,4,24,0,West,297 +2023Q4,102.8,0.092,560,4880,1,4,1,0,Northeast,0 +2023Q4,30.7,0.261,752,6950,1,3,30,0,South,517 +2023Q4,57.0,0.346,649,5880,0,3,15,0,West,0 +2023Q4,27.0,0.393,650,5620,3,1,26,0,South,613 +2023Q4,48.0,0.31,779,7700,1,3,20,0,South,665 +2023Q4,70.0,0.118,758,7340,2,1,14,0,South,443 +2023Q4,35.3,0.43,635,5400,0,5,26,0,Northeast,186 +2023Q4,81.6,0.196,699,6910,1,7,18,0,Midwest,132 +2023Q4,33.7,0.307,724,6610,0,4,6,0,Midwest,451 +2023Q4,44.5,0.38,657,5670,3,5,13,0,Midwest,424 +2023Q4,42.5,0.314,733,6980,0,2,18,0,Northeast,345 +2023Q4,75.7,0.437,658,6140,0,6,29,0,South,91 +2023Q4,21.4,0.386,685,6160,2,1,0,0,Northeast,459 +2023Q4,24.3,0.332,636,5430,1,4,16,0,West,538 +2023Q4,23.2,0.306,845,8410,1,1,29,0,Northeast,988 +2023Q4,25.5,0.397,628,5270,0,6,30,0,Midwest,188 +2023Q4,24.9,0.373,683,5930,1,1,20,0,Northeast,504 +2023Q4,19.0,0.311,641,5470,2,5,24,0,West,463 +2023Q4,31.7,0.362,711,6480,4,2,7,0,West,659 +2023Q4,64.4,0.041,662,6030,2,1,25,0,West,61 +2023Q4,47.0,0.39,647,5820,2,4,0,0,Midwest,431 +2023Q4,119.8,0.302,610,5780,1,3,3,0,Northeast,0 +2023Q4,24.7,0.556,677,5650,1,3,16,0,West,499 +2023Q4,17.7,0.498,746,6890,1,3,24,0,Northeast,803 +2023Q4,22.9,0.27,662,5620,1,4,12,0,West,400 +2023Q4,27.0,0.431,634,5150,1,1,0,0,Midwest,209 +2023Q4,35.1,0.313,739,6940,1,2,7,0,Midwest,569 +2023Q4,41.0,0.297,738,7130,1,7,28,1,Midwest,1021 +2023Q4,25.7,0.319,518,3710,2,1,12,0,Midwest,224 +2023Q4,156.6,0.064,646,6680,2,2,23,0,West,0 +2023Q4,86.4,0.178,612,5620,1,1,11,0,Midwest,0 +2023Q4,34.6,0.209,582,4560,0,6,12,0,West,0 +2023Q4,23.2,0.246,631,5060,2,2,21,0,Midwest,446 +2023Q4,28.3,0.248,773,7200,4,7,30,0,Northeast,989 +2023Q4,66.5,0.227,714,6940,1,2,18,0,Midwest,105 +2023Q4,47.2,0.286,748,7030,1,6,3,0,West,737 +2023Q4,21.0,0.455,673,5850,1,5,22,0,Northeast,353 +2023Q4,26.2,0.318,581,4670,2,6,26,0,Midwest,143 +2023Q4,21.1,0.222,691,6160,2,4,29,1,South,953 +2023Q4,53.9,0.199,582,4820,0,5,28,0,West,0 +2023Q4,103.9,0.14,532,4680,1,3,25,0,Midwest,0 +2023Q4,85.8,0.195,542,4430,4,2,23,0,West,0 +2023Q4,29.4,0.48,689,6140,1,4,14,0,Midwest,560 +2023Q4,47.7,0.184,587,4860,2,6,20,0,Northeast,29 +2023Q4,22.4,0.408,627,5120,2,6,17,0,Northeast,326 +2023Q4,50.5,0.352,809,8240,1,5,29,0,West,738 +2023Q4,56.8,0.231,610,5120,1,5,22,0,West,99 +2023Q4,10.8,0.199,700,5950,0,5,7,0,Northeast,367 +2023Q4,31.6,0.368,613,4990,1,7,22,0,Midwest,133 +2023Q4,27.6,0.39,723,6530,1,4,27,0,West,661 +2023Q4,24.1,0.241,663,5620,2,6,2,0,Northeast,484 +2023Q4,23.6,0.291,585,4450,1,1,17,0,Northeast,146 +2023Q4,149.7,0.0,716,7610,1,1,12,0,Northeast,0 +2023Q4,43.4,0.441,690,6240,2,4,15,0,Northeast,767 +2023Q4,29.2,0.273,593,4780,0,4,25,0,South,88 +2023Q4,25.0,0.267,618,4850,2,2,9,0,Midwest,365 +2023Q4,30.0,0.437,710,6520,1,7,27,0,West,901 +2023Q4,23.5,0.244,701,6190,0,2,29,0,West,418 +2023Q4,40.2,0.07,504,3530,2,4,10,0,Northeast,87 +2023Q4,18.4,0.375,720,6510,4,4,1,1,Midwest,1295 +2023Q4,29.4,0.305,601,4940,0,3,17,0,West,132 +2023Q4,59.0,0.38,604,5340,2,4,13,0,West,444 +2023Q4,64.4,0.352,652,6010,0,7,29,0,South,84 +2023Q4,11.5,0.497,599,4510,1,4,2,0,South,232 +2023Q4,58.1,0.187,767,7450,1,1,4,1,Northeast,907 +2023Q4,25.8,0.347,557,3870,1,2,18,0,West,63 +2023Q4,77.4,0.197,646,5900,0,6,30,0,South,0 +2023Q4,20.6,0.457,721,6390,0,1,27,0,Midwest,498 +2023Q4,32.3,0.322,674,5850,1,3,6,1,Midwest,831 +2023Q4,41.9,0.337,791,7940,4,5,20,0,South,934 +2023Q4,65.0,0.329,658,6240,2,4,23,0,Midwest,390 +2023Q4,65.9,0.367,718,6970,3,6,4,0,Midwest,823 +2023Q4,24.5,0.538,618,4990,0,2,6,0,Northeast,54 +2023Q4,24.6,0.396,667,5680,5,6,14,0,West,813 +2023Q4,75.9,0.191,640,6150,1,4,27,0,West,0 +2023Q4,27.3,0.357,706,6560,1,3,20,0,Northeast,533 +2023Q4,112.8,0.173,786,8560,2,5,7,0,Northeast,334 +2023Q4,58.0,0.169,675,6170,1,1,5,0,Midwest,186 +2023Q4,20.4,0.412,639,5530,2,5,29,1,South,1109 +2023Q4,67.8,0.142,760,7430,1,1,26,0,South,301 +2023Q4,47.5,0.119,716,6570,3,1,8,0,Midwest,490 +2023Q4,36.4,0.283,619,5250,2,1,28,0,West,226 +2023Q4,31.4,0.086,599,4780,1,7,28,0,Midwest,0 +2023Q4,63.1,0.414,733,7130,0,2,15,0,South,241 +2023Q4,24.1,0.415,492,3140,1,3,25,1,West,396 +2023Q4,25.1,0.407,643,5340,0,4,7,0,Northeast,19 +2023Q4,27.3,0.28,615,4920,0,6,2,0,Midwest,56 +2023Q4,29.3,0.326,605,4990,1,3,18,0,Northeast,119 +2023Q4,44.6,0.164,583,4860,2,2,26,0,South,67 +2023Q4,47.4,0.269,789,7810,1,1,9,0,West,565 +2023Q4,62.4,0.176,608,5080,0,6,25,0,West,0 +2023Q4,140.0,0.03,747,8090,2,7,20,0,South,267 +2023Q4,60.5,0.39,638,5840,2,4,9,0,West,287 +2023Q4,30.5,0.391,663,5780,0,6,13,0,South,222 +2023Q4,44.8,0.227,667,6020,1,3,13,0,South,266 +2023Q4,39.5,0.531,599,5020,1,6,19,0,South,370 +2023Q4,18.6,0.149,677,5780,0,7,28,0,South,608 +2023Q4,48.5,0.457,693,6440,0,6,22,1,West,625 +2023Q4,19.7,0.296,531,3600,1,5,13,0,South,72 +2023Q4,45.1,0.127,658,5940,4,6,13,0,Northeast,601 +2023Q4,33.4,0.268,495,3390,0,1,3,0,West,0 +2023Q4,15.9,0.408,599,4530,2,4,17,0,Northeast,511 +2023Q4,62.7,0.374,518,3940,2,6,21,0,Midwest,216 +2023Q4,54.7,0.276,624,5430,3,2,23,0,South,440 +2023Q4,76.0,0.175,598,4970,1,3,16,0,Northeast,0 +2023Q4,24.7,0.509,552,3760,1,2,12,0,West,77 +2023Q4,36.9,0.23,594,4930,1,6,0,0,Northeast,52 +2023Q4,54.3,0.228,692,6430,2,2,22,1,South,864 +2023Q4,152.0,0.1,776,8840,1,7,23,0,South,94 +2023Q4,40.6,0.326,649,5570,0,4,4,0,Midwest,189 +2023Q4,30.5,0.22,766,7180,0,5,29,0,Northeast,686 +2023Q4,24.8,0.48,616,5000,1,1,2,0,Northeast,101 +2023Q4,91.1,0.249,587,5220,1,5,7,0,Northeast,0 +2023Q4,80.6,0.214,817,8430,0,5,5,0,South,652 +2023Q4,34.3,0.236,635,5220,2,7,14,0,West,291 +2023Q4,49.1,0.174,741,7030,1,1,8,0,South,403 +2023Q4,103.7,0.187,639,5830,2,6,2,0,Northeast,0 +2023Q4,56.6,0.448,647,5920,3,2,0,0,Northeast,519 +2023Q4,26.2,0.294,797,7740,1,7,22,0,Midwest,1084 +2023Q4,34.0,0.487,789,7630,5,2,5,0,Midwest,1119 +2023Q4,46.9,0.374,639,5660,0,7,17,0,Northeast,204 +2023Q4,128.7,0.049,747,8080,3,1,13,0,Northeast,7 +2023Q4,15.2,0.372,732,6450,2,6,13,0,West,823 +2023Q4,54.0,0.372,687,6390,0,2,9,0,West,219 +2023Q4,41.6,0.28,717,6640,0,3,23,0,Midwest,245 +2023Q4,132.5,0.068,535,4870,0,2,11,0,Northeast,0 +2023Q4,63.1,0.345,751,7510,1,6,18,0,Northeast,798 +2023Q4,38.1,0.256,719,6650,3,7,25,0,South,628 +2023Q4,38.9,0.187,701,6270,0,5,30,1,Northeast,720 +2023Q4,37.6,0.177,630,5270,2,3,23,0,South,219 +2023Q4,52.0,0.463,517,3890,0,1,23,0,South,0 +2023Q4,59.2,0.41,548,4610,0,7,4,1,Midwest,454 +2023Q4,22.6,0.349,808,7820,1,6,2,0,Northeast,1113 +2023Q4,28.7,0.326,654,5730,1,6,10,0,South,200 +2023Q4,49.3,0.236,782,7640,2,4,5,0,West,774 +2023Q4,44.8,0.069,635,5380,0,1,20,0,South,0 +2023Q4,31.2,0.016,727,6800,1,6,17,0,West,432 +2023Q4,12.4,0.342,705,6150,0,7,12,0,Northeast,552 +2023Q4,23.8,0.353,675,5780,1,2,4,0,South,407 +2023Q4,31.9,0.244,774,7170,1,2,12,0,South,527 +2023Q4,30.6,0.16,669,5850,0,3,16,0,South,116 +2023Q4,77.4,0.234,731,7150,1,2,28,0,Midwest,305 +2023Q4,30.5,0.215,850,8620,0,4,0,0,Northeast,835 +2023Q4,28.9,0.329,654,5510,2,5,22,0,Northeast,262 +2023Q4,64.8,0.125,610,5190,1,6,23,0,Northeast,0 +2023Q4,63.1,0.145,577,4790,3,4,25,0,Midwest,379 +2023Q4,18.6,0.352,588,4460,3,4,10,0,South,463 +2023Q4,74.8,0.243,445,2820,3,7,16,0,South,0 +2023Q4,67.0,0.262,544,4330,1,5,2,0,Northeast,0 +2023Q4,27.3,0.328,644,5460,1,1,6,0,South,173 +2023Q4,19.0,0.278,660,5680,3,7,8,0,South,980 +2023Q4,61.9,0.222,550,4480,3,1,7,1,West,628 +2023Q4,35.0,0.197,579,4460,1,2,24,0,South,288 +2023Q4,26.7,0.348,615,4930,4,3,2,0,Northeast,320 +2023Q4,32.8,0.466,600,4780,1,3,19,0,West,269 +2023Q4,17.3,0.373,717,6160,2,3,21,0,South,748 +2023Q4,45.3,0.222,607,5160,3,1,23,0,Northeast,15 +2023Q4,63.3,0.127,707,6860,1,6,12,0,Northeast,545 +2023Q4,45.6,0.337,647,5640,0,3,19,0,West,152 +2023Q4,49.6,0.441,652,5750,2,3,14,0,Northeast,375 +2023Q4,32.0,0.311,562,4340,1,3,14,1,South,675 +2023Q4,50.0,0.289,620,5240,2,3,1,0,Northeast,278 +2023Q4,72.2,0.23,747,7500,2,1,7,0,South,526 +2023Q4,17.8,0.235,519,3490,3,3,21,0,Midwest,314 +2023Q4,18.0,0.397,605,4830,0,1,21,0,Midwest,0 +2023Q4,43.6,0.033,711,6560,4,3,25,0,West,475 +2023Q4,34.9,0.268,635,5480,2,1,16,0,Midwest,284 +2023Q4,22.8,0.33,604,4530,1,4,26,1,South,678 +2023Q4,67.9,0.091,676,6150,3,7,6,0,South,232 +2023Q4,48.0,0.109,850,8890,2,5,11,0,West,1219 +2023Q4,28.9,0.286,619,5060,1,4,25,0,South,105 +2023Q4,86.9,0.314,493,3610,2,4,22,0,South,0 +2023Q4,22.6,0.351,691,6000,2,1,29,0,Midwest,496 +2023Q4,12.2,0.365,650,5410,2,2,2,0,Northeast,558 +2023Q4,18.3,0.599,763,7100,2,3,19,0,West,1019 +2023Q4,28.6,0.284,778,7530,1,1,20,0,Midwest,440 +2023Q4,43.1,0.36,599,4820,0,1,8,0,Midwest,0 +2023Q4,54.8,0.293,652,6040,1,3,25,0,Northeast,492 +2023Q4,26.9,0.444,816,7990,4,5,17,0,South,1335 +2023Q4,59.0,0.164,612,5090,1,2,14,0,South,0 +2023Q4,54.0,0.155,559,4350,2,2,5,0,West,189 +2023Q4,25.5,0.374,712,6450,0,1,22,0,Midwest,380 +2023Q4,115.3,0.074,694,7190,3,6,12,0,West,103 +2023Q4,102.5,0.218,803,8730,3,1,4,0,Midwest,427 +2023Q4,37.2,0.299,712,6440,2,1,8,0,Northeast,535 +2023Q4,65.7,0.354,714,6900,3,4,3,0,Midwest,650 +2023Q4,33.7,0.28,599,4680,1,3,30,0,South,57 +2023Q4,37.8,0.197,729,6790,5,4,28,0,Northeast,922 +2023Q4,30.6,0.146,697,6160,1,1,6,0,Northeast,235 +2023Q4,48.9,0.305,639,5430,1,1,9,0,Northeast,125 +2023Q4,17.9,0.135,757,6890,1,1,9,0,Midwest,559 +2023Q4,54.0,0.365,708,6760,3,1,0,1,South,1029 +2023Q4,42.1,0.158,730,6810,0,1,1,0,Northeast,217 +2023Q4,67.3,0.183,659,6000,1,4,11,0,South,17 +2023Q4,77.5,0.219,821,8680,0,1,3,0,Midwest,417 +2023Q4,46.9,0.257,594,4870,1,5,23,0,West,103 +2023Q4,47.6,0.392,452,2950,2,3,6,0,West,0 +2023Q4,27.8,0.259,685,5940,0,1,23,0,West,305 +2023Q4,16.8,0.447,595,4760,2,3,27,0,West,501 +2023Q4,48.2,0.246,669,5980,2,3,11,0,South,464 +2023Q4,74.0,0.267,726,7120,1,6,26,0,South,317 +2023Q4,61.9,0.335,622,5250,2,5,3,0,South,0 +2023Q4,33.6,0.258,533,3800,2,4,8,0,West,153 +2023Q4,22.5,0.284,757,7100,2,6,12,1,Midwest,1450 +2023Q4,37.8,0.24,553,4220,1,2,18,0,Midwest,264 +2023Q4,54.7,0.441,615,5040,0,2,4,0,South,0 +2023Q4,34.1,0.449,651,5720,2,4,27,0,Midwest,568 +2023Q4,61.0,0.234,850,8770,1,2,8,0,West,798 +2023Q4,51.8,0.328,723,6670,2,2,21,0,Northeast,510 +2023Q4,19.8,0.312,527,3580,0,5,11,0,South,0 +2023Q4,25.3,0.551,670,5590,2,1,6,0,Midwest,699 +2023Q4,34.6,0.195,745,6770,3,5,27,0,West,619 +2023Q4,124.9,0.032,810,9140,0,6,13,0,West,228 +2023Q4,49.5,0.186,654,6000,2,2,26,0,Northeast,311 +2023Q4,74.3,0.324,695,6780,3,2,29,0,Midwest,379 +2023Q4,26.9,0.272,639,5410,0,6,13,0,Midwest,303 +2023Q4,21.0,0.492,556,4170,1,6,7,0,Northeast,604 +2023Q4,35.5,0.385,511,3400,0,3,29,1,Midwest,234 +2023Q4,51.5,0.354,598,5130,3,5,29,0,West,286 +2023Q4,50.2,0.09,567,4540,3,3,8,0,South,53 +2023Q4,35.8,0.212,665,5600,1,1,12,0,Midwest,283 +2023Q4,85.5,0.327,690,6550,2,1,29,0,South,274 +2023Q4,54.6,0.444,641,5820,1,7,30,0,Northeast,191 +2023Q4,26.9,0.316,621,5270,1,7,30,1,West,887 +2023Q4,173.9,0.124,754,8600,3,1,24,0,South,44 +2023Q4,62.9,0.19,568,4780,0,5,25,0,West,5 +2023Q4,36.0,0.391,622,5180,0,7,28,0,Northeast,43 +2023Q4,45.0,0.192,649,5740,1,3,19,0,Midwest,203 +2023Q4,43.1,0.248,673,6020,0,2,28,0,West,121 +2023Q4,13.3,0.25,690,5930,1,2,7,0,Midwest,496 +2023Q4,28.6,0.476,551,4100,1,4,9,0,South,402 +2023Q4,56.5,0.245,707,6660,3,7,26,0,Midwest,577 +2023Q4,49.8,0.112,811,8430,4,1,23,0,South,932 +2023Q4,58.3,0.057,727,7100,4,7,21,0,Northeast,658 +2023Q4,47.8,0.385,621,5470,1,1,17,0,South,140 +2023Q4,147.5,0.048,617,6220,4,3,24,0,Midwest,0 +2023Q4,34.2,0.215,614,5090,1,6,14,0,South,117 +2023Q4,34.2,0.308,695,6470,1,5,4,0,South,419 +2023Q4,34.0,0.336,591,4800,2,1,4,0,South,341 +2023Q4,30.6,0.321,850,8530,3,4,1,0,West,1355 +2023Q4,200.0,0.024,719,8390,1,3,19,0,South,0 +2024Q1,35.4,0.47,690,6280,2,7,25,0,West,709 +2024Q1,40.1,0.251,599,5040,1,4,25,0,South,54 +2024Q1,86.6,0.28,700,6760,2,1,22,0,South,0 +2024Q1,30.1,0.234,697,6330,0,6,18,0,West,367 +2024Q1,123.8,0.218,607,5880,1,3,2,0,Midwest,0 +2024Q1,51.4,0.163,825,8440,2,2,30,0,South,961 +2024Q1,32.5,0.161,590,4720,3,2,13,0,South,181 +2024Q1,53.6,0.258,642,5780,1,4,18,0,South,154 +2024Q1,46.0,0.246,580,4680,1,1,23,0,Northeast,0 +2024Q1,61.7,0.226,819,8450,1,6,13,1,Northeast,1280 +2024Q1,28.1,0.262,704,6380,0,7,28,0,Northeast,692 +2024Q1,32.6,0.331,544,3970,4,1,7,0,West,448 +2024Q1,138.8,0.0,637,6510,0,6,18,0,Northeast,0 +2024Q1,53.2,0.38,435,2610,2,5,14,1,Northeast,173 +2024Q1,28.3,0.35,564,4340,0,4,14,0,Midwest,324 +2024Q1,33.6,0.461,754,7140,2,3,9,0,Northeast,1000 +2024Q1,54.4,0.197,615,5390,2,3,25,0,Midwest,276 +2024Q1,25.7,0.359,695,6150,2,3,23,0,Midwest,479 +2024Q1,62.1,0.369,726,7200,1,7,13,0,South,921 +2024Q1,48.1,0.259,634,5630,1,2,6,0,West,0 +2024Q1,15.0,0.465,530,3480,0,5,11,0,Northeast,0 +2024Q1,25.4,0.476,560,4340,2,5,13,0,Northeast,601 +2024Q1,40.3,0.482,652,5730,1,5,27,0,Midwest,436 +2024Q1,31.9,0.165,624,5260,2,6,13,0,South,297 +2024Q1,42.5,0.28,708,6600,3,6,0,0,Northeast,559 +2024Q1,20.3,0.267,726,6470,1,2,11,1,West,1129 +2024Q1,26.0,0.481,552,4160,0,3,25,0,Northeast,90 +2024Q1,69.9,0.183,655,5840,1,6,18,1,Northeast,320 +2024Q1,30.2,0.411,672,6050,2,7,14,0,Midwest,587 +2024Q1,21.3,0.423,637,5250,0,6,25,0,Northeast,512 +2024Q1,26.8,0.428,656,5710,0,2,3,1,Northeast,719 +2024Q1,39.6,0.372,743,7030,1,7,25,0,Midwest,748 +2024Q1,42.7,0.259,850,8750,0,3,4,0,West,747 +2024Q1,37.5,0.372,586,4760,0,1,16,0,Midwest,0 +2024Q1,30.8,0.322,632,5450,2,2,19,0,South,338 +2024Q1,55.8,0.192,694,6470,2,7,28,0,Midwest,550 +2024Q1,54.8,0.445,617,5230,1,3,17,0,West,48 +2024Q1,17.6,0.468,521,3460,0,2,16,0,West,0 +2024Q1,166.9,0.056,710,7970,1,7,8,0,South,0 +2024Q1,75.1,0.246,539,4570,2,4,27,0,Northeast,279 +2024Q1,40.1,0.398,551,4300,0,7,16,0,Northeast,106 +2024Q1,47.7,0.084,633,5360,2,7,17,0,Midwest,422 +2024Q1,44.5,0.313,663,5820,1,4,17,0,Midwest,290 +2024Q1,81.7,0.193,617,5540,2,6,13,0,West,13 +2024Q1,34.7,0.426,655,5770,1,7,27,1,South,1124 +2024Q1,28.5,0.356,551,3960,1,5,20,0,Midwest,375 +2024Q1,40.1,0.499,695,6390,1,7,12,0,South,631 +2024Q1,16.5,0.272,611,4880,3,1,11,0,Midwest,197 +2024Q1,10.0,0.342,557,3860,1,7,26,0,Northeast,508 +2024Q1,38.7,0.399,695,6300,1,1,25,0,Northeast,516 +2024Q1,65.8,0.438,552,4580,1,5,6,0,Northeast,235 +2024Q1,12.5,0.262,675,5720,1,7,17,1,Northeast,1169 +2024Q1,22.4,0.313,650,5620,1,7,15,0,Midwest,471 +2024Q1,25.9,0.183,737,6680,2,3,9,0,South,596 +2024Q1,51.7,0.177,698,6340,3,4,10,0,Northeast,266 +2024Q1,73.5,0.314,623,5520,3,6,27,1,Midwest,836 +2024Q1,51.0,0.396,724,6870,2,6,15,0,Midwest,810 +2024Q1,36.3,0.286,632,5320,1,4,9,0,West,103 +2024Q1,46.5,0.156,646,5540,1,5,17,0,West,175 +2024Q1,66.9,0.269,599,5200,2,4,16,0,South,0 +2024Q1,54.1,0.16,690,6470,1,7,30,0,West,384 +2024Q1,29.7,0.363,488,3190,2,2,11,0,South,106 +2024Q1,14.7,0.395,590,4400,1,7,2,0,South,354 +2024Q1,121.9,0.212,663,6530,0,4,6,0,Midwest,0 +2024Q1,11.3,0.481,669,5690,3,7,22,0,Northeast,799 +2024Q1,111.5,0.317,701,7180,1,5,2,0,South,301 +2024Q1,40.1,0.184,665,5750,0,3,18,0,South,18 +2024Q1,58.3,0.205,815,8360,0,2,1,0,Northeast,572 +2024Q1,53.4,0.23,514,3710,1,4,7,0,Midwest,0 +2024Q1,49.0,0.234,713,6650,1,5,8,0,South,313 +2024Q1,55.3,0.083,782,7610,2,5,3,1,South,1215 +2024Q1,26.1,0.324,486,2800,0,3,10,1,South,301 +2024Q1,52.0,0.408,652,5630,0,1,0,0,South,0 +2024Q1,14.1,0.357,597,4700,1,1,29,0,South,231 +2024Q1,74.5,0.255,604,5330,1,3,17,0,South,0 +2024Q1,14.0,0.369,652,5260,3,7,26,0,Midwest,743 +2024Q1,25.7,0.415,562,4220,3,3,26,0,West,639 +2024Q1,57.6,0.202,617,5360,3,5,17,0,Midwest,133 +2024Q1,110.1,0.128,490,4050,0,6,15,0,Northeast,0 +2024Q1,56.5,0.263,700,6670,1,1,24,0,West,199 +2024Q1,11.1,0.286,595,4450,2,2,15,0,Midwest,105 +2024Q1,47.1,0.249,663,5820,2,1,20,0,Midwest,378 +2024Q1,28.3,0.33,581,4270,0,5,3,0,Northeast,0 +2024Q1,41.4,0.307,672,5810,2,3,12,0,Midwest,486 +2024Q1,119.1,0.291,594,5680,3,1,23,0,Midwest,0 +2024Q1,51.4,0.343,499,3440,0,2,11,0,South,0 +2024Q1,34.4,0.336,700,6250,1,1,12,1,South,849 +2024Q1,15.7,0.287,540,3650,0,4,21,1,West,582 +2024Q1,18.0,0.478,504,3460,3,7,17,0,Midwest,532 +2024Q1,36.5,0.394,641,5500,0,4,28,0,South,126 +2024Q1,55.4,0.338,540,4060,2,4,12,0,West,305 +2024Q1,43.3,0.083,758,7300,2,1,16,1,West,915 +2024Q1,23.3,0.307,570,4120,2,1,30,0,Northeast,232 +2024Q1,65.5,0.165,785,8100,0,4,12,0,West,437 +2024Q1,21.6,0.44,731,6660,1,2,10,0,Northeast,721 +2024Q1,36.1,0.254,678,5990,1,7,14,0,Midwest,347 +2024Q1,88.0,0.142,587,5220,1,5,11,0,West,0 +2024Q1,23.1,0.281,664,5740,3,5,17,0,West,749 +2024Q1,34.7,0.578,687,6040,1,1,14,0,West,506 +2024Q1,50.6,0.331,658,5940,2,2,29,0,South,348 +2024Q1,11.5,0.294,544,3850,0,4,12,0,Midwest,212 +2024Q1,43.3,0.2,709,6680,3,5,30,0,Northeast,729 +2024Q1,44.5,0.446,551,4210,0,2,5,0,West,0 +2024Q1,13.7,0.31,825,8230,1,7,9,0,Northeast,1400 +2024Q1,46.3,0.284,725,6830,1,2,12,0,West,313 +2024Q1,14.1,0.302,698,6070,1,5,14,0,West,569 +2024Q1,96.7,0.432,593,5350,0,1,7,0,Northeast,0 +2024Q1,42.5,0.337,452,2690,1,5,10,0,Midwest,0 +2024Q1,19.0,0.399,726,6620,1,7,30,0,Midwest,820 +2024Q1,12.0,0.345,507,3040,2,6,17,0,Midwest,147 +2024Q1,121.3,0.015,661,6640,1,5,30,0,Northeast,0 +2024Q1,41.5,0.321,769,7470,0,7,28,0,South,722 +2024Q1,113.8,0.184,736,7770,3,2,17,0,West,154 +2024Q1,14.7,0.345,569,4170,0,1,11,0,Midwest,237 +2024Q1,47.9,0.305,703,6340,2,1,21,0,South,393 +2024Q1,54.5,0.256,682,6190,3,7,19,0,Midwest,686 +2024Q1,33.4,0.148,786,7480,0,5,17,1,West,1084 +2024Q1,18.4,0.184,673,5920,1,2,20,0,South,422 +2024Q1,42.1,0.297,764,7460,0,6,20,1,Northeast,1007 +2024Q1,33.4,0.255,607,5030,0,1,4,0,West,0 +2024Q1,84.6,0.17,609,5680,2,4,19,0,Northeast,0 +2024Q1,31.1,0.268,677,5910,3,5,5,0,Northeast,806 +2024Q1,38.7,0.275,397,1910,1,1,5,0,Midwest,0 +2024Q1,43.1,0.487,743,7230,1,7,24,0,South,763 +2024Q1,98.1,0.068,564,5040,0,4,15,0,West,0 +2024Q1,58.4,0.238,652,5860,2,4,29,0,Midwest,207 +2024Q1,60.8,0.365,548,4160,0,7,16,0,South,34 +2024Q1,18.6,0.615,501,3240,3,4,30,0,Midwest,346 +2024Q1,137.3,0.097,672,6990,0,5,30,0,Northeast,0 +2024Q1,17.7,0.403,584,4510,1,3,29,0,Midwest,89 +2024Q1,43.0,0.232,715,6730,1,2,30,0,West,474 +2024Q1,17.9,0.28,787,7310,0,5,0,0,Northeast,600 +2024Q1,74.8,0.269,456,3060,2,7,20,0,Midwest,0 +2024Q1,24.1,0.253,719,6520,1,6,3,1,South,1185 +2024Q1,16.0,0.437,722,6660,0,4,20,0,South,702 +2024Q1,31.9,0.3,689,6350,1,6,23,0,South,635 +2024Q1,58.7,0.165,593,5070,3,6,4,1,Northeast,533 +2024Q1,57.3,0.273,719,6810,0,7,23,0,South,410 +2024Q1,10.8,0.445,714,6270,3,5,19,0,West,977 +2024Q1,14.5,0.321,660,5360,0,4,15,0,Midwest,273 +2024Q1,40.9,0.281,602,4900,0,2,23,0,Midwest,0 +2024Q1,94.2,0.141,642,6210,2,4,26,0,West,132 +2024Q1,60.1,0.296,769,7870,0,3,18,0,West,634 +2024Q1,18.2,0.36,669,5890,2,2,29,0,South,149 +2024Q1,14.2,0.434,546,3860,1,2,27,0,South,347 +2024Q1,27.1,0.154,613,5010,1,5,24,0,Midwest,281 +2024Q1,22.7,0.178,590,4690,2,4,6,0,Northeast,312 +2024Q1,72.3,0.203,458,3060,4,3,23,0,South,0 +2024Q1,56.1,0.305,695,6440,4,1,13,0,South,617 +2024Q1,15.2,0.373,688,5960,0,2,25,0,South,406 +2024Q1,34.2,0.156,496,3300,0,4,18,0,West,0 +2024Q1,29.6,0.37,693,6020,3,2,5,0,West,540 +2024Q1,27.6,0.3,638,5380,2,5,4,1,South,701 +2024Q1,79.7,0.318,734,7160,3,4,30,0,South,453 +2024Q1,48.1,0.235,652,5980,1,5,27,0,West,377 +2024Q1,75.2,0.265,649,5820,4,2,15,0,West,218 +2024Q1,34.2,0.291,589,4560,0,5,5,1,West,220 +2024Q1,94.9,0.058,594,5240,0,7,25,0,Northeast,0 +2024Q1,10.0,0.407,551,3820,1,2,23,0,West,434 +2024Q1,18.0,0.31,691,5940,1,1,4,0,West,430 +2024Q1,24.3,0.215,621,5030,0,6,18,0,West,98 +2024Q1,26.7,0.542,643,5510,0,2,23,0,South,307 +2024Q1,31.5,0.418,707,6450,1,2,21,0,Northeast,619 +2024Q1,53.2,0.307,690,6160,2,5,12,0,West,258 +2024Q1,47.8,0.367,570,4460,1,5,28,0,Northeast,168 +2024Q1,68.0,0.271,460,2990,2,7,26,1,Midwest,214 +2024Q1,34.8,0.362,512,3540,0,7,16,0,West,108 +2024Q1,28.4,0.25,705,6440,1,4,9,0,Northeast,506 +2024Q1,22.6,0.459,715,6560,1,7,7,0,Midwest,915 +2024Q1,62.0,0.129,612,5440,3,6,5,0,Midwest,99 +2024Q1,68.9,0.129,672,6280,3,3,12,0,South,267 +2024Q1,65.4,0.321,560,4700,2,7,9,0,South,576 +2024Q1,16.0,0.338,571,4360,1,1,28,0,South,329 +2024Q1,47.8,0.331,672,6140,1,5,18,0,West,252 +2024Q1,52.2,0.28,654,5780,2,6,13,0,West,259 +2024Q1,59.1,0.245,586,4840,2,2,3,0,South,0 +2024Q1,33.8,0.413,520,3580,2,7,25,0,Midwest,404 +2024Q1,83.4,0.087,764,7950,2,7,7,1,West,1054 +2024Q1,61.2,0.288,514,3560,0,4,15,0,Northeast,0 +2024Q1,44.4,0.26,713,6890,1,7,24,1,West,1164 +2024Q1,24.5,0.466,595,4590,2,2,10,1,Northeast,914 +2024Q1,10.0,0.371,660,5650,0,6,22,0,West,383 +2024Q1,82.9,0.274,603,5440,5,2,6,0,Midwest,0 +2024Q1,38.6,0.269,719,6770,4,1,17,0,South,602 +2024Q1,51.6,0.35,484,3160,2,7,2,0,Midwest,0 +2024Q1,53.6,0.305,690,6340,2,5,27,1,Midwest,660 +2024Q1,40.5,0.112,817,8270,3,5,22,0,South,1156 +2024Q1,47.1,0.36,589,4920,2,2,11,0,Northeast,0 +2024Q1,27.0,0.275,523,3540,2,4,15,0,South,365 +2024Q1,91.5,0.088,750,7440,2,5,23,0,Northeast,422 +2024Q1,125.5,0.285,654,6480,2,2,24,0,Midwest,0 +2024Q1,66.4,0.401,566,4600,1,6,18,0,West,0 +2024Q1,26.1,0.365,621,5160,0,2,13,0,Northeast,95 +2024Q1,11.9,0.454,654,5550,1,7,5,0,South,464 +2024Q1,81.3,0.189,590,5400,1,1,12,0,West,0 +2024Q1,32.1,0.441,519,3660,2,1,26,0,Midwest,181 +2024Q1,20.8,0.358,627,5270,2,5,9,0,West,580 +2024Q1,44.0,0.125,672,5830,0,4,16,0,West,0 +2024Q1,73.3,0.067,769,7930,0,1,26,0,Midwest,134 +2024Q1,52.9,0.255,605,5060,0,3,5,0,Northeast,6 +2024Q1,54.8,0.125,606,5150,2,3,11,0,West,0 +2024Q1,128.5,0.01,669,6890,3,7,16,0,West,0 +2024Q1,42.9,0.448,610,4950,3,1,5,0,Midwest,288 +2024Q1,38.4,0.27,505,3350,1,4,13,0,Midwest,0 +2024Q1,52.4,0.332,703,6470,0,2,22,0,Northeast,134 +2024Q1,30.5,0.325,787,7470,2,7,27,0,South,919 +2024Q1,54.9,0.329,655,5840,2,7,27,0,South,431 +2024Q1,143.2,0.098,718,7740,2,1,0,0,Northeast,10 +2024Q1,45.8,0.035,586,4570,0,7,20,0,South,0 +2024Q1,51.2,0.395,586,4940,0,6,30,0,Midwest,68 +2024Q1,23.4,0.222,729,6640,1,4,23,0,West,646 +2024Q1,18.5,0.302,720,6470,1,5,22,0,South,651 +2024Q1,64.3,0.136,654,5910,1,2,10,0,Midwest,0 +2024Q1,13.5,0.542,611,4850,2,2,16,0,South,447 +2024Q1,69.3,0.0,742,7300,1,4,28,0,West,204 +2024Q1,47.6,0.422,616,5390,1,1,25,0,Northeast,343 +2024Q1,76.5,0.294,731,7070,0,3,9,0,West,314 +2024Q1,12.0,0.316,743,6780,1,7,3,0,West,778 +2024Q1,65.8,0.207,771,7760,0,6,23,0,Midwest,337 +2024Q1,89.9,0.343,624,5810,0,5,20,0,West,0 +2024Q1,33.0,0.545,619,5110,1,2,14,0,South,350 +2024Q1,31.7,0.252,675,6180,0,3,16,0,South,149 +2024Q1,27.2,0.564,730,6660,2,1,12,0,South,789 +2024Q1,68.9,0.234,715,7060,2,1,3,0,Northeast,340 +2024Q1,177.5,0.0,720,8000,2,2,28,0,Midwest,0 +2024Q1,38.4,0.499,545,4080,0,2,4,0,South,0 +2024Q1,64.6,0.296,794,8100,2,7,22,1,South,1145 +2024Q1,34.5,0.271,705,6490,2,1,24,0,Northeast,647 +2024Q1,21.0,0.41,552,3940,1,5,25,0,Northeast,324 +2024Q1,38.8,0.245,850,8760,2,3,26,0,South,1177 +2024Q1,26.7,0.47,621,4940,1,1,1,0,West,177 +2024Q1,29.1,0.341,578,4400,2,7,21,0,Northeast,511 +2024Q1,23.6,0.294,426,1980,0,2,26,0,Midwest,0 +2024Q1,53.7,0.427,647,5720,3,1,2,0,West,318 +2024Q1,64.8,0.319,654,6090,1,3,0,0,West,0 +2024Q1,34.3,0.207,595,4680,3,1,25,0,Northeast,0 +2024Q1,198.4,0.0,730,8540,2,3,10,0,South,0 +2024Q1,23.8,0.29,553,4100,0,2,28,0,West,42 +2024Q1,13.5,0.397,742,6890,1,2,7,0,Northeast,1056 +2024Q1,32.4,0.424,643,5400,0,5,19,1,West,667 +2024Q1,33.3,0.217,629,5220,0,4,4,0,West,56 +2024Q1,19.8,0.427,614,4970,2,2,5,0,Midwest,385 +2024Q1,115.6,0.061,700,7330,1,5,7,0,Northeast,155 +2024Q1,50.9,0.436,590,4760,1,2,30,0,South,0 +2024Q1,69.1,0.13,623,5690,0,5,10,0,Northeast,0 +2024Q1,14.5,0.36,698,6120,1,2,30,0,Northeast,678 +2024Q1,109.8,0.397,711,7420,1,4,8,0,South,96 +2024Q1,15.7,0.166,668,5730,3,2,1,0,Midwest,537 +2024Q1,55.6,0.12,693,6230,3,4,24,0,West,431 +2024Q1,22.8,0.417,759,7240,3,3,25,0,South,1240 +2024Q1,53.1,0.262,368,1520,3,6,27,0,West,0 +2024Q1,43.7,0.35,631,5420,1,6,20,0,Midwest,240 +2024Q1,87.3,0.217,722,7220,0,4,18,0,South,13 +2024Q1,35.6,0.202,583,4770,2,2,3,1,South,465 +2024Q1,26.3,0.284,705,6340,0,3,21,0,South,294 +2024Q1,34.7,0.304,724,6890,1,5,2,0,Northeast,855 +2024Q1,21.0,0.344,585,4460,2,7,17,0,Midwest,129 +2024Q1,72.0,0.325,579,4980,0,2,15,0,South,0 +2024Q1,38.7,0.302,580,4460,2,7,10,0,Northeast,71 +2024Q1,59.5,0.154,644,5720,1,4,30,0,Midwest,0 +2024Q1,84.1,0.163,791,8140,3,1,27,0,West,512 +2024Q1,18.5,0.297,658,5670,2,7,19,0,West,737 +2024Q1,61.5,0.208,617,5400,2,4,0,0,South,0 +2024Q1,26.7,0.348,607,4870,0,6,15,0,West,136 +2024Q1,31.8,0.25,786,7600,3,2,15,0,Northeast,978 +2024Q1,41.3,0.471,658,5960,4,1,16,0,Northeast,492 +2024Q1,21.1,0.365,625,4950,1,5,20,0,West,671 +2024Q1,15.9,0.249,580,4510,2,2,1,0,West,262 +2024Q1,36.4,0.246,636,5380,3,3,9,0,West,513 +2024Q1,38.4,0.373,714,6600,2,4,25,0,West,799 +2024Q1,72.9,0.241,708,7000,2,3,12,0,West,433 +2024Q1,30.6,0.154,635,5450,0,1,22,0,South,0 +2024Q1,21.6,0.141,692,6040,1,4,13,0,West,298 +2024Q1,39.4,0.34,573,4590,2,3,22,0,West,236 +2024Q1,22.0,0.249,664,5530,0,1,16,1,Northeast,567 +2024Q1,85.0,0.101,794,8230,2,5,21,0,South,720 +2024Q1,36.6,0.279,528,3720,1,2,5,0,Midwest,0 +2024Q1,54.8,0.18,726,6830,2,3,11,0,Midwest,422 +2024Q1,28.6,0.444,609,4770,2,5,4,0,Midwest,258 +2024Q1,45.8,0.25,524,3820,2,1,19,0,West,115 +2024Q1,48.1,0.321,641,5580,0,7,6,0,South,64 +2024Q1,39.9,0.37,501,3330,0,7,23,0,South,0 +2024Q1,61.2,0.217,685,6190,1,6,26,1,West,649 +2024Q1,42.9,0.129,689,6290,2,7,11,0,West,321 +2024Q1,41.1,0.281,585,4830,0,2,15,0,South,0 +2024Q1,50.4,0.164,711,6730,2,6,2,0,West,446 +2024Q1,19.7,0.349,583,4620,2,1,7,0,Northeast,125 +2024Q1,72.7,0.266,709,6810,0,3,15,0,South,136 +2024Q1,28.7,0.318,647,5620,2,1,19,1,Northeast,689 +2024Q1,48.9,0.389,656,5860,0,5,6,0,Northeast,0 +2024Q1,63.6,0.25,612,5400,1,2,0,0,Midwest,54 +2024Q1,91.6,0.255,722,7420,1,7,2,0,Northeast,304 +2024Q1,47.7,0.136,667,6150,1,6,16,0,Northeast,491 +2024Q1,70.6,0.125,558,4590,1,3,4,0,South,42 +2024Q1,33.3,0.549,613,5070,0,1,9,0,Northeast,236 +2024Q1,46.2,0.359,571,4480,0,6,12,0,South,244 +2024Q1,47.8,0.187,573,4430,0,3,13,0,South,0 +2024Q1,88.0,0.308,603,5250,1,3,26,0,Midwest,0 +2024Q1,73.7,0.194,663,6300,0,4,20,0,South,0 +2024Q1,93.7,0.36,510,3960,2,5,21,0,Midwest,0 +2024Q1,45.4,0.161,547,3800,1,7,21,1,Northeast,517 +2024Q1,10.0,0.277,764,7080,1,2,14,0,South,807 +2024Q1,11.2,0.43,613,4820,0,1,4,0,Northeast,186 +2024Q1,21.4,0.394,559,4030,1,5,14,0,West,482 +2024Q1,43.5,0.454,664,5770,1,7,29,0,South,553 +2024Q1,13.4,0.442,548,3960,1,5,29,1,Midwest,851 +2024Q1,49.9,0.233,686,6140,3,2,30,0,West,212 +2024Q1,98.4,0.192,567,5000,3,4,5,0,South,30 +2024Q1,23.3,0.465,576,4540,1,5,26,1,South,780 +2024Q1,25.4,0.345,664,5700,3,7,11,0,South,534 +2024Q1,40.2,0.23,662,5930,2,3,17,0,South,197 +2024Q1,32.3,0.272,662,5750,0,4,2,0,Northeast,202 +2024Q1,47.2,0.326,534,3760,2,7,27,0,Northeast,123 +2024Q1,85.2,0.348,727,7520,3,1,10,0,West,311 +2024Q1,22.1,0.234,681,5860,2,5,26,0,West,613 +2024Q1,83.6,0.381,625,5630,2,4,2,0,Midwest,159 +2024Q1,88.8,0.37,780,8100,1,1,10,0,South,646 +2024Q1,39.3,0.168,607,5060,1,1,13,0,West,41 +2024Q1,92.9,0.322,593,5300,3,6,22,0,Northeast,136 +2024Q1,16.4,0.324,638,5290,2,1,9,0,Midwest,598 +2024Q1,49.9,0.052,766,7670,0,5,28,0,Midwest,529 +2024Q1,17.7,0.235,603,4910,1,2,28,0,West,535 +2024Q1,35.2,0.329,728,6690,3,1,0,0,Midwest,596 +2024Q1,35.1,0.19,747,6910,2,1,25,0,West,443 +2024Q1,52.8,0.391,557,4380,2,5,21,0,South,226 +2024Q1,68.8,0.275,701,6630,2,4,6,0,West,314 +2024Q1,32.9,0.305,765,7460,0,3,25,0,Northeast,586 +2024Q1,51.7,0.136,615,5240,3,6,14,0,Northeast,178 +2024Q1,39.4,0.355,788,7810,1,6,27,0,Northeast,867 +2024Q1,56.2,0.225,762,7450,0,2,25,1,Midwest,612 +2024Q1,42.5,0.256,710,6590,0,5,22,0,Midwest,380 +2024Q1,40.4,0.294,773,7600,2,4,2,0,West,919 +2024Q1,167.7,0.147,799,9070,3,2,25,0,Midwest,155 +2024Q1,29.4,0.319,645,5590,0,4,9,0,Northeast,65 +2024Q1,43.5,0.277,728,6820,1,6,21,0,Midwest,738 +2024Q1,17.3,0.325,850,8470,3,1,25,0,West,1127 +2024Q1,36.4,0.187,608,4980,2,7,14,0,Midwest,72 +2024Q1,13.4,0.433,630,5160,4,2,15,0,South,461 +2024Q1,163.5,0.0,640,6670,2,3,21,1,Midwest,0 +2024Q1,83.1,0.161,518,4250,1,3,27,0,South,0 +2024Q1,14.2,0.472,658,5500,0,6,7,0,West,514 +2024Q1,64.0,0.303,745,7220,2,5,20,1,Northeast,1049 +2024Q1,68.5,0.246,623,5810,0,1,21,0,South,0 +2024Q1,23.6,0.242,640,5370,1,7,19,0,Northeast,372 +2024Q1,65.8,0.131,668,6090,2,7,20,1,West,675 +2024Q1,29.1,0.207,661,5650,1,4,25,0,Northeast,360 +2024Q1,23.9,0.322,654,5500,1,5,5,0,West,646 +2024Q1,27.4,0.142,638,5400,0,5,5,0,West,0 +2024Q1,117.1,0.341,730,7600,0,2,13,1,South,279 +2024Q1,50.4,0.259,684,6230,2,1,11,0,South,434 +2024Q1,87.5,0.047,725,7150,3,6,9,0,Midwest,141 +2024Q1,64.5,0.424,806,8180,1,6,21,0,Midwest,902 +2024Q1,31.5,0.142,645,5260,2,1,12,0,South,19 +2024Q1,17.0,0.43,597,4730,2,2,7,0,South,366 +2024Q1,27.5,0.487,518,3450,1,2,21,0,Midwest,0 +2024Q1,36.1,0.229,553,4370,0,1,8,0,West,49 +2024Q1,30.7,0.232,778,7570,0,6,20,0,South,610 +2024Q1,23.8,0.271,660,5760,1,3,30,1,South,700 +2024Q1,25.4,0.315,504,3400,1,4,24,0,Northeast,0 +2024Q1,24.3,0.298,647,5490,0,3,11,0,South,27 +2024Q1,38.3,0.335,728,6780,0,4,12,0,Northeast,338 +2024Q1,86.6,0.075,731,7240,0,1,15,0,South,0 +2024Q1,43.5,0.157,715,6440,1,6,2,0,South,144 +2024Q1,42.4,0.185,658,6070,2,5,8,1,Northeast,937 +2024Q1,32.1,0.447,492,3190,2,2,7,0,South,84 +2024Q1,52.9,0.208,579,4750,0,2,19,0,Midwest,0 +2024Q1,58.5,0.195,732,7000,1,4,4,0,South,370 +2024Q1,34.7,0.414,608,4760,1,1,4,0,Midwest,233 +2024Q1,41.7,0.271,655,5820,0,2,6,0,West,397 +2024Q1,79.4,0.179,582,5110,0,6,12,0,South,0 +2024Q1,47.1,0.319,708,6750,4,7,9,0,South,930 +2024Q1,41.1,0.289,850,8800,0,4,16,0,Midwest,834 +2024Q1,51.0,0.206,707,6720,1,4,22,0,Midwest,507 +2024Q1,59.6,0.235,706,6760,2,3,23,0,Northeast,544 +2024Q1,40.5,0.161,649,5460,2,2,29,0,West,238 +2024Q1,28.8,0.539,598,4820,2,1,6,0,Northeast,497 +2024Q1,37.3,0.256,566,4230,1,1,25,1,Northeast,565 +2024Q1,75.7,0.17,791,8350,0,5,29,1,Northeast,897 +2024Q1,47.1,0.048,758,7350,2,5,20,0,Midwest,773 +2024Q1,13.6,0.372,656,5470,1,7,11,0,South,634 +2024Q1,66.2,0.298,599,5000,2,3,1,0,South,0 +2024Q1,33.7,0.429,809,7970,3,1,27,0,South,1102 +2024Q1,28.3,0.345,595,4810,5,3,0,0,Midwest,422 +2024Q1,73.7,0.197,660,6070,2,1,9,0,Northeast,266 +2024Q1,68.8,0.271,580,4740,2,4,6,0,Northeast,86 +2024Q1,32.9,0.381,591,4590,0,3,12,0,West,0 +2024Q1,16.9,0.452,732,6520,2,3,8,0,West,699 +2024Q1,13.2,0.284,558,4070,3,1,7,0,West,670 +2024Q1,28.9,0.341,802,8050,1,4,13,0,South,980 +2024Q1,28.0,0.27,681,6000,4,5,30,0,Northeast,865 +2024Q1,16.0,0.412,591,4390,2,4,0,0,Northeast,369 +2024Q1,90.7,0.147,707,7160,0,6,12,0,Midwest,99 +2024Q1,28.1,0.329,668,5620,3,3,10,0,West,746 +2024Q1,70.0,0.351,551,4600,1,6,1,0,South,0 +2024Q1,14.2,0.373,547,3800,2,6,10,0,West,273 +2024Q1,32.7,0.469,706,6430,1,6,1,1,Midwest,909 +2024Q1,46.3,0.252,708,6440,1,3,12,0,Northeast,369 +2024Q1,33.9,0.386,644,5300,0,5,18,0,South,262 +2024Q1,31.7,0.433,700,6240,5,5,25,0,South,773 +2024Q1,200.0,0.0,785,9110,2,2,11,0,Midwest,32 +2024Q1,95.5,0.148,722,7160,0,3,25,0,Midwest,0 +2024Q1,128.8,0.242,509,4340,1,2,22,1,Midwest,0 +2024Q1,26.9,0.43,541,3980,4,4,1,0,South,447 +2024Q1,67.2,0.088,646,5980,1,1,14,0,Northeast,0 +2024Q1,28.7,0.293,723,6710,2,5,25,0,South,820 +2024Q1,70.1,0.328,813,8330,0,1,9,0,Midwest,278 +2024Q1,10.5,0.466,629,4910,1,7,28,0,South,480 +2024Q1,30.5,0.444,549,3990,2,4,1,0,Midwest,248 +2024Q1,26.5,0.419,694,6430,2,4,10,0,Northeast,624 +2024Q1,14.0,0.276,705,6520,1,6,2,0,South,844 +2024Q1,23.6,0.413,608,4880,0,6,11,0,South,0 +2024Q1,60.1,0.364,726,6890,1,7,5,0,West,587 +2024Q1,62.0,0.097,578,4830,1,5,29,1,West,384 +2024Q1,74.5,0.185,681,6590,0,2,21,0,West,0 +2024Q1,30.0,0.325,768,7130,2,1,24,0,Northeast,564 +2024Q1,36.0,0.238,592,4660,4,7,13,0,West,134 +2024Q1,95.6,0.229,642,6180,3,7,1,0,West,30 +2024Q1,15.2,0.507,608,4850,1,1,16,0,West,279 +2024Q1,52.5,0.353,526,3920,3,2,1,0,Northeast,372 +2024Q1,42.2,0.376,570,4440,0,2,22,0,Northeast,0 +2024Q1,128.8,0.162,500,4060,1,1,2,0,South,0 +2024Q1,38.7,0.482,575,4650,0,2,0,0,West,61 +2024Q1,25.0,0.338,677,5770,2,1,8,0,Northeast,417 +2024Q1,44.3,0.363,690,6340,4,5,26,0,Midwest,606 +2024Q1,24.0,0.447,622,5040,0,7,7,0,Northeast,223 +2024Q1,60.4,0.212,769,7560,2,6,15,0,Midwest,672 +2024Q1,60.3,0.232,647,5880,1,1,24,0,Northeast,0 +2024Q1,22.7,0.366,640,5430,0,2,15,0,Northeast,121 +2024Q1,21.2,0.404,798,7790,3,6,28,0,South,1241 +2024Q2,50.7,0.171,595,5040,0,7,7,0,Midwest,0 +2024Q2,36.4,0.184,621,5280,0,4,26,0,South,124 +2024Q2,67.3,0.318,641,5710,2,7,7,0,Midwest,222 +2024Q2,60.1,0.327,710,6890,3,5,10,0,West,457 +2024Q2,77.1,0.305,618,5630,2,3,3,1,Northeast,487 +2024Q2,54.0,0.195,821,8350,1,2,21,0,West,913 +2024Q2,46.4,0.332,629,5440,1,7,18,0,Northeast,182 +2024Q2,30.4,0.222,672,5750,0,1,4,0,West,19 +2024Q2,51.4,0.223,744,7230,1,1,14,0,Midwest,543 +2024Q2,55.0,0.204,840,8680,1,3,4,0,Northeast,850 +2024Q2,12.0,0.284,665,5760,1,5,28,0,Midwest,561 +2024Q2,32.4,0.039,678,5770,2,5,3,0,South,233 +2024Q2,75.5,0.105,732,7380,1,5,30,0,Northeast,388 +2024Q2,72.3,0.245,592,5120,0,7,1,0,Northeast,0 +2024Q2,124.2,0.181,751,7880,1,5,4,0,Northeast,70 +2024Q2,14.4,0.422,583,4390,0,7,14,0,Midwest,329 +2024Q2,51.1,0.274,669,5880,1,4,1,1,Northeast,734 +2024Q2,40.4,0.173,720,6690,1,5,27,0,Midwest,566 +2024Q2,61.6,0.291,702,6770,1,2,24,1,South,680 +2024Q2,40.7,0.377,689,6390,1,4,22,0,South,504 +2024Q2,52.9,0.429,546,4220,1,2,1,0,Northeast,95 +2024Q2,31.5,0.35,540,3710,0,2,12,0,Northeast,0 +2024Q2,43.3,0.258,594,4790,2,5,13,0,Midwest,183 +2024Q2,82.7,0.158,611,5440,4,2,15,0,Midwest,0 +2024Q2,12.2,0.276,723,6250,1,4,15,0,Midwest,637 +2024Q2,38.5,0.241,704,6350,1,2,10,0,South,265 +2024Q2,13.9,0.317,684,5930,4,1,28,0,West,719 +2024Q2,66.9,0.289,600,5250,1,3,28,1,South,358 +2024Q2,15.9,0.434,612,4940,2,1,2,0,Midwest,510 +2024Q2,69.3,0.081,518,4060,2,2,22,0,West,0 +2024Q2,81.0,0.156,732,7310,2,4,0,0,Midwest,413 +2024Q2,26.1,0.269,739,6890,2,6,14,0,West,890 +2024Q2,19.6,0.168,709,6280,2,7,28,0,South,535 +2024Q2,49.7,0.329,600,4960,2,5,22,0,South,376 +2024Q2,36.3,0.352,472,2980,1,2,30,0,Midwest,7 +2024Q2,71.4,0.205,692,6600,1,4,30,1,Northeast,686 +2024Q2,40.1,0.319,578,4480,0,4,17,0,South,74 +2024Q2,37.6,0.28,568,4420,2,1,4,0,West,235 +2024Q2,34.9,0.283,603,4720,3,7,6,0,Midwest,184 +2024Q2,26.7,0.431,558,4160,1,5,22,0,Northeast,428 +2024Q2,36.7,0.37,566,4330,1,1,15,0,West,52 +2024Q2,14.4,0.347,720,6480,0,4,16,0,West,649 +2024Q2,36.7,0.258,633,5490,0,7,5,0,Northeast,58 +2024Q2,173.3,0.095,778,8860,2,5,30,0,West,0 +2024Q2,19.1,0.323,667,5730,2,2,1,0,West,415 +2024Q2,24.0,0.227,675,5900,2,5,29,0,Midwest,696 +2024Q2,24.8,0.206,735,6710,0,1,5,0,Midwest,410 +2024Q2,68.1,0.082,681,6280,3,5,0,0,West,165 +2024Q2,95.4,0.089,545,4520,0,6,17,0,Northeast,0 +2024Q2,76.0,0.191,592,5010,2,6,5,0,West,0 +2024Q2,11.6,0.279,573,4230,2,6,19,0,South,678 +2024Q2,58.3,0.234,709,6930,0,7,5,0,Northeast,237 +2024Q2,72.1,0.363,716,6750,2,7,6,1,West,901 +2024Q2,38.4,0.255,724,6670,2,7,28,0,South,735 +2024Q2,31.5,0.218,613,5040,3,5,6,0,South,379 +2024Q2,33.5,0.267,703,6400,7,2,0,0,Midwest,814 +2024Q2,65.1,0.287,649,5740,0,5,1,0,Northeast,74 +2024Q2,53.3,0.247,733,6920,0,2,21,0,Midwest,210 +2024Q2,42.5,0.315,739,6950,1,1,12,0,Midwest,487 +2024Q2,13.9,0.45,569,4060,1,6,29,0,Midwest,464 +2024Q2,55.7,0.3,595,5120,0,3,18,0,West,0 +2024Q2,45.7,0.237,492,3330,1,2,16,0,Midwest,0 +2024Q2,22.2,0.269,676,5820,3,2,24,1,Northeast,883 +2024Q2,39.5,0.323,588,4660,0,6,5,0,South,0 +2024Q2,52.5,0.444,638,5570,0,1,15,0,Northeast,225 +2024Q2,10.0,0.328,764,7080,2,4,26,0,Midwest,906 +2024Q2,50.4,0.35,757,7510,0,2,4,0,West,658 +2024Q2,48.8,0.131,734,6970,1,4,6,0,Midwest,330 +2024Q2,83.5,0.133,556,4540,1,4,13,0,West,0 +2024Q2,51.5,0.361,630,5660,1,6,8,0,South,304 +2024Q2,56.5,0.149,702,6580,2,2,5,0,Northeast,586 +2024Q2,37.2,0.346,617,5170,2,3,18,0,West,393 +2024Q2,38.9,0.236,540,3930,3,6,20,0,South,204 +2024Q2,23.0,0.607,719,6560,1,3,14,0,South,590 +2024Q2,22.2,0.16,564,4270,0,5,28,0,West,84 +2024Q2,36.7,0.403,735,6880,2,4,6,0,Northeast,730 +2024Q2,27.1,0.362,668,5780,1,3,0,0,West,623 +2024Q2,31.8,0.332,530,3600,3,6,0,0,Northeast,257 +2024Q2,46.0,0.366,737,7040,0,1,5,0,South,332 +2024Q2,30.9,0.275,617,5180,1,2,13,0,West,278 +2024Q2,64.8,0.412,758,7670,0,6,0,0,Midwest,594 +2024Q2,43.4,0.2,659,6000,0,2,15,0,West,81 +2024Q2,75.1,0.425,843,8860,1,6,24,1,Midwest,1404 +2024Q2,10.0,0.247,626,5100,2,5,25,0,Midwest,810 +2024Q2,59.0,0.213,708,6590,1,2,26,0,Northeast,251 +2024Q2,115.4,0.186,739,7590,1,2,14,0,Midwest,0 +2024Q2,23.6,0.311,590,4420,1,1,27,0,Midwest,0 +2024Q2,17.0,0.31,538,3670,2,7,22,0,Midwest,457 +2024Q2,49.7,0.282,706,6390,2,4,11,1,West,909 +2024Q2,27.7,0.242,690,6230,1,4,4,0,Midwest,403 +2024Q2,56.8,0.331,699,6540,0,7,8,1,Midwest,725 +2024Q2,10.2,0.28,528,3470,1,1,3,0,Midwest,264 +2024Q2,45.7,0.318,622,5420,1,3,17,0,West,117 +2024Q2,36.8,0.396,721,6710,2,2,29,0,South,764 +2024Q2,68.7,0.191,652,5980,1,7,30,0,Northeast,95 +2024Q2,27.2,0.395,725,6630,2,5,20,0,West,987 +2024Q2,19.7,0.246,736,6620,1,1,0,0,West,406 +2024Q2,49.4,0.31,850,8390,1,7,16,0,Midwest,1075 +2024Q2,34.2,0.286,768,7180,1,6,29,1,Northeast,1330 +2024Q2,29.0,0.274,705,6490,3,4,4,0,West,617 +2024Q2,42.2,0.257,648,5600,1,5,18,0,Midwest,237 +2024Q2,77.9,0.216,741,7580,1,7,23,0,Northeast,418 +2024Q2,99.1,0.28,745,7670,0,2,0,0,South,411 +2024Q2,70.2,0.041,688,6580,2,6,18,0,West,312 +2024Q2,32.6,0.325,775,7610,0,6,23,0,West,629 +2024Q2,77.5,0.277,778,7940,1,1,14,0,Midwest,338 +2024Q2,75.8,0.235,628,5610,4,1,18,0,West,7 +2024Q2,22.5,0.416,669,5740,0,1,11,1,South,595 +2024Q2,21.7,0.41,708,6470,0,2,4,0,Midwest,433 +2024Q2,54.4,0.383,684,6280,1,4,24,0,Northeast,595 +2024Q2,24.2,0.371,539,3730,0,3,29,1,Northeast,349 +2024Q2,62.4,0.28,565,4450,3,2,24,0,West,319 +2024Q2,10.0,0.507,659,5420,0,5,14,1,South,988 +2024Q2,18.0,0.391,580,4300,3,4,20,1,Northeast,722 +2024Q2,26.0,0.356,622,5140,2,3,26,1,South,911 +2024Q2,65.9,0.357,616,5490,1,3,19,0,Northeast,19 +2024Q2,36.3,0.472,535,3920,1,3,8,0,Midwest,173 +2024Q2,63.7,0.397,643,5950,1,7,24,1,Northeast,803 +2024Q2,62.5,0.239,538,4160,1,7,16,0,Northeast,128 +2024Q2,56.4,0.38,805,8340,2,1,25,0,South,916 +2024Q2,19.6,0.227,533,3660,1,5,7,0,South,63 +2024Q2,58.1,0.281,695,6620,1,1,9,0,West,41 +2024Q2,48.4,0.203,682,5990,3,3,25,0,Northeast,409 +2024Q2,74.4,0.417,675,6330,0,5,16,0,West,309 +2024Q2,26.5,0.242,580,4390,3,3,3,0,South,229 +2024Q2,28.8,0.31,793,7610,1,4,7,0,South,795 +2024Q2,10.0,0.404,610,4750,1,1,0,0,Midwest,263 +2024Q2,112.1,0.08,720,7480,1,4,18,0,Midwest,0 +2024Q2,22.6,0.322,486,2760,1,5,8,0,Northeast,0 +2024Q2,61.2,0.292,766,7760,0,5,1,0,Northeast,330 +2024Q2,76.5,0.514,601,5300,2,7,12,0,Midwest,216 +2024Q2,28.8,0.461,676,5840,2,3,8,0,Northeast,534 +2024Q2,41.4,0.403,723,6980,1,7,17,0,South,759 +2024Q2,37.1,0.251,607,5050,2,3,27,0,Midwest,0 +2024Q2,36.9,0.216,703,6550,1,1,14,0,Midwest,386 +2024Q2,50.0,0.154,616,5210,1,4,9,0,Northeast,0 +2024Q2,34.4,0.359,661,5880,0,5,8,0,Northeast,403 +2024Q2,29.8,0.226,737,6850,0,1,14,0,Midwest,163 +2024Q2,76.9,0.408,476,3340,0,2,18,0,Northeast,0 +2024Q2,18.9,0.471,681,5940,1,2,18,0,South,857 +2024Q2,41.7,0.276,436,2240,2,1,21,1,West,114 +2024Q2,25.9,0.204,562,4070,0,4,23,0,Northeast,0 +2024Q2,30.0,0.437,646,5620,1,4,29,0,South,562 +2024Q2,63.7,0.27,654,6080,1,1,4,0,West,253 +2024Q2,55.1,0.378,668,6230,2,4,17,0,Northeast,510 +2024Q2,37.3,0.173,572,4370,3,2,27,0,Midwest,472 +2024Q2,12.3,0.309,673,5810,0,3,29,0,West,80 +2024Q2,38.7,0.408,649,5720,2,2,5,0,Midwest,433 +2024Q2,19.0,0.176,531,3680,1,4,1,1,Midwest,423 +2024Q2,42.0,0.139,639,5410,2,4,18,1,Midwest,726 +2024Q2,28.3,0.24,655,5640,2,5,28,0,Northeast,540 +2024Q2,13.4,0.472,555,4030,0,4,23,1,Midwest,791 +2024Q2,34.1,0.321,766,7180,1,3,9,0,Midwest,600 +2024Q2,49.3,0.385,504,3330,0,6,29,0,South,173 +2024Q2,184.3,0.0,738,8300,4,1,26,0,South,0 +2024Q2,12.6,0.42,697,6140,2,5,22,0,West,741 +2024Q2,99.5,0.296,724,7560,0,2,10,0,South,178 +2024Q2,27.9,0.339,664,5700,1,1,5,0,Midwest,428 +2024Q2,57.7,0.346,603,5390,2,5,17,0,Northeast,396 +2024Q2,50.7,0.115,606,5070,1,1,2,0,South,0 +2024Q2,25.4,0.277,600,4590,1,5,2,0,West,259 +2024Q2,87.3,0.217,683,6680,2,3,9,0,Midwest,208 +2024Q2,35.9,0.301,716,6360,1,1,21,0,West,554 +2024Q2,24.5,0.308,672,5760,2,5,8,0,Midwest,585 +2024Q2,32.2,0.363,587,4550,1,1,1,0,South,20 +2024Q2,17.6,0.39,479,2820,4,3,19,0,Midwest,171 +2024Q2,56.4,0.231,685,6020,2,2,8,0,West,286 +2024Q2,35.9,0.332,653,5630,0,5,24,0,Midwest,107 +2024Q2,23.7,0.566,475,2750,0,7,28,1,West,324 +2024Q2,62.7,0.157,676,6250,1,5,21,0,South,139 +2024Q2,27.8,0.378,615,5020,1,2,20,0,Northeast,141 +2024Q2,93.8,0.072,606,5660,3,1,18,0,West,0 +2024Q2,48.4,0.189,599,4930,5,4,9,0,South,0 +2024Q2,19.8,0.383,635,5390,1,5,1,0,Midwest,382 +2024Q2,26.8,0.347,569,4360,1,4,14,0,Northeast,440 +2024Q2,32.3,0.293,741,6890,2,4,12,1,South,1253 +2024Q2,47.5,0.269,731,7030,2,2,18,0,Midwest,449 +2024Q2,12.4,0.161,661,5680,1,7,3,0,West,503 +2024Q2,60.1,0.168,723,7090,1,1,22,1,West,497 +2024Q2,46.4,0.041,668,5990,1,5,27,1,Midwest,611 +2024Q2,35.1,0.31,736,6840,2,2,21,1,South,1067 +2024Q2,10.0,0.453,789,7470,1,7,5,0,South,1141 +2024Q2,21.9,0.407,629,5220,0,2,24,0,West,273 +2024Q2,87.1,0.206,691,6530,1,5,8,1,South,371 +2024Q2,23.9,0.502,584,4540,1,2,1,1,South,458 +2024Q2,42.2,0.31,509,3570,2,3,2,0,Midwest,0 +2024Q2,27.8,0.423,615,5080,2,1,13,1,Northeast,691 +2024Q2,82.3,0.257,784,8180,1,3,23,0,West,637 +2024Q2,53.3,0.263,801,7900,2,7,22,0,Midwest,1035 +2024Q2,22.9,0.46,490,2860,2,6,13,0,South,188 +2024Q2,22.0,0.296,721,6410,2,5,23,0,Northeast,621 +2024Q2,41.0,0.344,779,7620,6,3,13,0,West,909 +2024Q2,42.5,0.35,630,5030,1,5,22,0,West,42 +2024Q2,19.8,0.407,661,5610,0,1,2,0,Northeast,24 +2024Q2,31.6,0.212,547,3890,1,1,23,0,Midwest,21 +2024Q2,39.5,0.226,713,6640,2,1,6,0,West,483 +2024Q2,39.8,0.295,590,4890,3,2,15,0,Northeast,288 +2024Q2,90.9,0.113,772,7970,0,1,2,1,Midwest,361 +2024Q2,34.7,0.205,672,5940,1,6,26,0,Midwest,410 +2024Q2,28.8,0.31,554,4300,4,7,15,0,Midwest,749 +2024Q2,159.2,0.112,651,7040,2,2,7,0,Midwest,0 +2024Q2,16.3,0.293,669,5930,3,7,0,1,Midwest,1161 +2024Q2,45.5,0.337,555,4060,1,7,14,0,Midwest,0 +2024Q2,58.0,0.247,600,5040,2,4,6,0,West,70 +2024Q2,12.8,0.254,716,6220,1,1,20,1,South,936 +2024Q2,30.1,0.404,587,4750,1,5,9,1,West,425 +2024Q2,27.2,0.309,734,6790,4,1,2,0,Midwest,945 +2024Q2,74.5,0.227,757,7510,3,5,30,1,Midwest,1153 +2024Q2,31.0,0.199,608,5000,0,6,14,0,South,11 +2024Q2,35.3,0.393,672,6030,1,1,15,0,Midwest,458 +2024Q2,24.5,0.159,582,4680,0,4,3,0,West,0 +2024Q2,19.6,0.231,603,4890,2,5,4,1,West,721 +2024Q2,55.7,0.432,782,7760,1,3,29,0,South,578 +2024Q2,23.5,0.376,760,7070,1,6,4,0,South,609 +2024Q2,29.3,0.18,641,5620,0,1,6,0,Northeast,262 +2024Q2,29.0,0.554,571,4180,3,7,14,0,South,654 +2024Q2,27.9,0.188,634,5110,1,3,10,0,Midwest,213 +2024Q2,25.9,0.327,637,5160,3,6,29,0,West,600 +2024Q2,31.0,0.37,581,4670,0,2,22,0,South,0 +2024Q2,86.8,0.193,654,6180,1,3,20,0,Midwest,0 +2024Q2,85.9,0.349,617,5570,2,3,7,1,West,331 +2024Q2,37.2,0.269,676,6030,1,5,14,0,Midwest,393 +2024Q2,80.3,0.431,704,6900,3,5,16,1,Northeast,1032 +2024Q2,44.0,0.431,625,5250,2,6,17,0,South,498 +2024Q2,48.6,0.185,521,3710,2,7,20,0,South,33 +2024Q2,23.5,0.384,696,6020,3,6,21,0,Northeast,773 +2024Q2,43.6,0.405,612,5110,1,2,0,0,Midwest,234 +2024Q2,27.0,0.208,611,4930,0,2,6,0,Midwest,0 +2024Q2,37.6,0.138,637,5500,2,2,30,0,West,143 +2024Q2,54.2,0.079,615,5240,2,5,8,0,Midwest,70 +2024Q2,43.5,0.334,684,6210,5,5,12,0,Midwest,756 +2024Q2,43.4,0.303,500,3320,0,1,13,0,West,0 +2024Q2,32.0,0.199,621,5000,1,4,25,0,Northeast,145 +2024Q2,18.5,0.363,634,5240,0,7,15,0,Midwest,86 +2024Q2,51.7,0.248,665,5830,3,7,21,0,Midwest,402 +2024Q2,71.4,0.227,751,7620,2,1,30,0,Northeast,552 +2024Q2,72.9,0.227,542,4430,1,7,3,0,South,0 +2024Q2,33.4,0.331,801,7850,2,7,23,0,West,1096 +2024Q2,42.8,0.237,605,4860,0,6,17,0,Midwest,0 +2024Q2,22.1,0.5,438,2400,2,5,21,0,Northeast,118 +2024Q2,21.3,0.457,707,6280,3,2,17,0,South,960 +2024Q2,67.7,0.185,743,7240,2,1,7,0,Northeast,589 +2024Q2,78.1,0.23,717,6820,3,3,26,0,Northeast,514 +2024Q2,33.3,0.263,549,4280,3,1,2,0,South,114 +2024Q2,15.2,0.354,517,3670,2,1,29,1,Northeast,541 +2024Q2,72.5,0.285,638,6000,2,4,6,0,Midwest,242 +2024Q2,48.3,0.348,681,6140,3,6,12,1,South,922 +2024Q2,70.2,0.057,673,6310,2,7,9,0,Midwest,288 +2024Q2,37.1,0.34,537,4030,2,2,27,0,West,456 +2024Q2,33.5,0.297,685,6180,1,2,22,0,South,574 +2024Q2,36.2,0.336,635,5350,0,2,4,1,Northeast,584 +2024Q2,34.2,0.477,569,4480,0,5,10,0,West,260 +2024Q2,55.4,0.268,694,6530,1,7,21,0,Midwest,665 +2024Q2,48.3,0.299,559,4170,0,5,5,0,West,0 +2024Q2,59.8,0.307,616,5320,2,3,11,0,Northeast,227 +2024Q2,29.5,0.228,608,4930,1,7,17,0,Midwest,65 +2024Q2,21.5,0.264,670,5730,2,2,10,0,South,478 +2024Q2,68.6,0.239,692,6590,2,4,5,0,Northeast,307 +2024Q2,12.5,0.236,541,3690,1,3,5,0,South,0 +2024Q2,32.4,0.352,619,5160,1,1,1,0,South,149 +2024Q2,58.8,0.434,561,4740,1,7,9,0,Northeast,239 +2024Q2,36.4,0.158,646,5610,3,4,16,0,West,505 +2024Q2,26.8,0.548,728,6770,2,6,3,0,West,1030 +2024Q2,36.3,0.139,772,7130,1,5,22,0,Midwest,623 +2024Q2,51.4,0.297,798,8080,2,1,16,0,South,870 +2024Q2,14.8,0.551,663,5670,2,2,18,1,Northeast,1065 +2024Q2,40.9,0.25,686,6230,1,5,21,0,West,633 +2024Q2,51.7,0.202,721,6880,2,2,1,0,Northeast,523 +2024Q2,52.3,0.407,766,7370,1,6,19,0,South,558 +2024Q2,33.1,0.251,643,5500,3,7,3,0,West,422 +2024Q2,18.1,0.359,583,4510,0,2,8,0,South,0 +2024Q2,20.1,0.413,591,4540,4,1,20,0,South,363 +2024Q2,43.2,0.265,677,6340,0,6,4,0,Midwest,420 +2024Q2,96.7,0.272,806,8630,1,5,3,0,Midwest,710 +2024Q2,38.3,0.323,633,5420,2,7,9,0,South,582 +2024Q2,74.1,0.156,731,7400,1,6,28,1,South,633 +2024Q2,26.6,0.24,815,7740,2,4,7,0,Midwest,999 +2024Q2,32.0,0.202,760,7470,1,6,26,0,South,613 +2024Q2,21.6,0.579,659,5440,2,7,2,0,Northeast,905 +2024Q2,63.6,0.279,838,8650,1,2,18,0,Midwest,844 +2024Q2,34.6,0.268,654,5630,0,1,28,0,Northeast,77 +2024Q2,68.4,0.284,671,6370,3,6,1,0,Northeast,475 +2024Q2,20.5,0.178,621,5060,2,4,15,0,South,314 +2024Q2,135.8,0.0,727,7910,1,6,2,0,South,122 +2024Q2,30.4,0.343,743,6890,0,4,23,0,Northeast,907 +2024Q2,55.2,0.235,676,6200,1,3,27,0,South,208 +2024Q2,32.0,0.377,676,5860,0,6,30,0,South,287 +2024Q2,64.9,0.053,602,5180,1,6,23,0,West,4 +2024Q2,46.4,0.31,748,7330,1,2,21,0,West,687 +2024Q2,28.0,0.241,611,4940,2,3,20,0,West,343 +2024Q2,14.6,0.388,755,6960,2,4,11,0,Northeast,1242 +2024Q2,104.8,0.166,610,5790,1,4,30,0,Northeast,0 +2024Q2,22.0,0.207,407,1630,2,3,24,0,Northeast,0 +2024Q2,124.7,0.0,658,6580,1,6,1,0,West,0 +2024Q2,64.0,0.174,693,6740,0,1,27,0,West,202 +2024Q2,93.4,0.163,522,4210,0,2,27,0,South,0 +2024Q2,51.8,0.353,556,4450,0,4,13,0,Midwest,0 +2024Q2,21.8,0.472,609,4880,4,4,5,0,Midwest,592 +2024Q2,30.3,0.41,524,3850,5,1,7,0,Midwest,504 +2024Q2,33.6,0.334,690,6270,1,7,22,0,West,501 +2024Q2,30.3,0.523,673,5950,2,1,18,0,West,617 +2024Q2,30.1,0.341,555,4020,4,4,0,0,South,561 +2024Q2,29.4,0.479,659,5640,1,6,18,0,West,571 +2024Q2,189.3,0.109,715,8210,2,4,28,0,West,0 +2024Q2,80.2,0.152,641,6050,4,1,12,0,South,263 +2024Q2,39.0,0.204,808,8030,0,4,13,1,West,859 +2024Q2,21.7,0.226,772,7330,1,3,9,0,West,907 +2024Q2,34.1,0.211,600,4680,0,6,22,1,South,312 +2024Q2,37.7,0.198,637,5430,0,6,23,0,West,244 +2024Q2,83.6,0.169,709,6960,1,2,6,0,Northeast,0 +2024Q2,29.9,0.282,670,6050,1,4,17,0,Midwest,540 +2024Q2,30.8,0.296,671,5900,2,2,11,0,Northeast,407 +2024Q2,81.9,0.232,793,8230,0,7,21,0,Northeast,530 +2024Q2,41.2,0.414,674,6100,3,2,10,0,South,677 +2024Q2,81.9,0.035,757,7720,2,4,23,0,Midwest,357 +2024Q2,44.0,0.372,724,6740,0,3,23,0,West,282 +2024Q2,44.2,0.367,561,4350,0,5,8,1,South,607 +2024Q2,51.1,0.262,747,7220,2,5,1,0,Midwest,783 +2024Q2,78.1,0.173,667,6300,2,2,19,0,Midwest,99 +2024Q2,37.9,0.217,634,5370,0,1,14,0,Midwest,0 +2024Q2,81.3,0.333,553,4720,2,3,26,0,Midwest,199 +2024Q2,28.6,0.15,850,8450,0,6,16,1,Northeast,1276 +2024Q2,42.7,0.395,703,6410,1,5,30,0,South,529 +2024Q2,23.4,0.227,573,4630,2,1,15,0,Midwest,289 +2024Q2,83.3,0.148,663,6190,2,1,9,0,South,0 +2024Q2,23.4,0.404,595,4600,2,6,12,1,West,870 +2024Q2,38.6,0.145,775,7650,1,5,12,1,Northeast,1108 +2024Q2,53.3,0.22,604,4960,0,3,15,0,Northeast,0 +2024Q2,57.0,0.201,810,8290,2,6,3,0,West,851 +2024Q2,48.0,0.297,649,5430,3,2,15,0,Midwest,383 +2024Q2,67.5,0.276,706,6680,4,3,12,0,Midwest,513 +2024Q2,93.8,0.006,500,3910,3,4,20,1,South,66 +2024Q2,20.3,0.51,518,3890,2,3,8,0,Midwest,566 +2024Q2,57.0,0.135,717,6790,1,7,22,0,Midwest,441 +2024Q2,48.4,0.129,689,6330,2,5,26,0,Northeast,537 +2024Q2,31.3,0.227,658,5750,1,1,30,0,South,220 +2024Q2,43.2,0.348,673,5990,2,3,1,0,South,719 +2024Q2,16.4,0.258,627,5020,1,7,0,0,South,415 +2024Q2,15.7,0.524,615,4790,2,1,16,0,South,282 +2024Q2,56.2,0.365,668,6290,0,4,5,0,Midwest,320 +2024Q2,36.3,0.483,591,4650,1,1,16,0,West,0 +2024Q2,41.9,0.346,562,4290,1,2,21,0,Northeast,139 +2024Q2,17.5,0.134,618,5230,2,6,24,0,West,572 +2024Q2,81.9,0.2,664,6270,1,3,6,0,Northeast,67 +2024Q2,52.4,0.398,511,3820,1,4,5,0,Midwest,178 +2024Q2,25.1,0.316,633,5470,2,3,14,0,Midwest,569 +2024Q2,22.9,0.265,741,6750,0,3,14,0,Northeast,469 +2024Q2,16.4,0.356,604,4720,1,2,12,0,Midwest,248 +2024Q2,38.2,0.229,660,5930,1,6,5,1,Northeast,656 +2024Q2,35.0,0.32,696,6300,0,7,5,1,West,832 +2024Q2,17.4,0.299,606,4760,0,5,4,0,South,207 +2024Q2,28.0,0.277,673,5910,2,7,13,0,South,621 +2024Q2,88.0,0.358,543,4700,1,7,26,0,Northeast,130 +2024Q2,13.6,0.313,659,5430,0,5,23,0,Midwest,234 +2024Q2,41.2,0.216,602,4950,1,3,4,0,Midwest,0 +2024Q2,31.9,0.324,593,4740,0,4,24,0,Northeast,208 +2024Q2,55.6,0.245,620,5200,1,5,12,0,Midwest,0 +2024Q2,12.3,0.319,601,4570,1,2,7,0,South,337 +2024Q2,29.4,0.371,740,6980,1,4,20,0,Northeast,626 +2024Q2,59.1,0.132,702,6700,0,1,20,1,Northeast,375 +2024Q2,103.6,0.334,609,5710,3,5,8,0,Midwest,0 +2024Q2,32.3,0.234,744,7100,3,2,28,0,Midwest,708 +2024Q2,20.0,0.288,689,6040,1,3,14,0,Northeast,547 +2024Q2,41.8,0.309,639,5540,4,1,1,0,Midwest,704 +2024Q2,20.4,0.347,586,4600,5,4,15,0,West,470 +2024Q2,79.7,0.284,614,5580,2,2,9,0,West,163 +2024Q2,53.6,0.371,552,4200,0,7,20,0,West,0 +2024Q2,26.6,0.434,659,5530,2,2,3,0,South,473 +2024Q2,58.1,0.311,704,6660,2,2,27,0,Northeast,281 +2024Q2,10.8,0.67,558,3890,0,4,28,0,Northeast,257 +2024Q2,24.0,0.248,695,6280,2,7,26,0,Northeast,695 +2024Q2,55.9,0.368,691,6520,4,2,0,0,West,727 +2024Q2,30.9,0.345,687,6040,1,5,13,0,Midwest,515 +2024Q2,36.7,0.256,769,7550,1,3,8,0,Midwest,573 +2024Q2,118.7,0.19,714,7290,4,2,11,0,South,125 +2024Q2,38.5,0.291,708,6620,0,4,13,1,Midwest,841 +2024Q2,54.8,0.259,658,5930,2,2,0,0,South,497 +2024Q2,18.3,0.192,536,3680,3,3,28,0,South,322 +2024Q2,42.0,0.21,726,6950,1,2,11,1,Midwest,886 +2024Q2,97.7,0.067,679,6680,2,3,4,0,West,0 +2024Q2,35.2,0.311,736,6890,0,2,5,0,Northeast,353 +2024Q2,42.1,0.18,551,4370,1,4,15,0,Midwest,13 +2024Q2,34.9,0.354,738,6770,2,2,19,0,Northeast,533 +2024Q2,26.5,0.345,605,4870,0,1,20,0,South,0 +2024Q2,41.5,0.281,678,6210,2,2,9,0,Midwest,437 +2024Q2,96.8,0.203,706,7260,1,7,1,0,Northeast,296 +2024Q2,30.8,0.344,790,7610,1,4,1,0,South,919 +2024Q2,58.3,0.252,779,7800,1,2,15,1,Midwest,974 +2024Q2,53.4,0.41,768,7480,0,1,26,0,Midwest,495 +2024Q2,99.6,0.163,660,6420,0,6,14,0,Northeast,0 +2024Q2,26.7,0.099,624,4880,0,2,6,0,South,0 +2024Q2,12.6,0.351,763,6920,0,6,24,0,Northeast,726 +2024Q3,105.7,0.113,779,8370,2,3,29,0,Northeast,531 +2024Q3,60.9,0.184,643,5740,0,6,3,0,Midwest,0 +2024Q3,41.2,0.277,767,7460,1,5,25,0,Midwest,670 +2024Q3,41.2,0.486,679,6270,1,2,25,0,South,292 +2024Q3,57.3,0.196,735,7070,1,4,10,0,Midwest,297 +2024Q3,44.2,0.339,546,4140,0,4,24,0,South,110 +2024Q3,91.3,0.114,627,5830,1,6,19,0,West,0 +2024Q3,40.2,0.364,756,7290,1,4,0,0,South,931 +2024Q3,27.7,0.236,850,8570,4,1,12,0,Midwest,1308 +2024Q3,48.3,0.107,626,5270,1,1,15,0,Northeast,0 +2024Q3,59.6,0.361,586,4840,2,5,2,0,Midwest,0 +2024Q3,12.6,0.208,562,3870,0,1,24,0,Midwest,263 +2024Q3,26.7,0.344,638,5390,1,4,24,0,Midwest,315 +2024Q3,25.6,0.405,596,4710,1,2,24,0,South,203 +2024Q3,42.2,0.281,724,6540,2,2,11,0,West,694 +2024Q3,34.9,0.324,607,4800,2,1,5,0,Northeast,0 +2024Q3,121.7,0.111,737,7690,1,4,15,0,Northeast,0 +2024Q3,37.3,0.325,620,5220,0,5,27,0,Midwest,0 +2024Q3,22.5,0.529,624,5080,1,6,29,0,West,620 +2024Q3,21.7,0.311,788,7550,0,5,12,0,South,733 +2024Q3,44.8,0.301,710,6470,0,7,19,0,West,330 +2024Q3,54.8,0.226,711,7060,1,7,21,0,West,457 +2024Q3,62.0,0.157,552,4490,0,3,27,0,Northeast,0 +2024Q3,63.2,0.202,569,4550,1,7,26,0,Northeast,0 +2024Q3,41.6,0.364,717,6600,0,6,2,0,West,508 +2024Q3,32.2,0.3,640,5350,0,3,11,0,South,167 +2024Q3,62.8,0.387,643,5750,2,6,9,0,West,340 +2024Q3,34.4,0.13,660,5790,1,7,21,0,Northeast,285 +2024Q3,79.5,0.292,736,7540,1,7,20,0,West,265 +2024Q3,66.7,0.237,786,8140,3,6,14,0,Northeast,971 +2024Q3,18.0,0.134,850,8370,3,4,9,0,Northeast,1304 +2024Q3,21.7,0.303,661,5510,1,1,12,1,South,773 +2024Q3,45.7,0.329,723,6850,2,4,28,0,South,590 +2024Q3,64.3,0.318,627,5530,2,5,10,0,West,96 +2024Q3,42.2,0.36,707,6640,1,6,11,0,Midwest,663 +2024Q3,32.5,0.297,758,7380,0,4,25,0,Northeast,641 +2024Q3,56.3,0.292,679,6250,2,4,12,0,Midwest,448 +2024Q3,35.1,0.381,751,7140,0,4,25,0,South,682 +2024Q3,26.4,0.247,696,6250,0,3,21,0,Midwest,300 +2024Q3,52.8,0.235,769,7620,2,3,3,0,Northeast,673 +2024Q3,51.0,0.448,606,4930,3,2,24,0,Midwest,169 +2024Q3,32.1,0.376,695,6420,1,2,5,0,South,677 +2024Q3,88.0,0.093,641,5950,2,3,21,0,South,0 +2024Q3,17.5,0.353,606,4700,2,3,6,0,West,270 +2024Q3,38.4,0.444,649,5490,1,5,6,0,South,372 +2024Q3,44.1,0.401,533,3900,1,3,24,0,Northeast,166 +2024Q3,24.2,0.291,640,5480,0,2,19,1,Northeast,293 +2024Q3,29.6,0.304,619,5270,3,7,15,0,West,589 +2024Q3,55.0,0.435,604,4980,1,5,2,0,Northeast,295 +2024Q3,42.7,0.457,620,5400,2,7,13,0,Northeast,364 +2024Q3,42.9,0.273,536,3930,2,5,15,0,West,66 +2024Q3,35.8,0.198,629,5310,2,5,11,0,South,140 +2024Q3,21.3,0.366,663,5560,2,3,28,0,South,433 +2024Q3,53.2,0.312,577,4740,2,4,26,0,South,237 +2024Q3,50.3,0.273,627,5470,3,4,25,0,South,161 +2024Q3,20.1,0.329,604,4730,3,6,1,0,South,526 +2024Q3,21.9,0.598,623,5100,2,5,14,0,West,615 +2024Q3,57.4,0.287,850,8660,1,2,30,0,Northeast,827 +2024Q3,17.2,0.356,660,5570,1,2,7,0,Midwest,328 +2024Q3,97.8,0.278,661,6520,3,4,15,0,Midwest,76 +2024Q3,68.4,0.318,654,5990,2,2,20,0,South,255 +2024Q3,15.2,0.459,530,3690,2,7,16,1,Midwest,1020 +2024Q3,50.1,0.342,502,3470,0,5,12,0,South,0 +2024Q3,54.5,0.005,765,7410,5,7,27,0,West,720 +2024Q3,17.2,0.236,587,4520,1,2,14,0,South,86 +2024Q3,68.7,0.249,520,3920,2,3,16,0,Midwest,11 +2024Q3,21.0,0.348,593,4640,2,2,30,0,Northeast,265 +2024Q3,23.2,0.257,610,4960,2,2,21,0,South,18 +2024Q3,106.6,0.157,679,6880,1,7,28,1,South,531 +2024Q3,37.0,0.303,567,4230,1,3,0,0,South,296 +2024Q3,57.0,0.469,628,5340,3,1,1,0,Northeast,343 +2024Q3,28.1,0.288,732,6860,2,4,16,1,South,1208 +2024Q3,41.6,0.359,487,3170,2,3,0,0,Midwest,0 +2024Q3,58.6,0.069,552,4410,0,3,1,0,Northeast,0 +2024Q3,21.3,0.467,680,5930,1,5,27,0,Midwest,394 +2024Q3,92.1,0.059,534,4650,4,5,13,0,Northeast,0 +2024Q3,142.7,0.272,722,7710,3,2,19,0,South,37 +2024Q3,34.4,0.467,525,3710,1,1,28,1,West,526 +2024Q3,47.6,0.314,778,7560,1,2,3,0,South,593 +2024Q3,87.7,0.207,669,6500,1,2,13,0,Midwest,53 +2024Q3,73.9,0.229,675,6250,2,1,17,0,Midwest,150 +2024Q3,30.3,0.345,583,4450,0,2,13,0,Northeast,0 +2024Q3,62.3,0.199,827,8600,0,1,11,0,South,591 +2024Q3,14.2,0.383,515,3420,1,7,13,0,Midwest,345 +2024Q3,82.6,0.246,729,7270,0,7,21,0,South,57 +2024Q3,24.8,0.331,661,5760,0,5,23,0,South,167 +2024Q3,44.3,0.323,497,3530,4,7,14,0,Northeast,138 +2024Q3,124.9,0.242,711,7310,2,7,0,1,Midwest,753 +2024Q3,63.1,0.517,722,6980,4,2,2,0,South,609 +2024Q3,108.4,0.025,603,5740,2,3,20,0,South,0 +2024Q3,25.4,0.35,651,5580,1,4,17,0,Northeast,480 +2024Q3,23.3,0.389,703,6250,1,6,11,0,Midwest,548 +2024Q3,32.5,0.344,611,4840,0,7,23,0,Northeast,0 +2024Q3,42.0,0.354,555,4240,2,7,26,0,Northeast,481 +2024Q3,164.2,0.0,706,7660,1,4,19,0,South,0 +2024Q3,34.0,0.187,619,4980,3,2,27,0,West,257 +2024Q3,46.3,0.289,674,6240,0,5,29,0,West,448 +2024Q3,84.3,0.204,518,4000,2,3,3,1,West,39 +2024Q3,17.3,0.518,648,5300,2,2,4,0,Midwest,752 +2024Q3,30.1,0.273,700,6230,1,3,4,0,South,380 +2024Q3,11.9,0.348,587,4390,2,4,8,0,Northeast,421 +2024Q3,26.9,0.452,646,5270,1,6,28,0,Northeast,271 +2024Q3,33.9,0.181,573,4310,0,3,4,0,Midwest,0 +2024Q3,112.3,0.235,624,5860,1,3,25,0,South,0 +2024Q3,34.6,0.158,643,5460,2,5,13,0,South,181 +2024Q3,29.4,0.323,578,4610,1,6,10,0,Midwest,503 +2024Q3,10.9,0.378,583,4500,3,4,23,0,West,528 +2024Q3,35.9,0.199,660,5810,4,1,27,0,Midwest,304 +2024Q3,61.6,0.408,533,4090,1,7,25,0,Northeast,121 +2024Q3,54.9,0.308,687,6260,2,4,15,0,South,524 +2024Q3,26.1,0.287,713,6320,1,4,7,0,Midwest,498 +2024Q3,162.9,0.067,766,8560,2,7,25,0,Midwest,0 +2024Q3,11.9,0.464,677,5910,4,5,3,0,South,1116 +2024Q3,43.6,0.099,742,7280,3,6,2,1,West,1135 +2024Q3,64.4,0.34,838,8690,0,2,2,0,Midwest,896 +2024Q3,25.6,0.318,828,8240,1,7,5,0,West,946 +2024Q3,30.2,0.365,519,3590,2,1,16,0,West,311 +2024Q3,26.9,0.361,668,5870,2,3,20,0,Midwest,656 +2024Q3,30.6,0.118,800,7880,1,5,24,0,Midwest,938 +2024Q3,52.1,0.356,621,5450,1,2,24,0,Midwest,139 +2024Q3,93.1,0.22,665,6570,5,1,27,0,South,113 +2024Q3,102.2,0.443,688,6870,3,7,26,0,South,350 +2024Q3,65.9,0.36,781,7770,0,2,24,0,South,626 +2024Q3,40.1,0.353,772,7330,2,3,12,0,West,804 +2024Q3,45.8,0.326,602,5110,0,4,25,0,South,0 +2024Q3,177.3,0.035,850,9910,0,3,2,0,West,0 +2024Q3,38.4,0.342,563,4220,1,5,19,0,West,169 +2024Q3,35.1,0.378,612,4960,0,1,17,0,West,243 +2024Q3,39.7,0.137,536,3930,2,2,30,0,Northeast,37 +2024Q3,51.7,0.144,646,5800,3,6,9,0,Midwest,466 +2024Q3,38.6,0.362,777,7540,3,1,11,1,South,1358 +2024Q3,61.9,0.219,734,7200,1,4,9,0,West,482 +2024Q3,43.3,0.199,588,4780,0,7,10,0,Northeast,0 +2024Q3,23.7,0.287,637,5210,0,3,7,0,West,132 +2024Q3,17.9,0.383,775,7380,0,7,26,0,Northeast,765 +2024Q3,49.6,0.137,761,7620,0,7,7,0,West,420 +2024Q3,28.8,0.396,627,5070,2,2,25,0,South,336 +2024Q3,107.2,0.118,717,7430,1,1,22,0,Northeast,0 +2024Q3,36.5,0.35,622,5070,0,3,26,0,Northeast,0 +2024Q3,36.3,0.334,633,5210,1,1,3,0,Midwest,146 +2024Q3,59.8,0.344,544,4220,1,4,2,0,Northeast,0 +2024Q3,51.5,0.316,616,5110,3,5,10,0,Northeast,402 +2024Q3,20.8,0.387,697,6200,0,4,25,0,South,199 +2024Q3,19.5,0.385,693,6200,4,3,11,0,South,836 +2024Q3,266.0,0.225,746,6870,2,3,1,1,South,1072 +2024Q3,29.2,0.323,671,5790,1,3,5,0,South,188 +2024Q3,30.6,0.35,695,6280,0,6,5,0,West,503 +2024Q3,32.1,0.45,759,7330,5,3,17,0,Midwest,672 +2024Q3,16.3,0.359,547,4030,2,6,20,0,West,369 +2024Q3,20.5,0.528,680,5830,2,2,30,0,South,898 +2024Q3,36.4,0.339,625,4960,0,6,15,0,West,32 +2024Q3,22.8,0.3,648,5420,1,2,8,0,Midwest,269 +2024Q3,36.4,0.324,715,6420,3,4,6,0,Midwest,628 +2024Q3,72.0,0.081,718,7020,0,6,5,0,West,233 +2024Q3,43.7,0.159,673,6150,1,4,0,0,Northeast,238 +2024Q3,23.0,0.31,732,6770,2,2,3,0,South,557 +2024Q3,178.1,0.164,620,6470,2,5,17,0,West,0 +2024Q3,27.6,0.223,586,4350,1,6,6,0,South,11 +2024Q3,30.9,0.618,649,5370,3,6,14,1,South,985 +2024Q3,38.1,0.268,752,7190,1,3,7,0,Northeast,554 +2024Q3,23.1,0.494,735,6960,2,4,7,0,Midwest,917 +2024Q3,50.0,0.314,835,8420,1,2,12,0,West,703 +2024Q3,93.4,0.232,581,5160,3,1,3,0,Northeast,0 +2024Q3,28.5,0.307,671,5650,0,6,14,0,South,214 +2024Q3,35.8,0.361,663,5960,1,1,22,0,Northeast,281 +2024Q3,42.0,0.367,765,7520,3,3,28,0,South,984 +2024Q3,10.0,0.346,546,3920,2,1,19,0,Midwest,609 +2024Q3,25.1,0.285,650,5420,3,4,25,0,Midwest,390 +2024Q3,47.8,0.253,833,8390,0,4,20,1,West,1098 +2024Q3,32.2,0.358,660,5780,1,4,14,0,Northeast,446 +2024Q3,17.7,0.427,703,6320,1,2,26,0,Midwest,912 +2024Q3,32.0,0.155,733,6970,1,4,13,0,West,435 +2024Q3,18.8,0.56,682,5950,0,6,21,0,South,501 +2024Q3,82.9,0.095,754,7540,1,3,25,0,South,353 +2024Q3,26.3,0.257,577,4430,2,5,15,0,South,300 +2024Q3,52.9,0.156,625,5460,2,7,15,0,South,299 +2024Q3,30.1,0.265,722,6560,1,4,30,0,West,686 +2024Q3,41.8,0.557,757,7390,1,2,16,1,South,1355 +2024Q3,65.2,0.016,833,8690,1,1,10,0,West,506 +2024Q3,33.5,0.265,558,4140,0,1,13,0,South,0 +2024Q3,56.6,0.227,633,5720,0,1,6,1,Midwest,323 +2024Q3,25.0,0.258,528,3740,1,4,24,0,West,297 +2024Q3,43.8,0.455,647,5550,1,5,22,0,Midwest,62 +2024Q3,73.3,0.248,720,6980,0,6,14,0,West,173 +2024Q3,46.3,0.262,719,6810,3,5,1,0,Northeast,718 +2024Q3,42.2,0.218,563,4500,2,1,16,0,Northeast,265 +2024Q3,14.4,0.444,646,5320,3,6,21,0,West,736 +2024Q3,60.1,0.222,644,5800,2,2,13,1,West,630 +2024Q3,200.0,0.11,705,8060,0,6,0,0,Midwest,0 +2024Q3,61.6,0.194,586,4980,0,1,9,0,Northeast,0 +2024Q3,47.8,0.33,642,5600,1,7,29,0,South,93 +2024Q3,75.2,0.217,620,5560,2,6,19,0,South,234 +2024Q3,71.8,0.321,681,6510,1,6,1,0,Midwest,347 +2024Q3,65.2,0.208,700,6710,0,4,28,0,West,91 +2024Q3,48.2,0.528,636,5490,0,2,9,0,West,12 +2024Q3,18.2,0.377,654,5380,2,7,6,0,Midwest,535 +2024Q3,52.1,0.282,694,6550,1,7,24,0,Northeast,496 +2024Q3,46.7,0.354,605,4880,3,7,9,0,West,66 +2024Q3,70.6,0.344,566,4620,2,1,4,0,West,211 +2024Q3,43.0,0.281,645,5370,1,7,1,0,West,335 +2024Q3,24.9,0.256,699,6030,1,4,29,1,West,679 +2024Q3,43.0,0.307,718,6790,1,3,4,0,Northeast,334 +2024Q3,21.5,0.287,739,6870,3,4,23,0,West,916 +2024Q3,27.6,0.351,669,5920,0,1,3,0,West,255 +2024Q3,71.8,0.285,690,6570,1,7,30,0,West,202 +2024Q3,10.0,0.329,458,2460,0,4,25,0,West,0 +2024Q3,38.9,0.305,490,3080,2,3,21,1,Midwest,438 +2024Q3,31.9,0.53,507,3410,2,7,30,0,Midwest,207 +2024Q3,52.9,0.411,526,3860,0,5,0,0,Midwest,0 +2024Q3,136.6,0.022,761,8320,3,5,24,0,South,58 +2024Q3,56.3,0.349,577,4680,3,3,12,0,Northeast,618 +2024Q3,60.0,0.274,578,4750,0,7,10,0,South,0 +2024Q3,62.8,0.261,805,8310,1,4,20,0,Northeast,874 +2024Q3,15.4,0.244,660,5630,2,3,26,0,Northeast,593 +2024Q3,69.3,0.265,711,6930,2,6,27,1,Midwest,897 +2024Q3,45.1,0.304,534,3940,1,7,17,0,Midwest,96 +2024Q3,21.7,0.502,559,4170,1,7,26,0,Midwest,450 +2024Q3,154.6,0.0,655,6910,2,7,6,0,Midwest,0 +2024Q3,42.0,0.467,570,4630,2,4,18,0,South,478 +2024Q3,17.2,0.143,738,6620,2,2,17,0,Midwest,607 +2024Q3,31.4,0.492,664,5610,1,1,20,0,Midwest,351 +2024Q3,39.5,0.245,687,6420,1,5,15,0,West,398 +2024Q3,72.0,0.223,726,6960,0,4,24,0,South,62 +2024Q3,33.7,0.072,688,6020,1,3,6,0,Midwest,255 +2024Q3,60.4,0.389,657,6090,2,4,29,0,South,329 +2024Q3,24.9,0.377,744,6800,1,3,17,0,South,578 +2024Q3,42.4,0.364,746,6960,0,6,22,0,South,461 +2024Q3,32.0,0.377,626,5440,5,7,15,0,South,693 +2024Q3,12.1,0.254,621,4820,2,4,3,0,South,313 +2024Q3,66.9,0.181,772,7840,1,7,28,0,West,661 +2024Q3,22.7,0.165,699,6250,2,7,7,0,Northeast,477 +2024Q3,18.7,0.34,659,5610,1,6,15,0,Midwest,506 +2024Q3,50.9,0.237,677,6190,0,7,13,0,Midwest,256 +2024Q3,50.5,0.281,527,4050,2,7,5,0,Midwest,250 +2024Q3,49.1,0.2,790,7840,0,5,18,1,West,875 +2024Q3,44.5,0.268,509,3530,0,4,23,0,Midwest,0 +2024Q3,72.9,0.16,566,4770,0,1,2,0,South,0 +2024Q3,14.3,0.341,600,4510,3,3,12,0,South,244 +2024Q3,46.2,0.277,598,4960,1,3,12,0,Midwest,0 +2024Q3,59.7,0.248,725,7000,0,4,7,0,Northeast,199 +2024Q3,10.0,0.213,729,6470,1,3,10,0,West,782 +2024Q3,36.4,0.307,547,4010,2,6,29,0,South,400 +2024Q3,18.8,0.36,649,5470,3,1,28,0,Northeast,512 +2024Q3,22.6,0.421,726,6550,5,5,24,0,Northeast,903 +2024Q3,32.7,0.398,695,6000,2,7,28,0,South,707 +2024Q3,54.0,0.301,721,6850,1,1,7,0,Midwest,502 +2024Q3,89.7,0.153,746,7600,2,4,12,0,South,611 +2024Q3,12.5,0.441,628,4970,3,5,27,0,South,594 +2024Q3,10.0,0.362,639,5410,1,1,1,0,Midwest,367 +2024Q3,115.0,0.0,707,7190,2,6,0,0,West,55 +2024Q3,27.5,0.223,606,4960,0,3,3,0,West,0 +2024Q3,36.0,0.251,594,4600,3,1,21,0,West,126 +2024Q3,73.0,0.247,806,8490,1,2,10,0,Midwest,723 +2024Q3,59.5,0.309,738,6950,3,2,4,0,West,526 +2024Q3,20.5,0.443,742,6580,3,4,7,0,Midwest,1043 +2024Q3,39.1,0.368,692,6270,0,3,15,0,West,404 +2024Q3,40.2,0.322,739,7000,0,1,20,0,West,391 +2024Q3,44.7,0.239,618,5220,2,2,14,0,South,329 +2024Q3,64.1,0.284,679,6350,0,1,4,0,Northeast,115 +2024Q3,22.6,0.306,646,5490,1,5,0,0,West,507 +2024Q3,53.0,0.11,517,3680,0,1,28,1,South,74 +2024Q3,13.1,0.397,578,4430,1,6,16,1,South,896 +2024Q3,18.9,0.26,667,5620,1,2,15,0,Northeast,421 +2024Q3,49.8,0.446,671,5890,1,7,17,0,South,309 +2024Q3,93.6,0.061,843,9090,0,5,17,0,Midwest,395 +2024Q3,63.2,0.261,636,5590,4,1,30,0,South,0 +2024Q3,20.7,0.447,682,5940,3,4,23,1,Northeast,942 +2024Q3,63.0,0.316,556,4690,1,1,19,0,West,36 +2024Q3,39.4,0.309,598,4880,1,7,15,0,West,239 +2024Q3,89.0,0.105,584,5130,4,6,16,0,Northeast,51 +2024Q3,34.3,0.309,687,6170,3,6,3,0,West,526 +2024Q3,68.8,0.108,621,5550,2,2,30,0,Midwest,0 +2024Q3,10.0,0.334,543,3660,2,7,11,0,Midwest,404 +2024Q3,33.2,0.286,578,4420,0,6,17,1,Northeast,291 +2024Q3,35.1,0.246,530,3940,2,4,13,0,Northeast,274 +2024Q3,23.0,0.341,578,4630,2,4,12,0,West,554 +2024Q3,13.7,0.271,684,5840,1,6,20,0,South,488 +2024Q3,46.7,0.138,700,6140,3,1,26,0,Northeast,468 +2024Q3,32.7,0.199,666,5830,2,2,0,1,West,911 +2024Q3,79.2,0.322,575,4890,0,7,23,0,Northeast,0 +2024Q3,16.3,0.376,728,6710,1,5,22,0,Midwest,775 +2024Q3,57.1,0.409,676,6300,0,2,3,0,South,42 +2024Q3,13.2,0.44,693,6170,0,5,5,0,Midwest,407 +2024Q3,16.4,0.175,599,4630,0,1,23,0,South,194 +2024Q3,89.5,0.336,551,4510,0,1,18,0,Midwest,0 +2024Q3,23.6,0.371,696,6060,1,6,2,0,Northeast,671 +2024Q3,25.1,0.2,488,3030,1,3,10,0,West,0 +2024Q3,25.4,0.351,564,4340,1,1,8,0,South,190 +2024Q3,176.8,0.0,717,7920,1,1,27,0,South,0 +2024Q3,31.8,0.411,612,5250,0,4,11,0,West,0 +2024Q3,39.6,0.407,706,6550,1,4,9,0,Midwest,597 +2024Q3,104.4,0.121,681,6490,3,7,20,0,Northeast,0 +2024Q3,10.0,0.312,715,6580,2,2,1,0,Northeast,848 +2024Q3,22.3,0.402,727,6670,1,6,16,0,Midwest,779 +2024Q3,35.8,0.316,511,3660,2,1,5,0,West,171 +2024Q3,97.5,0.172,844,9070,1,6,16,0,Midwest,697 +2024Q3,86.3,0.252,514,3930,4,1,27,0,West,0 +2024Q3,66.6,0.285,444,2800,1,2,8,0,Northeast,0 +2024Q3,41.5,0.163,619,5360,1,2,7,0,West,278 +2024Q3,12.8,0.401,644,5070,1,7,24,0,South,551 +2024Q3,42.6,0.159,610,4980,3,1,22,0,West,56 +2024Q3,28.1,0.087,700,6160,0,4,19,0,Northeast,308 +2024Q3,29.9,0.262,602,4910,2,3,11,0,South,34 +2024Q3,63.5,0.295,726,7160,0,4,4,0,South,416 +2024Q3,200.0,0.007,642,7060,2,7,26,0,South,0 +2024Q3,35.3,0.229,612,5040,0,1,4,0,Northeast,0 +2024Q3,16.5,0.357,580,4200,2,6,22,0,West,341 +2024Q3,55.3,0.132,621,5470,1,2,29,0,Midwest,0 +2024Q3,51.2,0.137,733,6780,4,1,25,0,South,528 +2024Q3,34.0,0.364,622,5080,3,7,7,0,Northeast,391 +2024Q3,20.6,0.559,661,5590,3,5,21,0,Northeast,709 +2024Q3,27.4,0.396,677,5930,6,4,9,0,Midwest,809 +2024Q3,32.8,0.397,620,5400,2,3,22,0,Midwest,298 +2024Q3,101.3,0.219,784,8340,2,4,26,0,South,542 +2024Q3,41.4,0.543,715,6600,1,1,10,1,South,923 +2024Q3,34.2,0.18,432,2490,1,4,1,0,South,0 +2024Q3,22.1,0.549,688,6190,1,7,24,0,West,736 +2024Q3,68.6,0.354,677,6370,3,6,13,0,Midwest,304 +2024Q3,37.6,0.256,712,6520,3,4,27,0,Midwest,747 +2024Q3,21.0,0.316,626,5390,0,7,9,0,Northeast,452 +2024Q3,78.7,0.031,646,5820,2,2,8,0,Northeast,0 +2024Q3,38.8,0.345,743,7080,2,5,5,0,Northeast,883 +2024Q3,48.8,0.387,715,6720,1,7,19,0,Northeast,534 +2024Q3,56.8,0.281,711,6700,1,7,18,0,Northeast,288 +2024Q3,62.8,0.361,842,8760,1,2,28,1,West,1358 +2024Q3,37.7,0.325,711,6440,1,3,2,0,Northeast,522 +2024Q3,55.7,0.285,654,5790,2,5,28,0,Midwest,367 +2024Q3,87.6,0.197,576,5000,1,3,22,0,Midwest,0 +2024Q3,95.5,0.159,611,5500,1,1,4,0,South,0 +2024Q3,18.2,0.535,594,4680,0,7,5,0,Midwest,383 +2024Q3,40.0,0.294,616,5200,3,7,20,0,Midwest,289 +2024Q3,81.2,0.0,778,7900,1,4,14,1,South,690 +2024Q3,181.1,0.0,734,8210,3,6,3,1,West,338 +2024Q3,46.1,0.407,613,5120,3,1,10,0,Midwest,208 +2024Q3,37.5,0.362,730,6650,2,1,21,0,West,620 +2024Q3,14.6,0.292,730,6540,1,4,28,1,West,1166 +2024Q3,50.6,0.215,651,5700,0,3,0,0,South,211 +2024Q3,65.4,0.243,628,5610,2,1,9,0,Midwest,222 +2024Q3,89.8,0.166,633,6000,2,2,5,0,South,0 +2024Q3,55.4,0.447,513,3810,0,7,0,1,West,420 +2024Q3,114.6,0.224,648,6460,3,3,23,0,Midwest,137 +2024Q3,61.0,0.313,537,4050,1,7,18,0,West,131 +2024Q3,94.2,0.242,548,4720,2,3,3,0,Northeast,0 +2024Q3,102.6,0.063,729,7240,1,1,4,0,Midwest,0 +2024Q3,39.3,0.334,668,5780,3,2,0,1,West,886 +2024Q3,56.8,0.091,725,7030,0,7,23,0,Midwest,260 +2024Q3,10.0,0.592,520,3330,0,4,26,0,Midwest,109 +2024Q3,39.2,0.515,730,6720,1,2,11,0,South,645 +2024Q3,24.5,0.526,641,5220,2,2,7,0,South,437 +2024Q3,23.6,0.223,615,4880,0,7,29,0,Northeast,60 +2024Q3,29.0,0.206,701,6290,3,1,14,0,South,688 +2024Q3,70.4,0.308,606,5470,0,2,18,0,South,0 +2024Q3,45.3,0.415,658,5710,3,3,1,0,West,504 +2024Q3,12.4,0.229,707,6130,1,2,0,0,West,569 +2024Q3,33.1,0.215,558,4300,1,4,9,0,South,115 +2024Q3,28.0,0.284,630,5190,3,7,8,0,West,442 +2024Q3,56.9,0.237,721,6720,1,4,16,0,Midwest,317 +2024Q3,42.9,0.413,729,6920,1,7,5,0,Northeast,898 +2024Q3,48.3,0.406,550,3960,0,2,20,0,West,29 +2024Q3,26.0,0.268,819,8030,1,3,5,0,West,886 +2024Q3,13.5,0.277,665,5530,1,5,27,0,Northeast,528 +2024Q3,21.9,0.378,783,7650,2,1,7,0,Midwest,1207 +2024Q3,33.6,0.419,725,6880,3,2,22,0,Northeast,942 +2024Q3,46.4,0.239,593,4810,4,5,29,0,Midwest,349 +2024Q3,115.1,0.0,723,7320,2,6,26,0,Northeast,68 +2024Q3,16.8,0.336,640,5380,2,1,23,1,West,735 +2024Q3,18.7,0.465,643,5280,2,1,19,0,West,617 +2024Q3,157.1,0.0,721,7890,1,5,15,0,Northeast,0 +2024Q3,25.4,0.222,518,3530,2,3,27,0,South,274 +2024Q3,68.1,0.337,757,7350,1,4,3,0,Midwest,580 +2024Q3,23.7,0.288,482,3090,2,5,1,0,West,0 +2024Q3,29.8,0.293,730,6920,0,6,16,0,Northeast,365 +2024Q3,27.5,0.426,678,5800,1,7,8,0,South,613 +2024Q3,38.2,0.36,705,6430,3,7,11,0,South,638 +2024Q3,41.5,0.339,633,5340,1,1,17,0,Northeast,164 +2024Q3,35.2,0.206,709,6550,2,3,23,0,South,538 +2024Q3,34.5,0.3,752,7020,2,4,25,0,Midwest,826 +2024Q3,20.9,0.119,575,4340,1,1,5,0,Midwest,282 +2024Q3,50.7,0.255,489,3340,0,3,17,0,South,0 +2024Q3,147.8,0.222,798,8990,0,6,18,0,West,0 +2024Q3,33.3,0.253,658,5790,2,2,17,0,Northeast,587 +2024Q3,21.5,0.41,527,3550,0,6,25,0,Midwest,43 +2024Q3,79.4,0.124,850,8970,1,2,28,0,Northeast,677 +2024Q3,53.1,0.137,660,5860,1,2,20,0,West,0 +2024Q3,15.5,0.476,624,5050,1,7,28,0,Midwest,249 +2024Q3,32.5,0.453,624,5380,2,2,25,0,West,183 +2024Q3,62.5,0.15,598,4980,1,4,17,0,South,0 +2024Q3,23.8,0.146,620,5110,0,2,8,0,West,0 +2024Q3,30.0,0.333,557,4360,2,1,9,0,Midwest,329 +2024Q3,47.6,0.34,824,8240,1,2,4,0,West,915 +2024Q3,31.2,0.357,500,3090,0,3,9,0,Midwest,0 +2024Q3,33.3,0.282,677,6060,2,4,19,1,Northeast,935 +2024Q3,87.5,0.139,566,5040,1,6,5,0,Midwest,74 +2024Q3,23.1,0.41,691,6000,2,1,27,0,Midwest,689 +2024Q4,25.9,0.18,589,4630,0,1,27,0,Midwest,111 +2024Q4,23.7,0.381,742,6970,0,1,3,0,Midwest,579 +2024Q4,59.6,0.096,497,3490,0,2,21,0,Northeast,0 +2024Q4,29.6,0.352,547,3860,1,3,20,0,Midwest,149 +2024Q4,70.3,0.16,618,5850,2,5,6,0,Northeast,0 +2024Q4,53.7,0.32,697,6190,0,4,11,0,West,107 +2024Q4,40.7,0.127,611,5170,0,6,5,0,West,0 +2024Q4,60.0,0.314,772,7670,0,6,23,0,West,539 +2024Q4,12.8,0.364,546,3720,1,7,10,0,Northeast,432 +2024Q4,23.3,0.249,682,5980,0,1,7,0,Northeast,270 +2024Q4,105.5,0.08,677,6610,2,7,21,0,South,77 +2024Q4,101.4,0.064,701,7050,2,2,30,0,Northeast,0 +2024Q4,12.6,0.338,808,7840,2,7,8,1,Northeast,1645 +2024Q4,71.0,0.101,719,7130,2,4,26,1,South,651 +2024Q4,19.4,0.41,606,4700,3,2,3,1,South,888 +2024Q4,38.8,0.217,670,6040,0,6,5,0,South,165 +2024Q4,21.3,0.308,605,4710,2,7,24,0,Northeast,411 +2024Q4,43.8,0.297,550,4190,0,7,25,0,Northeast,116 +2024Q4,35.2,0.342,585,4480,0,3,17,0,Midwest,0 +2024Q4,76.7,0.166,657,6120,3,6,14,0,West,302 +2024Q4,53.3,0.349,575,4440,0,4,22,0,Midwest,25 +2024Q4,26.1,0.188,664,5690,2,4,10,0,Northeast,497 +2024Q4,48.6,0.245,588,4700,0,4,16,0,Northeast,0 +2024Q4,27.4,0.217,738,6840,1,3,24,0,West,486 +2024Q4,41.0,0.344,567,4290,5,7,3,0,Northeast,511 +2024Q4,72.6,0.23,632,5790,1,3,2,0,West,0 +2024Q4,51.5,0.274,710,6850,1,1,17,0,South,370 +2024Q4,135.2,0.129,732,7970,3,7,26,0,Northeast,217 +2024Q4,51.9,0.253,567,4520,3,2,8,0,South,348 +2024Q4,46.3,0.229,681,6060,2,1,16,0,Northeast,427 +2024Q4,102.1,0.219,766,8030,1,1,12,0,Midwest,294 +2024Q4,57.0,0.092,573,4630,1,4,29,0,Midwest,29 +2024Q4,41.9,0.258,560,4390,0,2,27,0,South,0 +2024Q4,39.8,0.451,682,6020,0,7,16,0,Midwest,169 +2024Q4,11.7,0.274,637,5330,1,5,9,0,Northeast,433 +2024Q4,27.0,0.49,757,7420,2,2,16,0,Northeast,1182 +2024Q4,95.4,0.063,782,8180,2,1,25,0,South,235 +2024Q4,84.6,0.199,587,5430,1,4,30,0,Northeast,0 +2024Q4,66.1,0.317,757,7520,1,2,0,0,Midwest,497 +2024Q4,17.7,0.151,639,5170,2,7,27,0,Midwest,357 +2024Q4,200.0,0.017,820,9840,0,6,28,0,South,0 +2024Q4,10.0,0.298,668,5790,1,1,1,0,Midwest,552 +2024Q4,147.5,0.05,839,9770,1,2,8,0,Midwest,479 +2024Q4,29.6,0.538,666,5450,0,2,8,0,West,114 +2024Q4,127.2,0.114,665,6630,0,3,30,0,Midwest,0 +2024Q4,25.0,0.317,718,6390,1,7,10,0,South,363 +2024Q4,40.9,0.279,619,4990,0,4,14,0,West,0 +2024Q4,76.5,0.098,698,6630,3,1,27,0,Midwest,0 +2024Q4,61.1,0.176,788,7900,0,4,1,0,Northeast,357 +2024Q4,52.4,0.156,687,6180,1,6,27,0,Northeast,334 +2024Q4,26.9,0.372,676,5810,0,6,2,0,Midwest,191 +2024Q4,28.2,0.232,739,6770,3,5,2,0,Midwest,893 +2024Q4,33.5,0.367,586,4780,3,3,18,1,South,833 +2024Q4,51.9,0.303,645,5690,0,6,26,0,South,0 +2024Q4,57.8,0.218,631,5480,2,6,18,1,South,750 +2024Q4,49.4,0.338,539,4050,5,2,19,0,West,147 +2024Q4,74.3,0.403,688,6740,1,7,15,0,South,348 +2024Q4,18.4,0.378,569,4240,2,2,1,1,West,1118 +2024Q4,21.4,0.307,768,7410,1,6,21,0,Midwest,1015 +2024Q4,32.5,0.208,677,6100,1,4,27,0,South,212 +2024Q4,36.0,0.098,538,3580,0,2,13,0,South,0 +2024Q4,18.4,0.3,542,3680,5,3,30,0,South,625 +2024Q4,27.9,0.098,801,7650,1,2,25,0,South,624 +2024Q4,19.4,0.399,741,7050,1,4,18,0,South,987 +2024Q4,18.9,0.201,526,3500,0,2,26,1,West,267 +2024Q4,61.7,0.169,647,5890,5,3,10,0,West,602 +2024Q4,40.7,0.219,588,4520,3,7,1,0,Northeast,150 +2024Q4,39.7,0.305,591,4760,3,3,28,0,South,371 +2024Q4,65.4,0.379,702,6900,3,1,14,0,West,444 +2024Q4,57.3,0.196,723,6660,0,2,29,0,West,251 +2024Q4,45.5,0.201,530,4130,1,7,13,0,Northeast,27 +2024Q4,10.0,0.415,606,4610,1,7,24,0,Midwest,305 +2024Q4,31.3,0.22,580,4530,5,5,23,0,West,202 +2024Q4,77.0,0.273,643,6010,3,1,18,0,Northeast,352 +2024Q4,31.9,0.334,679,5860,1,5,18,0,West,454 +2024Q4,34.5,0.403,729,6940,3,4,8,0,Midwest,851 +2024Q4,76.8,0.426,659,5960,1,4,30,0,West,322 +2024Q4,54.2,0.152,823,8290,0,6,7,0,South,712 +2024Q4,85.5,0.122,780,8120,1,5,1,0,South,483 +2024Q4,23.0,0.354,617,4890,3,7,12,0,West,606 +2024Q4,75.3,0.206,646,6200,1,3,16,0,West,173 +2024Q4,55.6,0.135,592,4880,3,4,22,0,Midwest,23 +2024Q4,15.5,0.437,568,4010,1,6,19,0,Midwest,494 +2024Q4,61.4,0.285,698,6480,0,6,16,0,West,378 +2024Q4,50.5,0.163,758,7390,2,5,30,0,West,531 +2024Q4,17.7,0.425,592,4440,4,6,30,0,West,430 +2024Q4,21.4,0.048,643,5280,1,7,10,0,South,191 +2024Q4,18.0,0.345,726,6710,3,6,22,0,West,888 +2024Q4,200.0,0.055,686,7750,1,5,28,0,South,0 +2024Q4,21.9,0.532,420,1820,3,5,0,0,Northeast,0 +2024Q4,73.4,0.319,822,8550,0,5,18,0,Northeast,529 +2024Q4,37.3,0.167,639,5470,1,6,19,0,South,139 +2024Q4,23.0,0.315,605,4640,4,4,6,0,West,366 +2024Q4,31.3,0.342,691,6100,3,4,9,0,Northeast,802 +2024Q4,29.8,0.56,626,4960,3,6,25,0,South,508 +2024Q4,27.4,0.387,552,4000,4,4,25,0,Midwest,341 +2024Q4,39.2,0.271,704,6220,2,3,7,0,Northeast,526 +2024Q4,17.9,0.433,658,5530,2,6,21,0,South,1053 +2024Q4,24.4,0.241,707,6090,0,2,25,0,Midwest,102 +2024Q4,26.9,0.247,666,5510,0,3,7,0,Midwest,268 +2024Q4,84.6,0.262,561,4640,3,2,22,0,Northeast,193 +2024Q4,17.2,0.333,703,6200,1,1,0,0,South,409 +2024Q4,86.0,0.27,679,6480,3,2,4,0,West,102 +2024Q4,59.5,0.15,655,5920,2,1,26,0,Midwest,28 +2024Q4,37.7,0.41,707,6420,2,5,20,0,Northeast,727 +2024Q4,15.9,0.131,774,7230,0,6,3,0,Midwest,654 +2024Q4,22.0,0.351,590,4520,2,5,11,0,Midwest,370 +2024Q4,44.8,0.147,650,5670,2,6,26,0,Northeast,483 +2024Q4,48.6,0.257,735,7030,1,1,2,0,Northeast,774 +2024Q4,25.4,0.387,577,4270,4,2,15,0,Midwest,501 +2024Q4,25.3,0.102,647,5530,2,3,25,0,West,356 +2024Q4,10.0,0.47,662,5480,1,7,15,0,West,635 +2024Q4,26.5,0.346,679,6040,1,4,7,0,Northeast,452 +2024Q4,69.3,0.364,712,6680,5,2,10,0,Midwest,348 +2024Q4,24.0,0.228,569,4070,0,3,18,0,West,0 +2024Q4,77.7,0.279,613,5270,2,5,3,0,Midwest,78 +2024Q4,59.9,0.179,660,6190,3,4,20,0,Northeast,480 +2024Q4,78.4,0.143,597,5480,1,5,13,1,Northeast,165 +2024Q4,81.8,0.043,738,7080,2,6,0,0,Northeast,282 +2024Q4,31.8,0.133,761,7160,1,1,3,0,South,517 +2024Q4,11.6,0.283,726,6520,0,3,20,0,South,468 +2024Q4,42.6,0.396,738,7120,5,6,15,0,Midwest,1119 +2024Q4,28.0,0.355,516,3690,1,7,24,0,South,308 +2024Q4,55.2,0.219,574,4670,2,1,13,1,Midwest,389 +2024Q4,15.3,0.26,616,4950,0,2,1,0,Northeast,0 +2024Q4,135.5,0.0,653,6550,0,7,29,0,West,0 +2024Q4,10.0,0.427,518,3530,3,6,3,1,West,1044 +2024Q4,61.9,0.213,633,5610,0,7,30,0,Midwest,0 +2024Q4,48.2,0.153,630,5590,0,1,4,0,Northeast,0 +2024Q4,42.5,0.241,628,5300,1,5,14,0,West,0 +2024Q4,21.0,0.373,820,8050,0,2,7,1,South,1234 +2024Q4,19.3,0.194,788,7460,1,6,2,0,South,673 +2024Q4,19.9,0.314,695,6190,1,6,21,0,Northeast,394 +2024Q4,54.0,0.29,652,5890,2,2,1,0,Midwest,127 +2024Q4,58.3,0.385,629,5440,0,4,6,0,West,0 +2024Q4,16.6,0.33,751,6780,1,7,16,0,Midwest,669 +2024Q4,41.6,0.137,614,5140,2,5,17,0,South,188 +2024Q4,47.9,0.322,725,6940,2,2,6,0,West,583 +2024Q4,27.2,0.192,647,5440,2,2,16,0,Northeast,258 +2024Q4,18.4,0.267,631,4940,2,3,1,0,Northeast,420 +2024Q4,20.5,0.41,602,4640,1,5,14,0,West,447 +2024Q4,33.2,0.362,727,6780,3,7,22,0,Midwest,862 +2024Q4,10.0,0.346,715,6360,3,6,2,1,West,1370 +2024Q4,32.9,0.266,757,7090,0,4,18,0,West,354 +2024Q4,12.9,0.268,520,3480,3,4,27,0,West,331 +2024Q4,76.3,0.165,525,4230,0,4,23,0,Midwest,0 +2024Q4,62.3,0.162,640,5680,2,7,1,0,Midwest,51 +2024Q4,50.0,0.335,608,5050,0,4,12,0,Northeast,0 +2024Q4,24.5,0.511,538,3770,1,2,9,0,Midwest,157 +2024Q4,47.6,0.202,584,4710,1,4,20,0,West,8 +2024Q4,55.5,0.385,681,6490,2,5,10,1,Northeast,866 +2024Q4,176.9,0.128,660,7210,1,4,2,0,Northeast,0 +2024Q4,49.2,0.256,674,6040,5,3,19,0,South,338 +2024Q4,112.0,0.177,727,7430,0,5,8,0,Northeast,15 +2024Q4,44.7,0.318,602,4910,5,4,1,0,West,282 +2024Q4,36.4,0.388,648,5730,2,6,24,0,Northeast,619 +2024Q4,40.8,0.348,735,6940,3,7,12,0,Northeast,933 +2024Q4,48.5,0.2,616,5350,3,3,20,0,South,254 +2024Q4,26.7,0.145,539,3910,0,4,21,0,West,0 +2024Q4,30.3,0.383,640,5360,3,1,25,0,West,566 +2024Q4,66.4,0.348,512,3790,0,5,2,0,South,0 +2024Q4,81.8,0.06,595,5440,1,1,22,0,West,0 +2024Q4,34.5,0.269,850,8550,0,1,7,0,Northeast,717 +2024Q4,24.4,0.197,810,7930,3,4,22,0,West,925 +2024Q4,26.4,0.37,629,5130,2,3,0,0,Midwest,561 +2024Q4,41.2,0.335,547,4330,1,5,6,0,Northeast,201 +2024Q4,15.5,0.36,488,3230,3,5,2,0,West,245 +2024Q4,11.5,0.347,493,2960,1,4,12,0,Northeast,3 +2024Q4,70.4,0.254,579,5060,3,6,24,0,South,468 +2024Q4,33.6,0.367,672,5730,1,3,9,0,South,470 +2024Q4,25.5,0.411,732,6860,2,3,2,1,Midwest,1191 +2024Q4,47.1,0.263,680,6400,1,3,3,0,Northeast,188 +2024Q4,16.4,0.31,637,5160,2,3,30,0,Northeast,674 +2024Q4,59.1,0.22,603,5260,2,4,7,0,West,96 +2024Q4,27.1,0.41,671,5810,0,6,21,0,Midwest,580 +2024Q4,15.6,0.179,561,4130,2,5,11,0,Midwest,457 +2024Q4,57.3,0.23,759,7610,2,2,14,0,West,600 +2024Q4,44.9,0.211,621,5290,1,1,19,0,West,91 +2024Q4,49.0,0.149,676,6150,1,1,5,0,Northeast,220 +2024Q4,32.2,0.428,590,4530,2,2,9,0,Midwest,85 +2024Q4,35.8,0.369,629,5460,0,7,26,0,West,201 +2024Q4,27.0,0.376,619,5210,2,2,1,0,West,407 +2024Q4,71.5,0.344,606,5270,2,1,15,0,South,0 +2024Q4,30.9,0.425,647,5540,0,4,11,0,West,0 +2024Q4,328.0,0.215,585,4620,0,5,25,0,Northeast,0 +2024Q4,64.6,0.326,655,6080,2,3,27,0,Midwest,366 +2024Q4,70.1,0.201,667,6100,1,5,15,0,Northeast,311 +2024Q4,24.9,0.486,579,4360,2,6,29,1,West,1028 +2024Q4,45.1,0.324,770,7360,1,1,25,0,Midwest,667 +2024Q4,30.8,0.295,600,4740,2,3,0,0,Midwest,259 +2024Q4,121.9,0.252,833,9250,1,4,11,0,Midwest,584 +2024Q4,29.8,0.248,654,5460,2,3,20,0,West,559 +2024Q4,27.0,0.278,736,7040,2,5,25,0,Midwest,805 +2024Q4,17.8,0.512,645,5260,3,6,1,0,South,668 +2024Q4,30.9,0.235,640,5530,1,1,30,0,West,126 +2024Q4,79.8,0.0,730,7230,0,5,6,1,Midwest,307 +2024Q4,34.7,0.419,727,6920,3,2,0,0,West,1115 +2024Q4,54.0,0.344,621,5490,0,1,7,0,West,0 +2024Q4,13.8,0.222,561,4030,1,5,4,0,West,301 +2024Q4,40.8,0.421,701,6400,3,5,9,0,Northeast,488 +2024Q4,44.4,0.32,641,5660,3,5,8,0,South,489 +2024Q4,200.0,0.0,596,6430,1,2,22,0,West,0 +2024Q4,53.9,0.189,537,4040,3,1,22,0,West,112 +2024Q4,93.3,0.275,567,4980,2,4,1,0,South,0 +2024Q4,32.0,0.31,708,6430,3,6,5,0,Northeast,788 +2024Q4,31.3,0.171,680,6160,2,3,6,0,South,800 +2024Q4,38.2,0.142,628,5430,1,4,10,0,West,226 +2024Q4,34.6,0.415,660,5870,0,2,8,0,Midwest,297 +2024Q4,69.0,0.1,693,6630,0,6,19,0,Northeast,109 +2024Q4,31.2,0.314,609,4960,0,7,20,0,South,237 +2024Q4,59.3,0.403,711,6820,4,6,2,1,Northeast,1240 +2024Q4,47.2,0.314,733,6980,1,1,22,0,West,397 +2024Q4,23.6,0.257,609,4940,0,6,15,0,West,0 +2024Q4,53.1,0.257,607,5010,0,1,25,0,South,0 +2024Q4,26.9,0.372,653,5390,1,3,19,0,Midwest,133 +2024Q4,48.4,0.144,690,6380,1,4,14,0,South,4 +2024Q4,17.5,0.413,573,4140,0,4,30,0,Midwest,181 +2024Q4,26.5,0.425,850,8420,2,4,5,0,South,1233 +2024Q4,14.1,0.435,545,3760,3,7,10,0,West,791 +2024Q4,45.9,0.296,567,4650,1,6,12,0,West,306 +2024Q4,200.0,0.0,828,9860,0,1,20,0,West,0 +2024Q4,28.3,0.299,630,5250,1,5,7,0,South,0 +2024Q4,15.9,0.386,544,3840,2,1,21,0,Northeast,117 +2024Q4,63.9,0.283,592,5030,1,4,3,0,Midwest,0 +2024Q4,35.6,0.464,577,4400,3,6,26,0,West,412 +2024Q4,59.3,0.211,617,5290,0,6,22,0,Midwest,0 +2024Q4,14.6,0.412,522,3300,1,5,15,0,Midwest,236 +2024Q4,65.2,0.191,544,4180,0,3,23,0,West,0 +2024Q4,74.8,0.0,567,4620,0,5,3,0,West,0 +2024Q4,19.9,0.523,576,4360,2,2,12,0,West,686 +2024Q4,49.7,0.238,603,4830,2,4,12,0,South,27 +2024Q4,14.4,0.273,541,3870,1,4,17,0,Midwest,188 +2024Q4,105.5,0.177,634,6150,5,1,9,0,West,5 +2024Q4,17.4,0.285,599,4500,2,1,27,0,South,107 +2024Q4,17.4,0.466,663,5640,1,3,9,0,South,508 +2024Q4,34.8,0.277,542,3800,3,2,17,0,West,216 +2024Q4,55.7,0.227,748,7350,2,6,23,1,West,914 +2024Q4,24.1,0.441,674,5920,4,7,0,0,Midwest,838 +2024Q4,70.7,0.301,616,5530,1,2,27,0,West,0 +2024Q4,84.5,0.27,602,5330,1,3,4,0,West,0 +2024Q4,12.2,0.47,725,6550,0,6,23,0,Northeast,740 +2024Q4,88.0,0.33,603,5420,1,3,17,0,South,0 +2024Q4,26.8,0.362,669,6020,1,4,19,0,Midwest,494 +2024Q4,97.4,0.138,737,7540,3,3,15,0,West,241 +2024Q4,87.8,0.164,691,6720,0,5,1,0,South,0 +2024Q4,24.0,0.55,688,5790,2,3,13,0,Midwest,799 +2024Q4,16.6,0.264,728,6680,1,6,21,1,South,931 +2024Q4,53.6,0.267,517,3710,3,2,15,0,Northeast,0 +2024Q4,60.9,0.16,671,6260,1,3,17,1,Northeast,599 +2024Q4,24.8,0.144,643,5270,2,4,17,0,Midwest,241 +2024Q4,20.5,0.506,587,4450,5,5,30,0,Northeast,318 +2024Q4,60.1,0.291,663,6260,1,6,16,0,Midwest,493 +2024Q4,46.5,0.074,807,8050,3,7,18,0,South,1103 +2024Q4,35.7,0.188,650,5750,0,4,24,0,Midwest,23 +2024Q4,33.3,0.282,519,3430,1,5,5,0,Northeast,0 +2024Q4,31.4,0.382,662,5660,3,3,8,0,West,623 +2024Q4,25.9,0.399,672,5840,3,3,29,0,South,640 +2024Q4,26.2,0.43,709,6330,2,6,0,0,Northeast,869 +2024Q4,31.6,0.327,486,3390,2,2,8,0,West,209 +2024Q4,83.4,0.039,617,5660,1,3,23,0,Northeast,0 +2024Q4,65.8,0.267,738,7250,2,6,13,1,Midwest,926 +2024Q4,92.2,0.0,706,7090,1,6,17,0,West,0 +2024Q4,77.4,0.325,504,3780,4,7,25,0,Midwest,67 +2024Q4,42.1,0.381,581,4630,3,1,14,0,Northeast,238 +2024Q4,29.5,0.394,661,5810,0,6,16,0,Midwest,433 +2024Q4,121.9,0.11,682,6840,1,7,8,1,West,296 +2024Q4,126.6,0.065,657,6670,0,6,19,0,Midwest,0 +2024Q4,84.9,0.159,712,7160,2,2,11,1,Northeast,476 +2024Q4,74.9,0.222,686,6590,1,3,23,0,South,217 +2024Q4,98.2,0.099,726,7200,1,7,20,0,Midwest,284 +2024Q4,22.1,0.312,562,4260,4,1,14,0,South,330 +2024Q4,63.9,0.088,788,8020,3,6,0,0,West,915 +2024Q4,36.6,0.348,762,7390,0,6,3,0,Midwest,516 +2024Q4,19.6,0.376,730,6460,2,2,17,0,South,923 +2024Q4,39.1,0.377,526,3740,3,7,1,0,Midwest,275 +2024Q4,28.1,0.294,651,5540,1,7,13,0,Midwest,532 +2024Q4,12.8,0.368,696,6190,0,1,5,0,South,402 +2024Q4,26.4,0.223,767,7350,0,4,4,0,Northeast,606 +2024Q4,145.2,0.03,733,7830,2,5,0,0,Midwest,73 +2024Q4,33.2,0.207,684,6020,1,7,11,0,Midwest,367 +2024Q4,124.0,0.135,586,5590,0,1,13,0,Northeast,0 +2024Q4,34.1,0.314,649,5640,0,5,30,0,South,26 +2024Q4,30.2,0.119,621,4910,3,3,10,1,West,561 +2024Q4,18.9,0.453,639,5310,1,3,12,0,South,415 +2024Q4,22.4,0.241,609,4950,1,1,27,0,South,292 +2024Q4,37.1,0.332,768,7420,1,1,28,0,Midwest,651 +2024Q4,32.0,0.442,704,6320,2,6,14,0,West,600 +2024Q4,18.9,0.504,568,4200,2,7,14,0,West,645 +2024Q4,72.0,0.156,808,8490,2,3,24,0,South,745 +2024Q4,84.6,0.17,605,5500,1,1,11,0,West,0 +2024Q4,25.3,0.325,636,5300,2,2,14,0,South,350 +2024Q4,39.4,0.256,676,5910,3,3,15,0,West,300 +2024Q4,103.9,0.001,581,5120,1,3,29,0,Northeast,0 +2024Q4,65.3,0.433,698,6500,2,5,22,0,Midwest,621 +2024Q4,24.2,0.224,605,4790,1,1,21,0,South,71 +2024Q4,28.2,0.253,779,7500,1,3,28,0,Northeast,699 +2024Q4,25.8,0.389,583,4480,4,7,22,0,Northeast,436 +2024Q4,60.5,0.242,679,6240,1,3,8,1,South,565 +2024Q4,87.4,0.316,802,8520,1,2,13,0,West,667 +2024Q4,17.0,0.155,590,4560,2,3,15,0,Northeast,56 +2024Q4,164.5,0.058,627,6440,0,2,18,0,South,0 +2024Q4,20.2,0.308,557,3830,0,5,20,0,South,257 +2024Q4,27.7,0.208,716,6640,2,3,0,0,Midwest,853 +2024Q4,12.9,0.486,672,5720,0,5,6,0,Northeast,565 +2024Q4,23.3,0.422,504,3460,1,1,6,0,Midwest,0 +2024Q4,31.6,0.411,662,5890,1,3,25,0,Northeast,576 +2024Q4,61.0,0.328,753,7400,2,4,0,0,Midwest,701 +2024Q4,19.8,0.436,607,4980,2,5,30,0,Northeast,518 +2024Q4,32.4,0.448,493,2910,3,1,11,0,South,19 +2024Q4,15.0,0.36,663,5530,1,2,7,0,South,459 +2024Q4,47.2,0.348,583,4560,2,5,27,0,Northeast,131 +2024Q4,26.7,0.27,538,3750,1,5,4,0,Northeast,29 +2024Q4,35.5,0.372,604,4930,0,4,26,0,West,129 +2024Q4,38.3,0.174,749,7160,0,7,9,1,West,692 +2024Q4,91.2,0.0,727,7480,1,1,3,0,West,269 +2024Q4,20.7,0.407,587,4500,1,4,10,0,Northeast,317 +2024Q4,51.7,0.3,748,7380,2,5,14,0,South,674 +2024Q4,10.0,0.276,732,6410,1,3,11,0,West,789 +2024Q4,52.0,0.372,678,6220,0,7,15,1,Midwest,525 +2024Q4,59.9,0.443,726,7080,2,3,22,0,Midwest,708 +2024Q4,31.7,0.522,574,4400,1,2,8,0,Northeast,623 +2024Q4,26.8,0.386,706,6370,1,2,28,1,Northeast,1192 +2024Q4,13.1,0.391,646,5290,2,2,1,0,West,681 +2024Q4,59.3,0.25,555,4260,2,4,29,0,South,0 +2024Q4,24.7,0.152,654,5690,4,5,13,0,South,688 +2024Q4,93.7,0.25,664,6400,1,7,2,0,West,0 +2024Q4,35.4,0.249,656,5540,5,1,29,0,Northeast,369 +2024Q4,24.7,0.109,587,4540,2,2,17,1,West,680 +2024Q4,51.9,0.273,718,6890,1,1,24,0,South,403 +2024Q4,14.9,0.395,778,7310,2,5,25,0,West,1239 +2024Q4,18.5,0.356,761,7020,0,3,7,0,Midwest,616 +2024Q4,18.4,0.349,528,3500,3,5,11,0,Northeast,458 +2024Q4,22.3,0.253,728,6530,0,6,24,0,Northeast,528 +2024Q4,49.1,0.311,732,7150,1,6,2,0,West,429 +2024Q4,35.1,0.264,672,5730,3,3,3,0,Midwest,504 +2024Q4,97.6,0.226,560,4920,0,7,20,0,Northeast,0 +2024Q4,68.7,0.088,660,6100,2,2,25,0,Midwest,82 +2024Q4,31.4,0.276,675,5970,0,4,30,0,Midwest,409 +2024Q4,75.5,0.264,567,4690,2,4,19,0,West,218 +2024Q4,163.1,0.137,621,6570,0,5,19,0,Northeast,0 +2024Q4,10.4,0.438,520,3580,1,5,14,0,West,504 +2024Q4,42.8,0.154,641,5470,1,3,7,0,Northeast,88 +2024Q4,10.0,0.297,685,5980,2,7,16,0,Midwest,850 +2024Q4,60.7,0.106,625,5510,2,7,0,0,Northeast,102 +2024Q4,26.8,0.471,540,3950,0,3,5,0,Northeast,0 +2024Q4,29.9,0.445,675,5870,0,7,6,0,Midwest,455 +2024Q4,35.9,0.401,641,5460,1,2,22,0,South,158 +2024Q4,138.4,0.184,747,8070,2,1,20,0,Midwest,0 +2024Q4,27.4,0.528,738,6840,1,6,0,0,Northeast,697 +2024Q4,21.4,0.415,827,8220,2,4,26,0,Northeast,1295 +2024Q4,108.1,0.072,705,7150,2,3,29,0,Northeast,0 +2024Q4,37.2,0.353,512,3640,2,7,28,0,Northeast,253 +2024Q4,48.3,0.232,667,6120,4,2,2,0,Northeast,534 +2024Q4,65.7,0.226,636,5760,0,2,30,0,Midwest,0 +2024Q4,10.0,0.357,604,4570,1,7,28,0,South,481 +2024Q4,10.0,0.29,591,4370,0,2,14,0,Northeast,0 +2024Q4,99.3,0.298,585,5290,1,7,4,0,Northeast,0 +2024Q4,17.4,0.42,520,3520,3,5,5,0,South,325 +2024Q4,113.6,0.087,673,6790,1,2,5,1,South,88 +2024Q4,56.2,0.319,730,6990,3,7,4,0,South,649 +2024Q4,32.0,0.447,587,4530,3,6,30,0,West,379 +2024Q4,39.5,0.22,713,6590,3,6,26,0,Midwest,545 +2024Q4,28.0,0.208,665,5700,3,2,20,0,Northeast,371 +2024Q4,49.8,0.417,699,6400,0,1,20,0,West,272 +2024Q4,56.0,0.434,846,8560,1,7,26,0,West,959 +2024Q4,100.0,0.164,711,7170,1,2,20,0,South,0 +2024Q4,42.4,0.178,668,6190,0,6,18,0,West,44 +2024Q4,22.6,0.446,591,4680,3,2,23,0,West,511 +2024Q4,18.8,0.303,621,5060,0,7,7,0,Midwest,144 +2024Q4,17.2,0.496,515,3390,0,3,22,0,Midwest,95 +2024Q4,58.6,0.167,795,7870,0,3,21,0,Midwest,449 +2024Q4,39.8,0.314,611,5170,1,1,4,0,South,120 +2024Q4,10.3,0.366,685,5610,1,4,0,0,West,642 +2024Q4,30.7,0.406,630,5370,1,2,11,0,Midwest,387 +2024Q4,36.4,0.266,685,5800,2,5,5,0,Midwest,430 +2024Q4,30.5,0.367,797,7830,2,1,13,0,Midwest,912 +2024Q4,111.7,0.232,717,7310,2,2,14,0,Midwest,158 +2024Q4,27.3,0.411,584,4790,0,3,2,0,Northeast,199 +2024Q4,68.3,0.182,722,6890,0,4,4,0,Northeast,300 +2024Q4,41.1,0.164,798,7740,2,5,27,0,West,954 +2024Q4,46.8,0.242,587,4670,4,2,26,0,Northeast,0 +2024Q4,49.4,0.324,619,5380,3,6,23,0,South,270 +2024Q4,28.9,0.394,639,5440,2,2,28,0,Midwest,528 +2024Q4,68.1,0.024,575,4710,2,1,14,0,Midwest,0 +2024Q4,30.9,0.386,655,5820,2,2,6,0,West,655 +2024Q4,24.5,0.476,557,4060,0,3,23,0,South,0 +2024Q4,10.0,0.384,696,5880,2,4,6,0,Northeast,756 +2024Q4,60.2,0.313,567,4570,0,4,24,0,Northeast,0 +2024Q4,17.1,0.384,527,3690,2,7,30,0,South,465 +2024Q4,30.2,0.279,714,6420,0,6,28,0,Midwest,490 +2024Q4,61.3,0.249,552,4590,1,7,23,0,Northeast,97 +2024Q4,23.5,0.346,593,4710,0,7,2,0,South,0 +2024Q4,30.4,0.221,524,3670,1,1,8,0,Northeast,0 +2024Q4,32.9,0.34,739,6860,2,2,18,0,West,831 +2024Q4,37.2,0.234,612,5170,1,7,15,0,South,196 +2024Q4,113.5,0.412,711,7350,2,5,26,0,West,36 +2024Q4,60.8,0.134,697,6840,0,5,21,0,Northeast,95 +2024Q4,12.9,0.41,662,5620,2,2,8,0,West,591 +2024Q4,11.4,0.255,724,6270,1,6,28,0,West,554 +2024Q4,55.2,0.195,603,4810,0,2,15,0,West,0 +2024Q4,17.1,0.344,648,5410,3,5,12,1,South,931 +2024Q4,33.4,0.424,596,4770,3,7,3,0,Northeast,334 +2024Q4,34.4,0.477,525,3660,1,5,28,0,Northeast,154 +2024Q4,35.6,0.182,604,4600,0,6,3,0,Midwest,0 +2024Q4,61.1,0.392,679,6300,1,5,9,1,West,765 +2025Q1,63.2,0.219,599,4940,2,5,27,0,West,0 +2025Q1,35.9,0.215,636,5130,4,1,4,0,West,263 +2025Q1,21.4,0.374,534,3570,0,4,14,0,West,0 +2025Q1,11.0,0.27,728,6590,2,4,25,0,Midwest,886 +2025Q1,23.1,0.454,661,5610,0,5,24,0,South,222 +2025Q1,90.9,0.35,601,5390,0,7,15,0,South,0 +2025Q1,29.6,0.519,453,2510,0,2,14,0,West,0 +2025Q1,21.7,0.365,556,3950,1,6,4,0,South,272 +2025Q1,38.5,0.358,809,8200,1,4,22,0,Northeast,902 +2025Q1,53.1,0.306,613,5300,0,4,19,0,Midwest,0 +2025Q1,42.7,0.273,710,6540,1,6,6,1,South,998 +2025Q1,38.6,0.257,708,6340,4,7,28,0,South,663 +2025Q1,10.0,0.311,663,5420,4,1,16,1,Midwest,1063 +2025Q1,33.6,0.382,633,5240,1,1,12,0,Northeast,296 +2025Q1,66.6,0.159,687,6560,4,1,10,0,West,211 +2025Q1,109.8,0.156,696,7220,0,3,6,0,Midwest,0 +2025Q1,14.8,0.316,625,4920,4,4,13,0,Midwest,379 +2025Q1,14.5,0.235,612,4900,3,5,29,0,Midwest,514 +2025Q1,22.9,0.352,641,5370,3,2,28,0,Midwest,649 +2025Q1,91.9,0.463,602,5490,0,4,18,0,South,0 +2025Q1,55.3,0.332,420,2390,1,2,28,0,West,0 +2025Q1,24.1,0.333,467,2580,1,6,26,1,West,291 +2025Q1,31.9,0.205,618,5110,3,2,2,0,Northeast,214 +2025Q1,30.0,0.26,512,3370,3,4,5,0,South,0 +2025Q1,18.9,0.508,715,6510,1,7,15,1,West,1183 +2025Q1,18.5,0.135,649,5430,2,3,9,0,South,346 +2025Q1,63.4,0.226,657,5940,2,1,16,0,Northeast,307 +2025Q1,39.4,0.423,760,7420,2,7,17,0,South,860 +2025Q1,29.5,0.339,716,6410,1,7,5,0,South,492 +2025Q1,62.0,0.265,603,5050,5,2,11,0,Northeast,128 +2025Q1,46.9,0.197,552,4310,0,5,19,0,Northeast,0 +2025Q1,19.6,0.48,691,6000,0,1,19,0,Midwest,337 +2025Q1,39.9,0.284,635,5160,1,3,18,0,Midwest,180 +2025Q1,34.8,0.351,662,5680,1,2,3,0,Midwest,471 +2025Q1,93.4,0.023,539,4560,1,5,19,0,West,0 +2025Q1,111.0,0.247,721,7490,6,7,0,0,South,689 +2025Q1,39.2,0.453,632,5450,3,4,9,0,Northeast,412 +2025Q1,14.1,0.445,485,2860,1,7,25,0,Midwest,168 +2025Q1,16.1,0.316,516,3250,0,5,13,0,Midwest,45 +2025Q1,31.5,0.346,534,3590,0,2,7,0,Northeast,173 +2025Q1,55.9,0.293,782,7740,0,5,28,0,South,331 +2025Q1,46.2,0.297,725,6910,1,3,2,1,Midwest,985 +2025Q1,50.8,0.423,533,4110,4,6,8,0,West,445 +2025Q1,11.4,0.339,683,5660,1,6,3,0,Northeast,452 +2025Q1,27.6,0.205,647,5650,3,6,0,1,Northeast,1074 +2025Q1,26.1,0.255,674,6000,1,1,8,0,West,488 +2025Q1,37.8,0.413,652,5670,3,7,7,0,South,714 +2025Q1,22.7,0.457,722,6530,1,4,22,1,Midwest,1123 +2025Q1,54.9,0.094,668,6180,0,6,5,1,South,701 +2025Q1,21.1,0.296,718,6410,1,5,26,0,Northeast,377 +2025Q1,216.0,0.434,585,4690,2,5,1,0,Midwest,454 +2025Q1,52.1,0.25,634,5600,2,5,15,0,Northeast,289 +2025Q1,61.2,0.5,562,4680,4,2,26,0,Midwest,299 +2025Q1,37.0,0.235,742,6910,2,2,0,0,Northeast,592 +2025Q1,28.3,0.18,725,6700,2,2,6,0,Northeast,412 +2025Q1,54.4,0.313,726,7190,2,7,12,1,Northeast,1211 +2025Q1,18.8,0.304,763,6980,2,7,21,0,Northeast,1191 +2025Q1,75.4,0.262,608,5370,0,4,6,1,Midwest,30 +2025Q1,56.7,0.261,673,6340,1,7,0,0,South,493 +2025Q1,74.0,0.286,577,4950,1,6,4,0,Northeast,26 +2025Q1,19.7,0.227,646,5400,1,2,30,0,West,248 +2025Q1,116.5,0.093,770,8160,1,5,24,0,South,165 +2025Q1,14.8,0.208,613,4950,0,2,29,0,West,0 +2025Q1,32.2,0.285,700,6480,6,2,26,0,West,782 +2025Q1,19.9,0.226,638,5390,3,6,14,0,Midwest,570 +2025Q1,73.0,0.319,636,5810,1,1,29,0,West,0 +2025Q1,76.3,0.471,678,6550,3,1,22,0,South,540 +2025Q1,18.8,0.358,693,6050,2,1,26,0,West,579 +2025Q1,88.3,0.203,800,8450,1,4,30,0,Northeast,385 +2025Q1,57.7,0.202,682,6330,1,6,27,0,South,255 +2025Q1,55.3,0.389,511,4100,1,5,23,0,Midwest,0 +2025Q1,20.0,0.445,635,5410,1,4,25,0,Midwest,643 +2025Q1,74.7,0.358,736,7250,1,4,21,0,West,522 +2025Q1,58.0,0.329,737,7260,1,2,13,0,South,451 +2025Q1,16.0,0.3,677,5760,1,4,6,0,Northeast,655 +2025Q1,157.4,0.181,665,7200,3,4,11,0,Northeast,0 +2025Q1,33.2,0.271,593,4800,1,6,19,0,South,141 +2025Q1,47.5,0.273,594,4890,2,1,7,0,West,0 +2025Q1,24.1,0.472,655,5580,2,2,19,0,West,516 +2025Q1,81.4,0.332,573,4990,2,4,27,0,South,376 +2025Q1,70.9,0.372,691,6710,4,2,27,0,Northeast,473 +2025Q1,17.2,0.394,741,6600,2,7,10,0,Midwest,896 +2025Q1,31.1,0.325,592,4510,0,3,20,0,Northeast,0 +2025Q1,33.5,0.307,599,4680,1,3,10,0,Northeast,0 +2025Q1,53.9,0.262,650,5710,0,7,6,0,Midwest,129 +2025Q1,69.0,0.254,576,4730,0,6,12,0,South,0 +2025Q1,14.7,0.379,640,5340,3,1,28,0,South,490 +2025Q1,51.6,0.195,671,6060,3,4,17,0,Northeast,498 +2025Q1,57.8,0.221,734,7240,1,2,12,0,South,479 +2025Q1,30.3,0.386,608,4990,2,2,2,0,Midwest,360 +2025Q1,32.6,0.405,833,8200,3,4,1,0,South,1347 +2025Q1,154.8,0.041,824,9330,1,1,29,0,Midwest,59 +2025Q1,15.3,0.311,667,5440,0,1,21,0,West,330 +2025Q1,62.5,0.274,634,5570,1,1,28,0,South,131 +2025Q1,67.1,0.2,644,5800,0,1,17,1,South,264 +2025Q1,68.3,0.253,665,6360,2,7,9,0,West,466 +2025Q1,29.4,0.161,627,5260,1,7,0,0,West,69 +2025Q1,34.5,0.354,739,6970,2,1,20,0,South,696 +2025Q1,48.6,0.182,566,4440,1,7,19,0,South,64 +2025Q1,18.0,0.325,666,5670,2,1,26,0,West,548 +2025Q1,22.1,0.174,654,5540,3,5,16,0,South,481 +2025Q1,42.9,0.202,582,4750,0,2,12,0,Midwest,0 +2025Q1,36.2,0.38,701,6350,1,6,16,0,Midwest,614 +2025Q1,23.9,0.294,632,5180,2,2,29,0,West,464 +2025Q1,39.3,0.444,651,5770,0,1,1,0,West,95 +2025Q1,49.3,0.256,596,4910,2,4,12,0,South,174 +2025Q1,11.5,0.397,571,4330,2,6,22,0,Midwest,647 +2025Q1,54.7,0.287,658,6210,2,1,27,0,West,359 +2025Q1,30.6,0.3,563,4420,1,3,3,0,West,349 +2025Q1,147.5,0.0,735,8050,2,6,8,0,Midwest,0 +2025Q1,47.7,0.026,850,8810,1,1,1,0,West,991 +2025Q1,38.3,0.402,589,4410,0,3,18,0,South,0 +2025Q1,58.1,0.083,750,7270,3,5,12,1,West,994 +2025Q1,58.8,0.302,567,4580,1,2,4,0,South,0 +2025Q1,36.9,0.339,578,4630,3,4,28,0,West,611 +2025Q1,27.7,0.423,627,5080,1,3,23,0,South,295 +2025Q1,59.3,0.284,568,4570,0,1,25,0,South,0 +2025Q1,40.8,0.299,678,6220,2,7,5,0,South,662 +2025Q1,63.2,0.234,759,7590,0,1,28,0,Midwest,280 +2025Q1,38.8,0.234,710,6570,0,4,9,0,South,365 +2025Q1,47.7,0.323,694,6370,0,5,24,0,Northeast,218 +2025Q1,37.4,0.402,674,6060,1,1,6,0,Northeast,434 +2025Q1,35.3,0.313,603,5150,0,5,17,0,Northeast,73 +2025Q1,19.4,0.423,648,5410,1,2,18,0,Midwest,447 +2025Q1,22.9,0.215,767,7280,0,3,24,0,West,692 +2025Q1,48.4,0.263,498,3640,1,3,9,0,Northeast,0 +2025Q1,58.8,0.22,687,6360,2,6,1,0,Midwest,408 +2025Q1,23.5,0.283,681,5950,2,7,22,0,South,687 +2025Q1,44.2,0.399,686,6180,0,3,15,0,Northeast,281 +2025Q1,91.6,0.225,801,8230,3,6,1,0,West,548 +2025Q1,22.4,0.463,739,6870,0,7,4,0,Midwest,1097 +2025Q1,31.5,0.352,620,5370,3,6,27,0,Midwest,708 +2025Q1,56.9,0.281,684,6250,2,3,26,0,Northeast,377 +2025Q1,60.5,0.306,658,6050,2,6,1,0,Midwest,246 +2025Q1,40.7,0.17,639,5510,2,6,29,0,West,283 +2025Q1,23.7,0.35,618,5000,0,2,6,0,West,189 +2025Q1,30.3,0.293,597,4770,2,6,23,0,West,368 +2025Q1,39.6,0.215,709,6610,2,2,0,0,Northeast,517 +2025Q1,119.1,0.155,713,7220,1,1,26,0,Northeast,54 +2025Q1,58.7,0.182,658,6070,4,1,19,0,Northeast,347 +2025Q1,37.5,0.409,558,4360,1,6,4,0,West,337 +2025Q1,66.5,0.121,850,8960,2,3,3,0,Northeast,841 +2025Q1,14.3,0.549,571,4290,1,1,19,0,West,320 +2025Q1,98.2,0.141,764,7910,3,6,17,0,Midwest,355 +2025Q1,74.2,0.374,628,5730,2,3,19,0,Midwest,134 +2025Q1,32.0,0.424,662,5690,0,4,2,1,West,535 +2025Q1,27.8,0.48,619,5070,1,4,22,1,West,675 +2025Q1,20.1,0.335,695,6090,2,2,6,0,South,543 +2025Q1,22.6,0.353,684,5960,2,6,18,0,West,414 +2025Q1,42.1,0.423,622,5230,2,4,11,0,Northeast,395 +2025Q1,10.0,0.428,452,2360,1,1,5,0,Northeast,0 +2025Q1,147.3,0.104,689,7430,2,6,15,0,West,0 +2025Q1,62.9,0.285,648,5660,0,6,23,0,Midwest,0 +2025Q1,180.1,0.119,604,6190,0,7,10,0,West,0 +2025Q1,38.8,0.421,567,4420,0,2,19,0,West,398 +2025Q1,84.6,0.268,822,8790,2,4,10,0,Midwest,1026 +2025Q1,43.9,0.306,738,7030,1,3,13,0,Midwest,290 +2025Q1,39.8,0.242,703,6410,1,4,0,0,Northeast,398 +2025Q1,35.7,0.138,678,6280,0,2,9,0,Midwest,144 +2025Q1,22.2,0.479,649,5470,3,7,25,0,Northeast,596 +2025Q1,68.0,0.283,850,8830,0,6,10,0,Midwest,776 +2025Q1,57.8,0.177,670,6110,5,5,3,0,West,382 +2025Q1,64.4,0.193,585,5020,0,7,30,0,Midwest,0 +2025Q1,43.7,0.188,651,5700,0,4,16,1,Northeast,236 +2025Q1,43.6,0.376,667,5840,3,2,29,0,West,487 +2025Q1,31.2,0.371,631,5380,2,6,10,0,South,484 +2025Q1,101.6,0.144,734,7560,1,2,17,0,Northeast,31 +2025Q1,30.2,0.417,682,6150,5,5,11,0,Northeast,866 +2025Q1,24.9,0.39,605,4820,4,7,15,0,West,632 +2025Q1,63.8,0.237,633,5530,2,4,27,0,Midwest,320 +2025Q1,41.2,0.299,584,4730,5,3,3,0,South,49 +2025Q1,32.1,0.324,630,5370,4,1,15,0,West,432 +2025Q1,39.7,0.307,651,5930,1,4,2,0,Midwest,423 +2025Q1,32.5,0.36,628,5460,2,4,6,1,South,731 +2025Q1,61.7,0.217,579,4880,3,4,17,0,Northeast,434 +2025Q1,20.3,0.473,642,5320,1,2,11,0,West,361 +2025Q1,34.8,0.342,570,4160,0,1,17,0,Northeast,109 +2025Q1,43.7,0.479,684,6220,3,7,3,0,West,735 +2025Q1,72.7,0.237,625,5570,2,5,15,0,South,0 +2025Q1,11.6,0.332,544,3770,0,5,21,0,West,150 +2025Q1,41.5,0.232,840,8440,0,4,18,0,Northeast,783 +2025Q1,55.6,0.043,741,7070,2,5,9,0,West,372 +2025Q1,17.5,0.381,596,4390,1,3,23,0,Northeast,217 +2025Q1,38.3,0.534,828,8260,2,1,20,0,Midwest,1252 +2025Q1,35.4,0.315,667,5780,1,5,6,0,South,454 +2025Q1,15.4,0.275,780,7450,1,2,28,0,Midwest,989 +2025Q1,31.3,0.343,646,5460,3,5,27,0,Midwest,753 +2025Q1,39.8,0.414,599,4830,1,7,30,0,West,175 +2025Q1,36.4,0.374,711,6490,4,7,28,0,Midwest,891 +2025Q1,49.2,0.159,582,4740,3,3,16,1,West,448 +2025Q1,33.6,0.244,665,5830,1,3,29,0,Northeast,561 +2025Q1,13.6,0.235,718,6530,1,3,30,0,Northeast,653 +2025Q1,24.5,0.456,669,5710,2,4,5,0,West,620 +2025Q1,42.6,0.374,506,3790,1,4,20,0,South,0 +2025Q1,33.0,0.376,640,5350,1,1,6,0,Northeast,108 +2025Q1,38.5,0.279,671,5880,2,7,21,0,Midwest,680 +2025Q1,27.9,0.136,693,6420,0,1,17,1,Midwest,766 +2025Q1,23.2,0.479,591,4670,2,2,2,0,Midwest,423 +2025Q1,43.9,0.362,569,4360,3,1,28,0,South,275 +2025Q1,75.2,0.33,636,5740,1,3,24,0,Midwest,0 +2025Q1,23.3,0.249,610,4880,1,2,16,0,South,283 +2025Q1,37.2,0.275,559,4250,2,4,0,0,South,194 +2025Q1,44.5,0.423,718,6810,1,7,30,0,Midwest,698 +2025Q1,55.9,0.23,653,5780,0,4,6,0,Midwest,0 +2025Q1,22.9,0.461,574,4320,3,3,5,0,West,616 +2025Q1,78.7,0.159,762,7670,1,5,20,0,South,374 +2025Q1,30.4,0.321,617,5110,3,1,20,1,Midwest,908 +2025Q1,23.6,0.422,535,3890,2,5,15,0,Northeast,375 +2025Q1,28.9,0.341,618,5100,1,7,3,0,South,145 +2025Q1,55.3,0.263,611,5150,3,3,16,0,West,274 +2025Q1,64.0,0.173,594,5010,5,7,1,0,Midwest,213 +2025Q1,136.7,0.178,731,7960,3,7,13,0,Midwest,276 +2025Q1,75.2,0.257,760,7720,0,5,9,0,South,203 +2025Q1,47.6,0.312,509,3440,3,6,14,0,West,212 +2025Q1,25.5,0.398,801,7620,4,4,20,0,Midwest,1300 +2025Q1,24.9,0.342,551,3820,0,3,30,0,Midwest,185 +2025Q1,64.0,0.0,707,6760,0,7,18,0,Northeast,147 +2025Q1,29.4,0.088,711,6450,2,6,8,0,South,485 +2025Q1,35.2,0.354,577,4480,0,1,7,0,Midwest,93 +2025Q1,26.3,0.374,705,6310,1,6,18,0,South,746 +2025Q1,14.1,0.321,560,4220,1,1,27,0,South,548 +2025Q1,35.3,0.29,547,4010,3,6,10,0,South,417 +2025Q1,34.1,0.325,627,5340,0,5,4,0,Northeast,57 +2025Q1,35.0,0.468,646,5670,0,2,12,0,West,313 +2025Q1,27.1,0.129,718,6600,1,5,27,0,South,651 +2025Q1,74.0,0.237,755,7600,1,7,23,1,Northeast,1107 +2025Q1,53.7,0.238,850,8580,3,4,2,0,South,1078 +2025Q1,11.9,0.217,698,6060,1,6,11,0,West,567 +2025Q1,19.3,0.247,534,3720,2,3,17,0,West,462 +2025Q1,10.0,0.331,598,4630,3,2,25,0,Midwest,355 +2025Q1,76.9,0.35,788,8100,1,2,4,0,Midwest,438 +2025Q1,61.4,0.37,605,5360,1,2,18,0,Midwest,148 +2025Q1,34.4,0.538,600,4740,2,7,6,0,Northeast,266 +2025Q1,43.4,0.473,663,5830,0,3,19,0,Northeast,177 +2025Q1,37.7,0.288,659,5740,1,7,26,0,Midwest,332 +2025Q1,15.9,0.448,686,6120,1,3,21,0,Midwest,662 +2025Q1,58.1,0.282,750,7300,3,7,0,0,South,773 +2025Q1,68.3,0.182,613,5520,1,7,20,0,South,0 +2025Q1,44.1,0.259,621,5440,1,6,30,0,Northeast,172 +2025Q1,56.5,0.438,671,6280,1,1,23,0,Midwest,281 +2025Q1,53.0,0.215,537,4170,2,5,26,0,Midwest,222 +2025Q1,102.8,0.367,772,8080,0,6,19,1,Northeast,675 +2025Q1,16.3,0.363,795,7590,1,7,10,1,South,1577 +2025Q1,12.9,0.366,507,3300,2,7,3,0,Northeast,501 +2025Q1,52.3,0.247,618,5190,2,3,19,0,Northeast,276 +2025Q1,84.0,0.0,636,6020,1,3,14,0,South,0 +2025Q1,19.9,0.307,647,5430,1,7,12,0,West,553 +2025Q1,58.3,0.261,635,5790,4,3,25,0,West,329 +2025Q1,65.0,0.125,657,5920,1,5,14,0,Northeast,62 +2025Q1,13.8,0.467,698,5960,2,6,10,0,West,866 +2025Q1,28.3,0.353,685,5910,0,6,29,1,Midwest,907 +2025Q1,24.9,0.284,418,2020,3,5,28,0,Midwest,0 +2025Q1,44.9,0.313,714,6790,3,2,13,0,South,795 +2025Q1,41.8,0.246,738,6640,3,5,13,0,West,577 +2025Q1,95.6,0.059,674,6500,0,4,7,1,Northeast,63 +2025Q1,26.8,0.327,663,5840,1,5,17,1,Northeast,849 +2025Q1,68.5,0.21,494,3760,4,1,18,0,South,0 +2025Q1,65.7,0.168,646,5770,1,2,1,0,South,0 +2025Q1,30.5,0.117,502,3370,3,1,19,0,West,0 +2025Q1,88.8,0.432,631,5910,2,5,18,0,West,131 +2025Q1,42.8,0.238,711,6450,1,5,8,0,West,641 +2025Q1,24.9,0.262,566,4310,3,6,20,0,Northeast,231 +2025Q1,61.9,0.371,653,5630,4,4,10,0,Midwest,341 +2025Q1,17.4,0.234,749,7010,2,5,26,0,South,822 +2025Q1,46.9,0.213,650,5930,1,2,2,1,South,574 +2025Q1,18.3,0.392,840,8380,1,1,15,0,West,1238 +2025Q1,58.6,0.235,705,6750,0,4,9,0,Northeast,125 +2025Q1,35.2,0.167,659,5820,0,1,21,0,South,179 +2025Q1,22.5,0.476,537,3700,1,2,30,0,Midwest,144 +2025Q1,25.6,0.297,617,4990,3,2,17,0,West,605 +2025Q1,73.2,0.333,698,6780,4,5,16,0,Midwest,352 +2025Q1,12.0,0.473,671,5720,2,2,22,1,West,1075 +2025Q1,108.1,0.192,667,6610,1,4,21,0,South,0 +2025Q1,25.5,0.281,607,4780,1,7,3,1,Midwest,761 +2025Q1,31.6,0.241,622,5210,0,7,21,0,West,71 +2025Q1,78.3,0.125,613,5310,2,4,18,0,South,0 +2025Q1,74.3,0.339,632,5880,5,4,21,0,Midwest,397 +2025Q1,27.1,0.401,612,4840,2,5,1,0,South,361 +2025Q1,40.5,0.504,737,7150,2,6,29,0,West,932 +2025Q1,54.8,0.319,666,5990,2,7,11,0,Midwest,318 +2025Q1,46.2,0.406,667,6070,2,7,29,0,West,314 +2025Q1,20.3,0.288,717,6470,0,3,5,1,West,723 +2025Q1,39.8,0.462,512,3730,0,7,5,0,Northeast,0 +2025Q1,16.0,0.394,702,6080,2,1,7,1,South,1004 +2025Q1,22.9,0.301,612,5080,3,3,23,0,West,286 +2025Q1,51.3,0.361,631,5530,0,6,19,0,South,0 +2025Q1,18.3,0.332,585,4460,1,3,14,0,South,206 +2025Q1,26.6,0.51,723,6660,2,3,11,0,Midwest,811 +2025Q1,90.8,0.021,608,5560,1,4,8,0,West,0 +2025Q1,55.8,0.149,424,2580,0,1,22,0,Midwest,0 +2025Q1,29.6,0.425,607,5060,0,1,29,0,Midwest,225 +2025Q1,37.4,0.057,642,5560,2,6,25,0,Midwest,420 +2025Q1,54.9,0.32,537,4210,2,2,21,0,Midwest,0 +2025Q1,86.9,0.146,695,6780,1,3,9,1,West,473 +2025Q1,22.7,0.339,725,6680,2,5,7,0,West,816 +2025Q1,34.4,0.345,696,6520,2,3,20,0,Midwest,493 +2025Q1,44.0,0.07,682,6220,0,1,29,0,Northeast,0 +2025Q1,64.1,0.439,610,5350,1,1,18,1,Midwest,247 +2025Q1,32.9,0.182,624,5150,0,2,12,0,West,0 +2025Q1,89.6,0.426,642,5820,0,6,17,0,Northeast,41 +2025Q1,39.9,0.342,652,5620,3,2,13,0,Midwest,608 +2025Q1,28.8,0.433,684,6120,2,1,29,0,South,628 +2025Q1,98.5,0.095,612,5620,1,7,10,0,South,0 +2025Q1,195.4,0.0,786,9270,1,6,16,1,West,335 +2025Q1,12.9,0.356,538,3780,0,7,24,1,West,552 +2025Q1,93.0,0.195,652,6000,2,4,8,0,Midwest,0 +2025Q1,41.5,0.351,512,3560,4,4,19,0,West,282 +2025Q1,63.4,0.33,503,3730,2,6,8,0,Midwest,114 +2025Q1,67.0,0.275,813,8480,0,3,3,1,West,1023 +2025Q1,42.0,0.45,617,5320,4,2,7,0,Midwest,584 +2025Q1,193.3,0.004,793,9390,2,4,21,0,South,0 +2025Q1,40.0,0.126,748,7190,3,3,11,0,Northeast,668 +2025Q1,47.0,0.34,731,6970,2,2,9,0,South,724 +2025Q1,54.2,0.078,726,7000,1,2,26,0,South,452 +2025Q1,170.7,0.128,736,8290,3,6,20,0,West,65 +2025Q1,57.5,0.143,569,5060,1,2,12,0,West,142 +2025Q1,46.2,0.275,698,6440,2,6,7,0,South,546 +2025Q1,15.5,0.294,668,5630,1,3,8,0,Midwest,336 +2025Q1,41.0,0.325,771,7560,1,2,15,0,South,638 +2025Q1,74.2,0.293,654,5960,0,5,11,1,Midwest,87 +2025Q1,63.7,0.293,648,5930,0,7,3,0,Northeast,0 +2025Q1,63.1,0.481,621,5370,2,3,1,0,South,29 +2025Q1,10.0,0.411,666,5370,2,6,26,0,Midwest,741 +2025Q1,53.4,0.325,720,6760,0,3,30,0,Midwest,251 +2025Q1,20.0,0.384,661,5720,0,3,30,0,Northeast,294 +2025Q1,24.8,0.147,723,6600,2,7,10,1,South,1153 +2025Q1,36.1,0.161,609,5070,1,6,5,0,West,0 +2025Q1,45.6,0.188,708,6520,1,5,12,0,Midwest,274 +2025Q1,36.3,0.183,607,5000,0,3,15,0,West,0 +2025Q1,51.5,0.409,520,3680,0,2,23,0,South,0 +2025Q1,51.5,0.215,656,5680,1,1,30,0,South,185 +2025Q1,46.2,0.324,731,6910,0,4,16,1,West,735 +2025Q1,18.7,0.33,633,5110,2,3,6,0,West,383 +2025Q1,56.0,0.357,667,6220,4,6,15,0,West,428 +2025Q1,105.4,0.291,676,6730,2,6,7,0,South,201 +2025Q1,39.9,0.348,644,5590,0,1,5,0,Northeast,74 +2025Q1,22.6,0.156,581,4340,2,3,24,0,Midwest,263 +2025Q1,37.4,0.209,724,6760,3,5,9,0,Northeast,613 +2025Q1,50.9,0.389,798,8220,1,3,12,0,South,875 +2025Q1,111.3,0.124,608,5820,2,1,1,0,West,0 +2025Q1,15.8,0.368,674,5880,4,6,30,1,West,1243 +2025Q1,26.5,0.31,606,4830,0,4,20,0,South,25 +2025Q1,27.9,0.447,719,6570,2,2,14,0,Midwest,659 +2025Q1,37.8,0.515,448,2630,3,2,17,0,West,99 +2025Q1,23.0,0.248,643,5260,1,4,17,0,West,321 +2025Q1,26.5,0.349,575,4380,3,2,27,1,Northeast,951 +2025Q1,60.1,0.365,707,6660,2,3,8,0,West,624 +2025Q1,22.4,0.468,691,6020,2,4,24,0,Midwest,778 +2025Q1,40.8,0.354,613,5140,5,2,19,0,South,424 +2025Q1,61.6,0.134,704,6620,2,7,0,0,South,230 +2025Q1,43.2,0.168,679,6020,2,6,13,0,Midwest,435 +2025Q1,136.9,0.196,476,4060,0,5,1,0,Northeast,0 +2025Q1,35.9,0.198,624,5280,1,2,29,0,West,116 +2025Q1,76.5,0.221,622,5650,0,4,0,0,South,0 +2025Q1,43.0,0.398,492,3330,2,2,25,0,South,3 +2025Q1,65.3,0.304,669,6130,4,2,1,0,West,232 +2025Q1,45.9,0.391,504,3410,0,2,29,0,South,0 +2025Q1,56.1,0.311,629,5530,1,4,1,0,West,36 +2025Q1,17.8,0.29,656,5400,1,6,25,1,Midwest,846 +2025Q1,23.3,0.467,738,6840,4,6,15,0,South,978 +2025Q1,43.7,0.21,850,8560,2,6,30,1,Northeast,1515 +2025Q1,65.9,0.323,662,5950,0,1,12,0,South,0 +2025Q1,47.0,0.292,796,8020,0,2,28,0,West,533 +2025Q1,94.9,0.098,655,6410,0,6,16,0,Midwest,0 +2025Q1,33.2,0.419,591,4520,1,1,0,0,Northeast,0 +2025Q1,26.7,0.414,639,5340,0,2,13,0,Northeast,207 +2025Q1,53.9,0.385,573,4560,3,7,9,0,West,288 +2025Q1,71.5,0.054,624,5520,1,3,17,0,West,0 +2025Q1,31.9,0.297,699,6330,2,1,8,0,Northeast,510 +2025Q1,33.6,0.362,554,4190,4,2,10,0,Midwest,411 +2025Q1,30.8,0.204,690,6110,4,3,9,0,Northeast,474 +2025Q1,43.1,0.264,722,6880,4,3,3,0,Midwest,549 +2025Q1,33.3,0.368,666,5870,2,4,20,1,Midwest,856 +2025Q1,42.5,0.453,610,4930,3,7,26,0,Midwest,609 +2025Q1,28.4,0.14,558,4180,1,4,30,0,West,0 +2025Q1,60.7,0.11,704,6730,0,7,14,0,Midwest,145 +2025Q1,64.8,0.259,638,5680,3,2,25,0,West,169 +2025Q1,49.0,0.451,688,6200,3,4,11,0,West,655 +2025Q1,25.5,0.261,681,5890,0,1,19,0,South,171 +2025Q1,19.1,0.399,597,4590,2,5,6,0,Northeast,208 +2025Q1,36.2,0.278,747,7060,1,3,5,0,Midwest,628 +2025Q1,54.8,0.156,792,7910,1,5,14,0,Midwest,697 +2025Q1,36.4,0.303,750,6950,2,5,3,0,West,776 +2025Q1,15.9,0.433,646,5400,2,2,23,0,Midwest,721 +2025Q1,32.0,0.389,606,4850,0,6,22,0,Northeast,115 +2025Q1,27.1,0.524,671,5770,1,4,27,0,Northeast,586 +2025Q1,84.6,0.063,657,6090,4,3,17,1,West,463 +2025Q1,124.1,0.176,789,8570,0,3,28,1,West,465 +2025Q1,31.1,0.495,673,5980,2,4,0,0,Northeast,740 +2025Q1,48.8,0.438,643,5790,1,3,0,0,West,547 +2025Q1,65.1,0.281,701,6840,1,3,23,0,West,442 +2025Q1,10.4,0.32,715,6400,0,2,17,0,Northeast,587 +2025Q1,81.8,0.0,776,7920,2,3,26,0,Midwest,464 +2025Q1,32.6,0.302,630,5350,2,4,14,0,South,529 +2025Q1,51.3,0.173,691,6200,1,5,20,1,South,536 +2025Q1,14.0,0.456,543,3770,5,6,19,0,Midwest,690 +2025Q1,24.2,0.345,620,5130,1,2,25,0,West,63 +2025Q1,43.3,0.374,662,5880,0,5,24,0,West,218 +2025Q1,50.0,0.501,581,4940,1,1,15,0,Northeast,0 +2025Q1,22.5,0.35,703,6380,1,1,5,0,Midwest,543 +2025Q1,24.5,0.124,600,5020,1,2,7,0,Midwest,101 +2025Q1,40.4,0.299,578,4560,2,3,2,0,South,465 +2025Q1,50.9,0.223,743,7200,3,4,7,1,Midwest,1087 +2025Q1,69.4,0.24,589,4980,2,5,29,0,Northeast,33 +2025Q1,35.4,0.238,627,4980,1,3,14,0,Midwest,222 +2025Q1,47.7,0.226,591,4650,0,6,28,0,South,0 +2025Q1,32.4,0.31,537,3850,2,2,17,0,Northeast,0 +2025Q1,26.9,0.221,473,2750,1,1,8,0,Midwest,0 +2025Q1,42.1,0.436,650,5540,2,5,15,0,West,401 +2025Q1,10.2,0.513,757,6810,2,4,11,0,Midwest,852 +2025Q1,40.8,0.201,668,5880,2,5,15,0,Northeast,196 +2025Q1,51.8,0.257,636,5650,0,2,10,1,Northeast,342 +2025Q1,67.4,0.271,717,6970,1,5,9,0,Midwest,208 +2025Q1,44.4,0.298,602,5000,1,3,12,0,South,299 +2025Q1,81.2,0.406,710,6940,0,1,22,0,Northeast,0 +2025Q1,22.5,0.39,791,7640,1,3,14,0,Midwest,1139 +2025Q1,61.6,0.251,513,3960,0,5,4,0,Northeast,0 +2025Q1,76.5,0.023,662,6060,2,4,17,0,Northeast,0 +2025Q1,42.6,0.216,684,6310,2,6,25,0,West,738 +2025Q1,23.3,0.214,664,5750,1,6,11,0,South,494 +2025Q1,19.5,0.478,583,4410,3,1,13,1,South,597 +2025Q1,16.0,0.272,721,6350,1,2,30,0,Midwest,460 +2025Q1,16.3,0.413,454,2620,2,3,25,0,South,150 +2025Q1,94.5,0.249,744,7690,0,5,30,0,West,0 +2025Q1,49.8,0.309,529,4110,2,6,2,0,West,222 +2025Q1,49.1,0.16,691,6340,4,5,15,0,Northeast,546 +2025Q1,20.6,0.43,550,3840,5,4,0,1,Midwest,995 +2025Q1,47.1,0.292,733,7010,1,5,29,0,South,317 +2025Q1,57.0,0.145,736,7100,3,1,16,0,South,688 +2025Q1,18.6,0.275,757,7060,3,5,14,0,Northeast,856 +2025Q1,64.8,0.205,684,6470,2,7,20,0,West,393 +2025Q1,100.8,0.185,772,8020,1,7,4,0,Northeast,336 +2025Q1,26.6,0.271,463,2760,1,5,4,0,South,0 +2025Q1,46.7,0.221,623,5240,0,4,27,0,South,46 +2025Q1,41.8,0.294,686,6350,2,6,26,0,Midwest,638 +2025Q1,50.8,0.363,495,3500,3,2,25,0,South,149 +2025Q1,36.7,0.212,514,3650,0,5,17,0,West,0 +2025Q1,69.0,0.201,745,7270,0,3,5,0,South,290 +2025Q1,38.1,0.338,667,5760,0,2,11,1,Midwest,498 +2025Q1,47.3,0.241,782,7940,2,4,3,0,West,821 +2025Q1,27.6,0.534,627,5090,2,2,20,0,Midwest,584 +2025Q2,25.0,0.353,553,4080,2,2,9,0,South,313 +2025Q2,33.9,0.29,489,2990,0,4,10,0,South,0 +2025Q2,22.2,0.3,512,3290,6,3,18,0,South,459 +2025Q2,40.6,0.368,644,5730,1,1,27,0,Midwest,413 +2025Q2,16.2,0.359,614,4790,1,2,11,0,West,409 +2025Q2,119.0,0.184,659,6550,1,5,8,0,West,0 +2025Q2,80.1,0.107,617,5690,2,6,0,0,West,0 +2025Q2,69.0,0.295,693,6630,2,1,3,0,Northeast,410 +2025Q2,28.1,0.301,651,5620,3,6,29,1,Northeast,925 +2025Q2,10.7,0.275,675,5840,0,6,9,0,Northeast,451 +2025Q2,19.2,0.039,850,8690,3,7,21,0,Midwest,1258 +2025Q2,28.5,0.158,724,6500,0,5,30,0,Midwest,243 +2025Q2,11.8,0.354,713,6250,2,3,25,0,West,699 +2025Q2,25.6,0.478,471,2850,3,5,21,0,South,190 +2025Q2,36.2,0.249,658,5600,3,1,5,0,Northeast,351 +2025Q2,41.3,0.346,588,4690,0,4,17,0,South,184 +2025Q2,48.9,0.251,651,5820,1,4,28,0,Midwest,309 +2025Q2,30.8,0.379,663,5740,0,3,21,0,Midwest,165 +2025Q2,45.2,0.167,695,6560,0,4,19,0,West,151 +2025Q2,47.0,0.11,732,6840,0,2,7,0,Midwest,275 +2025Q2,119.5,0.0,643,6210,2,1,0,0,South,0 +2025Q2,27.9,0.287,662,5960,2,1,15,0,Northeast,503 +2025Q2,49.9,0.07,850,8780,0,2,4,0,Northeast,466 +2025Q2,79.1,0.075,647,5920,1,4,4,0,South,0 +2025Q2,26.3,0.526,664,5910,2,4,2,0,Midwest,867 +2025Q2,31.7,0.404,686,6070,2,6,16,0,West,617 +2025Q2,195.9,0.0,849,10290,2,4,8,0,South,67 +2025Q2,35.7,0.23,796,7940,1,4,21,0,Midwest,805 +2025Q2,33.9,0.339,671,5850,3,4,7,0,West,476 +2025Q2,16.1,0.427,658,5510,0,7,13,0,Northeast,614 +2025Q2,81.7,0.147,592,5170,1,1,23,0,Northeast,0 +2025Q2,18.8,0.15,655,5540,2,6,2,0,South,290 +2025Q2,24.9,0.301,573,4200,1,3,25,0,South,107 +2025Q2,32.3,0.335,531,3730,1,4,0,0,Midwest,141 +2025Q2,43.2,0.28,673,5980,0,7,12,0,Northeast,180 +2025Q2,21.7,0.438,601,4680,0,3,26,0,South,94 +2025Q2,33.0,0.399,550,4220,5,3,17,0,West,736 +2025Q2,21.7,0.449,640,5340,0,5,0,0,Midwest,353 +2025Q2,47.4,0.345,615,5250,1,3,12,0,South,321 +2025Q2,18.7,0.361,547,3890,2,4,2,0,Midwest,282 +2025Q2,45.1,0.332,589,4860,2,7,13,0,South,156 +2025Q2,62.0,0.136,722,7120,2,5,8,0,West,574 +2025Q2,14.2,0.341,733,6550,2,2,16,0,South,942 +2025Q2,30.7,0.202,609,4920,3,3,8,0,West,403 +2025Q2,88.4,0.096,623,5530,0,1,14,0,West,0 +2025Q2,83.6,0.291,658,6210,1,5,19,1,South,435 +2025Q2,15.7,0.302,658,5580,1,5,15,0,Midwest,522 +2025Q2,43.1,0.268,790,7840,1,4,17,0,West,925 +2025Q2,21.9,0.391,711,6440,1,5,18,0,West,565 +2025Q2,86.7,0.083,681,6660,1,1,12,0,Midwest,49 +2025Q2,75.7,0.229,601,5330,2,6,13,0,South,0 +2025Q2,21.2,0.37,644,5410,1,1,28,0,West,258 +2025Q2,111.5,0.393,662,6470,0,1,21,1,Midwest,65 +2025Q2,39.0,0.158,595,4760,3,5,17,0,West,319 +2025Q2,24.6,0.083,629,5120,0,7,19,0,Midwest,0 +2025Q2,53.9,0.342,610,5190,0,3,11,0,Midwest,0 +2025Q2,76.8,0.319,589,5200,2,7,12,0,Northeast,81 +2025Q2,39.6,0.203,579,4310,2,7,16,0,Northeast,424 +2025Q2,193.1,0.07,751,8570,3,1,2,0,Northeast,0 +2025Q2,10.0,0.364,753,6880,0,4,7,0,Midwest,629 +2025Q2,54.3,0.137,500,3720,3,1,2,0,South,0 +2025Q2,68.3,0.321,542,4420,2,4,16,1,South,453 +2025Q2,13.1,0.41,631,5110,1,1,27,0,Northeast,421 +2025Q2,23.9,0.287,574,4360,3,2,8,0,Northeast,486 +2025Q2,32.1,0.311,571,4440,2,7,0,0,Midwest,272 +2025Q2,10.4,0.443,686,5810,2,1,3,0,South,707 +2025Q2,10.0,0.481,529,3410,1,3,7,1,Midwest,528 +2025Q2,58.8,0.168,639,5670,1,4,12,0,Northeast,147 +2025Q2,28.7,0.377,633,5430,2,2,22,0,Northeast,420 +2025Q2,88.7,0.207,750,7560,1,1,18,0,West,367 +2025Q2,23.3,0.286,766,7290,1,4,28,0,Northeast,870 +2025Q2,115.5,0.163,624,6080,3,4,13,0,West,0 +2025Q2,44.7,0.291,574,4570,2,6,1,0,Northeast,439 +2025Q2,180.4,0.134,672,7210,0,2,29,0,West,0 +2025Q2,37.8,0.306,562,4090,0,4,27,0,West,0 +2025Q2,25.9,0.35,641,5300,1,6,12,0,West,353 +2025Q2,58.7,0.134,724,7070,1,3,16,0,South,193 +2025Q2,68.8,0.171,738,7280,4,3,9,1,Northeast,1018 +2025Q2,61.7,0.187,633,5500,0,4,26,0,Midwest,0 +2025Q2,101.5,0.021,460,3500,0,4,28,0,Northeast,0 +2025Q2,31.4,0.33,514,3330,0,5,27,0,Midwest,0 +2025Q2,68.2,0.313,634,5730,1,3,20,0,Northeast,113 +2025Q2,133.9,0.131,624,5830,1,6,20,0,Northeast,0 +2025Q2,23.6,0.303,653,5490,1,7,30,0,Northeast,442 +2025Q2,40.5,0.312,588,4510,0,7,13,1,South,506 +2025Q2,58.2,0.14,658,5990,2,4,1,0,Northeast,337 +2025Q2,70.6,0.066,625,5430,2,2,4,1,Northeast,361 +2025Q2,86.1,0.123,775,7900,1,1,14,0,Northeast,318 +2025Q2,57.9,0.253,591,4920,2,2,2,0,West,128 +2025Q2,74.6,0.109,825,8700,1,6,13,0,Midwest,677 +2025Q2,22.8,0.301,603,4760,1,1,5,1,South,518 +2025Q2,25.0,0.306,780,7300,1,7,24,0,Midwest,870 +2025Q2,42.3,0.324,625,5070,1,3,9,0,Northeast,0 +2025Q2,21.0,0.322,621,4870,1,2,13,0,Midwest,374 +2025Q2,56.5,0.321,635,5290,5,5,15,0,South,189 +2025Q2,28.7,0.27,703,6430,1,4,20,0,Northeast,455 +2025Q2,37.4,0.36,558,4220,0,5,9,0,Midwest,38 +2025Q2,50.1,0.196,638,5630,4,2,25,1,West,641 +2025Q2,150.1,0.208,572,5530,1,3,19,0,Northeast,0 +2025Q2,40.8,0.383,606,4960,3,1,4,0,West,256 +2025Q2,56.7,0.389,597,5160,4,4,7,0,Midwest,232 +2025Q2,50.4,0.332,667,5830,0,1,11,0,Midwest,0 +2025Q2,54.3,0.225,684,6300,2,5,23,0,Northeast,409 +2025Q2,32.8,0.408,562,4020,2,1,20,0,South,240 +2025Q2,31.9,0.3,750,7200,1,4,6,0,South,557 +2025Q2,23.7,0.149,760,7190,0,2,11,0,Midwest,544 +2025Q2,22.0,0.308,614,4950,2,1,17,0,Northeast,433 +2025Q2,43.5,0.399,700,6330,0,4,20,0,Midwest,192 +2025Q2,44.3,0.236,586,4800,2,4,12,0,South,200 +2025Q2,23.8,0.207,482,2920,1,4,9,0,Midwest,0 +2025Q2,90.7,0.312,667,6480,0,5,14,0,Northeast,0 +2025Q2,32.3,0.288,660,5750,2,1,19,0,Northeast,407 +2025Q2,28.5,0.26,621,5060,2,7,30,0,South,213 +2025Q2,23.6,0.241,620,5110,3,7,14,0,South,364 +2025Q2,53.7,0.408,595,4840,0,7,7,0,South,0 +2025Q2,43.1,0.251,686,6350,1,7,19,0,South,327 +2025Q2,66.5,0.448,675,6320,2,1,7,0,Northeast,185 +2025Q2,27.6,0.231,660,5700,0,6,24,0,South,275 +2025Q2,20.9,0.567,562,4060,1,3,1,0,South,447 +2025Q2,52.9,0.288,711,6510,3,4,28,0,West,657 +2025Q2,46.7,0.282,741,7020,1,7,14,0,South,534 +2025Q2,57.1,0.338,757,7460,4,2,2,0,Midwest,751 +2025Q2,15.0,0.506,666,5560,1,6,28,0,Midwest,453 +2025Q2,134.4,0.022,526,4870,1,3,1,0,Midwest,0 +2025Q2,103.9,0.033,637,6000,5,7,18,0,South,0 +2025Q2,30.3,0.372,605,4690,2,5,24,0,Midwest,316 +2025Q2,31.8,0.31,688,6190,3,1,26,0,South,453 +2025Q2,39.6,0.382,776,7550,1,6,11,0,West,691 +2025Q2,47.1,0.225,471,3020,0,7,7,0,West,0 +2025Q2,38.2,0.354,733,7020,0,3,19,0,South,568 +2025Q2,43.8,0.095,591,4710,2,1,17,0,Midwest,87 +2025Q2,36.0,0.293,693,6050,0,4,19,0,Northeast,145 +2025Q2,65.5,0.192,600,5190,2,4,7,0,South,0 +2025Q2,89.1,0.416,549,4630,0,3,22,1,West,243 +2025Q2,55.1,0.313,636,5480,0,2,4,0,West,0 +2025Q2,32.7,0.367,785,7770,1,2,19,0,Northeast,727 +2025Q2,45.0,0.316,586,4850,4,2,12,0,Midwest,244 +2025Q2,116.8,0.195,603,5860,0,6,4,0,South,0 +2025Q2,35.0,0.371,641,5430,2,5,23,0,Midwest,119 +2025Q2,72.5,0.297,646,5990,2,2,22,1,South,444 +2025Q2,36.3,0.177,726,6570,1,4,21,0,West,504 +2025Q2,20.9,0.279,708,6210,0,1,6,1,Northeast,578 +2025Q2,57.9,0.188,797,8050,2,2,6,0,Midwest,816 +2025Q2,72.4,0.295,736,7110,2,6,19,1,South,917 +2025Q2,121.9,0.354,650,6560,1,4,14,1,Northeast,295 +2025Q2,106.6,0.207,684,6910,1,3,7,0,West,0 +2025Q2,200.0,0.0,641,7130,2,4,13,0,West,0 +2025Q2,127.8,0.031,642,6480,2,6,8,0,Northeast,0 +2025Q2,52.7,0.377,545,4210,2,7,19,0,Midwest,377 +2025Q2,40.2,0.357,538,3940,4,1,5,0,Northeast,395 +2025Q2,33.9,0.189,722,6640,1,7,0,0,Midwest,535 +2025Q2,27.7,0.456,745,6680,2,3,16,0,Midwest,623 +2025Q2,104.8,0.114,768,8010,1,3,21,0,Midwest,356 +2025Q2,23.4,0.301,607,4730,2,6,10,0,Midwest,341 +2025Q2,25.7,0.292,625,5220,2,1,13,0,Northeast,46 +2025Q2,55.2,0.062,738,7080,3,7,28,0,West,721 +2025Q2,66.2,0.385,624,5470,2,1,10,0,West,216 +2025Q2,11.9,0.316,618,5020,2,5,12,0,South,474 +2025Q2,41.1,0.339,564,4410,1,1,21,0,South,81 +2025Q2,21.4,0.201,627,5170,0,2,11,0,West,75 +2025Q2,51.6,0.379,599,5110,1,4,7,0,South,169 +2025Q2,28.5,0.578,572,4320,1,5,19,0,Northeast,496 +2025Q2,67.1,0.015,711,7030,2,3,30,0,South,435 +2025Q2,26.1,0.147,795,7560,0,7,10,0,Midwest,848 +2025Q2,77.6,0.223,575,5050,2,7,2,0,South,110 +2025Q2,73.9,0.133,751,7690,0,6,2,0,South,304 +2025Q2,47.1,0.428,632,5590,3,6,8,0,Northeast,231 +2025Q2,15.7,0.182,659,5580,1,6,20,0,West,460 +2025Q2,15.2,0.477,656,5370,3,5,4,1,South,1035 +2025Q2,22.7,0.406,600,4560,2,1,16,1,Northeast,566 +2025Q2,28.9,0.196,653,5680,2,5,11,1,Midwest,1075 +2025Q2,18.7,0.295,602,4760,2,1,24,0,West,217 +2025Q2,136.3,0.108,709,7540,0,7,23,0,West,0 +2025Q2,38.1,0.459,654,5670,2,5,25,0,South,250 +2025Q2,19.9,0.448,548,3850,4,1,1,0,West,386 +2025Q2,35.4,0.301,558,4120,2,4,15,0,West,50 +2025Q2,45.8,0.298,545,4220,1,3,7,0,Midwest,0 +2025Q2,36.5,0.272,580,4700,2,6,0,0,Northeast,142 +2025Q2,17.5,0.425,657,5610,2,1,8,0,South,523 +2025Q2,27.2,0.529,614,5080,1,6,9,0,West,54 +2025Q2,20.7,0.321,689,6290,1,6,26,0,West,646 +2025Q2,30.7,0.218,557,4110,0,4,30,0,Midwest,0 +2025Q2,21.8,0.28,717,6540,0,6,4,0,Northeast,437 +2025Q2,48.9,0.315,679,6000,0,1,26,1,West,390 +2025Q2,21.5,0.238,548,4040,1,2,9,0,Northeast,218 +2025Q2,89.3,0.139,554,4610,2,3,13,0,West,0 +2025Q2,41.0,0.549,600,4720,1,4,21,0,South,258 +2025Q2,162.0,0.0,810,9290,1,2,20,0,South,0 +2025Q2,107.5,0.307,742,7860,2,2,2,0,South,344 +2025Q2,54.7,0.265,680,6090,1,2,30,0,West,231 +2025Q2,88.5,0.225,610,5580,0,1,2,0,Midwest,0 +2025Q2,110.5,0.211,720,7410,3,1,11,0,South,166 +2025Q2,17.2,0.404,643,5140,1,1,8,1,West,520 +2025Q2,34.9,0.299,759,7070,4,5,7,0,South,992 +2025Q2,28.6,0.289,586,4590,0,5,5,0,Midwest,0 +2025Q2,33.9,0.337,575,4470,2,4,5,0,South,255 +2025Q2,46.5,0.3,541,4060,3,5,4,0,Northeast,88 +2025Q2,19.0,0.252,664,5760,0,4,11,0,Midwest,375 +2025Q2,41.0,0.36,626,5240,0,4,3,0,West,119 +2025Q2,53.3,0.232,771,7870,0,3,0,0,Northeast,593 +2025Q2,26.8,0.267,793,7750,2,6,22,0,Northeast,1177 +2025Q2,15.7,0.238,441,2190,0,1,29,0,South,0 +2025Q2,49.9,0.08,825,8320,1,5,18,0,Midwest,910 +2025Q2,32.8,0.035,676,6070,0,7,30,0,Northeast,239 +2025Q2,26.7,0.151,582,4430,1,1,9,0,Northeast,0 +2025Q2,13.4,0.386,619,4900,2,2,12,1,South,694 +2025Q2,27.7,0.352,726,6560,1,6,5,0,Midwest,648 +2025Q2,35.4,0.385,578,4540,1,1,25,0,South,288 +2025Q2,47.4,0.135,608,5110,1,6,11,0,West,0 +2025Q2,56.4,0.295,712,6980,3,4,1,0,West,615 +2025Q2,22.3,0.4,653,5480,5,4,25,0,West,569 +2025Q2,31.6,0.178,593,4620,1,5,24,0,Midwest,192 +2025Q2,25.6,0.28,677,5970,1,6,4,0,West,256 +2025Q2,42.5,0.38,433,2480,2,4,10,1,Northeast,224 +2025Q2,27.5,0.193,577,4440,0,4,15,0,Midwest,212 +2025Q2,37.3,0.262,816,8200,1,6,18,0,Midwest,772 +2025Q2,22.8,0.198,732,6600,2,4,27,0,Northeast,892 +2025Q2,53.0,0.124,561,4200,2,7,27,0,Northeast,157 +2025Q2,142.9,0.031,691,7170,2,1,6,0,West,0 +2025Q2,27.0,0.068,729,6810,2,7,17,0,Midwest,699 +2025Q2,10.6,0.273,691,6130,2,2,22,0,West,717 +2025Q2,29.5,0.207,605,4830,2,5,20,0,West,138 +2025Q2,40.5,0.377,586,4610,1,6,4,0,West,124 +2025Q2,23.1,0.354,584,4430,1,1,9,0,West,0 +2025Q2,32.3,0.029,638,5180,3,6,1,0,Midwest,369 +2025Q2,56.2,0.333,657,5890,0,3,22,0,West,44 +2025Q2,28.8,0.41,495,3210,2,7,6,0,South,187 +2025Q2,26.7,0.16,652,5670,1,4,1,0,South,298 +2025Q2,102.5,0.202,671,6680,0,2,4,0,Midwest,0 +2025Q2,42.5,0.144,707,6340,3,6,9,0,Northeast,703 +2025Q2,13.7,0.476,630,5210,2,5,16,0,Midwest,637 +2025Q2,34.7,0.344,685,6160,3,3,13,0,Northeast,580 +2025Q2,27.6,0.311,583,4380,1,5,29,0,Midwest,151 +2025Q2,86.9,0.18,664,6500,6,3,18,0,Northeast,609 +2025Q2,21.4,0.212,579,4440,4,7,26,0,Midwest,595 +2025Q2,58.2,0.164,595,5020,0,2,17,0,Northeast,0 +2025Q2,149.4,0.078,663,7130,2,3,27,0,Midwest,0 +2025Q2,35.2,0.117,646,5500,2,6,3,0,Midwest,299 +2025Q2,50.6,0.195,655,5630,2,4,18,0,South,157 +2025Q2,55.3,0.312,656,5880,0,4,17,0,Northeast,0 +2025Q2,69.9,0.179,722,7350,1,1,2,0,South,340 +2025Q2,35.0,0.216,765,7410,2,3,24,0,Midwest,831 +2025Q2,37.7,0.224,685,6120,2,1,26,1,Northeast,756 +2025Q2,13.2,0.38,611,4860,2,4,16,0,South,447 +2025Q2,54.0,0.244,564,4450,1,1,5,0,Northeast,0 +2025Q2,49.8,0.376,656,5860,0,3,21,0,West,75 +2025Q2,10.6,0.473,695,5990,0,1,18,0,South,492 +2025Q2,38.7,0.179,745,7280,2,1,7,0,Midwest,900 +2025Q2,52.2,0.215,614,5170,3,7,11,0,West,245 +2025Q2,15.7,0.275,639,5250,1,4,10,0,South,542 +2025Q2,48.5,0.211,669,5900,2,7,28,1,South,1044 +2025Q2,78.1,0.269,832,8870,3,3,13,0,Northeast,1122 +2025Q2,111.9,0.068,599,5640,1,5,27,0,Northeast,0 +2025Q2,47.1,0.074,623,5400,0,7,2,0,Midwest,0 +2025Q2,51.7,0.175,577,4500,1,5,24,0,South,69 +2025Q2,54.3,0.499,619,5380,2,5,20,0,West,420 +2025Q2,115.0,0.256,759,8060,1,4,0,0,Midwest,140 +2025Q2,200.0,0.0,795,9360,0,6,15,0,Northeast,0 +2025Q2,25.2,0.523,713,6500,2,2,28,0,Midwest,747 +2025Q2,14.4,0.463,588,4250,2,7,3,0,Midwest,372 +2025Q2,12.6,0.279,572,4240,0,7,21,1,Midwest,764 +2025Q2,31.8,0.284,665,5870,2,5,7,0,South,461 +2025Q2,87.9,0.401,702,6850,3,7,21,1,Midwest,950 +2025Q2,27.1,0.308,582,4460,3,5,9,0,South,483 +2025Q2,45.2,0.308,591,4840,3,2,1,0,South,51 +2025Q2,26.4,0.328,624,5290,1,1,17,1,Midwest,436 +2025Q2,32.5,0.338,752,7090,2,3,15,1,Northeast,1141 +2025Q2,35.2,0.108,676,5930,1,6,26,0,Northeast,191 +2025Q2,46.5,0.365,742,6880,0,4,0,0,West,273 +2025Q2,25.6,0.378,563,4120,3,4,12,0,Northeast,570 +2025Q2,68.0,0.116,604,5180,0,5,6,0,Midwest,0 +2025Q2,40.2,0.335,553,4300,0,3,2,1,Northeast,203 +2025Q2,95.7,0.373,779,7990,3,5,11,0,West,710 +2025Q2,22.1,0.237,744,6580,5,7,7,0,South,1215 +2025Q2,18.9,0.327,617,4850,3,2,5,0,West,344 +2025Q2,43.5,0.341,544,4220,4,3,26,0,South,331 +2025Q2,58.6,0.291,672,6270,1,3,0,0,Midwest,89 +2025Q2,65.0,0.001,667,6260,3,5,6,0,South,234 +2025Q2,79.9,0.311,732,6970,3,6,4,0,South,479 +2025Q2,94.6,0.381,758,8160,2,5,26,1,Midwest,923 +2025Q2,27.1,0.204,684,6010,2,6,6,0,Midwest,683 +2025Q2,19.6,0.399,709,6600,0,3,23,0,West,562 +2025Q2,32.1,0.425,644,5470,0,6,14,0,West,89 +2025Q2,36.5,0.393,542,4070,4,3,0,0,West,446 +2025Q2,67.0,0.19,710,6990,1,6,28,0,West,220 +2025Q2,19.0,0.262,571,4120,1,7,12,0,West,386 +2025Q2,23.4,0.231,587,4300,5,2,6,0,West,66 +2025Q2,31.8,0.357,458,2660,1,6,23,0,South,0 +2025Q2,26.2,0.426,682,6250,0,5,3,0,Midwest,532 +2025Q2,26.0,0.34,707,6290,0,1,1,0,South,353 +2025Q2,17.1,0.499,741,6850,1,1,11,0,Midwest,632 +2025Q2,74.8,0.148,786,8050,0,2,3,0,Midwest,405 +2025Q2,55.5,0.4,665,6010,1,2,25,0,West,121 +2025Q2,72.6,0.245,768,7790,1,5,16,0,Northeast,632 +2025Q2,35.3,0.327,608,4890,1,3,23,0,West,100 +2025Q2,70.0,0.282,730,7110,0,2,10,0,West,381 +2025Q2,17.6,0.291,579,4210,2,3,6,0,West,348 +2025Q2,61.1,0.171,554,4310,2,6,25,1,Midwest,513 +2025Q2,32.7,0.277,590,4640,2,5,23,0,Midwest,245 +2025Q2,38.6,0.528,669,5780,0,1,29,0,Northeast,175 +2025Q2,50.9,0.375,746,7300,4,5,3,1,Northeast,1299 +2025Q2,119.9,0.143,704,7290,1,7,18,0,South,0 +2025Q2,11.0,0.284,598,4840,3,2,8,0,South,381 +2025Q2,43.9,0.376,537,4220,0,3,0,0,South,0 +2025Q2,113.0,0.288,735,7760,3,2,25,0,West,444 +2025Q2,55.3,0.066,729,6860,4,3,24,0,Northeast,567 +2025Q2,82.5,0.226,652,5910,1,7,15,0,Midwest,27 +2025Q2,25.2,0.239,539,3940,0,4,22,1,West,299 +2025Q2,34.0,0.261,763,7380,2,5,6,0,West,779 +2025Q2,106.4,0.289,652,6430,2,4,6,0,South,109 +2025Q2,27.3,0.412,623,5080,2,6,9,0,West,189 +2025Q2,107.4,0.263,673,6890,0,1,22,0,Midwest,0 +2025Q2,40.1,0.252,627,5180,1,5,8,0,Northeast,159 +2025Q2,77.3,0.159,569,4680,2,2,21,0,West,0 +2025Q2,45.9,0.282,517,3520,0,7,22,1,Midwest,80 +2025Q2,33.6,0.281,499,3310,2,3,18,0,West,258 +2025Q2,42.1,0.447,684,6320,3,7,26,0,Northeast,708 +2025Q2,47.9,0.39,556,4140,0,7,5,0,West,263 +2025Q2,68.5,0.425,533,4250,1,1,0,0,Northeast,0 +2025Q2,39.5,0.351,730,7010,1,4,3,1,West,1184 +2025Q2,39.9,0.267,658,5810,2,7,17,0,West,623 +2025Q2,93.9,0.135,620,5840,0,6,9,0,Northeast,0 +2025Q2,25.2,0.267,618,5240,3,2,12,0,Northeast,462 +2025Q2,47.6,0.307,612,5280,0,5,5,0,Midwest,0 +2025Q2,90.6,0.144,606,5600,0,7,9,0,Northeast,0 +2025Q2,39.6,0.262,639,5460,0,4,16,0,Midwest,19 +2025Q2,27.8,0.34,628,5110,2,3,5,0,West,436 +2025Q2,80.9,0.429,583,5250,3,2,29,0,West,33 +2025Q2,67.7,0.317,606,5330,2,7,8,0,Northeast,238 +2025Q2,58.0,0.258,713,6850,2,7,23,0,Midwest,685 +2025Q2,40.5,0.32,731,6760,1,6,7,0,Midwest,662 +2025Q2,27.3,0.224,590,4770,2,3,7,0,Midwest,209 +2025Q2,56.3,0.139,624,5460,3,2,18,0,Northeast,53 +2025Q2,25.1,0.206,664,5600,1,5,29,0,Northeast,303 +2025Q2,30.9,0.414,503,3410,3,3,6,0,South,476 +2025Q2,40.2,0.205,850,8560,1,3,17,0,West,697 +2025Q2,36.1,0.343,639,5470,2,6,6,0,Northeast,508 +2025Q2,30.0,0.249,564,4340,2,6,26,0,Northeast,564 +2025Q2,20.1,0.431,547,3740,2,4,13,0,West,402 +2025Q2,37.1,0.361,706,6610,0,3,16,1,West,690 +2025Q2,43.9,0.194,548,4210,2,4,22,0,Northeast,3 +2025Q2,18.4,0.274,640,5050,0,1,7,0,South,0 +2025Q2,41.7,0.321,674,5930,3,2,7,1,West,1122 +2025Q2,34.8,0.243,670,5800,2,2,7,0,West,372 +2025Q2,39.1,0.233,775,7530,2,6,5,0,West,1011 +2025Q2,23.3,0.225,556,4020,0,7,16,0,Northeast,0 +2025Q2,26.7,0.478,717,6690,0,6,11,0,West,727 +2025Q2,90.0,0.0,793,8160,0,1,14,0,Northeast,68 +2025Q2,23.0,0.326,569,4210,3,1,28,1,South,752 +2025Q2,29.9,0.189,533,3730,2,6,21,0,West,327 +2025Q2,78.5,0.161,560,4640,0,3,22,0,West,0 +2025Q2,22.8,0.186,704,6180,0,4,20,0,Midwest,99 +2025Q2,27.1,0.398,629,5410,1,3,6,0,West,353 +2025Q2,59.1,0.372,474,3220,0,6,25,0,West,0 +2025Q2,63.6,0.245,616,5420,3,7,17,0,South,293 +2025Q2,29.3,0.368,674,5860,2,1,3,0,West,499 +2025Q2,42.9,0.285,606,5050,3,3,3,0,South,345 +2025Q2,69.4,0.142,779,8020,2,3,23,0,West,771 +2025Q2,41.1,0.296,618,5110,1,6,6,0,South,136 +2025Q2,13.4,0.406,663,5890,4,2,4,0,South,787 +2025Q2,49.0,0.137,814,8230,1,7,0,0,Northeast,990 +2025Q2,70.4,0.005,699,6520,3,2,29,0,Northeast,273 +2025Q2,50.4,0.309,613,5230,3,6,30,0,Northeast,340 +2025Q2,32.2,0.407,693,6120,3,2,9,0,Northeast,790 +2025Q2,40.6,0.23,589,4720,2,7,7,0,West,221 +2025Q2,32.4,0.077,537,3860,0,3,9,0,South,0 +2025Q2,32.6,0.287,624,5190,0,4,19,0,Midwest,208 +2025Q2,42.6,0.045,608,5000,2,7,17,0,West,261 +2025Q2,46.9,0.289,405,2090,0,3,12,0,West,0 +2025Q2,22.0,0.251,688,5850,1,7,12,0,Northeast,382 +2025Q2,42.4,0.259,612,5420,1,4,18,0,West,174 +2025Q2,39.9,0.55,644,5540,0,4,28,0,South,108 +2025Q2,21.9,0.17,644,5270,5,2,1,0,South,512 +2025Q2,98.1,0.142,718,7490,0,5,7,0,West,238 +2025Q2,42.0,0.067,728,6680,1,7,6,0,South,399 +2025Q2,67.3,0.166,795,7990,0,2,29,0,West,313 +2025Q2,40.1,0.131,630,5140,2,1,20,0,South,89 +2025Q2,95.0,0.235,613,5450,0,2,29,0,West,0 +2025Q2,59.1,0.024,832,8490,0,2,10,0,Northeast,418 +2025Q2,81.4,0.181,689,6670,5,2,11,0,West,451 +2025Q2,39.4,0.271,679,6360,3,4,25,0,Midwest,553 +2025Q2,69.2,0.46,699,6560,2,2,14,0,West,477 +2025Q2,22.3,0.28,609,4700,0,2,4,0,South,0 +2025Q2,48.7,0.178,666,5940,4,1,11,0,West,402 +2025Q2,20.9,0.309,588,4530,0,2,10,0,South,52 +2025Q2,57.3,0.406,745,7240,0,4,14,1,Midwest,922 +2025Q2,70.6,0.086,741,7230,1,4,26,0,Northeast,341 +2025Q2,135.3,0.114,687,7300,2,7,5,0,South,153 +2025Q2,46.6,0.33,823,8220,1,1,11,0,Northeast,691 +2025Q2,74.9,0.261,778,7900,0,7,6,0,West,354 +2025Q2,16.7,0.308,580,4410,1,2,8,0,Northeast,108 +2025Q2,40.7,0.215,592,4820,4,4,8,0,Midwest,90 +2025Q2,44.8,0.131,689,6280,0,7,12,0,Northeast,15 +2025Q2,22.5,0.238,651,5310,0,7,30,0,South,265 +2025Q2,25.9,0.416,545,4060,1,3,22,0,Northeast,244 +2025Q2,59.1,0.427,571,4600,1,2,22,1,Northeast,684 +2025Q2,54.5,0.324,558,4210,0,2,22,1,Midwest,310 +2025Q2,76.1,0.17,780,8020,1,7,5,0,West,372 +2025Q2,56.3,0.232,669,6200,2,7,5,0,South,258 +2025Q2,62.1,0.351,717,6750,0,3,18,0,Midwest,210 +2025Q2,63.1,0.279,629,5530,1,1,29,0,Northeast,0 +2025Q2,101.2,0.19,658,6490,3,6,6,0,Midwest,0 +2025Q2,90.4,0.206,616,5440,1,3,8,0,South,0 +2025Q2,56.0,0.184,772,7460,1,2,24,0,West,258 +2025Q2,23.2,0.091,647,5520,1,3,4,0,West,266 +2025Q2,29.4,0.327,540,4040,1,3,11,0,West,382 +2025Q2,75.2,0.171,674,6100,2,4,11,0,Northeast,20 +2025Q2,26.9,0.493,616,4950,4,7,2,0,Midwest,698 +2025Q2,40.3,0.317,755,7190,2,3,19,0,Midwest,793 +2025Q2,38.4,0.298,762,7280,1,6,19,0,Midwest,782 +2025Q2,26.0,0.295,631,5190,1,7,5,0,South,393 +2025Q2,73.0,0.233,605,5270,1,3,16,0,Northeast,0 +2025Q2,41.3,0.189,678,6070,3,4,20,0,Midwest,403 +2025Q2,46.8,0.253,697,6450,3,5,17,0,Northeast,487 +2025Q2,29.9,0.335,603,4920,4,5,26,0,West,503 +2025Q2,46.5,0.325,651,5790,1,2,5,0,Northeast,212 +2025Q2,28.2,0.465,588,4560,1,5,9,0,Midwest,204 +2025Q3,13.9,0.41,560,3980,2,3,12,0,West,642 +2025Q3,23.3,0.312,677,5670,4,6,20,0,West,717 +2025Q3,42.8,0.404,787,7700,1,1,1,0,Midwest,903 +2025Q3,69.8,0.206,610,5420,1,1,26,0,South,0 +2025Q3,12.1,0.325,601,4380,2,6,22,0,Northeast,586 +2025Q3,11.1,0.39,638,5240,0,1,2,0,West,345 +2025Q3,19.5,0.262,696,6060,2,4,7,0,South,976 +2025Q3,29.9,0.427,594,4600,0,1,9,0,Midwest,0 +2025Q3,32.5,0.25,564,4190,2,3,17,1,West,1045 +2025Q3,15.6,0.349,583,4400,0,3,12,0,West,444 +2025Q3,32.7,0.273,575,4430,0,2,27,0,Midwest,77 +2025Q3,10.0,0.479,706,6260,2,7,8,0,Midwest,1230 +2025Q3,35.3,0.328,678,6110,1,5,11,0,South,612 +2025Q3,60.6,0.252,689,6520,1,7,10,0,Midwest,478 +2025Q3,10.6,0.552,521,3550,2,4,30,0,South,665 +2025Q3,21.5,0.442,667,5650,3,4,10,0,West,963 +2025Q3,20.8,0.278,702,6270,1,3,6,0,West,756 +2025Q3,66.9,0.286,735,7180,1,3,2,0,South,526 +2025Q3,33.0,0.205,511,3330,0,1,12,0,West,0 +2025Q3,17.6,0.303,702,6140,1,5,21,0,South,930 +2025Q3,33.9,0.192,850,8700,2,6,4,0,Midwest,1325 +2025Q3,56.4,0.2,622,5280,4,2,20,0,West,475 +2025Q3,35.2,0.199,818,8030,0,6,26,0,Midwest,846 +2025Q3,17.0,0.337,778,7310,1,1,7,0,South,1079 +2025Q3,43.5,0.384,634,5540,0,6,27,0,South,303 +2025Q3,45.0,0.242,603,5160,2,3,20,0,Midwest,594 +2025Q3,27.2,0.361,589,4470,0,7,8,0,Midwest,198 +2025Q3,24.6,0.302,538,3650,0,6,25,0,South,45 +2025Q3,56.7,0.348,662,5950,1,2,4,0,West,459 +2025Q3,19.6,0.336,693,6150,4,4,18,0,West,824 +2025Q3,38.6,0.438,540,3920,1,1,13,0,South,374 +2025Q3,29.8,0.198,693,6230,2,2,11,0,Northeast,558 +2025Q3,28.1,0.399,600,4860,0,2,18,0,South,0 +2025Q3,72.8,0.202,721,7110,0,1,8,0,South,239 +2025Q3,39.6,0.298,594,4850,2,3,0,0,Northeast,454 +2025Q3,43.9,0.226,761,7220,1,7,5,0,Midwest,815 +2025Q3,39.4,0.206,681,6270,0,6,22,0,Northeast,485 +2025Q3,21.5,0.353,636,5200,2,3,20,0,West,753 +2025Q3,32.0,0.432,652,5380,2,1,18,0,West,572 +2025Q3,62.2,0.324,572,4640,0,4,29,0,South,0 +2025Q3,28.2,0.276,597,4990,0,1,20,0,Northeast,229 +2025Q3,27.4,0.315,614,4980,1,2,27,0,Midwest,280 +2025Q3,18.4,0.25,625,5010,3,1,18,0,West,345 +2025Q3,10.0,0.301,592,4510,0,3,22,0,Midwest,89 +2025Q3,14.0,0.339,722,6500,0,7,16,0,South,748 +2025Q3,25.7,0.38,653,5380,2,4,9,0,West,609 +2025Q3,50.2,0.254,689,6380,1,1,24,0,West,715 +2025Q3,53.4,0.196,703,6460,1,7,15,0,Midwest,397 +2025Q3,41.7,0.335,588,4880,0,7,6,0,Northeast,300 +2025Q3,22.4,0.378,686,6260,0,7,26,0,Northeast,592 +2025Q3,10.0,0.291,670,5610,0,6,27,1,Northeast,946 +2025Q3,29.1,0.24,596,4580,3,5,9,0,West,444 +2025Q3,22.0,0.434,571,4190,0,2,13,0,West,487 +2025Q3,12.1,0.329,656,5520,1,3,4,0,Northeast,759 +2025Q3,26.1,0.332,712,6400,1,2,27,0,Northeast,596 +2025Q3,70.5,0.205,590,5120,3,5,29,0,West,342 +2025Q3,30.0,0.257,607,4860,1,6,1,0,West,275 +2025Q3,26.8,0.368,608,5110,1,6,27,0,Northeast,487 +2025Q3,59.2,0.254,565,4490,0,5,18,0,South,185 +2025Q3,41.4,0.346,804,7970,0,6,19,0,West,930 +2025Q3,13.3,0.225,648,5160,1,4,12,0,South,392 +2025Q3,15.8,0.183,563,4100,2,6,10,0,South,490 +2025Q3,54.8,0.258,644,5750,1,5,20,0,Midwest,309 +2025Q3,12.1,0.476,541,3540,2,3,0,0,South,622 +2025Q3,39.1,0.287,683,6120,2,4,19,0,West,477 +2025Q3,26.5,0.301,588,4580,5,5,30,0,Midwest,687 +2025Q3,29.9,0.263,734,6990,4,1,15,0,West,885 +2025Q3,57.9,0.229,595,4980,1,2,30,0,West,0 +2025Q3,25.1,0.42,640,5240,4,5,0,0,South,627 +2025Q3,43.2,0.248,570,4530,2,2,19,0,West,248 +2025Q3,15.2,0.395,604,4770,5,7,11,0,Midwest,905 +2025Q3,19.4,0.267,534,3430,2,4,1,0,West,316 +2025Q3,65.5,0.187,577,4830,2,4,25,0,Midwest,354 +2025Q3,22.3,0.371,723,6690,0,2,3,1,South,1096 +2025Q3,100.1,0.128,779,8390,0,6,21,0,West,474 +2025Q3,20.4,0.339,536,3740,3,4,27,0,West,433 +2025Q3,10.0,0.301,635,5230,2,6,1,0,Midwest,630 +2025Q3,13.1,0.282,704,6090,3,2,25,0,West,816 +2025Q3,21.2,0.284,526,3500,0,2,21,0,Midwest,0 +2025Q3,29.0,0.209,733,6880,2,6,20,0,Northeast,1069 +2025Q3,10.0,0.28,726,6300,5,3,27,0,Northeast,900 +2025Q3,38.7,0.215,593,4640,2,5,24,0,Midwest,114 +2025Q3,28.3,0.273,761,7220,1,7,19,0,Midwest,1024 +2025Q3,21.4,0.284,694,6060,0,6,7,0,Midwest,415 +2025Q3,90.2,0.1,568,4860,0,3,22,0,Northeast,0 +2025Q3,32.5,0.226,689,6120,1,5,23,0,Northeast,564 +2025Q3,29.2,0.439,670,5890,1,4,9,1,Northeast,1199 +2025Q3,10.0,0.343,688,5830,1,3,11,0,Midwest,808 +2025Q3,95.8,0.193,764,7780,1,1,4,0,Midwest,359 +2025Q3,22.8,0.364,694,6000,3,6,23,0,Midwest,957 +2025Q3,38.6,0.169,570,4700,4,3,19,1,Midwest,985 +2025Q3,27.4,0.302,547,3930,1,6,26,1,Midwest,900 +2025Q3,26.4,0.597,703,6620,4,2,2,0,South,912 +2025Q3,40.2,0.164,781,7610,0,5,19,0,South,561 +2025Q3,13.2,0.227,614,4820,2,4,16,0,West,429 +2025Q3,72.0,0.183,676,6430,1,1,21,0,Northeast,264 +2025Q3,45.3,0.382,556,4130,4,6,4,0,Northeast,640 +2025Q3,33.9,0.296,669,5800,4,6,4,0,West,960 +2025Q3,77.7,0.272,785,8060,2,1,26,0,South,807 +2025Q3,15.1,0.452,659,5450,2,1,30,0,Northeast,708 +2025Q3,28.5,0.169,800,7850,0,4,2,0,West,765 +2025Q3,55.6,0.452,815,8310,2,4,28,0,South,1016 +2025Q3,44.7,0.3,742,7130,0,1,16,0,South,585 +2025Q3,46.5,0.0,850,8670,2,4,25,0,South,921 +2025Q3,13.5,0.39,668,5820,3,5,15,0,Northeast,872 +2025Q3,10.0,0.358,643,5150,3,2,23,0,Midwest,642 +2025Q3,37.9,0.41,623,5260,1,3,24,0,South,397 +2025Q3,30.2,0.311,735,6860,4,7,9,0,South,1134 +2025Q3,19.8,0.198,684,5830,0,6,21,1,Midwest,723 +2025Q3,49.2,0.264,658,5840,1,7,2,0,West,431 +2025Q3,21.8,0.289,641,5170,0,5,4,0,Northeast,263 +2025Q3,31.4,0.346,591,4620,1,1,9,0,West,31 +2025Q3,40.8,0.313,698,6320,1,3,9,0,Midwest,517 +2025Q3,73.8,0.085,617,5400,0,5,7,0,Midwest,0 +2025Q3,50.2,0.388,482,3250,2,1,18,0,Northeast,12 +2025Q3,43.5,0.318,754,7430,2,5,6,0,West,1007 +2025Q3,24.4,0.397,534,3640,1,1,2,0,West,209 +2025Q3,56.9,0.194,686,6360,2,1,5,0,South,563 +2025Q3,10.0,0.249,642,5300,1,7,1,0,Midwest,634 +2025Q3,15.4,0.393,664,5640,2,5,19,0,Midwest,795 +2025Q3,29.2,0.233,623,5070,1,4,28,1,Midwest,826 +2025Q3,35.3,0.235,625,5300,1,7,12,0,South,301 +2025Q3,21.3,0.182,611,4830,0,6,28,0,Northeast,32 +2025Q3,67.2,0.416,696,6750,1,1,20,0,South,374 +2025Q3,36.7,0.486,650,5590,1,6,10,0,Northeast,557 +2025Q3,41.4,0.21,837,8500,2,6,13,0,Northeast,1089 +2025Q3,29.1,0.349,560,4350,1,7,4,1,Midwest,1192 +2025Q3,94.7,0.154,705,7020,0,6,0,0,West,187 +2025Q3,51.5,0.297,834,8440,0,6,13,0,Midwest,996 +2025Q3,35.6,0.185,714,6640,2,5,6,0,South,892 +2025Q3,178.7,0.114,632,6690,1,2,16,0,West,0 +2025Q3,23.3,0.354,668,5800,0,6,29,0,Northeast,403 +2025Q3,38.2,0.31,747,6890,2,2,29,0,Northeast,807 +2025Q3,18.6,0.385,712,6400,2,1,24,0,South,796 +2025Q3,12.2,0.257,693,6050,2,1,18,0,West,974 +2025Q3,25.3,0.363,594,4460,2,5,22,1,Midwest,852 +2025Q3,48.3,0.203,669,5780,4,1,11,1,Midwest,860 +2025Q3,10.9,0.345,550,3750,2,4,14,0,Northeast,136 +2025Q3,40.1,0.289,697,6200,2,2,13,0,South,582 +2025Q3,15.9,0.421,591,4380,0,2,5,0,Midwest,240 +2025Q3,37.8,0.467,662,5790,2,6,14,0,Midwest,701 +2025Q3,13.9,0.305,657,5410,5,2,0,0,Midwest,793 +2025Q3,40.3,0.291,749,7070,4,4,15,0,West,891 +2025Q3,10.0,0.449,627,5090,0,2,6,0,Northeast,578 +2025Q3,46.9,0.434,652,5770,2,6,14,0,South,733 +2025Q3,28.7,0.348,657,5630,0,7,29,0,South,265 +2025Q3,15.3,0.422,714,6360,0,6,18,0,Midwest,588 +2025Q3,20.9,0.465,567,4110,2,1,22,0,West,616 +2025Q3,42.9,0.279,614,5140,1,2,16,0,Midwest,48 +2025Q3,30.3,0.194,821,8140,4,2,20,0,Midwest,1127 +2025Q3,12.2,0.345,583,4240,2,3,28,0,West,476 +2025Q3,31.5,0.221,688,6090,3,4,27,0,West,828 +2025Q3,19.7,0.38,585,4220,1,1,18,0,South,88 +2025Q3,14.7,0.398,438,2250,1,1,21,0,West,50 +2025Q3,41.6,0.237,638,5410,1,6,2,0,South,277 +2025Q3,36.6,0.269,674,6010,1,7,10,0,West,380 +2025Q3,10.0,0.49,651,5470,0,3,16,0,West,434 +2025Q3,85.0,0.167,664,6350,3,2,19,0,Northeast,497 +2025Q3,42.5,0.068,479,3240,1,5,28,0,Northeast,100 +2025Q3,32.4,0.432,611,4870,2,6,4,0,Northeast,564 +2025Q3,15.5,0.296,662,5840,2,4,15,0,Midwest,764 +2025Q3,28.3,0.207,816,7940,1,4,19,0,Midwest,1089 +2025Q3,25.0,0.223,594,4550,0,1,6,0,South,0 +2025Q3,53.2,0.228,604,5140,1,2,16,0,South,41 +2025Q3,64.3,0.306,600,5210,1,2,9,0,West,0 +2025Q3,72.0,0.33,598,5100,1,4,28,0,Northeast,189 +2025Q3,76.4,0.302,743,7510,0,5,22,0,West,655 +2025Q3,37.3,0.338,641,5530,3,4,21,0,Northeast,450 +2025Q3,30.3,0.291,746,7030,1,5,27,1,Midwest,1376 +2025Q3,18.8,0.508,562,3930,1,2,7,0,Northeast,368 +2025Q3,18.3,0.254,667,5540,1,3,12,0,Midwest,531 +2025Q3,10.0,0.325,549,3980,0,5,5,0,Midwest,558 +2025Q3,22.6,0.245,660,5730,0,4,2,0,Northeast,409 +2025Q3,21.3,0.264,750,7040,2,1,26,0,Midwest,834 +2025Q3,17.7,0.197,716,6420,1,7,3,0,Midwest,686 +2025Q3,14.9,0.335,583,4340,2,4,17,0,Northeast,667 +2025Q3,32.6,0.319,695,6040,4,2,8,0,Midwest,747 +2025Q3,42.2,0.198,654,5560,2,6,9,0,Northeast,351 +2025Q3,41.0,0.334,690,6070,2,4,15,0,Northeast,523 +2025Q3,67.8,0.22,621,5550,0,2,9,0,South,0 +2025Q3,10.4,0.408,508,3280,0,6,4,0,South,242 +2025Q3,61.5,0.345,582,4740,1,5,5,0,South,11 +2025Q3,58.1,0.357,568,4620,1,4,3,0,Northeast,538 +2025Q3,32.3,0.344,488,3090,2,5,24,0,South,413 +2025Q3,22.8,0.346,616,5000,1,1,17,0,Northeast,434 +2025Q3,10.0,0.461,620,4880,1,2,5,0,South,490 +2025Q3,32.7,0.303,601,4960,1,7,20,0,West,223 +2025Q3,38.0,0.186,835,8510,4,1,3,0,West,1329 +2025Q3,19.8,0.302,642,5270,5,3,12,0,Midwest,675 +2025Q3,30.8,0.159,751,7340,1,5,29,0,South,718 +2025Q3,29.1,0.252,583,4560,2,7,8,1,Northeast,961 +2025Q3,19.2,0.321,589,4430,2,7,6,0,Midwest,311 +2025Q3,104.1,0.148,577,5210,2,7,13,0,West,170 +2025Q3,10.0,0.166,584,4350,1,1,2,0,Northeast,109 +2025Q3,13.0,0.326,622,4970,2,1,16,0,South,549 +2025Q3,52.6,0.254,627,5550,3,4,16,0,Northeast,579 +2025Q3,40.8,0.291,582,4560,2,3,26,0,West,235 +2025Q3,18.0,0.433,545,3770,3,6,3,0,South,757 +2025Q3,124.0,0.211,703,7060,1,4,15,0,South,0 +2025Q3,15.4,0.293,627,4980,2,6,14,1,West,1058 +2025Q3,34.5,0.305,679,5940,4,4,22,0,South,794 +2025Q3,17.3,0.371,656,5680,3,4,8,0,Midwest,785 +2025Q3,25.4,0.569,709,6460,1,6,15,0,South,847 +2025Q3,35.1,0.332,481,3260,0,5,13,0,West,0 +2025Q3,19.4,0.468,654,5450,2,1,11,0,West,780 +2025Q3,26.9,0.383,479,3190,0,2,7,0,Northeast,0 +2025Q3,35.1,0.365,723,6530,0,5,26,0,Northeast,611 +2025Q3,45.5,0.396,655,5760,1,7,17,1,Northeast,993 +2025Q3,20.3,0.254,647,5490,1,3,14,0,South,556 +2025Q3,29.1,0.45,613,4800,1,7,21,0,Northeast,160 +2025Q3,84.3,0.284,660,6130,1,7,6,0,West,234 +2025Q3,119.2,0.195,496,4350,0,1,25,0,West,0 +2025Q3,10.0,0.285,600,4650,0,5,25,0,Midwest,203 +2025Q3,19.9,0.499,541,3720,1,4,17,0,Northeast,444 +2025Q3,35.0,0.436,649,5820,2,6,25,0,Midwest,831 +2025Q3,34.8,0.493,541,3850,1,1,13,0,South,247 +2025Q3,10.0,0.383,534,3640,2,4,28,0,Northeast,421 +2025Q3,18.3,0.358,655,5490,4,2,13,0,Midwest,744 +2025Q3,46.1,0.598,633,5400,3,3,9,0,Midwest,639 +2025Q3,200.0,0.0,685,7710,1,2,29,0,Northeast,0 +2025Q3,57.2,0.338,635,5810,1,7,13,0,West,510 +2025Q3,54.4,0.041,762,7810,2,6,9,0,West,971 +2025Q3,21.2,0.469,623,4990,1,1,26,0,West,355 +2025Q3,17.2,0.399,611,4960,2,2,17,0,Midwest,647 +2025Q3,33.1,0.357,647,5780,0,1,9,0,Midwest,248 +2025Q3,74.7,0.303,735,7380,3,3,8,1,Northeast,1045 +2025Q3,15.8,0.394,567,4130,0,4,9,0,South,539 +2025Q3,24.0,0.134,818,8020,2,5,22,0,West,1060 +2025Q3,22.4,0.219,763,7180,0,1,16,0,South,677 +2025Q3,26.2,0.408,577,4690,1,7,23,0,South,787 +2025Q3,39.6,0.306,706,6580,0,6,15,0,Northeast,433 +2025Q3,30.8,0.283,782,7460,0,3,1,0,West,1041 +2025Q3,16.4,0.444,663,5570,2,4,22,0,South,590 +2025Q3,18.9,0.29,557,3880,5,2,22,0,South,677 +2025Q3,27.2,0.259,601,5020,0,5,11,0,Midwest,435 +2025Q3,21.7,0.438,616,5050,2,6,0,0,Northeast,450 +2025Q3,40.5,0.292,749,7260,4,6,25,0,West,1077 +2025Q3,55.1,0.0,587,4730,1,5,6,0,Northeast,0 +2025Q3,127.4,0.361,626,6350,4,1,16,0,West,0 +2025Q3,40.5,0.247,532,3940,1,4,21,0,Northeast,49 +2025Q3,11.6,0.2,678,5850,1,1,0,0,West,639 +2025Q3,57.9,0.406,539,4360,1,3,24,0,West,224 +2025Q3,14.7,0.446,550,3920,0,6,17,0,Northeast,206 +2025Q3,20.5,0.296,763,7280,1,7,4,0,Midwest,922 +2025Q3,48.7,0.338,528,3840,1,6,26,0,Northeast,262 +2025Q3,26.3,0.328,674,5860,0,1,4,0,South,559 +2025Q3,23.4,0.21,685,5790,0,4,27,0,Northeast,432 +2025Q3,10.0,0.486,565,4000,2,6,9,0,West,692 +2025Q3,22.4,0.281,632,5260,2,6,20,0,South,443 +2025Q3,22.9,0.371,706,6400,5,4,9,1,South,1290 +2025Q3,19.4,0.327,503,3400,2,7,9,0,Midwest,382 +2025Q3,23.7,0.564,520,3600,3,5,4,0,Northeast,508 +2025Q3,14.7,0.313,589,4400,2,4,23,0,West,435 +2025Q3,12.8,0.432,739,6650,1,7,18,0,South,1140 +2025Q3,19.0,0.207,516,3480,2,2,20,0,West,246 +2025Q3,39.6,0.471,650,5670,0,7,23,0,Midwest,274 +2025Q3,10.0,0.373,700,6240,0,4,9,0,Midwest,730 +2025Q3,43.4,0.201,775,7360,4,1,19,0,Midwest,1029 +2025Q3,42.6,0.444,646,5810,1,5,23,0,Midwest,457 +2025Q3,60.9,0.237,698,6840,0,1,3,0,South,54 +2025Q3,21.1,0.194,582,4620,1,5,9,0,West,326 +2025Q3,29.1,0.291,567,4220,2,3,26,0,West,698 +2025Q3,61.9,0.263,656,5940,2,4,15,0,Northeast,352 +2025Q3,18.0,0.268,580,4250,3,4,10,1,South,875 +2025Q3,10.8,0.321,612,4690,1,2,23,1,Northeast,701 +2025Q3,29.7,0.489,695,6220,1,3,23,0,West,794 +2025Q3,22.3,0.506,661,5780,1,7,24,0,Northeast,947 +2025Q3,32.6,0.237,529,3960,3,4,21,0,Northeast,402 +2025Q3,16.7,0.372,603,4450,1,4,3,0,Northeast,383 +2025Q3,22.0,0.345,758,6860,3,2,15,0,South,1144 +2025Q3,37.3,0.278,613,5020,1,2,18,0,West,298 +2025Q3,31.5,0.275,643,5370,1,5,26,0,Northeast,436 +2025Q3,27.5,0.32,636,5400,1,1,12,0,Midwest,540 +2025Q3,10.0,0.457,735,6660,1,3,10,0,South,994 +2025Q3,16.3,0.413,750,7010,1,2,10,0,West,1202 +2025Q3,14.6,0.368,640,5210,1,7,17,0,South,581 +2025Q3,150.9,0.024,619,6360,1,7,1,0,South,0 +2025Q3,25.5,0.324,664,5430,0,4,23,0,West,485 +2025Q3,19.4,0.458,641,5290,0,6,12,0,South,369 +2025Q3,39.0,0.278,701,6350,2,2,0,0,Midwest,484 +2025Q3,10.9,0.252,582,4430,1,5,25,0,West,171 +2025Q3,35.6,0.298,607,5110,3,4,11,0,South,358 +2025Q3,67.3,0.229,496,3640,3,6,22,0,Midwest,198 +2025Q3,53.7,0.287,648,5750,1,3,11,0,Midwest,326 +2025Q3,19.7,0.161,584,4470,2,6,8,0,South,478 +2025Q3,89.1,0.272,659,6190,2,5,18,1,Midwest,756 +2025Q3,83.9,0.0,604,5260,2,7,30,1,South,261 +2025Q3,10.0,0.446,773,7230,1,2,14,0,West,1239 +2025Q3,30.0,0.474,535,3950,2,3,2,0,West,431 +2025Q3,21.2,0.258,757,6900,6,7,16,0,Midwest,1153 +2025Q3,17.9,0.353,742,6890,1,3,18,0,West,779 +2025Q3,20.4,0.194,739,6650,1,4,5,0,West,786 +2025Q3,17.4,0.457,719,6500,1,4,5,0,South,880 +2025Q3,62.9,0.498,657,6210,0,1,19,0,Northeast,323 +2025Q3,31.4,0.379,477,2910,4,5,26,0,Midwest,259 +2025Q3,32.5,0.389,620,5000,2,6,1,0,South,464 +2025Q3,70.0,0.172,665,6080,0,3,22,0,Northeast,0 +2025Q3,24.4,0.514,513,3470,2,2,18,0,West,425 +2025Q3,34.4,0.206,680,6090,1,3,11,0,Midwest,328 +2025Q3,16.7,0.429,668,5810,3,1,7,0,West,837 +2025Q3,34.8,0.186,616,5080,0,4,24,0,Midwest,20 +2025Q3,112.1,0.0,628,6110,1,3,17,0,West,0 +2025Q3,42.3,0.259,703,6600,1,7,26,1,Midwest,1039 +2025Q3,21.7,0.433,634,5380,0,5,9,0,Midwest,380 +2025Q3,22.7,0.56,574,4340,1,2,7,0,West,557 +2025Q3,26.0,0.061,535,3650,2,4,3,0,Midwest,525 +2025Q3,46.0,0.274,617,5430,0,6,10,0,South,297 +2025Q3,19.5,0.385,698,6110,2,7,5,0,West,821 +2025Q3,33.5,0.237,606,4800,1,7,2,0,Midwest,244 +2025Q3,30.2,0.319,710,6480,1,7,12,0,South,910 +2025Q3,54.0,0.282,755,7150,3,7,14,0,South,934 +2025Q3,22.9,0.345,659,5630,3,6,12,0,West,810 +2025Q3,80.3,0.227,658,6010,2,1,3,0,Midwest,339 +2025Q3,40.8,0.152,684,6370,2,3,14,0,West,784 +2025Q3,16.1,0.359,627,5080,1,2,25,0,South,502 +2025Q3,15.3,0.196,566,4050,2,2,7,0,West,621 +2025Q3,19.2,0.43,699,6170,2,2,29,0,Midwest,791 +2025Q3,20.3,0.18,578,4170,1,2,24,0,West,377 +2025Q3,17.4,0.229,656,5490,2,5,19,0,West,759 +2025Q3,52.0,0.221,734,6880,2,2,8,0,West,530 +2025Q3,48.7,0.174,589,4790,0,2,28,0,Northeast,0 +2025Q3,27.5,0.266,710,6440,2,1,23,0,West,896 +2025Q3,37.7,0.545,589,4740,3,6,17,0,Northeast,591 +2025Q3,30.0,0.319,672,5790,1,6,10,0,West,375 +2025Q3,52.4,0.262,735,7070,2,1,24,0,Northeast,706 +2025Q3,29.6,0.326,724,6550,0,5,24,1,South,1047 +2025Q3,17.1,0.367,485,2930,1,2,10,0,Northeast,210 +2025Q3,58.2,0.142,660,5810,1,5,8,1,West,742 +2025Q3,34.7,0.112,767,7390,2,6,30,0,Midwest,881 +2025Q3,55.5,0.238,770,7640,1,1,24,0,South,693 +2025Q3,12.6,0.305,684,5950,0,1,2,0,Northeast,405 +2025Q3,44.2,0.429,486,3250,1,3,10,0,West,237 +2025Q3,70.5,0.146,649,5990,1,6,5,0,Northeast,51 +2025Q3,16.6,0.347,619,5240,1,1,17,1,Northeast,884 +2025Q3,32.9,0.216,755,7160,3,6,22,0,Midwest,883 +2025Q3,21.6,0.44,656,5830,2,7,15,0,South,950 +2025Q3,72.6,0.175,666,6380,0,1,6,1,West,441 +2025Q3,14.7,0.275,586,4560,2,6,15,1,West,783 +2025Q3,16.5,0.411,585,4490,1,2,15,0,Northeast,438 +2025Q3,35.2,0.159,758,7260,0,1,16,0,West,619 +2025Q3,16.7,0.283,651,5380,4,4,26,1,Midwest,1057 +2025Q3,19.7,0.418,545,3920,0,1,2,1,Northeast,542 +2025Q3,61.3,0.437,675,6080,1,7,16,0,West,371 +2025Q3,11.5,0.21,679,5630,2,7,4,0,Northeast,927 +2025Q3,23.4,0.26,746,7060,1,7,8,0,Northeast,985 +2025Q3,93.0,0.2,850,9320,2,7,8,0,West,1196 +2025Q3,56.8,0.392,574,4810,0,2,16,1,Northeast,432 +2025Q3,13.2,0.34,689,6130,0,5,1,0,West,481 +2025Q3,37.5,0.31,667,5670,1,1,14,0,Northeast,290 +2025Q3,39.4,0.431,691,6200,0,6,21,0,South,683 +2025Q3,53.6,0.319,729,7060,0,5,25,0,South,539 +2025Q3,34.6,0.303,516,3450,1,6,5,0,Northeast,338 +2025Q3,26.7,0.26,557,4190,2,2,24,0,Northeast,503 +2025Q3,25.0,0.337,681,6210,2,5,30,0,South,1073 +2025Q3,51.3,0.349,706,6680,3,2,29,1,West,1219 +2025Q3,13.5,0.306,685,5890,3,6,14,1,South,1311 +2025Q3,38.5,0.367,777,7420,0,2,4,1,Northeast,1131 +2025Q3,10.0,0.316,831,8330,1,7,0,0,Northeast,1452 +2025Q3,18.4,0.348,677,5640,0,6,17,0,South,497 +2025Q3,32.4,0.24,616,5100,2,6,4,0,South,676 +2025Q3,70.6,0.433,661,6150,2,3,24,0,Midwest,609 +2025Q3,11.5,0.238,850,8280,1,3,28,0,Northeast,1011 +2025Q3,30.5,0.407,652,5580,4,1,27,0,South,800 +2025Q3,32.5,0.205,729,6950,3,2,7,0,Northeast,880 +2025Q3,32.8,0.63,532,3810,2,4,18,1,South,1070 +2025Q3,24.8,0.371,561,4050,2,1,27,0,Midwest,548 +2025Q3,57.4,0.201,710,6650,0,2,14,0,Northeast,121 +2025Q3,20.9,0.353,576,4360,1,3,12,0,West,396 +2025Q3,45.2,0.382,697,6370,0,1,2,0,South,631 +2025Q3,24.0,0.334,596,4570,0,1,17,0,Midwest,75 +2025Q3,23.6,0.278,638,5160,3,2,7,0,West,738 +2025Q3,50.6,0.141,627,5560,2,5,4,0,South,264 +2025Q3,36.9,0.367,710,6400,0,1,4,0,Midwest,511 +2025Q3,27.5,0.423,709,6380,2,5,2,1,Northeast,1211 +2025Q3,55.9,0.243,620,5570,2,5,15,0,Midwest,401 +2025Q3,32.2,0.269,562,4320,1,1,13,0,South,320 +2025Q3,16.5,0.405,653,5540,0,3,27,0,West,654 +2025Q3,16.4,0.272,627,5280,2,7,21,0,Northeast,1005 +2025Q3,26.2,0.283,689,6230,1,3,21,0,South,653 +2025Q3,41.1,0.191,703,6460,2,6,29,1,West,1227 +2025Q3,11.9,0.312,674,5830,1,3,20,0,Midwest,610 +2025Q3,27.9,0.402,706,6360,1,5,20,0,West,604 +2025Q3,30.6,0.383,607,4770,0,6,27,0,Midwest,18 +2025Q3,28.2,0.275,661,5610,2,6,0,0,Northeast,550 +2025Q3,61.4,0.22,634,5570,1,1,1,0,Northeast,283 +2025Q3,15.3,0.419,422,2040,1,5,2,0,Northeast,16 +2025Q3,76.9,0.263,572,4720,3,2,4,0,South,203 +2025Q3,43.5,0.279,673,6000,0,1,25,0,Midwest,393 +2025Q3,34.8,0.652,580,4500,1,3,22,0,Midwest,561 +2025Q3,60.6,0.143,711,6900,2,7,15,0,South,586 +2025Q3,19.9,0.404,660,5440,2,5,10,0,Northeast,615 +2025Q3,21.6,0.256,592,4620,1,2,16,0,South,222 +2025Q3,24.5,0.4,602,4730,2,3,26,0,Midwest,596 +2025Q3,11.7,0.24,677,5880,2,7,16,0,South,793 +2025Q3,24.7,0.236,543,3990,1,7,15,1,West,760 +2025Q3,22.2,0.416,648,5530,3,5,17,0,West,822 +2025Q3,19.5,0.284,593,4670,0,7,7,0,Northeast,132 +2025Q3,10.4,0.34,484,2820,1,6,16,0,Northeast,233 +2025Q3,21.3,0.18,616,4870,2,7,3,0,Northeast,495 +2025Q3,21.4,0.4,417,2130,0,5,10,1,West,248 +2025Q3,22.8,0.284,747,6870,3,1,29,1,West,1394 +2025Q3,27.8,0.361,579,4360,1,5,21,0,Midwest,455 +2025Q3,19.4,0.411,631,5270,1,3,21,0,Midwest,428 +2025Q3,23.9,0.558,532,3650,1,6,30,0,Northeast,697 +2025Q3,29.7,0.369,556,4280,1,6,21,0,West,334 +2025Q3,37.5,0.331,578,4570,1,5,1,0,Northeast,647 +2025Q3,73.3,0.323,725,7250,3,7,10,0,South,901 +2025Q3,46.1,0.132,693,6440,3,3,5,0,Northeast,574 +2025Q3,56.0,0.316,553,4300,0,5,19,0,Midwest,156 +2025Q3,66.8,0.172,613,5450,0,7,14,0,West,127 +2025Q3,23.2,0.109,764,7200,0,1,25,0,Northeast,585 +2025Q3,15.4,0.344,751,6990,2,1,20,0,Northeast,914 +2025Q3,15.5,0.262,631,5000,1,2,2,1,Midwest,768 +2025Q3,42.3,0.26,603,5020,1,5,16,0,West,281 +2025Q3,41.5,0.249,622,5340,3,7,17,1,Northeast,1075 +2025Q3,68.0,0.245,762,7810,1,7,3,0,Midwest,788 +2025Q3,30.6,0.415,688,6180,0,4,19,0,South,775 +2025Q3,11.7,0.123,558,4150,1,3,20,0,West,299 +2025Q3,38.1,0.325,646,5570,2,1,9,1,West,758 +2025Q3,31.1,0.141,677,5870,1,3,14,1,Midwest,858 +2025Q3,36.4,0.296,557,4070,0,6,26,0,Midwest,151 +2025Q3,34.9,0.305,733,6800,0,2,12,0,West,379 +2025Q3,26.5,0.304,714,6400,2,3,25,1,Northeast,1164 +2025Q3,45.1,0.335,573,4690,3,6,12,0,West,540 +2025Q3,29.6,0.298,719,6400,1,3,9,0,South,532 +2025Q3,41.0,0.215,696,6340,1,6,11,0,South,727 +2025Q3,19.9,0.447,629,5220,1,6,4,0,South,504 +2025Q3,11.8,0.233,645,5250,2,6,13,0,South,787 +2025Q3,51.3,0.3,584,4900,0,7,24,0,Northeast,43 +2025Q3,12.2,0.455,717,6160,0,3,29,0,West,640 +2025Q3,49.2,0.115,744,7300,2,7,12,0,South,880 +2025Q3,18.1,0.515,771,7180,4,2,9,0,Midwest,1187 +2025Q3,29.1,0.105,611,4900,2,6,24,0,South,222 +2025Q3,12.5,0.373,606,4910,2,5,17,0,Northeast,507 +2025Q3,11.7,0.561,660,5400,3,7,12,0,Midwest,953 +2025Q3,26.5,0.326,549,4030,0,7,20,1,West,648 +2025Q3,31.0,0.28,579,4480,0,4,2,0,West,253 +2025Q3,10.2,0.25,559,3720,0,4,19,0,Midwest,109 +2025Q3,23.1,0.393,632,5320,0,6,13,0,West,235 +2025Q3,15.3,0.349,653,5570,1,2,10,0,Northeast,667 +2025Q4,10.0,0.384,503,3020,1,6,20,0,Midwest,433 +2025Q4,17.9,0.163,648,5380,0,4,4,0,Northeast,322 +2025Q4,24.9,0.535,684,5960,0,1,13,0,Northeast,548 +2025Q4,35.3,0.45,631,5240,3,1,19,0,Midwest,492 +2025Q4,16.5,0.517,657,5400,2,3,6,1,Northeast,1113 +2025Q4,18.2,0.292,764,7230,2,5,2,1,South,1513 +2025Q4,51.2,0.366,629,5440,2,4,12,1,Midwest,856 +2025Q4,87.1,0.053,720,7210,1,6,3,0,West,466 +2025Q4,10.2,0.437,554,3910,2,4,29,0,South,799 +2025Q4,43.3,0.223,752,7220,1,6,4,0,Northeast,762 +2025Q4,33.0,0.33,721,6500,2,4,22,0,Midwest,888 +2025Q4,51.7,0.152,642,5600,2,7,0,0,West,337 +2025Q4,21.1,0.334,553,4110,3,1,4,0,Midwest,554 +2025Q4,39.7,0.348,623,5460,4,1,22,0,Northeast,762 +2025Q4,22.7,0.206,636,5300,4,1,24,0,Northeast,384 +2025Q4,28.0,0.309,635,5400,1,1,19,0,Northeast,464 +2025Q4,30.0,0.375,694,6120,2,1,1,0,West,807 +2025Q4,30.1,0.319,728,6670,2,1,24,0,Midwest,682 +2025Q4,22.6,0.429,601,4710,2,2,22,0,Midwest,360 +2025Q4,13.8,0.613,501,3010,0,3,28,0,West,344 +2025Q4,49.8,0.292,548,4190,0,7,3,1,Northeast,454 +2025Q4,64.9,0.336,752,7360,1,5,28,0,Northeast,804 +2025Q4,14.3,0.243,705,6280,2,7,21,0,West,924 +2025Q4,13.7,0.434,669,5640,3,6,11,0,West,955 +2025Q4,92.3,0.163,525,4160,1,4,9,0,Northeast,0 +2025Q4,13.7,0.12,601,4680,1,2,12,0,South,392 +2025Q4,34.1,0.208,647,5570,1,2,3,0,South,254 +2025Q4,13.4,0.38,597,4710,1,2,24,0,Northeast,197 +2025Q4,72.1,0.094,714,6890,2,7,25,0,South,514 +2025Q4,36.1,0.296,698,6450,2,5,14,0,Northeast,879 +2025Q4,13.5,0.548,628,4930,0,5,16,0,West,609 +2025Q4,67.3,0.157,603,5270,2,5,7,0,Northeast,114 +2025Q4,79.2,0.291,770,7840,1,2,15,0,Midwest,602 +2025Q4,33.8,0.386,664,5770,1,7,7,0,Northeast,706 +2025Q4,60.0,0.405,750,7310,0,5,28,0,Northeast,754 +2025Q4,29.6,0.469,757,7190,0,3,17,0,Northeast,652 +2025Q4,22.0,0.369,685,5960,0,2,21,0,Northeast,590 +2025Q4,18.5,0.331,759,7030,2,2,6,1,Midwest,1631 +2025Q4,24.5,0.124,611,4630,0,5,26,0,West,73 +2025Q4,107.1,0.091,630,6000,1,6,24,0,Northeast,0 +2025Q4,28.6,0.385,671,6020,1,5,1,1,West,719 +2025Q4,108.8,0.13,472,3570,1,7,9,0,West,0 +2025Q4,10.0,0.305,612,4750,0,5,8,0,Midwest,407 +2025Q4,23.4,0.392,585,4530,1,2,6,1,West,539 +2025Q4,10.0,0.363,610,4730,1,7,16,0,West,438 +2025Q4,15.1,0.304,749,6860,0,6,10,0,Midwest,810 +2025Q4,67.3,0.29,830,8610,3,2,0,0,West,1316 +2025Q4,36.7,0.125,669,5970,2,6,26,1,Northeast,1024 +2025Q4,31.5,0.405,597,4940,0,1,2,0,South,124 +2025Q4,32.7,0.276,773,7500,3,2,25,0,Northeast,1035 +2025Q4,21.3,0.408,648,5420,0,3,20,0,West,429 +2025Q4,15.2,0.304,650,5350,1,6,28,0,Northeast,709 +2025Q4,69.3,0.315,761,7540,2,4,8,0,West,452 +2025Q4,32.7,0.254,678,5810,1,3,27,0,Midwest,435 +2025Q4,10.9,0.331,508,3270,2,1,25,0,Northeast,448 +2025Q4,17.0,0.299,788,7670,0,5,17,0,South,1045 +2025Q4,16.5,0.515,723,6460,0,6,15,0,Northeast,899 +2025Q4,46.3,0.263,629,5330,1,7,10,0,Northeast,371 +2025Q4,41.8,0.477,680,6160,2,3,10,0,South,683 +2025Q4,34.5,0.348,730,6830,1,4,26,0,West,806 +2025Q4,26.8,0.404,597,4930,1,2,28,0,West,423 +2025Q4,18.4,0.368,617,4980,1,6,14,0,Midwest,523 +2025Q4,18.1,0.376,524,3470,1,2,6,0,Northeast,259 +2025Q4,26.4,0.242,688,5900,1,1,18,1,Midwest,1089 +2025Q4,38.1,0.225,667,5970,0,4,9,0,West,434 +2025Q4,28.0,0.182,727,6680,1,1,11,1,Midwest,1029 +2025Q4,31.2,0.474,669,5860,0,3,9,0,West,558 +2025Q4,19.9,0.448,580,4180,1,2,23,0,West,331 +2025Q4,39.7,0.165,690,6400,1,7,8,1,West,980 +2025Q4,47.5,0.406,729,6850,2,7,16,0,West,1025 +2025Q4,12.3,0.258,735,6590,3,6,27,0,Northeast,1152 +2025Q4,41.6,0.274,631,5370,1,7,2,0,West,293 +2025Q4,46.1,0.186,553,4350,1,1,24,0,Northeast,227 +2025Q4,10.0,0.438,518,3400,0,7,2,0,South,115 +2025Q4,10.8,0.477,618,5060,1,3,24,0,West,567 +2025Q4,45.7,0.382,751,7210,2,4,0,0,West,1101 +2025Q4,35.5,0.496,482,3010,1,6,11,0,South,204 +2025Q4,46.8,0.154,595,4790,1,1,18,0,West,5 +2025Q4,37.5,0.487,507,3490,0,6,13,0,Midwest,123 +2025Q4,19.9,0.27,678,6060,0,5,7,0,South,330 +2025Q4,20.4,0.284,632,5410,3,4,15,0,Northeast,745 +2025Q4,13.2,0.514,656,5570,1,7,20,0,South,875 +2025Q4,32.2,0.255,779,7530,3,3,7,1,West,1334 +2025Q4,27.0,0.247,586,4530,1,3,20,0,Midwest,283 +2025Q4,67.7,0.384,553,4580,0,1,29,0,Midwest,0 +2025Q4,13.0,0.257,684,6080,1,6,13,0,Midwest,457 +2025Q4,41.3,0.281,678,6170,1,7,17,0,West,567 +2025Q4,57.9,0.253,708,6450,1,4,17,0,Midwest,405 +2025Q4,22.2,0.205,470,2700,1,7,26,0,Northeast,151 +2025Q4,23.8,0.204,664,5690,1,4,14,1,West,620 +2025Q4,17.7,0.295,669,5600,2,1,1,0,Midwest,466 +2025Q4,17.4,0.34,655,5360,2,6,3,0,West,636 +2025Q4,33.8,0.323,622,4860,2,6,25,1,Northeast,557 +2025Q4,10.0,0.407,597,4560,1,3,6,0,West,314 +2025Q4,10.0,0.443,670,5690,1,7,12,0,Northeast,878 +2025Q4,107.5,0.19,485,3820,1,4,14,0,West,0 +2025Q4,17.3,0.458,751,6980,0,4,19,0,Midwest,806 +2025Q4,17.9,0.378,659,5620,0,1,12,0,Northeast,409 +2025Q4,16.0,0.261,641,5340,1,5,24,0,Midwest,427 +2025Q4,59.3,0.242,695,6590,0,3,12,0,South,265 +2025Q4,25.0,0.228,616,5010,2,5,10,0,Northeast,425 +2025Q4,55.4,0.228,634,5480,2,3,8,0,Midwest,505 +2025Q4,59.9,0.428,685,5990,3,7,30,0,Northeast,750 +2025Q4,10.0,0.484,581,4350,0,1,26,0,West,451 +2025Q4,31.4,0.345,628,5420,1,1,5,0,Northeast,106 +2025Q4,23.6,0.208,810,8030,2,2,14,0,Midwest,986 +2025Q4,17.8,0.478,663,5600,1,3,4,0,Northeast,750 +2025Q4,33.2,0.23,586,4490,1,5,13,0,West,92 +2025Q4,28.7,0.38,615,5190,1,4,20,0,Midwest,391 +2025Q4,40.9,0.404,649,5700,1,6,28,1,West,939 +2025Q4,10.0,0.458,714,6160,2,3,10,0,Midwest,966 +2025Q4,35.7,0.178,780,7480,1,3,4,0,Northeast,866 +2025Q4,48.6,0.268,717,6980,3,4,25,0,West,809 +2025Q4,29.7,0.123,638,5410,2,7,18,0,Northeast,374 +2025Q4,16.2,0.416,731,6680,2,1,6,0,Northeast,851 +2025Q4,31.1,0.227,764,7090,2,1,20,0,Midwest,852 +2025Q4,43.5,0.187,554,4140,2,7,12,0,Midwest,341 +2025Q4,67.6,0.153,676,6320,0,4,11,1,Northeast,235 +2025Q4,16.6,0.396,723,6440,0,1,3,0,South,579 +2025Q4,27.3,0.383,519,3450,0,2,10,0,West,109 +2025Q4,25.9,0.293,850,8640,1,6,17,1,Midwest,1754 +2025Q4,22.0,0.239,638,5130,1,3,10,1,West,593 +2025Q4,22.3,0.365,545,3830,1,6,6,0,South,580 +2025Q4,42.3,0.381,547,4110,3,6,27,0,Midwest,536 +2025Q4,91.1,0.234,692,6870,2,1,10,0,Midwest,365 +2025Q4,20.1,0.606,645,5270,1,2,0,0,South,538 +2025Q4,31.3,0.346,649,5640,1,6,19,0,Midwest,588 +2025Q4,29.2,0.305,700,6530,0,4,14,1,Northeast,985 +2025Q4,25.7,0.39,735,6770,1,1,17,0,West,823 +2025Q4,10.0,0.346,714,6230,1,5,14,0,Midwest,945 +2025Q4,15.0,0.34,558,4110,2,1,3,0,South,731 +2025Q4,26.8,0.396,589,4620,1,1,14,0,Northeast,401 +2025Q4,45.4,0.204,734,7020,3,4,26,0,Northeast,983 +2025Q4,47.4,0.371,581,4610,2,1,14,0,South,130 +2025Q4,22.2,0.238,531,3720,1,5,8,0,West,240 +2025Q4,110.7,0.218,631,6180,2,2,1,0,South,0 +2025Q4,20.7,0.399,576,4440,2,1,25,0,South,575 +2025Q4,48.9,0.249,752,7170,3,4,7,0,Midwest,1029 +2025Q4,17.6,0.403,663,5470,1,2,3,0,Midwest,637 +2025Q4,29.7,0.348,684,6070,2,2,13,0,South,619 +2025Q4,20.8,0.118,607,4950,0,3,9,0,Northeast,122 +2025Q4,47.7,0.11,775,7460,2,4,18,0,Midwest,701 +2025Q4,38.9,0.233,556,3960,1,4,0,0,Midwest,453 +2025Q4,24.0,0.498,669,5850,3,7,24,0,West,980 +2025Q4,68.2,0.45,736,7110,1,7,15,0,South,586 +2025Q4,40.6,0.394,501,3500,2,7,21,0,Midwest,414 +2025Q4,30.6,0.293,782,7550,4,6,20,0,Northeast,1130 +2025Q4,76.3,0.069,624,6000,1,5,9,0,West,192 +2025Q4,24.4,0.266,560,4380,2,7,5,0,Midwest,748 +2025Q4,90.3,0.16,850,9190,1,7,29,0,Midwest,792 +2025Q4,14.6,0.436,690,6100,4,2,13,0,South,926 +2025Q4,32.9,0.264,631,5370,5,4,7,1,Northeast,1124 +2025Q4,59.3,0.325,682,6210,1,7,2,1,South,710 +2025Q4,14.7,0.269,434,2290,1,1,4,0,Midwest,0 +2025Q4,61.1,0.281,629,5280,2,4,22,0,Midwest,268 +2025Q4,10.2,0.308,587,4470,0,1,17,0,West,41 +2025Q4,40.9,0.403,683,6130,1,4,11,1,Northeast,991 +2025Q4,43.0,0.368,685,6300,0,4,5,0,Midwest,346 +2025Q4,40.9,0.252,673,5760,2,3,30,0,Midwest,547 +2025Q4,27.3,0.358,648,5430,2,4,10,0,Midwest,618 +2025Q4,30.8,0.252,697,6080,1,7,27,0,West,856 +2025Q4,26.3,0.229,736,6770,3,4,6,0,Northeast,807 +2025Q4,10.9,0.487,782,7370,0,3,26,1,West,1571 +2025Q4,40.6,0.259,632,5360,0,6,14,0,Midwest,0 +2025Q4,41.4,0.447,601,4820,2,3,19,0,South,366 +2025Q4,29.9,0.206,644,5510,0,6,18,0,West,151 +2025Q4,22.3,0.428,623,5160,0,3,2,0,Northeast,317 +2025Q4,55.2,0.232,656,5860,1,4,6,0,South,203 +2025Q4,50.2,0.321,664,5840,1,3,13,0,South,249 +2025Q4,93.4,0.139,590,5240,1,4,23,1,Midwest,411 +2025Q4,22.1,0.505,759,7140,2,3,16,0,Northeast,1297 +2025Q4,11.8,0.298,722,6360,1,7,22,0,West,1049 +2025Q4,20.3,0.33,773,7210,0,6,24,0,West,898 +2025Q4,29.0,0.308,748,6990,0,6,23,0,Northeast,802 +2025Q4,39.2,0.346,530,3780,3,5,2,0,South,387 +2025Q4,21.8,0.275,643,5420,1,5,5,1,Midwest,848 +2025Q4,28.2,0.337,583,4410,2,5,10,0,West,472 +2025Q4,61.8,0.296,829,8400,0,5,1,0,West,892 +2025Q4,18.9,0.214,569,4190,1,7,0,0,West,463 +2025Q4,29.0,0.121,576,4470,2,6,25,0,South,528 +2025Q4,47.0,0.17,631,5490,2,6,21,0,Northeast,360 +2025Q4,47.3,0.312,729,6850,1,5,4,0,South,726 +2025Q4,35.5,0.267,513,3660,1,1,22,0,South,124 +2025Q4,20.7,0.227,678,6330,3,4,1,0,West,589 +2025Q4,53.2,0.272,669,6280,1,4,8,0,Midwest,346 +2025Q4,24.8,0.31,508,3590,2,5,16,0,West,330 +2025Q4,14.0,0.344,592,4540,0,1,1,1,Midwest,545 +2025Q4,29.4,0.216,690,6090,2,4,15,0,West,490 +2025Q4,10.3,0.253,612,4830,2,2,16,0,Northeast,600 +2025Q4,82.9,0.217,649,6190,1,7,5,0,Northeast,28 +2025Q4,31.3,0.534,694,6060,0,7,10,0,South,590 +2025Q4,141.6,0.247,816,9000,1,5,1,0,South,435 +2025Q4,18.9,0.297,527,3380,0,2,30,0,West,155 +2025Q4,11.1,0.421,771,7260,2,3,27,0,West,1177 +2025Q4,25.1,0.378,571,4340,0,6,25,0,Northeast,276 +2025Q4,40.5,0.337,802,7900,1,7,30,0,South,928 +2025Q4,60.2,0.326,850,8790,1,7,29,0,West,1068 +2025Q4,24.0,0.396,657,5470,4,3,23,0,South,687 +2025Q4,22.2,0.223,632,5200,3,5,13,1,West,740 +2025Q4,51.0,0.325,612,5430,1,6,25,1,West,647 +2025Q4,18.9,0.397,558,4310,2,6,16,0,South,626 +2025Q4,33.0,0.197,646,5560,3,7,8,0,Northeast,905 +2025Q4,22.8,0.22,682,5870,0,3,14,0,South,488 +2025Q4,43.9,0.386,705,6520,3,2,6,0,Midwest,590 +2025Q4,42.7,0.262,701,6430,1,5,18,0,South,252 +2025Q4,40.2,0.115,585,4800,2,1,7,0,West,135 +2025Q4,26.8,0.517,729,6720,2,5,14,0,Northeast,985 +2025Q4,10.0,0.34,550,3710,0,5,12,0,Midwest,234 +2025Q4,35.1,0.374,508,3520,3,7,21,0,South,641 +2025Q4,32.2,0.163,773,7520,0,4,22,0,West,841 +2025Q4,55.7,0.205,670,6000,2,2,24,0,Midwest,628 +2025Q4,49.4,0.293,844,8660,2,2,17,1,Midwest,1498 +2025Q4,32.1,0.321,582,4460,1,2,9,0,Midwest,209 +2025Q4,32.4,0.324,679,6220,0,2,25,0,Northeast,446 +2025Q4,71.3,0.204,636,5870,1,5,17,0,Northeast,193 +2025Q4,45.7,0.397,642,5430,2,5,13,0,West,319 +2025Q4,30.7,0.401,727,6710,0,5,3,0,Northeast,669 +2025Q4,47.7,0.106,721,6810,0,7,19,0,South,319 +2025Q4,30.2,0.468,645,5440,3,6,10,0,West,559 +2025Q4,27.4,0.172,728,6750,0,5,19,0,South,479 +2025Q4,43.1,0.247,698,6330,2,3,11,1,Midwest,921 +2025Q4,35.5,0.355,782,7480,3,5,11,0,South,1137 +2025Q4,91.2,0.206,719,7210,2,2,22,0,South,479 +2025Q4,16.8,0.351,730,6760,2,2,25,0,Northeast,1089 +2025Q4,12.2,0.367,666,5560,2,7,5,0,South,912 +2025Q4,54.8,0.508,571,4960,1,2,27,0,West,357 +2025Q4,23.3,0.178,783,7700,1,4,7,0,Northeast,959 +2025Q4,29.4,0.386,582,4650,1,3,8,0,South,90 +2025Q4,26.7,0.323,671,5790,1,4,26,0,West,530 +2025Q4,30.3,0.215,755,7130,4,3,28,0,Midwest,1005 +2025Q4,12.6,0.465,497,3200,5,4,26,0,South,712 +2025Q4,24.2,0.444,588,4630,3,6,28,0,South,578 +2025Q4,31.6,0.442,637,5130,1,3,14,0,Northeast,430 +2025Q4,37.1,0.456,533,3670,3,6,3,0,West,458 +2025Q4,34.3,0.481,555,4110,1,1,11,0,Midwest,480 +2025Q4,39.7,0.234,625,5220,1,5,10,0,Midwest,238 +2025Q4,41.6,0.308,690,6300,0,6,4,0,Midwest,459 +2025Q4,13.1,0.393,567,3980,2,2,27,0,Northeast,773 +2025Q4,64.7,0.313,679,6150,2,1,5,0,South,437 +2025Q4,10.0,0.352,555,3950,1,6,19,0,Northeast,341 +2025Q4,38.6,0.286,704,6510,0,6,1,0,South,402 +2025Q4,15.6,0.216,590,4210,1,6,0,0,West,361 +2025Q4,25.8,0.349,601,4730,0,3,20,0,Midwest,75 +2025Q4,15.0,0.576,552,3720,2,2,9,1,Midwest,818 +2025Q4,40.9,0.238,503,3590,4,4,5,0,South,215 +2025Q4,15.4,0.535,629,5020,1,7,23,0,West,577 +2025Q4,21.3,0.273,523,3500,1,3,11,0,Northeast,235 +2025Q4,45.3,0.25,687,6220,2,7,23,0,Midwest,686 +2025Q4,12.1,0.269,798,7510,2,1,10,0,South,1074 +2025Q4,41.3,0.274,700,6580,4,5,0,0,West,1018 +2025Q4,24.3,0.278,534,3650,3,3,1,0,West,432 +2025Q4,22.3,0.764,527,3650,0,5,15,0,Midwest,622 +2025Q4,15.1,0.468,757,6880,0,3,10,0,West,784 +2025Q4,53.0,0.16,653,5820,3,5,3,0,West,473 +2025Q4,55.8,0.411,715,6630,3,2,22,0,Northeast,940 +2025Q4,39.8,0.29,529,3700,0,6,0,0,Northeast,126 +2025Q4,10.6,0.424,577,4110,3,6,5,0,South,934 +2025Q4,34.1,0.293,631,5370,0,4,16,0,Midwest,283 +2025Q4,23.4,0.264,617,4960,2,3,28,0,Northeast,501 +2025Q4,20.5,0.386,575,4320,3,6,6,0,Northeast,768 +2025Q4,53.7,0.381,645,5840,3,7,0,0,Northeast,753 +2025Q4,20.5,0.446,599,4720,1,4,13,1,West,1054 +2025Q4,29.8,0.364,670,5860,2,3,26,1,Midwest,1018 +2025Q4,31.0,0.222,720,6530,1,1,18,0,Midwest,762 +2025Q4,26.6,0.25,834,8240,2,4,7,0,Midwest,1418 +2025Q4,74.0,0.258,616,5530,2,2,16,0,West,100 +2025Q4,31.5,0.328,611,4890,1,2,27,0,Northeast,193 +2025Q4,63.4,0.358,686,6290,0,5,20,0,South,246 +2025Q4,10.0,0.499,679,5690,0,6,20,1,South,1260 +2025Q4,14.5,0.213,788,7550,1,3,7,0,Midwest,1179 +2025Q4,21.4,0.364,684,6150,3,6,13,0,West,1048 +2025Q4,52.8,0.125,769,7680,4,2,27,0,Midwest,857 +2025Q4,37.0,0.352,770,7340,1,7,20,0,South,760 +2025Q4,39.8,0.287,773,7470,1,4,11,0,Midwest,617 +2025Q4,57.7,0.268,609,5250,2,6,19,0,Midwest,214 +2025Q4,20.6,0.22,775,7450,0,6,25,0,South,883 +2025Q4,76.3,0.243,707,6680,1,3,9,0,South,275 +2025Q4,19.5,0.34,678,5860,4,5,19,0,Northeast,1005 +2025Q4,71.1,0.148,604,5430,0,2,14,0,West,0 +2025Q4,83.2,0.182,548,4370,0,1,25,0,Midwest,0 +2025Q4,32.4,0.328,694,6010,3,5,28,0,Midwest,621 +2025Q4,16.5,0.345,534,3610,1,5,8,0,South,399 +2025Q4,40.2,0.207,658,5790,2,2,3,0,Midwest,396 +2025Q4,10.0,0.539,705,6080,0,5,7,0,West,809 +2025Q4,16.2,0.343,467,2570,3,3,24,0,Northeast,285 +2025Q4,66.6,0.292,650,5850,4,7,30,0,South,666 +2025Q4,38.1,0.255,499,3630,2,7,1,0,West,543 +2025Q4,22.7,0.516,665,5460,1,2,24,0,South,574 +2025Q4,24.7,0.344,523,3640,2,4,2,0,Midwest,267 +2025Q4,34.8,0.172,654,5700,2,3,25,0,South,601 +2025Q4,15.7,0.336,649,5420,0,6,22,0,Midwest,537 +2025Q4,35.6,0.367,572,4520,1,5,15,0,Midwest,208 +2025Q4,39.7,0.245,642,5660,1,3,16,0,South,454 +2025Q4,27.2,0.238,590,4430,0,7,9,0,Northeast,273 +2025Q4,15.2,0.553,619,5040,3,4,2,0,Midwest,619 +2025Q4,10.0,0.487,658,5510,4,3,23,0,South,776 +2025Q4,40.8,0.165,650,5690,0,5,8,0,South,159 +2025Q4,35.5,0.348,616,5120,0,5,14,0,Midwest,207 +2025Q4,41.2,0.264,631,5360,1,2,17,0,West,98 +2025Q4,39.0,0.337,691,6140,3,2,8,0,Midwest,688 +2025Q4,29.8,0.344,717,6710,3,7,0,0,South,953 +2025Q4,20.2,0.222,817,7760,1,2,23,0,Midwest,1081 +2025Q4,15.2,0.369,637,5170,2,7,5,0,Midwest,788 +2025Q4,18.3,0.235,777,7140,1,7,16,0,Northeast,1073 +2025Q4,23.4,0.42,699,6450,2,2,13,0,Midwest,886 +2025Q4,14.4,0.464,564,4150,0,6,18,1,West,1009 +2025Q4,46.2,0.565,705,6380,2,3,8,1,West,976 +2025Q4,43.7,0.307,711,6590,1,4,29,1,Midwest,1274 +2025Q4,57.5,0.265,530,3940,0,6,8,1,Midwest,338 +2025Q4,35.7,0.378,574,4490,1,3,23,0,Midwest,463 +2025Q4,26.5,0.268,614,4810,2,2,1,0,Midwest,260 +2025Q4,39.1,0.316,736,6730,4,5,13,0,Midwest,919 +2025Q4,32.8,0.347,432,2350,2,2,29,0,Midwest,149 +2025Q4,41.9,0.275,715,6750,2,3,21,1,Northeast,1091 +2025Q4,23.7,0.189,572,4290,0,1,5,0,Northeast,283 +2025Q4,18.7,0.236,680,5900,1,1,19,0,Midwest,446 +2025Q4,29.4,0.368,585,4370,2,4,4,0,South,420 +2025Q4,15.3,0.328,460,2440,0,1,30,0,South,0 +2025Q4,39.6,0.429,503,3630,2,4,7,0,Northeast,425 +2025Q4,55.8,0.281,660,6010,1,6,3,0,Northeast,285 +2025Q4,38.3,0.388,699,6120,1,3,9,0,West,528 +2025Q4,24.0,0.309,455,2540,1,4,12,0,Midwest,125 +2025Q4,36.5,0.302,626,5230,0,1,28,0,West,25 +2025Q4,15.2,0.41,641,5330,2,6,14,0,Northeast,714 +2025Q4,39.3,0.153,605,5010,2,5,7,1,Midwest,505 +2025Q4,47.6,0.326,621,5320,4,7,24,0,South,541 +2025Q4,33.2,0.356,589,4580,1,2,21,0,South,290 +2025Q4,62.2,0.2,749,7430,0,3,30,0,West,355 +2025Q4,29.5,0.422,569,4430,2,7,5,1,West,1050 +2025Q4,11.4,0.347,620,4640,3,5,4,0,Midwest,576 +2025Q4,16.1,0.315,532,3710,3,7,7,0,Northeast,664 +2025Q4,34.8,0.494,622,5150,2,1,15,0,Northeast,697 +2025Q4,34.8,0.098,622,5220,2,4,4,0,Northeast,228 +2025Q4,34.3,0.379,694,6640,1,2,21,1,South,1167 +2025Q4,55.8,0.23,826,8330,0,5,22,0,South,658 +2025Q4,35.9,0.316,731,6850,1,1,4,1,Northeast,986 +2025Q4,25.0,0.257,740,6780,0,3,4,0,West,462 +2025Q4,37.8,0.187,699,6210,2,2,21,0,Northeast,562 +2025Q4,39.8,0.329,585,4440,1,4,6,0,Northeast,0 +2025Q4,39.3,0.216,634,5300,1,7,23,0,West,243 +2025Q4,27.5,0.384,728,6830,2,1,11,0,Northeast,877 +2025Q4,40.2,0.094,631,5300,1,2,22,0,South,154 +2025Q4,38.7,0.374,457,2990,2,5,16,1,Midwest,616 +2025Q4,52.5,0.249,531,3990,3,5,8,0,Midwest,277 +2025Q4,54.9,0.405,719,6720,3,3,25,0,West,1053 +2025Q4,24.6,0.339,654,5500,1,6,13,0,Midwest,438 +2025Q4,18.2,0.403,693,6000,2,7,30,0,Midwest,953 +2025Q4,39.0,0.195,634,5450,0,5,3,0,Midwest,73 +2025Q4,28.8,0.304,638,5140,2,4,24,0,West,704 +2025Q4,14.8,0.284,550,3780,4,4,7,0,South,592 +2025Q4,53.1,0.437,614,5250,0,5,30,0,South,0 +2025Q4,28.7,0.32,547,3950,1,6,18,0,Northeast,344 +2025Q4,44.1,0.262,620,5310,0,5,17,0,Midwest,168 +2025Q4,100.0,0.325,657,6090,3,6,11,0,South,257 +2025Q4,38.8,0.361,645,5410,2,7,14,0,Midwest,632 +2025Q4,30.4,0.252,518,3590,2,6,0,0,Midwest,205 +2025Q4,35.6,0.245,636,5560,0,2,17,0,Midwest,50 +2025Q4,14.8,0.304,722,6560,1,2,16,0,Midwest,567 +2025Q4,30.4,0.361,603,4910,0,1,13,0,South,80 +2025Q4,25.2,0.415,677,5720,2,6,6,0,Midwest,819 +2025Q4,32.4,0.322,736,6620,1,3,29,0,Northeast,827 +2025Q4,14.2,0.414,548,3790,1,4,29,1,Midwest,908 +2025Q4,30.1,0.2,836,8140,1,6,4,0,South,1149 +2025Q4,38.5,0.425,634,5540,0,5,12,0,Northeast,356 +2025Q4,12.7,0.473,690,6100,2,7,9,0,Midwest,999 +2025Q4,21.4,0.413,486,2940,0,7,1,0,South,179 +2025Q4,32.2,0.258,610,4990,1,3,16,0,South,195 +2025Q4,18.2,0.411,612,4690,2,6,2,1,South,1152 +2025Q4,41.3,0.375,697,6370,0,2,29,0,South,190 +2025Q4,49.2,0.346,639,5600,1,4,14,0,Midwest,241 +2025Q4,21.0,0.334,596,4630,6,4,28,0,Midwest,607 +2025Q4,15.3,0.326,508,3310,1,7,14,0,West,508 +2025Q4,31.8,0.189,671,5840,1,1,30,0,Midwest,467 +2025Q4,20.3,0.197,716,6520,1,5,22,0,West,958 +2025Q4,19.7,0.317,712,6240,0,3,11,0,Northeast,588 +2025Q4,36.8,0.26,768,7390,1,7,11,0,West,784 +2025Q4,44.8,0.284,693,6220,0,3,7,0,Midwest,138 +2025Q4,161.8,0.137,561,5700,1,6,2,0,South,0 +2025Q4,57.0,0.332,648,5830,1,6,17,0,Northeast,203 +2025Q4,19.0,0.223,497,3220,2,6,12,0,West,345 +2025Q4,28.6,0.198,630,5400,2,3,20,0,Midwest,423 +2025Q4,33.6,0.307,600,4530,0,3,24,0,West,111 +2025Q4,40.9,0.276,544,3970,0,1,11,1,Northeast,392 +2025Q4,49.3,0.321,707,6470,0,6,10,0,South,336 +2025Q4,22.2,0.419,579,4380,1,7,19,0,West,521 +2025Q4,42.0,0.293,728,6980,0,7,10,0,South,652 +2025Q4,16.5,0.42,718,6380,2,2,27,0,South,944 +2025Q4,75.1,0.247,806,8540,4,6,8,0,West,1158 +2025Q4,64.9,0.524,774,7600,2,6,15,0,Midwest,1157 +2025Q4,29.6,0.42,655,5820,1,6,27,0,Midwest,569 +2025Q4,39.4,0.381,643,5610,1,3,20,0,South,441 +2025Q4,46.5,0.272,697,6400,0,5,29,1,West,637 +2025Q4,55.6,0.0,572,4710,5,4,21,0,Midwest,534 +2025Q4,104.9,0.054,642,6290,0,4,22,0,West,0 +2025Q4,22.8,0.145,451,2520,1,1,8,0,Midwest,0 +2025Q4,23.9,0.388,525,3460,1,7,2,0,West,391 +2025Q4,60.6,0.358,698,6710,1,1,19,0,Midwest,577 +2025Q4,10.5,0.286,725,6730,1,1,22,0,South,999 +2025Q4,36.5,0.333,654,5600,2,2,25,0,Northeast,517 +2025Q4,16.5,0.23,532,3640,0,2,10,0,West,0 +2025Q4,81.1,0.277,604,5490,1,5,5,0,Midwest,84 +2025Q4,29.9,0.329,648,5540,1,4,21,0,Midwest,606 +2025Q4,15.0,0.411,507,3170,2,4,16,0,South,462 +2025Q4,12.9,0.317,769,7150,2,7,7,0,West,1121 +2025Q4,13.0,0.207,583,4450,0,6,16,0,West,377 +2025Q4,16.5,0.384,632,5030,1,1,19,0,West,455 +2025Q4,30.1,0.256,748,7020,2,1,15,0,Northeast,760 +2025Q4,33.8,0.452,702,6470,2,6,4,0,West,839 +2025Q4,27.9,0.369,722,6680,1,1,23,0,Northeast,716 +2025Q4,44.6,0.398,595,4750,0,3,4,0,Northeast,0 +2025Q4,18.2,0.298,526,3480,2,1,6,0,Midwest,331 +2025Q4,23.7,0.406,582,4570,2,6,9,0,South,457 +2025Q4,24.2,0.316,579,4400,2,2,15,0,South,328 +2025Q4,22.6,0.394,522,3540,0,5,12,0,West,42 +2025Q4,28.0,0.492,796,7800,1,5,4,0,West,1180 +2025Q4,33.6,0.332,714,6460,4,2,11,0,Midwest,862 +2025Q4,10.0,0.367,677,5810,0,3,20,0,West,659 +2025Q4,34.9,0.132,701,6320,0,1,12,0,Midwest,453 +2025Q4,26.6,0.396,612,4740,1,1,11,0,South,217 +2025Q4,14.0,0.363,728,6700,0,3,6,0,West,598 +2025Q4,55.4,0.406,602,5160,0,7,21,0,South,0 +2025Q4,24.5,0.278,631,5260,1,2,27,0,West,225 +2025Q4,24.6,0.241,719,6500,2,1,26,0,Midwest,889 +2025Q4,21.3,0.453,749,6860,1,5,26,0,South,939 +2025Q4,31.4,0.196,706,6390,1,3,22,0,West,720 +2025Q4,30.7,0.239,623,5320,0,6,11,0,Northeast,242 +2025Q4,28.5,0.257,777,7320,1,4,11,0,Midwest,991 +2025Q4,53.2,0.29,688,6440,4,5,5,0,Midwest,844 +2025Q4,28.2,0.344,634,5120,0,5,16,0,Midwest,145 +2025Q4,10.0,0.291,824,7810,0,6,10,0,South,1090 +2025Q4,23.0,0.258,699,6250,1,2,22,0,Northeast,557 +2025Q4,14.5,0.496,683,5940,1,5,21,0,Northeast,857 +2025Q4,26.6,0.272,685,6240,3,1,28,0,South,872 +2025Q4,91.9,0.156,494,3780,0,6,8,0,Midwest,0 +2025Q4,15.7,0.48,706,6280,3,7,18,1,West,1641 +2025Q4,29.2,0.472,723,6640,0,7,10,0,Midwest,787 diff --git a/validmind/datasets/regression/generate_credit_card_balance_csv.py b/validmind/datasets/regression/generate_credit_card_balance_csv.py new file mode 100644 index 000000000..eb76d05a0 --- /dev/null +++ b/validmind/datasets/regression/generate_credit_card_balance_csv.py @@ -0,0 +1,113 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +""" +Helper script to generate the bundled synthetic credit card balance CSV file. + +The data is synthetic: every value is drawn from the distributions below with a +fixed seed. Each row is a cardholder, and the target is their average monthly +revolving balance. The relationships are chosen so a linear regression +validation has something real to find: + +- CreditLimit is driven by CreditScore, so the two are almost collinear (high VIF). +- TenureYears and Region have no effect on Balance (not statistically significant; + Region is a control segment where segment tests should find nothing). +- RecentInquiries has a diminishing effect on Balance, so a straight-line term + misspecifies it. +- Customers who pay in full have a Balance of 0, which breaks the normality and + constant-variance assumptions of ordinary least squares. +- Subprime customers (CreditScore below 580) carry more than their other + characteristics explain. A model without CreditScore under-predicts this segment, + which only a segment-level check reveals. +- A few incomes were keyed with a misplaced decimal point (x10), creating + influential observations. +- Customers are observed over 12 quarters (2023Q1-2025Q4). From OOT_START a new + acquisition campaign brings in lower-income customers (population drift), and + higher rates make every customer carry a larger balance (behaviour drift), so a + model developed on earlier quarters under-predicts the out-of-time sample. + +Run this script to regenerate the bundled dataset file: + python validmind/datasets/regression/generate_credit_card_balance_csv.py +""" + +import os + +import numpy as np +import pandas as pd + +current_path = os.path.dirname(os.path.abspath(__file__)) +dataset_path = os.path.join(current_path, "datasets") +output_file = os.path.join(dataset_path, "credit_card_balance.csv") + +SEED = 43 +N_CUSTOMERS = 5000 +N_INCOME_ENTRY_ERRORS = 5 +REGIONS = ["Northeast", "Midwest", "South", "West"] +QUARTERS = [f"{year}Q{q}" for year in (2023, 2024, 2025) for q in (1, 2, 3, 4)] +OOT_START = "2025Q3" + + +def generate(n=N_CUSTOMERS, seed=SEED): + rng = np.random.default_rng(seed) + + quarter = np.sort(rng.choice(QUARTERS, n)) + oot = quarter >= OOT_START + + median_income = np.where(oot, 30, 40) # acquisition campaign shifts the mix + income = np.clip(rng.lognormal(np.log(median_income), 0.6), 10, 200).round(1) + dti = np.clip(rng.normal(0.30, 0.10, n) - 0.002 * (income - 40), 0.0, 0.8) + dti = dti.round(3) + score = rng.normal(650, 80, n) + 0.2 * (income - 40) - 100 * (dti - 0.3) + score = np.clip(score, 300, 850).round() + limit = (15 * (score - 300) + 10 * income + rng.normal(0, 120, n)).clip(500) + limit = limit.round(-1) + inquiries = np.clip(rng.poisson(1.5, n), 0, 10) + cards = rng.integers(1, 8, n) + tenure = rng.integers(0, 31, n) + student = (rng.random(n) < 0.1).astype(int) + region = rng.choice(REGIONS, n) + + balance = ( + -1250 + + 0.25 * limit + - 8 * income + + 600 * dti + + 250 * np.log1p(inquiries) # diminishing effect + + 20 * cards + + 400 * student + + 250 * (score < 580) # subprime customers revolve more + + 150 * oot # higher rates: everyone revolves more + + rng.normal(0, 120, n) + ) + balance = balance.clip(0).round() + + # Income keyed with a misplaced decimal point for a few customers. Balance was + # generated from the true income, so these rows are data errors, not signal. + errors = rng.choice(np.where(~oot)[0], N_INCOME_ENTRY_ERRORS, replace=False) + income[errors] = income[errors] * 10 + + return pd.DataFrame( + { + "Quarter": quarter, + "Income": income, + "DebtToIncome": dti, + "CreditScore": score.astype(int), + "CreditLimit": limit.astype(int), + "RecentInquiries": inquiries, + "NumCards": cards, + "TenureYears": tenure, + "IsStudent": student, + "Region": region, + "Balance": balance.astype(int), + } + ) + + +if __name__ == "__main__": + df = generate() + + os.makedirs(dataset_path, exist_ok=True) + df.to_csv(output_file, index=False) + + print(f"✓ Saved {len(df)} rows and {len(df.columns)} columns to {output_file}") diff --git a/validmind/tests/__types__.py b/validmind/tests/__types__.py index d936d38f6..572274833 100644 --- a/validmind/tests/__types__.py +++ b/validmind/tests/__types__.py @@ -83,15 +83,26 @@ "validmind.data_validation.nlp.Toxicity", "validmind.model_validation.BertScore", "validmind.model_validation.BleuScore", + "validmind.model_validation.CalibrationByDecile", "validmind.model_validation.ClusterSizeDistribution", "validmind.model_validation.ContextualRecall", + "validmind.model_validation.FeatureSensitivity", "validmind.model_validation.FeaturesAUC", + "validmind.model_validation.Heteroscedasticity", "validmind.model_validation.MeteorScore", "validmind.model_validation.ModelMetadata", "validmind.model_validation.ModelPredictionResiduals", + "validmind.model_validation.PerformanceConfidenceIntervals", + "validmind.model_validation.PerformanceOverTime", + "validmind.model_validation.PredictionRange", "validmind.model_validation.RegardScore", "validmind.model_validation.RegressionResidualsPlot", + "validmind.model_validation.RegressionWeakspots", + "validmind.model_validation.ResidualBiasBySegment", + "validmind.model_validation.ResidualNormality", "validmind.model_validation.RougeScore", + "validmind.model_validation.ScenarioAnalysis", + "validmind.model_validation.TailPerformance", "validmind.model_validation.TimeSeriesPredictionWithCI", "validmind.model_validation.TimeSeriesPredictionsPlot", "validmind.model_validation.TimeSeriesR2SquareBySegments", @@ -158,11 +169,16 @@ "validmind.model_validation.sklearn.VMeasure", "validmind.model_validation.sklearn.WeakspotsDiagnosis", "validmind.model_validation.statsmodels.AutoARIMA", + "validmind.model_validation.statsmodels.CoefficientStability", "validmind.model_validation.statsmodels.CumulativePredictionProbabilities", "validmind.model_validation.statsmodels.DurbinWatsonTest", + "validmind.model_validation.statsmodels.ExpectedCoefficientSigns", "validmind.model_validation.statsmodels.GINITable", + "validmind.model_validation.statsmodels.InfluentialObservations", "validmind.model_validation.statsmodels.KolmogorovSmirnov", "validmind.model_validation.statsmodels.Lilliefors", + "validmind.model_validation.statsmodels.LinearitySpecification", + "validmind.model_validation.statsmodels.PredictionIntervals", "validmind.model_validation.statsmodels.PredictionProbabilitiesHistogram", "validmind.model_validation.statsmodels.RegressionCoeffs", "validmind.model_validation.statsmodels.RegressionFeatureSignificance", @@ -171,7 +187,9 @@ "validmind.model_validation.statsmodels.RegressionModelSensitivityPlot", "validmind.model_validation.statsmodels.RegressionModelSummary", "validmind.model_validation.statsmodels.RegressionPermutationFeatureImportance", + "validmind.model_validation.statsmodels.RobustStandardErrors", "validmind.model_validation.statsmodels.ScorecardHistogram", + "validmind.model_validation.statsmodels.VarianceInflationFactor", "validmind.ongoing_monitoring.CalibrationCurveDrift", "validmind.ongoing_monitoring.ClassDiscriminationDrift", "validmind.ongoing_monitoring.ClassImbalanceDrift", diff --git a/validmind/tests/model_validation/CalibrationByDecile.py b/validmind/tests/model_validation/CalibrationByDecile.py new file mode 100644 index 000000000..080c9b346 --- /dev/null +++ b/validmind/tests/model_validation/CalibrationByDecile.py @@ -0,0 +1,139 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import Tuple + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +from validmind import RawData, tags, tasks +from validmind.vm_models import VMDataset, VMModel + +from ._segments import mean_and_half_width, z_value + + +@tags("model_diagnosis", "calibration", "visualization") +@tasks("regression") +def CalibrationByDecile( + dataset: VMDataset, + model: VMModel, + n_bins: int = 10, + confidence_level: float = 0.95, + adjust_for_multiple_bins: bool = True, +) -> Tuple[pd.DataFrame, go.Figure, bool, RawData]: + """ + Checks that a regression model is right on average across the whole range of its + predictions, by comparing mean actual and mean predicted values within prediction + deciles. + + ### Purpose + + A well-calibrated model's predictions mean what they say: customers predicted at 500 + average about 500. Miscalibration at the low or high end — over-predicting small + values, under-predicting large ones — is common for linear models of bounded or skewed + targets, and matters because decisions are often taken at the extremes (the + highest-exposure customers, the cases near a threshold). + + ### Test Mechanism + + Observations are sorted by prediction and split into `n_bins` equal-sized bins + (deciles by default). For each bin the test reports the mean prediction, the mean + actual value, and their difference with a confidence interval. A bin is **flagged** + when its interval excludes zero; with `adjust_for_multiple_bins` (default) the + confidence level is Bonferroni-adjusted for the number of bins. The plot shows mean + actual against mean predicted per bin, with the diagonal of perfect calibration. + + The test passes when no bin is flagged. + + ### Signs of High Risk + + - Flagged bins at either end of the range. + - A systematic S-shape or tilt in the plot: the model is too flat or too steep. + - Mean predictions outside the feasible range in the lowest or highest bin. + + ### Strengths + + - Works for any regression model with predictions assigned to the dataset. + - Shows where in the range the model is wrong, in the target's own units. + + ### Limitations + + - Calibration on average does not mean individual predictions are accurate. + - On training data, OLS is calibrated overall by construction; use held-out data. + """ + actual = np.asarray(dataset.y, dtype=float) + predicted = np.asarray(dataset.y_pred(model), dtype=float) + bins = pd.qcut(pd.Series(predicted).rank(method="first"), q=n_bins, labels=False) + + n_found = int(bins.nunique()) + z = z_value(confidence_level, n_found if adjust_for_multiple_bins else 1) + + rows = [] + for b in range(n_found): + mask = (bins == b).to_numpy() + gap, half_width = mean_and_half_width(actual[mask] - predicted[mask], z) + rows.append( + { + "Bin": b + 1, + "Count": int(mask.sum()), + "Prediction Range": f"{predicted[mask].min():,.4g} – {predicted[mask].max():,.4g}", + "Mean Predicted": float(predicted[mask].mean()), + "Mean Actual": float(actual[mask].mean()), + "Actual − Predicted": gap, + "CI Lower": gap - half_width, + "CI Upper": gap + half_width, + "Result": "Miscalibrated" if abs(gap) > half_width else "OK", + } + ) + table = pd.DataFrame(rows) + + low = float(min(table["Mean Predicted"].min(), table["Mean Actual"].min())) + high = float(max(table["Mean Predicted"].max(), table["Mean Actual"].max())) + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=[low, high], + y=[low, high], + mode="lines", + line=dict(color="#757575", dash="dash"), + name="Perfect calibration", + ) + ) + fig.add_trace( + go.Scatter( + x=table["Mean Predicted"], + y=table["Mean Actual"], + mode="lines+markers", + marker=dict( + size=9, + color=np.where(table["Result"] == "OK", "#3949ab", "#c62828"), + ), + line=dict(color="#3949ab"), + error_y=dict( + type="data", + array=table["CI Upper"] - table["Actual − Predicted"], + arrayminus=table["Actual − Predicted"] - table["CI Lower"], + ), + name="Mean actual by prediction bin", + ) + ) + fig.update_layout( + title=f"Calibration by Prediction Bin — {model.input_id} on {dataset.input_id}", + xaxis_title="Mean predicted", + yaxis_title="Mean actual", + ) + + passed = bool((table["Result"] == "OK").all()) + + return ( + table.round(4), + fig, + passed, + RawData( + bins=bins.to_numpy(), + model=model.input_id, + dataset=dataset.input_id, + ), + ) diff --git a/validmind/tests/model_validation/FeatureSensitivity.py b/validmind/tests/model_validation/FeatureSensitivity.py new file mode 100644 index 000000000..fa9bda38c --- /dev/null +++ b/validmind/tests/model_validation/FeatureSensitivity.py @@ -0,0 +1,158 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +from validmind import RawData, tags, tasks +from validmind.errors import SkipTestError +from validmind.vm_models import VMDataset, VMModel + +from .statsmodels.statsutils import categorical_columns, predict_frame + + +def _is_binary(values): + return set(np.unique(values[~np.isnan(values)])) <= {0, 1} + + +@tags("sensitivity_analysis", "visualization") +@tasks("regression") +def FeatureSensitivity( + dataset: VMDataset, + model: VMModel, + features: Optional[List[str]] = None, + shock_std: float = 1.0, + shocks: Optional[Dict[str, float]] = None, + clip_to_range: bool = True, +) -> Tuple[pd.DataFrame, go.Figure, RawData]: + """ + Measures how a regression model's portfolio-level prediction responds when each feature + is shocked up and down, one at a time. + + ### Purpose + + Sensitivity analysis shows which inputs drive the model's output and by how much, in + business units: "a one-standard-deviation rise in income lowers the average predicted + balance by $40". It reveals features whose influence is larger than their business + importance would justify, effects that are asymmetric (a non-linear model reacting + differently to rises and falls), and features that barely matter. + `statsmodels.RegressionModelSensitivityPlot` is the time-series counterpart: it applies + a multiplicative shock and plots predictions along the series. This test is for + cross-sectional data and reports the effect on the portfolio mean per feature. + + ### Test Mechanism + + For each feature (by default every numeric feature of the dataset), two copies of the + dataset are made with only that feature changed: + + - **Continuous features:** shifted up and down by `shock_std` standard deviations, or by + the amount given for that feature in `shocks` (in the feature's own units). + - **Binary (0/1) features:** set to 1 for everyone (up) and to 0 for everyone (down). + + With `clip_to_range`, shocked values are kept within the feature's observed range, so + no shock creates an impossible value (for example a negative credit limit). + + The model predicts on each copy, and the test reports the mean prediction under each + shock and its change from the baseline, in units and in percent. A tornado chart ranks + the features by the size of their effect. + + ### Signs of High Risk + + - A feature with a much larger effect than business knowledge supports. + - An effect with the wrong direction. + - Large asymmetry between up and down shocks that the model's purpose does not explain. + + ### Strengths + + - Works for any regression model that can predict on the dataset's features. + - Effects are expressed in the target's units at portfolio level, which is how the + model's output is used. + + ### Limitations + + - One feature at a time: correlated features would move together in reality; use + scenario analysis for joint movements. + - Categorical features are not shocked: non-numeric columns, and numeric columns a + statsmodels formula treats as categorical (`C(x)`). + - The baseline is the dataset's own predictions recomputed by the model; models that + cannot predict on the dataset's features are not supported. + """ + df = dataset.df + feature_columns = dataset.feature_columns + features = features or list(dataset.feature_columns_numeric) + # a numeric column the formula wraps in C() has levels, not a scale to shift along + categorical = categorical_columns(model, features) + features = [feature for feature in features if feature not in categorical] + shocks = shocks or {} + if not features: + raise SkipTestError("FeatureSensitivity requires numeric features") + + baseline = predict_frame(model, df, feature_columns).mean() + + rows = [] + for feature in features: + values = df[feature].to_numpy(dtype=float) + if _is_binary(values): + missing = np.isnan(values) # missing values stay missing + up, down = np.where(missing, np.nan, 1.0), np.where(missing, np.nan, 0.0) + shock_label = "0 → 1" + else: + amount = shocks.get(feature, shock_std * np.nanstd(values, ddof=1)) + up, down = values + amount, values - amount + if clip_to_range: + up = np.clip(up, np.nanmin(values), np.nanmax(values)) + down = np.clip(down, np.nanmin(values), np.nanmax(values)) + shock_label = f"± {amount:,.4g}" + + means = {} + for direction, shocked in (("Up", up), ("Down", down)): + frame = df.copy() + frame[feature] = shocked + means[direction] = predict_frame(model, frame, feature_columns).mean() + + rows.append( + { + "Feature": feature, + "Shock": shock_label, + "Baseline Mean Prediction": baseline, + "Mean Prediction (Up)": means["Up"], + "Mean Prediction (Down)": means["Down"], + "Change (Up)": means["Up"] - baseline, + "Change (Down)": means["Down"] - baseline, + "% Change (Up)": (means["Up"] - baseline) / abs(baseline), + "% Change (Down)": (means["Down"] - baseline) / abs(baseline), + } + ) + + table = pd.DataFrame(rows) + table["Range"] = (table["Change (Up)"] - table["Change (Down)"]).abs() + table = table.sort_values("Range", ascending=False).reset_index(drop=True) + + ordered = table.iloc[::-1] + fig = go.Figure() + for direction, color in (("Up", "#3949ab"), ("Down", "#d81b60")): + fig.add_trace( + go.Bar( + y=ordered["Feature"], + x=ordered[f"Change ({direction})"], + orientation="h", + name=f"Shock {direction.lower()}", + marker_color=color, + ) + ) + fig.update_layout( + barmode="overlay", + title=f"Feature Sensitivity — {model.input_id} on {dataset.input_id}", + xaxis_title=f"Change in mean prediction (baseline {baseline:,.4g})", + height=200 + 35 * len(table), + ) + + return ( + table.drop(columns="Range").round(4), + fig, + RawData(sensitivity=table, model=model.input_id, dataset=dataset.input_id), + ) diff --git a/validmind/tests/model_validation/Heteroscedasticity.py b/validmind/tests/model_validation/Heteroscedasticity.py new file mode 100644 index 000000000..e5bdf9935 --- /dev/null +++ b/validmind/tests/model_validation/Heteroscedasticity.py @@ -0,0 +1,178 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import Dict, Tuple + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +from validmind import RawData, tags, tasks +from validmind.errors import MissingDependencyError, SkipTestError +from validmind.vm_models import VMDataset, VMModel + +try: + from statsmodels.stats.diagnostic import het_breuschpagan, het_white + from statsmodels.tools.tools import add_constant +except ImportError as e: + if "statsmodels" in str(e): + raise MissingDependencyError( + "Missing required package `statsmodels` for Heteroscedasticity. " + "Please run `pip install validmind[stats]` to use statistical tests", + required_dependencies=["statsmodels"], + extra="stats", + ) from e + + raise e + + +@tags("model_diagnosis", "statistical_test", "visualization") +@tasks("regression") +def Heteroscedasticity( + dataset: VMDataset, + model: VMModel, + significance_level: float = 0.05, + n_bins: int = 10, +) -> Tuple[Dict[str, pd.DataFrame], go.Figure, bool, RawData]: + """ + Tests whether a regression model's error variance is constant, using the Breusch-Pagan + and White tests and a residuals-versus-fitted plot. + + ### Purpose + + Ordinary least squares assumes every observation has the same error variance. When the + variance changes with the regressors or the prediction (heteroscedasticity), the + coefficients remain unbiased but their standard errors, p-values and prediction + intervals are wrong — usually too optimistic where the errors are largest. It also tells + a business reader the model is much less reliable for some customers than for others. + + ### Test Mechanism + + Residuals are `actual - predicted` on the dataset, using the predictions assigned to it + for the model. The squared residuals are regressed on the dataset's numeric features: + + - **Breusch-Pagan:** tests for variance that changes linearly with the features. + - **White:** adds squares and cross-products of the features, so it also detects + non-linear patterns. + + A p-value below `significance_level` rejects constant variance. The plot shows residuals + against predictions, with the residual standard deviation in `n_bins` bins of the + prediction overlaid; a constant-variance model gives a flat band. + + The test passes when neither test rejects. + + ### Signs of High Risk + + - Rejection by both tests. + - A funnel or bow shape in the residuals-versus-fitted plot, or a residual standard + deviation that changes several-fold across prediction bins. + + ### Strengths + + - Works for any regression model with predictions. + - Pairs formal tests with a plot that shows where the variance changes. + + ### Limitations + + - Uses the dataset's numeric feature columns as the explanatory variables; categorical + features are not included, and variance driven only by them is not tested. + - The White test uses many terms when there are many features, which lowers its power. + - In large samples both tests reject small, immaterial variance changes; use the binned + standard deviations to judge materiality. + """ + actual = np.asarray(dataset.y, dtype=float) + predicted = np.asarray(dataset.y_pred(model), dtype=float) + residuals = actual - predicted + + numeric = list(dataset.feature_columns_numeric) + if not numeric: + raise SkipTestError("Heteroscedasticity requires numeric feature columns") + + features = dataset.df[numeric].to_numpy(dtype=float) + # models such as HistGradientBoostingRegressor predict on missing values; the tests + # cannot use those rows + valid = np.isfinite(features).all(axis=1) & np.isfinite(residuals) + if valid.sum() <= len(numeric) + 1: + raise SkipTestError( + "Heteroscedasticity found too few rows without missing values" + ) + regressors = add_constant(features[valid], has_constant="add") + + rows = [] + for name, test in [("Breusch-Pagan", het_breuschpagan), ("White", het_white)]: + lm, lm_p, _, _ = test(residuals[valid], regressors) + rows.append( + { + "Test": name, + "Observations": int(valid.sum()), + "LM Statistic": float(lm), + "p-value": float(lm_p), + "Significance Level": significance_level, + "Pass/Fail": "Pass" if lm_p >= significance_level else "Fail", + } + ) + tests_df = pd.DataFrame(rows) + + bins = pd.qcut(predicted, q=n_bins, duplicates="drop") + binned = ( + pd.DataFrame({"bin": bins, "predicted": predicted, "residual": residuals}) + .groupby("bin", observed=True) + .agg( + Count=("residual", "size"), + Mean_Prediction=("predicted", "mean"), + Residual_Std=("residual", "std"), + ) + .reset_index(drop=True) + .rename( + columns={ + "Mean_Prediction": "Mean Prediction", + "Residual_Std": "Residual Std. Deviation", + } + ) + ) + + fig = go.Figure() + fig.add_trace( + go.Scattergl( + x=predicted, + y=residuals, + mode="markers", + marker=dict(size=3, opacity=0.35, color="#5c6bc0"), + name="Residuals", + ) + ) + for sign in (1, -1): + fig.add_trace( + go.Scatter( + x=binned["Mean Prediction"], + y=sign * binned["Residual Std. Deviation"], + mode="lines+markers", + line=dict(color="#d81b60"), + name="± residual std. deviation by bin", + showlegend=sign == 1, + ) + ) + fig.add_hline(y=0, line_dash="dash", line_color="#757575") + fig.update_layout( + title=f"Residuals vs Predicted — {model.input_id} on {dataset.input_id}", + xaxis_title="Predicted", + yaxis_title="Residual", + ) + + passed = bool((tests_df["Pass/Fail"] == "Pass").all()) + + return ( + { + "Heteroscedasticity Tests": tests_df.round(6), + "Residual Spread by Prediction Bin": binned.round(4), + }, + fig, + passed, + RawData( + residuals=residuals, + predicted=predicted, + model=model.input_id, + dataset=dataset.input_id, + ), + ) diff --git a/validmind/tests/model_validation/PerformanceConfidenceIntervals.py b/validmind/tests/model_validation/PerformanceConfidenceIntervals.py new file mode 100644 index 000000000..7b7b17e80 --- /dev/null +++ b/validmind/tests/model_validation/PerformanceConfidenceIntervals.py @@ -0,0 +1,202 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import Dict, List, Tuple + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from plotly.subplots import make_subplots + +from validmind import RawData, tags, tasks +from validmind.vm_models import VMDataset, VMModel + + +def _metrics(actual, predicted): + error = actual - predicted + total = ((actual - actual.mean()) ** 2).sum() + return { + "R-Squared": 1 - (error**2).sum() / total, + "RMSE": np.sqrt((error**2).mean()), + "MAE": np.abs(error).mean(), + "Mean Bias": error.mean(), + } + + +_METRICS = ["R-Squared", "RMSE", "MAE", "Mean Bias"] + +# For each compared metric: does a higher value mean a better model? +_HIGHER_IS_BETTER = {"R-Squared": True, "RMSE": False, "MAE": False} + + +def _difference_table(actual, predictions, draws, alpha, pct): + """Each model minus the first, per metric, with paired-bootstrap intervals.""" + names = list(predictions) + reference = names[0] + reference_point = _metrics(actual, predictions[reference]) + rows = [] + for name in names[1:]: + point = _metrics(actual, predictions[name]) + for metric, higher_is_better in _HIGHER_IS_BETTER.items(): + diff = draws[name][metric] - draws[reference][metric] + difference = point[metric] - reference_point[metric] + low, high = np.quantile(diff, [alpha / 2, 1 - alpha / 2]) + significant = bool(low > 0 or high < 0) + if not significant: + better = "No significant difference" + elif (difference > 0) == higher_is_better: + better = name + else: + better = reference + rows.append( + { + "Comparison": f"{name} - {reference}", + "Metric": metric, + "Difference": difference, + f"{pct} CI Lower": low, + f"{pct} CI Upper": high, + "CI Excludes Zero": significant, + "Better Model": better, + } + ) + return pd.DataFrame(rows).round(4) + + +def _forest_plot(performance_df, pct, n_models): + fig = make_subplots(rows=1, cols=len(_METRICS), subplot_titles=_METRICS) + palette = ["#3949ab", "#d81b60", "#00897b", "#f9a825", "#6d4c41"] + for k, metric in enumerate(_METRICS): + rows = performance_df[performance_df["Metric"] == metric] + fig.add_trace( + go.Scatter( + x=rows["Estimate"], + y=rows["Model"], + mode="markers", + marker=dict( + size=10, color=[palette[i % len(palette)] for i in range(len(rows))] + ), + error_x=dict( + type="data", + symmetric=False, + array=rows[f"{pct} CI Upper"] - rows["Estimate"], + arrayminus=rows["Estimate"] - rows[f"{pct} CI Lower"], + ), + showlegend=False, + ), + row=1, + col=k + 1, + ) + fig.update_layout(height=200 + 60 * n_models) + return fig + + +@tags("model_performance", "uncertainty", "model_comparison", "visualization") +@tasks("regression") +def PerformanceConfidenceIntervals( + dataset: VMDataset, + models: List[VMModel], + n_bootstrap: int = 1000, + confidence_level: float = 0.95, + seed: int = 42, +) -> Tuple[Dict[str, pd.DataFrame], go.Figure, RawData]: + """ + Puts confidence intervals on regression performance metrics and, when several models + are given, on the difference between each model and the first — so a comparison says + whether one model is really better or just luckier on this sample. + + ### Purpose + + A test set is one sample. An R² of 0.80 against 0.78, or an MAE of 95 against 110, may + or may not survive another sample; a champion/challenger decision taken on point + estimates alone can replace a model for noise. Bootstrap intervals quantify that + uncertainty, and the interval of the *difference* is what answers the comparison. + + ### Test Mechanism + + The rows of the dataset are resampled with replacement `n_bootstrap` times (seeded by + `seed`). On every resample each model's R², RMSE, MAE and mean bias (`actual - + predicted`) are computed from the predictions assigned to the dataset. Because all + models are scored on the same resamples (a paired bootstrap), the differences account + for the correlation between their errors. + + - **Performance:** each model's point estimate and percentile interval at + `confidence_level`. + - **Difference vs the first model:** for every other model, `model - first` for each + metric, its interval, whether the interval excludes zero, and which model is better. + Pass the reference model (for example the champion) first. + + A forest plot shows each metric's interval per model. + + ### Signs of High Risk + + - A challenger chosen on point estimates whose difference interval includes zero. + - Wide intervals, meaning the dataset is too small to rank the models. + - A mean bias interval that excludes zero: systematic over- or under-prediction. + + ### Strengths + + - Works for any regression models with predictions assigned to the dataset. + - The paired design gives much tighter, and correct, intervals for differences than + comparing two separate intervals. + + ### Limitations + + - Resampling rows assumes observations are independent. + - Percentile intervals can be inaccurate for small datasets. + - Quantifies sampling uncertainty only — not whether the dataset represents future + data; use out-of-time data for that. + """ + actual = np.asarray(dataset.y, dtype=float) + predictions = { + m.input_id: np.asarray(dataset.y_pred(m), dtype=float) for m in models + } + names = list(predictions) + alpha = 1 - confidence_level + + rng = np.random.default_rng(seed) + n = len(actual) + draws = {name: [] for name in names} + for _ in range(n_bootstrap): + idx = rng.integers(0, n, n) + for name in names: + draws[name].append(_metrics(actual[idx], predictions[name][idx])) + draws = {name: pd.DataFrame(rows) for name, rows in draws.items()} + + pct = f"{confidence_level:.0%}" + performance_rows = [] + for name in names: + point = _metrics(actual, predictions[name]) + for metric, value in point.items(): + low, high = np.quantile(draws[name][metric], [alpha / 2, 1 - alpha / 2]) + performance_rows.append( + { + "Model": name, + "Metric": metric, + "Estimate": value, + f"{pct} CI Lower": low, + f"{pct} CI Upper": high, + } + ) + performance_df = pd.DataFrame(performance_rows) + + tables = {"Performance with Confidence Intervals": performance_df.round(4)} + + if len(names) > 1: + tables[f"Difference vs {names[0]}"] = _difference_table( + actual, predictions, draws, alpha, pct + ) + + fig = _forest_plot(performance_df, pct, len(names)) + fig.update_layout( + title=f"Performance with {pct} Bootstrap Confidence Intervals — {dataset.input_id}" + ) + + return ( + tables, + fig, + RawData( + bootstrap_metrics=pd.concat(draws, names=["model", "draw"]), + dataset=dataset.input_id, + ), + ) diff --git a/validmind/tests/model_validation/PerformanceOverTime.py b/validmind/tests/model_validation/PerformanceOverTime.py new file mode 100644 index 000000000..b8571246e --- /dev/null +++ b/validmind/tests/model_validation/PerformanceOverTime.py @@ -0,0 +1,173 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import Tuple + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from plotly.subplots import make_subplots + +from validmind import RawData, tags, tasks +from validmind.vm_models import VMDataset, VMModel + +from ._segments import MISSING, mean_and_half_width, segment_columns_or_default, z_value + + +@tags("model_performance", "stability", "time_series_data", "visualization") +@tasks("regression") +def PerformanceOverTime( + dataset: VMDataset, + model: VMModel, + time_column: str, + min_period_size: int = 30, + confidence_level: float = 0.95, + adjust_for_multiple_periods: bool = True, +) -> Tuple[pd.DataFrame, go.Figure, bool, RawData]: + """ + Tracks a regression model's bias and accuracy period by period, to show whether and + when its performance changes over time. + + ### Purpose + + A model validated on one period is used on the next. Customer mix, behaviour and the + economic environment change, and a model can degrade gradually or break at a point. + Out-of-time performance as a single number says *whether* it degraded; performance per + period says *when* and *how* — a step change points to an event, a trend to drift — + which is what an ongoing-monitoring plan and a recalibration decision need. + + ### Test Mechanism + + The dataset is grouped by `time_column` (any sortable period label, such as `2025Q3` + or a date), in sorted order. For each period the test reports the count, the mean + actual and predicted values, the mean residual (`actual - predicted`) with its + confidence interval, the MAE and the R². A period is **flagged** when its bias + interval excludes zero; with `adjust_for_multiple_periods` (default) the confidence + level is Bonferroni-adjusted for the number of periods assessed. Periods smaller than + `min_period_size` are reported but not assessed. Rows with no period are reported + together as a final `Missing` period. + + The plot shows the bias with its interval and the MAE over time. + + The test passes when no period is flagged. + + ### Signs of High Risk + + - Consecutive flagged periods, especially the most recent ones. + - A step change in bias or MAE: a structural break the model does not capture. + - A steady trend in bias: gradual drift that will keep growing. + + ### Strengths + + - Works for any regression model with predictions assigned to the dataset. + - Distinguishes a one-off bad period from a persistent shift. + + ### Limitations + + - Needs enough observations per period to be informative. + - Shows that performance changed, not why; pair it with feature drift (for example PSI) + to separate a change in the population from a change in behaviour. + """ + segment_columns_or_default(dataset, [time_column]) # validates the column + # rows without a period are reported together, after the last period + missing = dataset.df[time_column].isna().to_numpy() + periods = dataset.df[time_column].to_numpy(dtype=object) + periods[missing] = MISSING + actual = np.asarray(dataset.y, dtype=float) + predicted = np.asarray(dataset.y_pred(model), dtype=float) + residuals = actual - predicted + + ordered = sorted(pd.unique(periods[~missing])) + ( + [MISSING] if missing.any() else [] + ) + sizes = {p: int((periods == p).sum()) for p in ordered} + assessed = sum(n >= min_period_size for n in sizes.values()) + z = z_value(confidence_level, assessed if adjust_for_multiple_periods else 1) + + rows = [] + for period in ordered: + mask = periods == period + bias, half_width = mean_and_half_width(residuals[mask], z) + total = ((actual[mask] - actual[mask].mean()) ** 2).sum() + assessable = sizes[period] >= min_period_size + rows.append( + { + "Period": str(period), + "Count": sizes[period], + "Mean Actual": float(actual[mask].mean()), + "Mean Predicted": float(predicted[mask].mean()), + "Mean Residual": bias, + "CI Lower": bias - half_width, + "CI Upper": bias + half_width, + "MAE": float(np.abs(residuals[mask]).mean()), + "R-Squared": ( + float(1 - (residuals[mask] ** 2).sum() / total) if total else np.nan + ), + "Result": ( + "Too small to assess" + if not assessable + else ("Biased" if abs(bias) > half_width else "OK") + ), + } + ) + table = pd.DataFrame(rows) + + fig = make_subplots( + rows=2, + cols=1, + shared_xaxes=True, + subplot_titles=["Mean residual (positive = under-prediction)", "MAE"], + ) + fig.add_trace( + go.Scatter( + x=list(table["Period"]) + list(table["Period"][::-1]), + y=list(table["CI Upper"]) + list(table["CI Lower"][::-1]), + fill="toself", + fillcolor="rgba(57, 73, 171, 0.15)", + line=dict(width=0), + name="Confidence interval", + ), + row=1, + col=1, + ) + fig.add_trace( + go.Scatter( + x=table["Period"], + y=table["Mean Residual"], + mode="lines+markers", + marker=dict( + size=8, + color=np.where(table["Result"] == "Biased", "#c62828", "#3949ab"), + ), + line=dict(color="#3949ab"), + name="Mean residual", + ), + row=1, + col=1, + ) + fig.add_hline(y=0, line_dash="dash", line_color="#757575", row=1, col=1) + fig.add_trace( + go.Scatter( + x=table["Period"], + y=table["MAE"], + mode="lines+markers", + line=dict(color="#d81b60"), + name="MAE", + ), + row=2, + col=1, + ) + fig.update_layout( + title=f"Performance Over Time — {model.input_id} on {dataset.input_id}", + height=600, + ) + + passed = bool((table["Result"] != "Biased").all()) + + return ( + table.round(4), + fig, + passed, + RawData(residuals=residuals, model=model.input_id, dataset=dataset.input_id), + ) diff --git a/validmind/tests/model_validation/PredictionRange.py b/validmind/tests/model_validation/PredictionRange.py new file mode 100644 index 000000000..0b74fa884 --- /dev/null +++ b/validmind/tests/model_validation/PredictionRange.py @@ -0,0 +1,116 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import Optional, Tuple + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +from validmind import RawData, tags, tasks +from validmind.vm_models import VMDataset, VMModel + + +@tags("model_diagnosis", "conceptual_soundness", "visualization") +@tasks("regression") +def PredictionRange( + dataset: VMDataset, + model: VMModel, + lower_bound: Optional[float] = None, + upper_bound: Optional[float] = None, + max_share_outside: float = 0.0, +) -> Tuple[pd.DataFrame, go.Figure, bool, RawData]: + """ + Checks that a regression model's predictions stay within the range the target can + take, and measures how many fall outside it. + + ### Purpose + + Many targets are bounded — a balance or a loss cannot be negative, a rate cannot exceed + 100%. A linear model has no such limits and will predict impossible values for some + observations. Those predictions are conceptually unsound and, if used directly + (summed into a portfolio forecast, compared with a limit), produce errors that fit + metrics do not reveal. + + ### Test Mechanism + + The validator supplies `lower_bound`, `upper_bound` or both (at least one is required). + For each bound the test counts the predictions beyond it, their share of the dataset, + and the most extreme prediction. A histogram of predictions marks the bounds. + + The test passes when the share of predictions outside the bounds is at most + `max_share_outside` (default 0: no impossible predictions). + + ### Signs of High Risk + + - A material share of predictions outside the feasible range. + - Predictions far beyond a bound, not just marginally past it. + + ### Strengths + + - Works for any regression model with predictions. + - Turns a domain constraint into an explicit, auditable check. + + ### Limitations + + - Only as good as the bounds supplied; it checks feasibility, not accuracy. + - Flooring or capping predictions fixes the symptom but not the misspecification that + caused it. + """ + if lower_bound is None and upper_bound is None: + raise ValueError("PredictionRange requires lower_bound, upper_bound or both") + + predicted = np.asarray(dataset.y_pred(model), dtype=float) + n = len(predicted) + + rows = [] + outside = np.zeros(n, dtype=bool) + for label, bound, beyond_fn, extreme in [ + ("Lower", lower_bound, np.less, predicted.min()), + ("Upper", upper_bound, np.greater, predicted.max()), + ]: + if bound is None: + continue + beyond = beyond_fn(predicted, bound) + outside |= beyond + rows.append( + { + "Bound": label, + "Value": bound, + "Predictions Beyond": int(beyond.sum()), + "Share Beyond": float(beyond.mean()), + "Most Extreme Prediction": float(extreme), + } + ) + rows.append( + { + "Bound": "Total outside range", + "Value": np.nan, + "Predictions Beyond": int(outside.sum()), + "Share Beyond": float(outside.mean()), + "Most Extreme Prediction": np.nan, + } + ) + table = pd.DataFrame(rows) + + fig = go.Figure( + go.Histogram(x=predicted, nbinsx=60, marker_color="#5c6bc0", name="Predictions") + ) + for bound in (lower_bound, upper_bound): + if bound is not None: + fig.add_vline(x=bound, line_dash="dash", line_color="#c62828") + fig.update_layout( + title=f"Prediction Range — {model.input_id} on {dataset.input_id}", + xaxis_title="Prediction", + yaxis_title="Count", + ) + + passed = bool(outside.mean() <= max_share_outside) + + return ( + table.round(6), + fig, + passed, + RawData(predicted=predicted, model=model.input_id, dataset=dataset.input_id), + ) diff --git a/validmind/tests/model_validation/RegressionWeakspots.py b/validmind/tests/model_validation/RegressionWeakspots.py new file mode 100644 index 000000000..eb76edfd4 --- /dev/null +++ b/validmind/tests/model_validation/RegressionWeakspots.py @@ -0,0 +1,167 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import List, Optional, Tuple + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +from validmind import RawData, tags, tasks +from validmind.vm_models import VMDataset, VMModel + +from ._segments import ( + SegmentColumn, + iter_segments, + mean_and_half_width, + segment_columns_or_default, + z_value, +) + + +@tags("model_diagnosis", "segment_analysis", "visualization") +@tasks("regression") +def RegressionWeakspots( + dataset: VMDataset, + model: VMModel, + segment_columns: Optional[List[SegmentColumn]] = None, + n_bins: int = 5, + max_categories: int = 20, + min_segment_size: int = 30, + max_error_ratio: float = 1.25, + confidence_level: float = 0.95, + adjust_for_multiple_segments: bool = True, +) -> Tuple[pd.DataFrame, go.Figure, bool, RawData]: + """ + Finds segments of the data in which a regression model is materially less accurate + than overall, by comparing each segment's error with the overall error. + + ### Purpose + + Overall accuracy metrics average over the whole dataset, so a model can look accurate + while being unreliable for a specific group — high-value customers, a region, a product. + Where the model is weak determines where it can be relied on and where compensating + controls or a different model are needed. This is the regression counterpart of a + weak-spots analysis for classifiers. + + ### Test Mechanism + + Segments are formed as in `ResidualBiasBySegment`: every column in `segment_columns` + (default: every column of the dataset except the target), numeric columns with more + than `max_categories` values cut into `n_bins` quantile bins, and lists of columns + segmenting on their combinations. + + For each segment the test reports the mean absolute error (MAE), root mean squared + error (RMSE), mean residual, and the ratio of the segment's MAE to the overall MAE. A + segment is **flagged** as a weak spot when both: + + - its MAE is at least `max_error_ratio` times the overall MAE (material), and + - the lower bound of its MAE confidence interval is above the overall MAE (not chance), + with the confidence level Bonferroni-adjusted for the number of segments tested when + `adjust_for_multiple_segments` is on. + + Segments smaller than `min_segment_size` are reported but not assessed. The test passes + when there is no weak spot. + + ### Signs of High Risk + + - Weak spots in large or high-exposure segments. + - Weak spots that coincide with bias in the same segment: the model is both wrong on + average and noisy there. + - Error that rises steadily across the bins of one column: a missing non-linearity. + + ### Strengths + + - Works for any regression model with predictions assigned to the dataset. + - Requires both a material and a statistically clear difference before flagging. + + ### Limitations + + - Segments with naturally larger values (for example high balances) have larger + absolute errors; read the relative bias and the business context before concluding. + - Bins are fixed in advance and the Bonferroni adjustment is conservative. + """ + df = dataset.df + columns = segment_columns_or_default(dataset, segment_columns) + actual = np.asarray(dataset.y, dtype=float) + residuals = actual - np.asarray(dataset.y_pred(model), dtype=float) + abs_errors = np.abs(residuals) + overall_mae = float(abs_errors.mean()) + overall_rmse = float(np.sqrt((residuals**2).mean())) + + segments = list(iter_segments(df, columns, n_bins, max_categories)) + assessed = sum(mask.sum() >= min_segment_size for _, _, mask in segments) + z = z_value(confidence_level, assessed if adjust_for_multiple_segments else 1) + + rows = [] + for column, segment, mask in segments: + n = int(mask.sum()) + mae, half_width = mean_and_half_width(abs_errors[mask], z) + ratio = mae / overall_mae if overall_mae else np.nan + assessable = n >= min_segment_size + weak = bool( + assessable and ratio >= max_error_ratio and mae - half_width > overall_mae + ) + rows.append( + { + "Segment Column": column, + "Segment": segment, + "Count": n, + "MAE": mae, + "MAE CI Lower": mae - half_width, + "MAE CI Upper": mae + half_width, + "RMSE": float(np.sqrt((residuals[mask] ** 2).mean())), + "Mean Residual": float(residuals[mask].mean()), + "MAE Ratio to Overall": ratio, + "Result": ( + "Too small to assess" + if not assessable + else ("Weak spot" if weak else "OK") + ), + } + ) + table = pd.DataFrame(rows) + + labels = table["Segment Column"] + ": " + table["Segment"] + colors = table["Result"].map( + {"Weak spot": "#c62828", "OK": "#3949ab", "Too small to assess": "#bdbdbd"} + ) + fig = go.Figure( + go.Bar( + x=table["MAE Ratio to Overall"], + y=labels, + orientation="h", + marker_color=colors, + ) + ) + fig.add_vline(x=1, line_dash="dash", line_color="#757575") + fig.add_vline( + x=max_error_ratio, + line_dash="dot", + line_color="#c62828", + annotation_text=f"{max_error_ratio}× overall MAE", + ) + fig.update_layout( + title=( + f"Segment MAE Relative to Overall MAE ({overall_mae:,.4g}) — " + f"{model.input_id} on {dataset.input_id}" + ), + xaxis_title="Segment MAE / overall MAE", + yaxis=dict(autorange="reversed"), + height=200 + 22 * len(table), + ) + + passed = bool((table["Result"] != "Weak spot").all()) + + return ( + table.round(4), + fig, + passed, + RawData( + overall_mae=overall_mae, + overall_rmse=overall_rmse, + model=model.input_id, + dataset=dataset.input_id, + ), + ) diff --git a/validmind/tests/model_validation/ResidualBiasBySegment.py b/validmind/tests/model_validation/ResidualBiasBySegment.py new file mode 100644 index 000000000..0c2b42770 --- /dev/null +++ b/validmind/tests/model_validation/ResidualBiasBySegment.py @@ -0,0 +1,166 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import List, Optional, Tuple + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +from validmind import RawData, tags, tasks +from validmind.vm_models import VMDataset, VMModel + +from ._segments import ( + SegmentColumn, + iter_segments, + mean_and_half_width, + segment_columns_or_default, + z_value, +) + + +@tags("model_diagnosis", "segment_analysis", "visualization") +@tasks("regression") +def ResidualBiasBySegment( + dataset: VMDataset, + model: VMModel, + segment_columns: Optional[List[SegmentColumn]] = None, + n_bins: int = 5, + max_categories: int = 20, + min_segment_size: int = 30, + confidence_level: float = 0.95, + adjust_for_multiple_segments: bool = True, +) -> Tuple[pd.DataFrame, go.Figure, bool, RawData]: + """ + Finds segments of the data in which a regression model systematically over- or + under-predicts, by measuring the mean residual in every segment with a confidence + interval. + + ### Purpose + + A model can be unbiased on average and badly biased for a group: over-predicting one + segment and under-predicting another cancel out in every portfolio-level metric. Bias + concentrated in a segment is often the most material model risk — it means the model + systematically misprices or misreserves that group. Segmenting on columns the model does + *not* use is especially revealing, because it exposes effects the model cannot see. + + ### Test Mechanism + + Residuals are `actual - predicted` (positive = under-prediction). The data is split into + segments for every column in `segment_columns` (default: every column of the dataset + except the target, including extra columns not used by the model): + + - numeric columns with more than `max_categories` distinct values are cut into + `n_bins` quantile bins; other columns are used as categories; + - a list of columns, for example `["IsStudent", "Income"]`, segments on their + combinations, to look for interactions. + + For each segment the test reports its size, mean actual and predicted values, the mean + residual with its confidence interval, and the bias relative to the mean actual value. + A segment is **flagged** when its interval excludes zero. With + `adjust_for_multiple_segments` (default), the confidence level is Bonferroni-adjusted + for the number of segments tested, so that scanning many segments does not flag some by + chance. Segments smaller than `min_segment_size` are reported but not assessed. + + The test passes when no segment is flagged. + + ### Signs of High Risk + + - Flagged segments with a material relative bias, especially large or high-exposure ones. + - Opposite biases in complementary segments (for example under-predicting one group and + over-predicting the rest), which average to zero overall. + - Bias along a column the model does not use: an omitted effect. + + ### Strengths + + - Works for any regression model with predictions assigned to the dataset. + - Scans every column by default, including ones the model does not use. + - Controls false alarms when many segments are tested. + + ### Limitations + + - Bins and categories are fixed in advance; a biased group that cuts across them can be + diluted. + - The Bonferroni adjustment is conservative: with many segments, small real biases may + not be flagged. + - Uses a normal approximation for the interval, which needs reasonably sized segments. + """ + df = dataset.df + columns = segment_columns_or_default(dataset, segment_columns) + actual = np.asarray(dataset.y, dtype=float) + predicted = np.asarray(dataset.y_pred(model), dtype=float) + residuals = actual - predicted + + segments = list(iter_segments(df, columns, n_bins, max_categories)) + assessed = sum(mask.sum() >= min_segment_size for _, _, mask in segments) + z = z_value(confidence_level, assessed if adjust_for_multiple_segments else 1) + + rows = [] + for column, segment, mask in segments: + n = int(mask.sum()) + bias, half_width = mean_and_half_width(residuals[mask], z) + assessable = n >= min_segment_size + flagged = bool(assessable and abs(bias) > half_width) + mean_actual = float(actual[mask].mean()) + rows.append( + { + "Segment Column": column, + "Segment": segment, + "Count": n, + "Mean Actual": mean_actual, + "Mean Predicted": float(predicted[mask].mean()), + "Mean Residual": bias, + "CI Lower": bias - half_width, + "CI Upper": bias + half_width, + "Relative Bias": bias / mean_actual if mean_actual else np.nan, + "Result": ( + "Too small to assess" + if not assessable + else ("Biased" if flagged else "OK") + ), + } + ) + table = pd.DataFrame(rows) + + labels = table["Segment Column"] + ": " + table["Segment"] + colors = table["Result"].map( + {"Biased": "#c62828", "OK": "#3949ab", "Too small to assess": "#bdbdbd"} + ) + fig = go.Figure( + go.Scatter( + x=table["Mean Residual"], + y=labels, + mode="markers", + marker=dict(size=8, color=colors), + error_x=dict( + type="data", + array=table["CI Upper"] - table["Mean Residual"], + arrayminus=table["Mean Residual"] - table["CI Lower"], + ), + ) + ) + fig.add_vline(x=0, line_dash="dash", line_color="#757575") + fig.update_layout( + title=( + f"Mean Residual by Segment (positive = under-prediction) — " + f"{model.input_id} on {dataset.input_id}" + ), + xaxis_title="Mean residual (actual − predicted)", + yaxis=dict(autorange="reversed"), + height=200 + 22 * len(table), + ) + + passed = bool((table["Result"] != "Biased").all()) + + return ( + table.round(4), + fig, + passed, + RawData( + residuals=residuals, + z_value=z, + model=model.input_id, + dataset=dataset.input_id, + ), + ) diff --git a/validmind/tests/model_validation/ResidualNormality.py b/validmind/tests/model_validation/ResidualNormality.py new file mode 100644 index 000000000..fa5c58d55 --- /dev/null +++ b/validmind/tests/model_validation/ResidualNormality.py @@ -0,0 +1,197 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import Dict, Tuple + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from plotly.subplots import make_subplots +from scipy import stats + +from validmind import RawData, tags, tasks +from validmind.vm_models import VMDataset, VMModel + +# scipy's Shapiro-Wilk p-value is only accurate up to 5000 observations +_SHAPIRO_MAX_N = 5000 + + +@tags("model_diagnosis", "statistical_test", "normality", "visualization") +@tasks("regression") +def ResidualNormality( + dataset: VMDataset, + model: VMModel, + significance_level: float = 0.05, + seed: int = 42, +) -> Tuple[Dict[str, pd.DataFrame], go.Figure, bool, RawData]: + """ + Tests whether a regression model's residuals are normally distributed, using the + Jarque-Bera, Shapiro-Wilk and Anderson-Darling tests and a Q-Q plot. + + ### Purpose + + Ordinary least squares does not need normal errors to estimate coefficients, but its + p-values, confidence intervals and prediction intervals do — especially in small samples + and in the tails. Skewed or heavy-tailed residuals also signal that the model misses part + of the target's structure, such as a floor at zero or a sub-population. This test checks + the residuals, not the features: feature normality is not an assumption of regression. + `stats.NormalityTests` tests the features; `ModelPredictionResiduals` plots residuals + with a single Kolmogorov-Smirnov test. This test adds Jarque-Bera, Shapiro-Wilk and + Anderson-Darling, skewness and kurtosis, a Q-Q plot, and a pass/fail result. + + ### Test Mechanism + + Residuals are `actual - predicted` on the dataset, using the predictions assigned to it + for the model. Three tests of normality are applied: + + - **Jarque-Bera:** based on skewness and kurtosis; sensitive to asymmetry and heavy tails. + - **Shapiro-Wilk:** a general test with good power; if there are more than 5,000 + residuals, a random sample of 5,000 is tested (seeded by `seed`). + - **Anderson-Darling:** gives extra weight to the tails. With SciPy 1.17 or later its + p-value is interpolated from tables (reported between 0.01 and 0.15); with older SciPy + the statistic is compared with its critical value at the level closest to + `significance_level`. + + The Q-Q plot compares standardized residual quantiles with normal quantiles; points off + the diagonal show where the distribution departs (one tail, both tails, a step). + + The test passes when none of the three tests rejects normality. + + ### Signs of High Risk + + - Rejection by all three tests, with large skewness or excess kurtosis. + - A Q-Q plot with a long tail or a flat section (for example many residuals at one value + from a target bounded at zero). + + ### Strengths + + - Assesses the actual model errors, for any regression model with predictions. + - Combines tests with different sensitivities with a visual diagnosis. + + ### Limitations + + - In large samples the tests reject small, immaterial departures; use the Q-Q plot and + the skewness and kurtosis to judge materiality. + - Residuals on training data understate the errors on new data. + """ + actual = np.asarray(dataset.y, dtype=float) + residuals = actual - np.asarray(dataset.y_pred(model), dtype=float) + n = len(residuals) + + jb_stat, jb_p = stats.jarque_bera(residuals) + + rng = np.random.default_rng(seed) + shapiro_sample = ( + rng.choice(residuals, _SHAPIRO_MAX_N, replace=False) + if n > _SHAPIRO_MAX_N + else residuals + ) + sw_stat, sw_p = stats.shapiro(shapiro_sample) + + try: + anderson = stats.anderson(residuals, dist="norm", method="interpolate") + except TypeError: # SciPy < 1.17: critical values only, no p-value + anderson = stats.anderson(residuals, dist="norm") + + if hasattr(anderson, "pvalue"): + ad_p = float(anderson.pvalue) + ad_fails_when = f"p-value < {significance_level}" + ad_passed = ad_p >= significance_level + else: + levels = np.asarray(anderson.significance_level) / 100 + level_idx = int(np.argmin(np.abs(levels - significance_level))) + ad_critical = float(anderson.critical_values[level_idx]) + ad_p = np.nan + ad_fails_when = ( + f"statistic > {ad_critical:.4f} (critical value at {levels[level_idx]:.1%})" + ) + ad_passed = anderson.statistic <= ad_critical + + tests_df = pd.DataFrame( + [ + { + "Test": "Jarque-Bera", + "Statistic": float(jb_stat), + "p-value": float(jb_p), + "Fails When": f"p-value < {significance_level}", + "Pass/Fail": "Pass" if jb_p >= significance_level else "Fail", + }, + { + "Test": f"Shapiro-Wilk (n={len(shapiro_sample)})", + "Statistic": float(sw_stat), + "p-value": float(sw_p), + "Fails When": f"p-value < {significance_level}", + "Pass/Fail": "Pass" if sw_p >= significance_level else "Fail", + }, + { + "Test": "Anderson-Darling", + "Statistic": float(anderson.statistic), + "p-value": ad_p, + "Fails When": ad_fails_when, + "Pass/Fail": "Pass" if ad_passed else "Fail", + }, + ] + ) + + shape_df = pd.DataFrame( + [ + { + "Observations": n, + "Mean Residual": float(residuals.mean()), + "Std. Deviation": float(residuals.std(ddof=1)), + "Skewness": float(stats.skew(residuals)), + "Excess Kurtosis": float(stats.kurtosis(residuals)), + } + ] + ) + + standardized = np.sort((residuals - residuals.mean()) / residuals.std(ddof=1)) + theoretical = stats.norm.ppf((np.arange(1, n + 1) - 0.5) / n) + + fig = make_subplots( + rows=1, cols=2, subplot_titles=["Normal Q-Q Plot", "Residual Distribution"] + ) + fig.add_trace( + go.Scattergl( + x=theoretical, + y=standardized, + mode="markers", + marker=dict(size=3, color="#5c6bc0"), + name="Residuals", + ), + row=1, + col=1, + ) + bound = float(max(abs(theoretical).max(), 1)) + fig.add_trace( + go.Scatter( + x=[-bound, bound], + y=[-bound, bound], + mode="lines", + line=dict(color="#757575", dash="dash"), + name="Normal", + ), + row=1, + col=1, + ) + fig.add_trace( + go.Histogram(x=residuals, nbinsx=60, marker_color="#5c6bc0", showlegend=False), + row=1, + col=2, + ) + fig.update_xaxes(title_text="Theoretical Quantiles", row=1, col=1) + fig.update_yaxes(title_text="Standardized Residual Quantiles", row=1, col=1) + fig.update_xaxes(title_text="Residual", row=1, col=2) + fig.update_layout( + title=f"Residual Normality — {model.input_id} on {dataset.input_id}" + ) + + passed = bool((tests_df["Pass/Fail"] == "Pass").all()) + + return ( + {"Normality Tests": tests_df.round(6), "Residual Shape": shape_df.round(4)}, + fig, + passed, + RawData(residuals=residuals, model=model.input_id, dataset=dataset.input_id), + ) diff --git a/validmind/tests/model_validation/ScenarioAnalysis.py b/validmind/tests/model_validation/ScenarioAnalysis.py new file mode 100644 index 000000000..3587c80e5 --- /dev/null +++ b/validmind/tests/model_validation/ScenarioAnalysis.py @@ -0,0 +1,158 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import Any, Dict, Optional, Tuple + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +from validmind import RawData, tags, tasks +from validmind.vm_models import VMDataset, VMModel + +from .statsmodels.statsutils import predict_frame + +_OPERATIONS = { + "multiply": lambda values, x: values * x, + "add": lambda values, x: values + x, + "set": lambda values, x: np.full(len(values), x), +} + + +def _apply(df, scenario_name, changes): + frame = df.copy() + for feature, change in changes.items(): + if feature not in frame.columns: + raise ValueError(f"Scenario {scenario_name!r}: unknown feature {feature!r}") + if not isinstance(change, dict) or len(change) != 1: + raise ValueError( + f"Scenario {scenario_name!r}, feature {feature!r}: give exactly one of " + f"{sorted(_OPERATIONS)}, for example {{'multiply': 0.8}}" + ) + ((operation, amount),) = change.items() + if operation not in _OPERATIONS: + raise ValueError( + f"Scenario {scenario_name!r}, feature {feature!r}: unknown operation " + f"{operation!r}; use one of {sorted(_OPERATIONS)}" + ) + frame[feature] = _OPERATIONS[operation](frame[feature].to_numpy(), amount) + return frame + + +@tags("sensitivity_analysis", "stress_testing", "visualization") +@tasks("regression") +def ScenarioAnalysis( + dataset: VMDataset, + model: VMModel, + scenarios: Dict[str, Dict[str, Dict[str, Any]]], + lower_bound: Optional[float] = None, +) -> Tuple[pd.DataFrame, go.Figure, RawData]: + """ + Projects a regression model's portfolio-level prediction under named stress scenarios + in which several features move together. + + ### Purpose + + Stress testing asks what the model forecasts if conditions change — a recession that + cuts incomes and raises debt burdens, a policy that raises credit limits. It checks that + the model's response is plausible in direction and size, and gives the business the + numbers it needs for planning. Unlike one-at-a-time sensitivity, scenarios move features + jointly, as they move in reality. + + ### Test Mechanism + + The validator defines `scenarios`, each a name mapped to feature changes. Each change + is one operation applied to every row: + + - `{"multiply": 0.8}` — scale the feature (income down 20%), + - `{"add": 0.05}` — shift it (debt-to-income up 5 points), + - `{"set": 1}` — set it to a value. + + For example `{"Recession": {"Income": {"multiply": 0.8}, "DebtToIncome": {"add": + 0.05}}}`. The model predicts on the baseline dataset and on each scenario's copy; the + test reports the mean and total prediction, and the change in the total from the + baseline in units and percent. With `lower_bound`, it also reports the share of + predictions below it, since a stress can push a linear model into impossible values. + + ### Signs of High Risk + + - A scenario whose impact has an implausible direction or size. + - A stress that pushes a material share of predictions outside the feasible range. + + ### Strengths + + - Works for any regression model that can predict on the dataset's features. + - Scenarios are explicit, named and reproducible, so results can be reviewed and + compared across model versions. + + ### Limitations + + - Only as good as the scenarios supplied; it applies them mechanically and does not + model second-round effects (for example customers leaving the portfolio). + - Applies the same change to every row. + """ + if "Baseline" in scenarios: + raise ValueError( + "'Baseline' is reserved for the unstressed predictions; rename that scenario" + ) + feature_columns = dataset.feature_columns + frames = {"Baseline": dataset.df} + for name, changes in scenarios.items(): + frames[name] = _apply(dataset.df, name, changes) + + predictions = { + name: predict_frame(model, frame, feature_columns) + for name, frame in frames.items() + } + baseline_total = predictions["Baseline"].sum() + + rows = [] + for name, predicted in predictions.items(): + row = { + "Scenario": name, + "Changes": ( + "; ".join( + f"{feature} {op} {amount}" + for feature, change in scenarios[name].items() + for op, amount in change.items() + ) + if name in scenarios + else "—" + ), + "Mean Prediction": predicted.mean(), + "Total Prediction": predicted.sum(), + "Change in Total": predicted.sum() - baseline_total, + "% Change in Total": (predicted.sum() - baseline_total) + / abs(baseline_total), + } + if lower_bound is not None: + row[f"Share Below {lower_bound}"] = float((predicted < lower_bound).mean()) + rows.append(row) + table = pd.DataFrame(rows) + + stressed = table[table["Scenario"] != "Baseline"] + fig = go.Figure( + go.Bar( + x=stressed["Scenario"], + y=stressed["% Change in Total"], + marker_color=np.where( + stressed["% Change in Total"] >= 0, "#3949ab", "#d81b60" + ), + text=[f"{v:+.1%}" for v in stressed["% Change in Total"]], + textposition="outside", + ) + ) + fig.update_layout( + title=f"Scenario Impact on Total Prediction — {model.input_id} on {dataset.input_id}", + yaxis_title="% change in total prediction vs baseline", + yaxis_tickformat=".0%", + ) + + return ( + table.round(4), + fig, + RawData( + predictions=predictions, model=model.input_id, dataset=dataset.input_id + ), + ) diff --git a/validmind/tests/model_validation/TailPerformance.py b/validmind/tests/model_validation/TailPerformance.py new file mode 100644 index 000000000..ade5a1328 --- /dev/null +++ b/validmind/tests/model_validation/TailPerformance.py @@ -0,0 +1,129 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import Tuple + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +from validmind import RawData, tags, tasks +from validmind.vm_models import VMDataset, VMModel + + +@tags("model_performance", "segment_analysis", "visualization") +@tasks("regression") +def TailPerformance( + dataset: VMDataset, + model: VMModel, + tail_share: float = 0.1, +) -> Tuple[pd.DataFrame, go.Figure, RawData]: + """ + Measures a regression model's accuracy and bias in the tails — the observations with + the largest and smallest actual values, and those with the largest and smallest + predictions — and compares them with the whole dataset. + + ### Purpose + + Exposure, loss and value are usually concentrated in the tails: the largest balances, + the most severe losses. A model can be accurate for typical observations and badly + wrong where the money is. Looking at the tails by actual value shows how the model + treats extreme outcomes; looking at them by prediction shows how reliable its most + extreme forecasts are — the ones most likely to trigger a decision. + + ### Test Mechanism + + Five groups are compared: all observations; the top and bottom `tail_share` (10% by + default) by actual value; and the top and bottom `tail_share` by prediction, with ties + broken by row order so each tail holds exactly that share. For each + group the test reports the count, the mean actual and predicted values, the mean + residual (`actual - predicted`), the MAE, and the MAE relative to the overall MAE. A + bar chart shows the mean residual by group. + + ### Signs of High Risk + + - Large under-prediction in the top tail by actual value: the model misses the extreme + outcomes that matter most. + - Large errors in the top tail by prediction: the model's highest forecasts are not + reliable. + - Predictions in the bottom tail outside the feasible range. + + ### Strengths + + - Works for any regression model with predictions assigned to the dataset. + - Separates "extreme outcomes" from "extreme forecasts", which fail differently. + + ### Limitations + + - Some regression to the mean is expected: any imperfect model under-predicts the top + tail by actual value and over-predicts the bottom one. Judge the size, not the sign. + - Tails defined by a share hold few observations in small datasets. + """ + actual = np.asarray(dataset.y, dtype=float) + predicted = np.asarray(dataset.y_pred(model), dtype=float) + residuals = actual - predicted + overall_mae = float(np.abs(residuals).mean()) + + # rank rather than compare with a quantile, so ties (such as many zero balances) + # cannot push a tail beyond its share + def ranks(values): + return pd.Series(values).rank(method="first").to_numpy() + + # round, so floating-point error cannot make the two tails different sizes + tail_size = round(len(actual) * tail_share) + + def top(values): + return ranks(values) > len(values) - tail_size + + def bottom(values): + return ranks(values) <= tail_size + + pct = f"{tail_share:.0%}" + groups = { + "All observations": np.ones(len(actual), dtype=bool), + f"Top {pct} by actual": top(actual), + f"Bottom {pct} by actual": bottom(actual), + f"Top {pct} by prediction": top(predicted), + f"Bottom {pct} by prediction": bottom(predicted), + } + + rows = [] + for group, mask in groups.items(): + mae = float(np.abs(residuals[mask]).mean()) + rows.append( + { + "Group": group, + "Count": int(mask.sum()), + "Mean Actual": float(actual[mask].mean()), + "Mean Predicted": float(predicted[mask].mean()), + "Mean Residual": float(residuals[mask].mean()), + "MAE": mae, + "MAE Ratio to Overall": mae / overall_mae if overall_mae else np.nan, + } + ) + table = pd.DataFrame(rows) + + fig = go.Figure( + go.Bar( + x=table["Group"], + y=table["Mean Residual"], + marker_color=np.where(table["Mean Residual"] >= 0, "#3949ab", "#d81b60"), + text=[f"{v:,.4g}" for v in table["Mean Residual"]], + textposition="outside", + ) + ) + fig.add_hline(y=0, line_color="#757575") + fig.update_layout( + title=( + f"Mean Residual in the Tails (positive = under-prediction) — " + f"{model.input_id} on {dataset.input_id}" + ), + yaxis_title="Mean residual (actual − predicted)", + ) + + return ( + table.round(4), + fig, + RawData(residuals=residuals, model=model.input_id, dataset=dataset.input_id), + ) diff --git a/validmind/tests/model_validation/_segments.py b/validmind/tests/model_validation/_segments.py new file mode 100644 index 000000000..0fee6b2c0 --- /dev/null +++ b/validmind/tests/model_validation/_segments.py @@ -0,0 +1,91 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +"""Shared segmentation helpers for the regression segment tests.""" + +from typing import List, Optional, Sequence, Union + +import numpy as np +import pandas as pd +from scipy import stats + +SegmentColumn = Union[str, Sequence[str]] + +MISSING = "Missing" + + +def segment_columns_or_default( + dataset, segment_columns: Optional[List[SegmentColumn]] +) -> List[SegmentColumn]: + """The requested segment columns, or every column of the dataset except the target.""" + if segment_columns is None: + return [c for c in dataset.df.columns if c != dataset.target_column] + + available = set(dataset.df.columns) + for spec in segment_columns: + for column in [spec] if isinstance(spec, str) else spec: + if column not in available: + raise ValueError( + f"Segment column {column!r} is not in the dataset. Columns the " + "model does not use can be added with " + "`dataset.add_extra_column(...)` or `extra_columns` in " + "`vm.init_dataset`." + ) + return list(segment_columns) + + +def segment_labels(values: pd.Series, n_bins: int, max_categories: int) -> pd.Series: + """Quantile bins for numeric columns with many values; the values themselves otherwise.""" + labels = values + if pd.api.types.is_numeric_dtype(values) and values.nunique() > max_categories: + bins = pd.qcut(values, q=n_bins, duplicates="drop") + # qcut nudges the first edge below the minimum; show the real minimum instead + lowest = values.min() + # add digits until every bin has its own label (e.g. YYYYMMDD dates need 8) + for digits in range(4, 16): + labels = bins.map( + lambda b: f"{max(b.left, lowest):,.{digits}g} – {b.right:,.{digits}g}", + na_action="ignore", + ) + if labels.nunique() == bins.nunique(): + break + return labels.astype(object).where(values.notna(), MISSING).astype(str) + + +def iter_segments(df, segment_columns, n_bins, max_categories): + """Yield (column label, segment label, boolean mask) for every segment.""" + for spec in segment_columns: + columns = [spec] if isinstance(spec, str) else list(spec) + labels = [segment_labels(df[c], n_bins, max_categories) for c in columns] + combined = labels[0] + for extra in labels[1:]: + combined = combined + " × " + extra + column_label = " × ".join(columns) + for segment in sorted(combined.unique(), key=_natural_key): + yield column_label, segment, (combined == segment).to_numpy() + + +def _natural_key(label): + """Sort numeric bin labels by their lower edge, other labels alphabetically, missing last.""" + head = label.split(" – ")[0].split(" × ")[0].replace(",", "") + if head == MISSING: + return (2, 0.0, label) + try: + return (0, float(head), label) + except ValueError: + return (1, 0.0, label) + + +def z_value(confidence_level: float, n_comparisons: int = 1) -> float: + """Two-sided normal quantile, Bonferroni-adjusted for `n_comparisons`.""" + alpha = (1 - confidence_level) / max(n_comparisons, 1) + return float(stats.norm.ppf(1 - alpha / 2)) + + +def mean_and_half_width(values: np.ndarray, z: float): + """Mean of `values` and the half-width of its normal-approximation interval.""" + n = len(values) + if n < 2: + return float(np.mean(values)) if n else np.nan, np.nan + return float(values.mean()), float(z * values.std(ddof=1) / np.sqrt(n)) diff --git a/validmind/tests/model_validation/statsmodels/CoefficientStability.py b/validmind/tests/model_validation/statsmodels/CoefficientStability.py new file mode 100644 index 000000000..f4ce6572b --- /dev/null +++ b/validmind/tests/model_validation/statsmodels/CoefficientStability.py @@ -0,0 +1,126 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import Tuple + +import numpy as np +import pandas as pd + +from validmind import RawData, tags, tasks +from validmind.errors import SkipTestError +from validmind.vm_models import VMModel + +from .statsutils import INTERCEPT_NAMES, ols_results + + +@tags("model_diagnosis", "robustness", "statsmodels") +@tasks("regression") +def CoefficientStability( + model: VMModel, + n_bootstrap: int = 500, + confidence_level: float = 0.95, + sign_stability_threshold: float = 0.95, + seed: int = 42, +) -> Tuple[pd.DataFrame, bool, RawData]: + """ + Assesses how stable a linear regression model's coefficients are under resampling of + the fitting data, using bootstrap confidence intervals and sign stability. + + ### Purpose + + A coefficient is only useful if a similar sample would give a similar value. Analytical + confidence intervals rely on the model's assumptions (normal, constant-variance errors); + the bootstrap does not. Sign stability answers the question a business reader cares + about: would another sample even agree on the direction of the effect? + + ### Test Mechanism + + The rows of the fitting data are resampled with replacement `n_bootstrap` times (seeded + by `seed`), and the model is re-estimated on each resample. For each coefficient the test + reports the original estimate, the analytical confidence interval, the bootstrap + percentile interval at `confidence_level`, the ratio of their widths, and the share of + resamples in which the coefficient keeps the sign of the original estimate. Resamples + that cannot estimate every coefficient (a rare dummy with no rows) are skipped. + + The test passes when every coefficient except the intercept keeps its sign in at least + `sign_stability_threshold` of resamples. + + ### Signs of High Risk + + - Sign stability below the threshold: the direction of the effect is not established. + - Bootstrap intervals much wider than analytical ones, suggesting the model's assumptions + understate the uncertainty. + + ### Strengths + + - Assumption-light: does not rely on normal or constant-variance errors. + - Reports uncertainty in terms a business reader can act on (direction of effect). + + ### Limitations + + - Requires a statsmodels OLS model. + - Resampling rows assumes observations are independent; it understates uncertainty for + clustered or time-dependent data. + - Coefficients of collinear regressors are legitimately unstable; read together with the + VIF test. + """ + results = ols_results(model, "CoefficientStability") + exog = np.asarray(results.model.exog, dtype=float) + endog = np.asarray(results.model.endog, dtype=float) + names = list(results.model.exog_names) + estimate = np.asarray(results.params, dtype=float) + + rng = np.random.default_rng(seed) + n = len(endog) + draws = [] + for _ in range(n_bootstrap): + idx = rng.integers(0, n, n) + # a resample missing every row of a rare dummy cannot estimate it; lstsq would + # report 0, which reads as a sign flip + if np.linalg.matrix_rank(exog[idx]) == len(names): + draws.append(np.linalg.lstsq(exog[idx], endog[idx], rcond=None)[0]) + if len(draws) < n_bootstrap / 2: + raise SkipTestError( + f"CoefficientStability: only {len(draws)} of {n_bootstrap} resamples could " + "estimate every coefficient; a regressor is too rare to bootstrap" + ) + draws = np.array(draws) + + alpha = 1 - confidence_level + lower, upper = np.quantile(draws, [alpha / 2, 1 - alpha / 2], axis=0) + analytical = np.asarray(results.conf_int(alpha=alpha), dtype=float) + sign_stability = (np.sign(draws) == np.sign(estimate)).mean(axis=0) + + pct = f"{confidence_level:.0%}" + table = pd.DataFrame( + { + "Coefficient": names, + "Estimate": estimate, + f"Analytical {pct} CI Lower": analytical[:, 0], + f"Analytical {pct} CI Upper": analytical[:, 1], + f"Bootstrap {pct} CI Lower": lower, + f"Bootstrap {pct} CI Upper": upper, + "CI Width Ratio (Bootstrap / Analytical)": (upper - lower) + / (analytical[:, 1] - analytical[:, 0]), + "Sign Stability": sign_stability, + } + ) + is_intercept = table["Coefficient"].isin(INTERCEPT_NAMES) + table["Pass/Fail"] = np.where( + is_intercept, + "N/A", + np.where(sign_stability >= sign_stability_threshold, "Pass", "Fail"), + ) + + passed = bool((table.loc[~is_intercept, "Pass/Fail"] == "Pass").all()) + + return ( + table.round(6), + passed, + RawData( + bootstrap_coefficients=pd.DataFrame(draws, columns=names), + rank_deficient_draws=n_bootstrap - len(draws), + model=model.input_id, + ), + ) diff --git a/validmind/tests/model_validation/statsmodels/ExpectedCoefficientSigns.py b/validmind/tests/model_validation/statsmodels/ExpectedCoefficientSigns.py new file mode 100644 index 000000000..588e59183 --- /dev/null +++ b/validmind/tests/model_validation/statsmodels/ExpectedCoefficientSigns.py @@ -0,0 +1,116 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import Dict, Tuple, Union + +import pandas as pd + +from validmind import RawData, tags, tasks +from validmind.vm_models import VMModel + +from .statsutils import ols_results + +_SIGNS = {"+": 1, "positive": 1, 1: 1, "-": -1, "negative": -1, -1: -1} + + +@tags("model_diagnosis", "conceptual_soundness", "statsmodels") +@tasks("regression") +def ExpectedCoefficientSigns( + model: VMModel, + expected_signs: Dict[str, Union[str, int]], + significance_level: float = 0.05, +) -> Tuple[pd.DataFrame, bool, RawData]: + """ + Checks that each coefficient of a linear regression model has the sign that business + knowledge or economic theory expects. + + ### Purpose + + Conceptual soundness requires that a model's effects make sense: higher income should + not raise predicted default, a higher price should not raise predicted demand. A + coefficient with the wrong sign usually points to multicollinearity, an omitted + variable, or a data problem — and it undermines the model's use for any decision that + relies on its explanations, even when its predictions are accurate. + + ### Test Mechanism + + The validator supplies `expected_signs`, mapping coefficient names (as they appear in + the model, for example `"Income"` or `"C(Region)[T.West]"`) to `"+"` / `"-"` (or + `"positive"` / `"negative"`, `1` / `-1`). For each listed coefficient the test reports + the estimate, its p-value and its actual sign: + + - **Pass:** the sign matches. + - **Fail:** the sign is opposite and the coefficient is significant at + `significance_level` — the data contradicts the expectation. + - **Fail (not significant):** the sign is opposite but the coefficient is not + significant — the data does not support the expected effect either. + + Coefficients not listed are not assessed. The test passes when every listed coefficient + passes. + + ### Signs of High Risk + + - A significant coefficient with the opposite sign to the expectation. + - Several sign failures among collinear regressors. + + ### Strengths + + - Makes the expected direction of each effect explicit and auditable. + - Distinguishes a contradicted expectation from an unsupported one. + + ### Limitations + + - Requires a statsmodels OLS model. + - Only as good as the expectations supplied; it does not judge magnitudes. + """ + if not expected_signs: + raise ValueError("expected_signs must name at least one coefficient") + results = ols_results(model, "ExpectedCoefficientSigns") + # a model fitted on numpy arrays has array params, named x1, x2, ... by statsmodels + names = results.model.exog_names + params = pd.Series(results.params, index=names) + pvalues = pd.Series(results.pvalues, index=names) + + unknown = [name for name in expected_signs if name not in params.index] + if unknown: + raise ValueError( + f"expected_signs names not in the model: {unknown}. " + f"Model coefficients: {list(params.index)}" + ) + + rows = [] + for name, expected in expected_signs.items(): + key = expected.lower() if isinstance(expected, str) else expected + if key not in _SIGNS: + raise ValueError( + f"Expected sign for {name!r} must be '+', '-', 'positive', " + f"'negative', 1 or -1, got {expected!r}" + ) + estimate = float(params[name]) + p_value = float(pvalues[name]) + matches = (estimate > 0) == (_SIGNS[key] > 0) + if matches: + result = "Pass" + elif p_value < significance_level: + result = "Fail" + else: + result = "Fail (not significant)" + rows.append( + { + "Coefficient": name, + "Expected Sign": "+" if _SIGNS[key] > 0 else "-", + "Estimate": round(estimate, 6), + "Actual Sign": "+" if estimate > 0 else "-", + "p-value": round(p_value, 6), + "Result": result, + } + ) + + table = pd.DataFrame(rows) + + return ( + table, + bool((table["Result"] == "Pass").all()), + RawData(expected_signs=dict(expected_signs), model=model.input_id), + ) diff --git a/validmind/tests/model_validation/statsmodels/InfluentialObservations.py b/validmind/tests/model_validation/statsmodels/InfluentialObservations.py new file mode 100644 index 000000000..721c9fff9 --- /dev/null +++ b/validmind/tests/model_validation/statsmodels/InfluentialObservations.py @@ -0,0 +1,200 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import Dict, Optional, Tuple + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +from validmind import RawData, tags, tasks +from validmind.vm_models import VMModel + +from .statsutils import ols_results, row_labels + + +def _externally_studentized(results, leverage): + """ + Externally studentized residuals in closed form. + + Equal to statsmodels' `resid_studentized_external`, which refits the model once per + observation and takes minutes on a few thousand rows. + """ + resid = np.asarray(results.resid, dtype=float) + dof = results.df_resid + sse = resid**2 / (1 - leverage) + sigma_without_i = np.sqrt((dof * results.scale - sse) / (dof - 1)) + return resid / (sigma_without_i * np.sqrt(1 - leverage)) + + +@tags("model_diagnosis", "outliers", "visualization", "statsmodels") +@tasks("regression") +def InfluentialObservations( + model: VMModel, + cooks_threshold: Optional[float] = None, + leverage_threshold: Optional[float] = None, + studentized_threshold: float = 3.0, + severe_cooks_threshold: float = 1.0, + top_n: int = 10, +) -> Tuple[Dict[str, pd.DataFrame], go.Figure, bool, RawData]: + """ + Identifies observations that individually drive a linear regression model's estimates, + using Cook's distance, leverage and studentized residuals, and shows how much the + coefficients move when the most influential observations are removed. + + ### Purpose + + Ordinary least squares weights every observation by its squared error, so a handful of + unusual rows — data entry errors, a different population, genuine extremes — can pull the + coefficients far from what the rest of the data supports. A validator needs to know + whether the model is a description of the portfolio or of a few rows. + + ### Test Mechanism + + For every observation in the fitting data the test computes: + + - **Leverage** (hat value): how unusual the observation's regressors are. Flagged above + `leverage_threshold` (default `2p/n`, p = number of coefficients). + - **Studentized residual**: how unusual its target is given the model. Flagged above + `studentized_threshold` in absolute value (default 3). + - **Cook's distance**: the combined effect of leverage and residual on all fitted values. + Flagged above `cooks_threshold` (default `4/n`); values above `severe_cooks_threshold` + (default 1) mean a single observation moves the fit materially. + + The model is then re-fitted without the severe observations, and each coefficient's + change is reported. The influence plot shows leverage against studentized residuals, + with marker size proportional to Cook's distance. + + The test passes when no observation exceeds `severe_cooks_threshold`. + + ### Signs of High Risk + + - Any observation with Cook's distance above 1. + - Coefficients that change sign or materially in size when a few observations are removed. + - Influential observations with implausible values (often data errors). + + ### Strengths + + - Pinpoints the specific rows to investigate, by their index in the fitting data. + - Quantifies their impact on each coefficient, not just on fit. + + ### Limitations + + - Requires a statsmodels OLS model. + - The `4/n` and `2p/n` thresholds flag many observations in large samples; they are + reported as counts for context, and only the severe threshold decides pass/fail. + - Influence measures assess one observation at a time and can miss a cluster of + observations that mask each other. + """ + results = ols_results(model, "InfluentialObservations") + n = int(results.nobs) + p = len(results.params) + cooks_threshold = cooks_threshold if cooks_threshold is not None else 4 / n + leverage_threshold = ( + leverage_threshold if leverage_threshold is not None else 2 * p / n + ) + + influence = results.get_influence() + cooks = np.asarray(influence.cooks_distance[0]) + leverage = np.asarray(influence.hat_matrix_diag) + studentized = _externally_studentized(results, leverage) + + measures = pd.DataFrame( + { + "Observation": row_labels(results), + "Cook's Distance": cooks, + "Leverage": leverage, + "Studentized Residual": studentized, + } + ) + + summary_df = pd.DataFrame( + [ + { + "Measure": "Cook's distance", + "Threshold": round(cooks_threshold, 6), + "Observations Above": int((cooks > cooks_threshold).sum()), + }, + { + "Measure": "Cook's distance (severe)", + "Threshold": severe_cooks_threshold, + "Observations Above": int((cooks > severe_cooks_threshold).sum()), + }, + { + "Measure": "Leverage", + "Threshold": round(leverage_threshold, 6), + "Observations Above": int((leverage > leverage_threshold).sum()), + }, + { + "Measure": "|Studentized residual|", + "Threshold": studentized_threshold, + "Observations Above": int( + (np.abs(studentized) > studentized_threshold).sum() + ), + }, + ] + ) + + top_df = ( + measures.sort_values("Cook's Distance", ascending=False) + .head(top_n) + .round(4) + .reset_index(drop=True) + ) + + severe = cooks > severe_cooks_threshold + if severe.any(): + keep = ~severe + refit = type(results.model)( + results.model.endog[keep], results.model.exog[keep] + ).fit() + impact_df = pd.DataFrame( + { + "Coefficient": results.model.exog_names, + "All Observations": results.params.values, + f"Without {int(severe.sum())} Severe": refit.params, + } + ) + impact_df["Change"] = ( + impact_df[f"Without {int(severe.sum())} Severe"] + - impact_df["All Observations"] + ) + impact_df = impact_df.round(4) + else: + impact_df = pd.DataFrame( + [{"Result": "No observation exceeds the severe Cook's distance threshold"}] + ) + + fig = go.Figure( + go.Scattergl( + x=leverage, + y=studentized, + mode="markers", + text=[f"Observation {o}" for o in measures["Observation"]], + marker=dict( + size=4 + 30 * np.sqrt(cooks / max(cooks.max(), 1e-12)), + color=np.where(cooks > cooks_threshold, "#c62828", "#5c6bc0"), + opacity=0.6, + ), + ) + ) + fig.add_vline(x=leverage_threshold, line_dash="dash", line_color="#757575") + for y in (studentized_threshold, -studentized_threshold): + fig.add_hline(y=y, line_dash="dash", line_color="#757575") + fig.update_layout( + title=f"Influence Plot — {model.input_id} (marker size ∝ Cook's distance)", + xaxis_title="Leverage", + yaxis_title="Studentized Residual", + ) + + return ( + { + "Influence Summary": summary_df, + f"Top {top_n} Observations by Cook's Distance": top_df, + "Coefficient Impact of Severe Observations": impact_df, + }, + fig, + bool(not severe.any()), + RawData(influence_measures=measures, model=model.input_id), + ) diff --git a/validmind/tests/model_validation/statsmodels/LinearitySpecification.py b/validmind/tests/model_validation/statsmodels/LinearitySpecification.py new file mode 100644 index 000000000..ac4c18ce5 --- /dev/null +++ b/validmind/tests/model_validation/statsmodels/LinearitySpecification.py @@ -0,0 +1,189 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +import math +from typing import Tuple + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from plotly.subplots import make_subplots + +from validmind import RawData, tags, tasks +from validmind.errors import MissingDependencyError +from validmind.vm_models import VMModel + +from .statsutils import INTERCEPT_NAMES, ols_results + +try: + from statsmodels.nonparametric.smoothers_lowess import lowess + from statsmodels.stats.diagnostic import linear_reset +except ImportError as e: + if "statsmodels" in str(e): + raise MissingDependencyError( + "Missing required package `statsmodels` for LinearitySpecification. " + "Please run `pip install validmind[stats]` to use statistical tests", + required_dependencies=["statsmodels"], + extra="stats", + ) from e + + raise e + + +@tags("model_diagnosis", "specification", "visualization", "statsmodels") +@tasks("regression") +def LinearitySpecification( + model: VMModel, + reset_power: int = 3, + significance_level: float = 0.05, + max_points: int = 2000, + seed: int = 42, +) -> Tuple[pd.DataFrame, go.Figure, bool, RawData]: + """ + Tests whether a linear regression model's functional form is adequate, using Ramsey's + RESET test and partial-residual plots for each continuous regressor. + + ### Purpose + + A linear model assumes each regressor affects the target along a straight line. When the + true relationship curves — a diminishing effect, a threshold, an interaction — the + coefficients describe an average slope that is wrong across much of the range, and + predictions are biased in a systematic, feature-dependent way. This test checks the + functional form as a whole and shows which regressor is responsible. + + ### Test Mechanism + + **RESET:** the model is re-estimated with powers of its fitted values (up to + `reset_power`) added as regressors, and an F-test checks whether they add explanatory + power. A p-value below `significance_level` rejects the linear specification. + + **Partial-residual (component-plus-residual) plots:** for every continuous regressor j, + `residual + coefficient_j * x_j` is plotted against `x_j` with a LOWESS smoother. If the + specification is right for that regressor, the smoother follows a straight line; a curve + shows the shape the model is missing. Binary and dummy columns are skipped. At most + `max_points` points per plot are drawn (sampled with `seed`); the smoother uses all data. + + The test passes when RESET does not reject. + + ### Signs of High Risk + + - RESET p-value below the significance level. + - A partial-residual smoother that bends, flattens or changes direction across a + regressor's range. + + ### Strengths + + - Combines a formal test of the whole specification with a per-regressor diagnosis. + - Partial-residual plots suggest the fix (for example a log or a spline term). + + ### Limitations + + - Requires a statsmodels OLS model. + - RESET also rejects for reasons other than a missing curve in one regressor — for example + a target bounded at zero — so read it together with the plots. + - In large samples RESET rejects small, immaterial departures from linearity. + """ + results = ols_results(model, "LinearitySpecification") + + reset = linear_reset(results, power=reset_power, use_f=True) + p_value = float(reset.pvalue) + passed = bool(p_value >= significance_level) + + reset_df = pd.DataFrame( + [ + { + "Test": f"Ramsey RESET (powers 2..{reset_power} of fitted values)", + "F Statistic": round(float(reset.fvalue), 4), + "p-value": round(p_value, 6), + "Significance Level": significance_level, + "Pass/Fail": "Pass" if passed else "Fail", + } + ] + ) + + exog = np.asarray(results.model.exog, dtype=float) + names = list(results.model.exog_names) + resid = np.asarray(results.resid, dtype=float) + params = np.asarray(results.params, dtype=float) + + continuous = [ + i + for i, name in enumerate(names) + if name not in INTERCEPT_NAMES and len(np.unique(exog[:, i])) > 2 + ] + + rng = np.random.default_rng(seed) + shown = ( + rng.choice(len(resid), max_points, replace=False) + if len(resid) > max_points + else np.arange(len(resid)) + ) + + n_cols = min(3, max(1, len(continuous))) + n_rows = max(1, math.ceil(len(continuous) / n_cols)) + fig = make_subplots( + rows=n_rows, + cols=n_cols, + subplot_titles=[names[i] for i in continuous], + ) + smoothers = {} + for k, i in enumerate(continuous): + x = exog[:, i] + partial_residual = resid + params[i] * x + # many tied values (a count with few levels) leave some local windows with no + # spread in x; lowess divides by zero there and still returns a finite smooth + with np.errstate(divide="ignore", invalid="ignore"): + smooth = lowess(partial_residual, x, frac=0.3, return_sorted=True) + smoothers[names[i]] = smooth + row, col = k // n_cols + 1, k % n_cols + 1 + fig.add_trace( + go.Scattergl( + x=x[shown], + y=partial_residual[shown], + mode="markers", + marker=dict(size=3, opacity=0.3, color="#5c6bc0"), + showlegend=False, + ), + row=row, + col=col, + ) + fig.add_trace( + go.Scatter( + x=x[[x.argmin(), x.argmax()]], + y=params[i] * x[[x.argmin(), x.argmax()]] + resid.mean(), + mode="lines", + line=dict(color="#757575", dash="dash"), + name="Linear fit", + showlegend=k == 0, + ), + row=row, + col=col, + ) + fig.add_trace( + go.Scatter( + x=smooth[:, 0], + y=smooth[:, 1], + mode="lines", + line=dict(color="#d81b60", width=2), + name="LOWESS", + showlegend=k == 0, + ), + row=row, + col=col, + ) + fig.update_layout( + title=f"Partial-Residual Plots — {model.input_id}", + height=320 * n_rows, + ) + + return ( + reset_df, + fig, + passed, + RawData( + reset_p_value=p_value, + lowess=smoothers, + model=model.input_id, + ), + ) diff --git a/validmind/tests/model_validation/statsmodels/PredictionIntervals.py b/validmind/tests/model_validation/statsmodels/PredictionIntervals.py new file mode 100644 index 000000000..21ac57f13 --- /dev/null +++ b/validmind/tests/model_validation/statsmodels/PredictionIntervals.py @@ -0,0 +1,245 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import Dict, Tuple + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +from validmind import RawData, tags, tasks +from validmind.errors import SkipTestError +from validmind.vm_models import VMDataset, VMModel + +from .statsutils import design_data, ols_results + + +@tags("model_performance", "uncertainty", "visualization", "statsmodels") +@tasks("regression") +def PredictionIntervals( + dataset: VMDataset, + model: VMModel, + confidence_level: float = 0.95, + coverage_tolerance: float = 0.02, + n_bins: int = 10, + max_points: int = 1000, + seed: int = 42, +) -> Tuple[Dict[str, pd.DataFrame], go.Figure, go.Figure, bool, RawData]: + """ + Evaluates whether a linear regression model's prediction intervals are honest, by + measuring how many actual values fall inside them on a dataset. + + ### Purpose + + A prediction is only as useful as the statement of its uncertainty. A 95% prediction + interval claims that 95% of new observations will fall inside it; if only 80% do, every + decision that relies on the interval — a limit, a reserve, a stress buffer — is + under-protected. Backtesting coverage on held-out or out-of-time data is the direct test + of that claim, and coverage by prediction range shows where it breaks. + + ### Test Mechanism + + For every row of the dataset, the model's prediction interval at `confidence_level` is + computed from the fitted model (for OLS: the prediction ± the t-quantile times the + standard error of a new observation, which combines parameter and residual + uncertainty). The test reports: + + - **Coverage:** the share of actual values inside their interval, with the shares below + and above it, and the average interval width. + - **Coverage by prediction bin:** coverage in `n_bins` quantile bins of the prediction. + + Two plots: coverage by bin against the nominal level, and actual values over the + prediction band for a sample of `max_points` observations (seeded by `seed`), sorted by + prediction. + + The test passes when overall coverage is at least `confidence_level - + coverage_tolerance`. + + ### Signs of High Risk + + - Coverage well below the nominal level, especially out of time. + - Misses concentrated on one side (all above, or all below): the model is biased, not + just uncertain. + - Bins with much lower coverage than others: the intervals are too narrow where the + errors are largest (heteroscedasticity). + + ### Strengths + + - Tests the uncertainty statement directly, not the assumptions behind it. + - Locates the failure by prediction range and by direction. + + ### Limitations + + - Requires a statsmodels OLS model. + - On the data the model was fitted on, coverage is close to nominal by construction; + use held-out or out-of-time data. + - Coverage near nominal overall can hide over-coverage in some bins and under-coverage + in others; read the bins. + """ + results = ols_results(model, "PredictionIntervals") + alpha = 1 - confidence_level + + # positions, not labels: a shuffled or duplicated index must not misalign rows + df = dataset.df.reset_index(drop=True) + if getattr(results.model, "formula", None) is not None: + from patsy import dmatrix + + # drops rows with a missing regressor but, unlike get_prediction's own + # transform, keeps the positions of the rows it keeps + exog = dmatrix(results.model.data.design_info, df, return_type="dataframe") + prediction = results.get_prediction(exog, transform=False) + else: + exog = design_data(results, df, dataset.feature_columns) + prediction = results.get_prediction(exog) + frame = prediction.summary_frame(alpha=alpha).set_axis(exog.index).reindex(df.index) + + columns = ["mean", "obs_ci_lower", "obs_ci_upper"] + actual = np.asarray(dataset.y, dtype=float) + # a NaN bound would count the row as inside the interval + valid = np.isfinite(frame[columns].to_numpy(dtype=float)).all(axis=1) & np.isfinite( + actual + ) + if not valid.any(): + raise SkipTestError("PredictionIntervals found no rows without missing values") + excluded = int((~valid).sum()) + predicted, lower, upper = ( + frame[column].to_numpy(dtype=float)[valid] for column in columns + ) + actual = actual[valid] + + below, above = actual < lower, actual > upper + inside = ~(below | above) + coverage = float(inside.mean()) + + coverage_df = pd.DataFrame( + [ + { + "Nominal Level": confidence_level, + "Observations": len(actual), + "Excluded (Missing)": excluded, + "Coverage": coverage, + "Below Interval": float(below.mean()), + "Above Interval": float(above.mean()), + "Mean Interval Width": float((upper - lower).mean()), + "Pass/Fail": ( + "Pass" + if coverage >= confidence_level - coverage_tolerance + else "Fail" + ), + } + ] + ) + + bins = pd.qcut(predicted, q=n_bins, duplicates="drop") + by_bin = ( + pd.DataFrame( + { + "bin": bins, + "predicted": predicted, + "inside": inside, + "below": below, + "above": above, + } + ) + .groupby("bin", observed=True) + .agg( + Count=("inside", "size"), + Mean_Prediction=("predicted", "mean"), + Coverage=("inside", "mean"), + Below=("below", "mean"), + Above=("above", "mean"), + ) + .reset_index(drop=True) + .rename( + columns={ + "Mean_Prediction": "Mean Prediction", + "Below": "Below Interval", + "Above": "Above Interval", + } + ) + ) + + coverage_fig = go.Figure( + go.Bar( + x=[f"{p:,.0f}" for p in by_bin["Mean Prediction"]], + y=by_bin["Coverage"], + marker_color=np.where( + by_bin["Coverage"] >= confidence_level - coverage_tolerance, + "#2e7d32", + "#c62828", + ), + ) + ) + coverage_fig.add_hline( + y=confidence_level, + line_dash="dash", + annotation_text=f"Nominal {confidence_level:.0%}", + ) + coverage_fig.update_layout( + title=f"Prediction Interval Coverage by Prediction Bin — {model.input_id} on {dataset.input_id}", + xaxis_title="Mean prediction in bin", + yaxis_title="Coverage", + yaxis_range=[0, 1.05], + ) + + rng = np.random.default_rng(seed) + shown = ( + np.sort(rng.choice(len(actual), max_points, replace=False)) + if len(actual) > max_points + else np.arange(len(actual)) + ) + order = shown[np.argsort(predicted[shown])] + rank = np.arange(len(order)) + band_fig = go.Figure() + band_fig.add_trace( + go.Scatter( + x=np.concatenate([rank, rank[::-1]]), + y=np.concatenate([upper[order], lower[order][::-1]]), + fill="toself", + fillcolor="rgba(92, 107, 192, 0.2)", + line=dict(width=0), + name=f"{confidence_level:.0%} prediction interval", + ) + ) + band_fig.add_trace( + go.Scatter( + x=rank, + y=predicted[order], + mode="lines", + line=dict(color="#3949ab"), + name="Prediction", + ) + ) + band_fig.add_trace( + go.Scattergl( + x=rank, + y=actual[order], + mode="markers", + marker=dict(size=4, color=np.where(inside[order], "#757575", "#c62828")), + name="Actual (red = outside)", + ) + ) + band_fig.update_layout( + title=f"Actual Values and Prediction Intervals — {model.input_id} on {dataset.input_id}", + xaxis_title="Observations sorted by prediction", + yaxis_title=dataset.target_column, + ) + + return ( + { + "Prediction Interval Coverage": coverage_df.round(4), + "Coverage by Prediction Bin": by_bin.round(4), + }, + coverage_fig, + band_fig, + bool(coverage >= confidence_level - coverage_tolerance), + RawData( + lower=lower, + upper=upper, + predicted=predicted, + excluded_missing=excluded, + model=model.input_id, + dataset=dataset.input_id, + ), + ) diff --git a/validmind/tests/model_validation/statsmodels/RegressionCoeffs.py b/validmind/tests/model_validation/statsmodels/RegressionCoeffs.py index 3ecf0cf31..4f0f4529d 100644 --- a/validmind/tests/model_validation/statsmodels/RegressionCoeffs.py +++ b/validmind/tests/model_validation/statsmodels/RegressionCoeffs.py @@ -9,43 +9,32 @@ import plotly.graph_objects as go from validmind import RawData, tags, tasks -from validmind.errors import MissingDependencyError, SkipTestError - -try: - from scipy import stats -except ImportError as e: - if "scipy" in str(e): - raise MissingDependencyError( - "Missing required package `scipy` for RegressionCoeffs. " - "Please run `pip install validmind[stats]` to use statistical tests", - required_dependencies=["scipy"], - extra="stats", - ) from e - - raise e +from validmind.errors import SkipTestError from validmind.vm_models import VMModel @tags("tabular_data", "visualization", "model_training") @tasks("regression") -def RegressionCoeffs(model: VMModel) -> Tuple[go.Figure, RawData, pd.DataFrame]: +def RegressionCoeffs( + model: VMModel, confidence_level: float = 0.95 +) -> Tuple[go.Figure, RawData, pd.DataFrame]: """ Assesses the significance and uncertainty of predictor variables in a regression model through visualization of - coefficients and their 95% confidence intervals. + coefficients and their confidence intervals. ### Purpose - The `RegressionCoeffs` metric visualizes the estimated regression coefficients alongside their 95% confidence intervals, + The `RegressionCoeffs` metric visualizes the estimated regression coefficients alongside their confidence intervals (95% by default), providing insights into the impact and significance of predictor variables on the response variable. This visualization helps to understand the variability and uncertainty in the model's estimates, aiding in the evaluation of the significance of each predictor. ### Test Mechanism - The function operates by extracting the estimated coefficients and their standard errors from the regression model. - Using these, it calculates the confidence intervals at a 95% confidence level, which indicates the range within which - the true coefficient value is expected to fall 95% of the time. The confidence intervals are computed using the - Z-value associated with the 95% confidence level. The coefficients and their confidence intervals are then visualized + The function operates by extracting the estimated coefficients and their confidence intervals at `confidence_level` + (default 95%) from the fitted statsmodels model, which uses the model's own reference distribution (Student's t for + OLS). The confidence interval indicates the range within which the true coefficient value is expected to fall with + that confidence. The coefficients and their confidence intervals are then visualized in a bar plot. The x-axis represents the predictor variables, the y-axis represents the estimated coefficients, and the error bars depict the confidence intervals. @@ -77,16 +66,18 @@ def RegressionCoeffs(model: VMModel) -> Tuple[go.Figure, RawData, pd.DataFrame]: if model.library != "statsmodels": raise SkipTestError("Only statsmodels are supported for this metric") - # Extract estimated coefficients and standard errors coefficients = model.regression_coefficients() - coef = pd.to_numeric(coefficients["coef"]) - std_err = pd.to_numeric(coefficients["std err"]) - - # Calculate confidence intervals - confidence_level = 0.95 # 95% confidence interval - z_value = stats.norm.ppf((1 + confidence_level) / 2) # Calculate Z-value - lower_ci = coef - z_value * std_err - upper_ci = coef + z_value * std_err + coef = pd.Series(model.model.params).to_numpy(dtype=float) + std_err = pd.Series(model.model.bse).to_numpy(dtype=float) + intervals = pd.DataFrame(model.model.conf_int(alpha=1 - confidence_level)) + lower_ci = intervals.iloc[:, 0].to_numpy(dtype=float) + upper_ci = intervals.iloc[:, 1].to_numpy(dtype=float) + + # the summary table's interval columns are always 95%; show the chart's level instead + alpha = 1 - confidence_level + coefficients = coefficients.drop(columns=list(coefficients.columns[-2:])) + coefficients[f"[{alpha / 2:g}"] = [f"{v:.3f}" for v in lower_ci] + coefficients[f"{1 - alpha / 2:g}]"] = [f"{v:.3f}" for v in upper_ci] # Create a bar plot with confidence intervals fig = go.Figure() @@ -99,15 +90,16 @@ def RegressionCoeffs(model: VMModel) -> Tuple[go.Figure, RawData, pd.DataFrame]: error_y=dict( type="data", symmetric=False, - arrayminus=lower_ci, - array=upper_ci, + # error bars are distances from the coefficient, not bounds + arrayminus=coef - lower_ci, + array=upper_ci - coef, visible=True, ), ) ) fig.update_layout( - title=f"{model.input_id} Coefficients with Confidence Intervals", + title=f"{model.input_id} Coefficients with {confidence_level:.0%} Confidence Intervals", xaxis_title="Predictor Variables", yaxis_title="Coefficients", ) diff --git a/validmind/tests/model_validation/statsmodels/RegressionModelSensitivityPlot.py b/validmind/tests/model_validation/statsmodels/RegressionModelSensitivityPlot.py index e0c949a5a..d47329569 100644 --- a/validmind/tests/model_validation/statsmodels/RegressionModelSensitivityPlot.py +++ b/validmind/tests/model_validation/statsmodels/RegressionModelSensitivityPlot.py @@ -23,7 +23,7 @@ def integrate_diff(series_diff, start_value): return series -@tags("senstivity_analysis", "visualization") +@tags("sensitivity_analysis", "visualization") @tasks("regression") def RegressionModelSensitivityPlot( dataset: VMDataset, diff --git a/validmind/tests/model_validation/statsmodels/RegressionModelSummary.py b/validmind/tests/model_validation/statsmodels/RegressionModelSummary.py index c1366219c..29e3dd55c 100644 --- a/validmind/tests/model_validation/statsmodels/RegressionModelSummary.py +++ b/validmind/tests/model_validation/statsmodels/RegressionModelSummary.py @@ -4,6 +4,7 @@ from typing import Dict, List, Tuple +import numpy as np from sklearn.metrics import mean_squared_error, r2_score from validmind import RawData, tags, tasks @@ -59,8 +60,8 @@ def RegressionModelSummary( "Adjusted R-Squared": adj_r2_score( y_true, y_pred, len(y_true), len(dataset.feature_columns) ), - "MSE": mean_squared_error(y_true, y_pred, squared=True), - "RMSE": mean_squared_error(y_true, y_pred, squared=False), + "MSE": mean_squared_error(y_true, y_pred), + "RMSE": np.sqrt(mean_squared_error(y_true, y_pred)), } ], RawData( y_true=y_true, y_pred=y_pred, model=model.input_id, dataset=dataset.input_id diff --git a/validmind/tests/model_validation/statsmodels/RobustStandardErrors.py b/validmind/tests/model_validation/statsmodels/RobustStandardErrors.py new file mode 100644 index 000000000..3a624b86b --- /dev/null +++ b/validmind/tests/model_validation/statsmodels/RobustStandardErrors.py @@ -0,0 +1,100 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import Tuple + +import numpy as np +import pandas as pd + +from validmind import RawData, tags, tasks +from validmind.vm_models import VMModel + +from .statsutils import ols_results + + +@tags("model_diagnosis", "statistical_test", "statsmodels") +@tasks("regression") +def RobustStandardErrors( + model: VMModel, + cov_type: str = "HC3", + significance_level: float = 0.05, +) -> Tuple[pd.DataFrame, bool, RawData]: + """ + Compares a linear regression model's ordinary standard errors and p-values with + heteroscedasticity-robust ones, and flags coefficients whose significance depends on + which is used. + + ### Purpose + + Ordinary least squares standard errors assume the error variance is the same for every + observation. When it is not (heteroscedasticity), they are wrong — usually too small — + and so are the p-values and confidence intervals built on them. Robust (sandwich) + standard errors remain valid without that assumption. If a coefficient is significant + only under the ordinary standard errors, the evidence for it is weaker than the model + summary suggests. + + ### Test Mechanism + + The model's covariance matrix is re-estimated with the robust estimator `cov_type` + (default `HC3`, which performs well in finite samples; `HC0`-`HC2` are also accepted). + For each coefficient the test reports both standard errors, their ratio, both p-values, + and whether the coefficient is significant at `significance_level` under each. + + The test passes when no coefficient changes significance. + + ### Signs of High Risk + + - A coefficient significant under ordinary standard errors but not under robust ones. + - Robust standard errors much larger than ordinary ones (ratio well above 1). + + ### Strengths + + - Directly measures how much the inference depends on the constant-variance assumption. + - Leaves the coefficients unchanged — only their uncertainty is re-assessed. + + ### Limitations + + - Requires a statsmodels OLS model. + - Robust standard errors correct inference, not bias: a misspecified model stays + misspecified. + - Does not account for clustered or autocorrelated errors. + """ + # refit: a model fitted with .fit(cov_type=...) already carries robust errors in bse + results = ols_results(model, "RobustStandardErrors").model.fit() + robust = results.get_robustcov_results(cov_type=cov_type) + + ols_se = np.asarray(results.bse, dtype=float) + robust_se = np.asarray(robust.bse, dtype=float) + ols_p = np.asarray(results.pvalues, dtype=float) + robust_p = np.asarray(robust.pvalues, dtype=float) + + table = pd.DataFrame( + { + "Coefficient": results.model.exog_names, + "Estimate": np.asarray(results.params, dtype=float), + "OLS Std. Error": ols_se, + f"{cov_type} Std. Error": robust_se, + "Ratio (Robust / OLS)": robust_se / ols_se, + "OLS p-value": ols_p, + f"{cov_type} p-value": robust_p, + "Significant (OLS)": ols_p < significance_level, + f"Significant ({cov_type})": robust_p < significance_level, + } + ) + table["Significance Changes"] = ( + table["Significant (OLS)"] != table[f"Significant ({cov_type})"] + ) + + passed = bool(not table["Significance Changes"].any()) + + return ( + table.round(6), + passed, + RawData( + ols_se=ols_se, + robust_se=robust_se, + cov_type=cov_type, + model=model.input_id, + ), + ) diff --git a/validmind/tests/model_validation/statsmodels/VarianceInflationFactor.py b/validmind/tests/model_validation/statsmodels/VarianceInflationFactor.py new file mode 100644 index 000000000..f9a0eee9a --- /dev/null +++ b/validmind/tests/model_validation/statsmodels/VarianceInflationFactor.py @@ -0,0 +1,160 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +from typing import Dict, Tuple + +import numpy as np +import pandas as pd +import plotly.graph_objects as go + +from validmind import RawData, tags, tasks +from validmind.errors import MissingDependencyError +from validmind.vm_models import VMModel + +from .statsutils import INTERCEPT_NAMES, ols_results + +try: + from statsmodels.stats.outliers_influence import variance_inflation_factor +except ImportError as e: + if "statsmodels" in str(e): + raise MissingDependencyError( + "Missing required package `statsmodels` for VarianceInflationFactor. " + "Please run `pip install validmind[stats]` to use statistical tests", + required_dependencies=["statsmodels"], + extra="stats", + ) from e + + raise e + + +@tags("model_diagnosis", "multicollinearity", "visualization", "statsmodels") +@tasks("regression") +def VarianceInflationFactor( + model: VMModel, + vif_threshold: float = 10.0, + moderate_vif_threshold: float = 5.0, + condition_number_threshold: float = 30.0, +) -> Tuple[Dict[str, pd.DataFrame], go.Figure, bool, RawData]: + """ + Detects multicollinearity among the regressors of a linear regression model using the + variance inflation factor (VIF) and the condition number of the design matrix. + + ### Purpose + + Multicollinearity — regressors that carry nearly the same information — inflates the + standard errors of the coefficients, makes their signs and sizes unstable, and makes the + contribution of each regressor impossible to separate. The model can still predict well, + which is why this needs a dedicated check: it is invisible in fit metrics such as R². + + ### Test Mechanism + + For each regressor j (excluding the intercept), the VIF is `1 / (1 - R²_j)`, where `R²_j` + is the R² of regressing that column on all other columns of the design matrix. A VIF of 5 + means the variance of the coefficient is five times what it would be with uncorrelated + regressors. Each regressor is labelled OK, Moderate (`>= moderate_vif_threshold`) or + Severe (`>= vif_threshold`). + + The condition number is computed on the design matrix with every column scaled to unit + length (Belsley's scaling), so it does not depend on the units of the regressors; values + above `condition_number_threshold` (30 by convention) indicate harmful collinearity. + + The test passes when no regressor reaches `vif_threshold` and the scaled condition number + is below `condition_number_threshold`. + + ### Signs of High Risk + + - One or more regressors with a VIF at or above 10. + - Pairs of regressors with similarly high VIFs, pointing to two variables that measure the + same thing. + - A scaled condition number above 30. + + ### Strengths + + - Quantifies multicollinearity per regressor, so the variables involved are identified. + - Independent of the units of the regressors (condition number is scaled). + - Works on the model's own design matrix, including transformed and dummy-coded terms. + + ### Limitations + + - Requires a statsmodels OLS model. + - Dummy columns of one categorical variable are assessed separately; a generalized VIF + would assess them together. + - VIF identifies collinearity but not which of the collinear variables to keep — that is a + modelling judgement. + """ + results = ols_results(model, "VarianceInflationFactor") + exog = np.asarray(results.model.exog, dtype=float) + names = list(results.model.exog_names) + + rows = [] + for i, name in enumerate(names): + if name in INTERCEPT_NAMES: + continue + # classify the value that is shown, so the status and the pass/fail agree + vif = round(float(variance_inflation_factor(exog, i)), 2) + if vif >= vif_threshold: + status = "Severe" + elif vif >= moderate_vif_threshold: + status = "Moderate" + else: + status = "OK" + rows.append({"Feature": name, "VIF": vif, "Status": status}) + + vif_df = pd.DataFrame(rows) + + scaled = exog / np.linalg.norm(exog, axis=0) + singular_values = np.linalg.svd(scaled, compute_uv=False) + condition_number = float(singular_values.max() / singular_values.min()) + + condition_df = pd.DataFrame( + [ + { + "Scaled Condition Number": round(condition_number, 2), + "Threshold": condition_number_threshold, + "Pass/Fail": ( + "Pass" if condition_number < condition_number_threshold else "Fail" + ), + } + ] + ) + + colors = {"OK": "#2e7d32", "Moderate": "#f9a825", "Severe": "#c62828"} + fig = go.Figure( + go.Bar( + x=vif_df["Feature"], + y=vif_df["VIF"], + marker_color=[colors[s] for s in vif_df["Status"]], + ) + ) + for threshold, label in [ + (moderate_vif_threshold, "Moderate"), + (vif_threshold, "Severe"), + ]: + fig.add_hline( + y=threshold, + line_dash="dash", + annotation_text=f"{label} ({threshold})", + annotation_position="top left", + ) + fig.update_layout( + title=f"Variance Inflation Factor — {model.input_id}", + xaxis_title="Regressor", + yaxis_title="VIF", + ) + + passed = bool( + (vif_df["VIF"] < vif_threshold).all() + and condition_number < condition_number_threshold + ) + + return ( + {"Variance Inflation Factor": vif_df, "Condition Number": condition_df}, + fig, + passed, + RawData( + vif=vif_df, + condition_number=condition_number, + model=model.input_id, + ), + ) diff --git a/validmind/tests/model_validation/statsmodels/statsutils.py b/validmind/tests/model_validation/statsmodels/statsutils.py index 18a1de896..acc1eb94b 100644 --- a/validmind/tests/model_validation/statsmodels/statsutils.py +++ b/validmind/tests/model_validation/statsmodels/statsutils.py @@ -2,7 +2,10 @@ # Refer to the LICENSE file in the root of this repository for details. # SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial +import re + import numpy as np +import pandas as pd from sklearn.metrics import r2_score @@ -15,3 +18,117 @@ def adj_r2_score( return 1 - (1 - r2_score(actual, predicted)) * (rowcount - 1) / ( rowcount - featurecount ) + + +INTERCEPT_NAMES = ("Intercept", "const") + + +def ols_results(model, test_name: str): + """ + Return the fitted statsmodels OLS results behind a VMModel. + + Works for models fitted with the formula API (`smf.ols`) and the array API + (`sm.OLS`), on a DataFrame or on numpy arrays. Raises SkipTestError for any other + model. + """ + from validmind.errors import SkipTestError + + results = getattr(model, "model", None) + fitted_model = getattr(results, "model", None) + + if getattr(model, "library", None) != "statsmodels" or fitted_model is None: + raise SkipTestError(f"{test_name} requires a fitted statsmodels OLS model") + + from statsmodels.regression.linear_model import OLS + + if not isinstance(fitted_model, OLS): + raise SkipTestError( + f"{test_name} supports statsmodels OLS models, " + f"not {type(fitted_model).__name__}" + ) + + return results + + +def row_labels(results): + """Row labels of the data the model was fitted on (positions if none).""" + labels = getattr(results.model.data, "row_labels", None) + return list(labels) if labels is not None else list(range(int(results.nobs))) + + +def design_data(results, df, feature_columns=None): + """ + Data to pass to `results.predict` / `results.get_prediction` for the rows of `df`. + + Formula models apply their own transformations to the original columns; array models + need their regressor columns in order, with the constant added back. An array model + fitted on numpy arrays names its regressors `x1, x2, ...`; those are taken to be + `feature_columns`, in the same order. + """ + from validmind.errors import SkipTestError + + if getattr(results.model, "formula", None) is not None: + return df + + names = list(results.model.exog_names) + regressors = [name for name in names if name not in INTERCEPT_NAMES] + if all(name in df.columns for name in regressors): + data = df.copy() + elif feature_columns is not None and len(feature_columns) == len(regressors): + data = pd.DataFrame( + { + name: df[column].to_numpy() + for name, column in zip(regressors, feature_columns) + }, + index=df.index, + ) + else: + raise SkipTestError( + f"The model's regressors {regressors} are not dataset columns, and the " + f"dataset has {len(feature_columns or [])} feature columns to match them " + "by position. Fit the model on a DataFrame, or set the dataset's " + "feature_columns in the order the model was fitted on." + ) + for name in names: + if name in INTERCEPT_NAMES: + data[name] = 1.0 + return data[names] + + +def predict_frame(model, df, feature_columns): + """ + Predictions of any regression VMModel for the rows of `df`. + + statsmodels models receive their design data (see `design_data`); every other model + receives the dataset's feature columns as a DataFrame. + """ + if getattr(model, "library", None) == "statsmodels": + results = model.model + return np.asarray( + results.predict(design_data(results, df, feature_columns)), dtype=float + ) + return np.asarray(model.predict(df[list(feature_columns)]), dtype=float) + + +def categorical_columns(model, columns): + """ + The `columns` a statsmodels formula treats as categorical, such as `cards` in + `C(cards)` or a string column. Empty for every other model. + """ + results = getattr(model, "model", None) + design_info = getattr( + getattr(getattr(results, "model", None), "data", None), "design_info", None + ) + if getattr(model, "library", None) != "statsmodels" or design_info is None: + return set() + + codes = [ + factor.code + for factor, info in design_info.factor_infos.items() + if info.type == "categorical" + ] + return { + column + for column in columns + if any(re.search(rf"\b{re.escape(column)}\b", code) for code in codes) + }