Upload 32 files
Browse files- Makefile +29 -0
- README.md +16 -19
- app.py +36 -4
- config/currency_rates.yaml +0 -4
- config/model_parameters.yaml +21 -0
- config/valid_categories.yaml +3 -7
- debug_prepare_features.py +10 -8
- diagnose_encoding.py +28 -22
- example_inference.py +8 -0
- guardrail_evaluation.py +261 -0
- models/model.pkl +2 -2
- pyproject.toml +16 -0
- src/infer.py +8 -0
- src/preprocessing.py +59 -14
- src/schema.py +2 -0
- src/train.py +149 -60
- test_feature_impact.py +191 -41
- test_fix.py +22 -17
- tests/__init__.py +0 -0
- tests/conftest.py +37 -0
- tests/test_infer.py +73 -0
- tests/test_preprocessing.py +140 -0
- tests/test_schema.py +75 -0
- uv.lock +521 -0
Makefile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.PHONY: lint format test coverage complexity maintainability audit security check all
|
| 2 |
+
|
| 3 |
+
lint:
|
| 4 |
+
uv run ruff check .
|
| 5 |
+
|
| 6 |
+
format:
|
| 7 |
+
uv run ruff format .
|
| 8 |
+
|
| 9 |
+
test:
|
| 10 |
+
uv run pytest
|
| 11 |
+
|
| 12 |
+
coverage:
|
| 13 |
+
uv run pytest --cov=src --cov-report=term-missing
|
| 14 |
+
|
| 15 |
+
complexity:
|
| 16 |
+
uv run radon cc . -a -s -nb
|
| 17 |
+
|
| 18 |
+
maintainability:
|
| 19 |
+
uv run radon mi . -s
|
| 20 |
+
|
| 21 |
+
audit:
|
| 22 |
+
uv run pip-audit
|
| 23 |
+
|
| 24 |
+
security:
|
| 25 |
+
uv run bandit -r . -x ./.venv
|
| 26 |
+
|
| 27 |
+
check: lint test complexity maintainability audit security
|
| 28 |
+
|
| 29 |
+
all: check
|
README.md
CHANGED
|
@@ -1,17 +1,3 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Developer Salary Prediction
|
| 3 |
-
emoji: 🚀
|
| 4 |
-
colorFrom: red
|
| 5 |
-
colorTo: red
|
| 6 |
-
sdk: docker
|
| 7 |
-
app_port: 8501
|
| 8 |
-
tags:
|
| 9 |
-
- streamlit
|
| 10 |
-
pinned: false
|
| 11 |
-
short_description: Developer salary prediction using 2025 Stackoverflow survey
|
| 12 |
-
license: apache-2.0
|
| 13 |
-
---
|
| 14 |
-
|
| 15 |
# Developer Salary Prediction
|
| 16 |
|
| 17 |
A minimal, local-first ML application that predicts developer salaries using Stack Overflow Developer Survey data. Built with Python, scikit-learn, Pydantic, and Streamlit.
|
|
@@ -44,7 +30,7 @@ Download the Stack Overflow Developer Survey CSV file:
|
|
| 44 |
data/survey_results_public.csv
|
| 45 |
```
|
| 46 |
|
| 47 |
-
**Required columns:** `Country`, `YearsCode`, `EdLevel`, `DevType`, `Industry`, `ConvertedCompYearly`
|
| 48 |
|
| 49 |
### 3. Train the Model
|
| 50 |
|
|
@@ -57,7 +43,7 @@ This will:
|
|
| 57 |
- Load and preprocess the survey data (with cardinality reduction)
|
| 58 |
- Train an XGBoost model with early stopping
|
| 59 |
- Save the model to `models/model.pkl`
|
| 60 |
-
- Generate `config/valid_categories.yaml` with valid country, education, developer type, and
|
| 61 |
|
| 62 |
### 4. Run the Streamlit App
|
| 63 |
|
|
@@ -74,9 +60,12 @@ The app will open in your browser at `http://localhost:8501`
|
|
| 74 |
Launch the Streamlit app and enter:
|
| 75 |
- **Country**: Developer's country
|
| 76 |
- **Years of Coding (Total)**: Total years coding including education
|
|
|
|
| 77 |
- **Education Level**: Highest degree completed
|
| 78 |
- **Developer Type**: Primary developer role
|
| 79 |
- **Industry**: Industry the developer works in
|
|
|
|
|
|
|
| 80 |
|
| 81 |
Click "Predict Salary" to see the estimated annual salary.
|
| 82 |
|
|
@@ -92,9 +81,12 @@ from src.infer import predict_salary
|
|
| 92 |
input_data = SalaryInput(
|
| 93 |
country="United States of America",
|
| 94 |
years_code=5.0,
|
|
|
|
| 95 |
education_level="Bachelor's degree (B.A., B.S., B.Eng., etc.)",
|
| 96 |
dev_type="Developer, full-stack",
|
| 97 |
-
industry="Software Development"
|
|
|
|
|
|
|
| 98 |
)
|
| 99 |
|
| 100 |
# Get prediction
|
|
@@ -118,6 +110,8 @@ The model validates inputs against actual training data categories:
|
|
| 118 |
- **Valid Education Levels**: Only education levels from training data (~9 levels)
|
| 119 |
- **Valid Developer Types**: Only developer types from training data (~20 types)
|
| 120 |
- **Valid Industries**: Only industries from training data (~15 industries)
|
|
|
|
|
|
|
| 121 |
|
| 122 |
The Streamlit app uses dropdown menus with only valid options. If you use the programmatic API with invalid values, you'll get a helpful error message pointing to the valid categories file.
|
| 123 |
|
|
@@ -130,9 +124,12 @@ from src.schema import SalaryInput
|
|
| 130 |
invalid_input = SalaryInput(
|
| 131 |
country="Japan", # Invalid!
|
| 132 |
years_code=5.0,
|
|
|
|
| 133 |
education_level="Bachelor's degree (B.A., B.S., B.Eng., etc.)",
|
| 134 |
dev_type="Developer, back-end",
|
| 135 |
-
industry="Software Development"
|
|
|
|
|
|
|
| 136 |
)
|
| 137 |
```
|
| 138 |
|
|
@@ -224,7 +221,7 @@ uv run python -m src.train
|
|
| 224 |
|
| 225 |
**Quick one-liner test:**
|
| 226 |
```bash
|
| 227 |
-
uv run python -c "from src.schema import SalaryInput; from src.infer import predict_salary; test = SalaryInput(country='United States of America', years_code=5.0, education_level='Bachelor'\''s degree (B.A., B.S., B.Eng., etc.)', dev_type='Developer, full-stack', industry='Software Development'); print(f'Prediction: \${predict_salary(test):,.0f}')"
|
| 228 |
```
|
| 229 |
|
| 230 |
**Or run the full example script:**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Developer Salary Prediction
|
| 2 |
|
| 3 |
A minimal, local-first ML application that predicts developer salaries using Stack Overflow Developer Survey data. Built with Python, scikit-learn, Pydantic, and Streamlit.
|
|
|
|
| 30 |
data/survey_results_public.csv
|
| 31 |
```
|
| 32 |
|
| 33 |
+
**Required columns:** `Country`, `YearsCode`, `WorkExp`, `EdLevel`, `DevType`, `Industry`, `Age`, `ICorPM`, `ConvertedCompYearly`
|
| 34 |
|
| 35 |
### 3. Train the Model
|
| 36 |
|
|
|
|
| 43 |
- Load and preprocess the survey data (with cardinality reduction)
|
| 44 |
- Train an XGBoost model with early stopping
|
| 45 |
- Save the model to `models/model.pkl`
|
| 46 |
+
- Generate `config/valid_categories.yaml` with valid country, education, developer type, industry, age, and IC/PM values
|
| 47 |
|
| 48 |
### 4. Run the Streamlit App
|
| 49 |
|
|
|
|
| 60 |
Launch the Streamlit app and enter:
|
| 61 |
- **Country**: Developer's country
|
| 62 |
- **Years of Coding (Total)**: Total years coding including education
|
| 63 |
+
- **Years of Professional Work Experience**: Years of professional work experience
|
| 64 |
- **Education Level**: Highest degree completed
|
| 65 |
- **Developer Type**: Primary developer role
|
| 66 |
- **Industry**: Industry the developer works in
|
| 67 |
+
- **Age**: Developer's age range
|
| 68 |
+
- **IC or PM**: Individual contributor or people manager
|
| 69 |
|
| 70 |
Click "Predict Salary" to see the estimated annual salary.
|
| 71 |
|
|
|
|
| 81 |
input_data = SalaryInput(
|
| 82 |
country="United States of America",
|
| 83 |
years_code=5.0,
|
| 84 |
+
work_exp=3.0,
|
| 85 |
education_level="Bachelor's degree (B.A., B.S., B.Eng., etc.)",
|
| 86 |
dev_type="Developer, full-stack",
|
| 87 |
+
industry="Software Development",
|
| 88 |
+
age="25-34 years old",
|
| 89 |
+
ic_or_pm="Individual contributor"
|
| 90 |
)
|
| 91 |
|
| 92 |
# Get prediction
|
|
|
|
| 110 |
- **Valid Education Levels**: Only education levels from training data (~9 levels)
|
| 111 |
- **Valid Developer Types**: Only developer types from training data (~20 types)
|
| 112 |
- **Valid Industries**: Only industries from training data (~15 industries)
|
| 113 |
+
- **Valid Age Ranges**: Only age ranges from training data (~7 ranges)
|
| 114 |
+
- **Valid IC/PM Values**: Only IC/PM values from training data (~3 values)
|
| 115 |
|
| 116 |
The Streamlit app uses dropdown menus with only valid options. If you use the programmatic API with invalid values, you'll get a helpful error message pointing to the valid categories file.
|
| 117 |
|
|
|
|
| 124 |
invalid_input = SalaryInput(
|
| 125 |
country="Japan", # Invalid!
|
| 126 |
years_code=5.0,
|
| 127 |
+
work_exp=3.0,
|
| 128 |
education_level="Bachelor's degree (B.A., B.S., B.Eng., etc.)",
|
| 129 |
dev_type="Developer, back-end",
|
| 130 |
+
industry="Software Development",
|
| 131 |
+
age="25-34 years old",
|
| 132 |
+
ic_or_pm="Individual contributor"
|
| 133 |
)
|
| 134 |
```
|
| 135 |
|
|
|
|
| 221 |
|
| 222 |
**Quick one-liner test:**
|
| 223 |
```bash
|
| 224 |
+
uv run python -c "from src.schema import SalaryInput; from src.infer import predict_salary; test = SalaryInput(country='United States of America', years_code=5.0, work_exp=3.0, education_level='Bachelor'\''s degree (B.A., B.S., B.Eng., etc.)', dev_type='Developer, full-stack', industry='Software Development', age='25-34 years old', ic_or_pm='Individual contributor'); print(f'Prediction: \${predict_salary(test):,.0f}')"
|
| 225 |
```
|
| 226 |
|
| 227 |
**Or run the full example script:**
|
app.py
CHANGED
|
@@ -32,6 +32,7 @@ with st.sidebar:
|
|
| 32 |
- Developer type
|
| 33 |
- Industry
|
| 34 |
- Age
|
|
|
|
| 35 |
"""
|
| 36 |
)
|
| 37 |
st.info("💡 Tip: Results are estimates based on survey averages.")
|
|
@@ -43,6 +44,7 @@ with st.sidebar:
|
|
| 43 |
st.write(f"**Developer Types:** {len(valid_categories['DevType'])} available")
|
| 44 |
st.write(f"**Industries:** {len(valid_categories['Industry'])} available")
|
| 45 |
st.write(f"**Age Ranges:** {len(valid_categories['Age'])} available")
|
|
|
|
| 46 |
st.caption("Only values from the training data are shown in the dropdowns.")
|
| 47 |
|
| 48 |
# Main input form
|
|
@@ -56,13 +58,35 @@ valid_education_levels = valid_categories["EdLevel"]
|
|
| 56 |
valid_dev_types = valid_categories["DevType"]
|
| 57 |
valid_industries = valid_categories["Industry"]
|
| 58 |
valid_ages = valid_categories["Age"]
|
|
|
|
| 59 |
|
| 60 |
# Set default values (if available)
|
| 61 |
-
default_country =
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
default_age = "25-34 years old" if "25-34 years old" in valid_ages else valid_ages[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
with col1:
|
| 68 |
country = st.selectbox(
|
|
@@ -119,6 +143,13 @@ age = st.selectbox(
|
|
| 119 |
help="Developer's age range",
|
| 120 |
)
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
# Prediction button
|
| 123 |
if st.button("🔮 Predict Salary", type="primary", use_container_width=True):
|
| 124 |
try:
|
|
@@ -131,6 +162,7 @@ if st.button("🔮 Predict Salary", type="primary", use_container_width=True):
|
|
| 131 |
dev_type=dev_type,
|
| 132 |
industry=industry,
|
| 133 |
age=age,
|
|
|
|
| 134 |
)
|
| 135 |
|
| 136 |
# Make prediction
|
|
|
|
| 32 |
- Developer type
|
| 33 |
- Industry
|
| 34 |
- Age
|
| 35 |
+
- Individual contributor or people manager
|
| 36 |
"""
|
| 37 |
)
|
| 38 |
st.info("💡 Tip: Results are estimates based on survey averages.")
|
|
|
|
| 44 |
st.write(f"**Developer Types:** {len(valid_categories['DevType'])} available")
|
| 45 |
st.write(f"**Industries:** {len(valid_categories['Industry'])} available")
|
| 46 |
st.write(f"**Age Ranges:** {len(valid_categories['Age'])} available")
|
| 47 |
+
st.write(f"**IC/PM Roles:** {len(valid_categories['ICorPM'])} available")
|
| 48 |
st.caption("Only values from the training data are shown in the dropdowns.")
|
| 49 |
|
| 50 |
# Main input form
|
|
|
|
| 58 |
valid_dev_types = valid_categories["DevType"]
|
| 59 |
valid_industries = valid_categories["Industry"]
|
| 60 |
valid_ages = valid_categories["Age"]
|
| 61 |
+
valid_icorpm = valid_categories["ICorPM"]
|
| 62 |
|
| 63 |
# Set default values (if available)
|
| 64 |
+
default_country = (
|
| 65 |
+
"United States of America"
|
| 66 |
+
if "United States of America" in valid_countries
|
| 67 |
+
else valid_countries[0]
|
| 68 |
+
)
|
| 69 |
+
default_education = (
|
| 70 |
+
"Bachelor's degree (B.A., B.S., B.Eng., etc.)"
|
| 71 |
+
if "Bachelor's degree (B.A., B.S., B.Eng., etc.)" in valid_education_levels
|
| 72 |
+
else valid_education_levels[0]
|
| 73 |
+
)
|
| 74 |
+
default_dev_type = (
|
| 75 |
+
"Developer, back-end"
|
| 76 |
+
if "Developer, back-end" in valid_dev_types
|
| 77 |
+
else valid_dev_types[0]
|
| 78 |
+
)
|
| 79 |
+
default_industry = (
|
| 80 |
+
"Software Development"
|
| 81 |
+
if "Software Development" in valid_industries
|
| 82 |
+
else valid_industries[0]
|
| 83 |
+
)
|
| 84 |
default_age = "25-34 years old" if "25-34 years old" in valid_ages else valid_ages[0]
|
| 85 |
+
default_icorpm = (
|
| 86 |
+
"Individual contributor"
|
| 87 |
+
if "Individual contributor" in valid_icorpm
|
| 88 |
+
else valid_icorpm[0]
|
| 89 |
+
)
|
| 90 |
|
| 91 |
with col1:
|
| 92 |
country = st.selectbox(
|
|
|
|
| 143 |
help="Developer's age range",
|
| 144 |
)
|
| 145 |
|
| 146 |
+
ic_or_pm = st.selectbox(
|
| 147 |
+
"Individual Contributor or People Manager",
|
| 148 |
+
options=valid_icorpm,
|
| 149 |
+
index=valid_icorpm.index(default_icorpm),
|
| 150 |
+
help="Are you an individual contributor or people manager?",
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
# Prediction button
|
| 154 |
if st.button("🔮 Predict Salary", type="primary", use_container_width=True):
|
| 155 |
try:
|
|
|
|
| 162 |
dev_type=dev_type,
|
| 163 |
industry=industry,
|
| 164 |
age=age,
|
| 165 |
+
ic_or_pm=ic_or_pm,
|
| 166 |
)
|
| 167 |
|
| 168 |
# Make prediction
|
config/currency_rates.yaml
CHANGED
|
@@ -46,10 +46,6 @@ Netherlands:
|
|
| 46 |
code: EUR
|
| 47 |
name: European Euro
|
| 48 |
rate: 0.86
|
| 49 |
-
Other:
|
| 50 |
-
code: EUR
|
| 51 |
-
name: European Euro
|
| 52 |
-
rate: 0.86
|
| 53 |
Poland:
|
| 54 |
code: PLN
|
| 55 |
name: Polish zloty
|
|
|
|
| 46 |
code: EUR
|
| 47 |
name: European Euro
|
| 48 |
rate: 0.86
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
Poland:
|
| 50 |
code: PLN
|
| 51 |
name: Polish zloty
|
config/model_parameters.yaml
CHANGED
|
@@ -29,6 +29,19 @@ features:
|
|
| 29 |
# Minimum occurrences for a category to be kept
|
| 30 |
min_frequency: 50
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
# One-hot encoding settings
|
| 33 |
encoding:
|
| 34 |
# Drop first category to avoid multicollinearity
|
|
@@ -79,6 +92,14 @@ training:
|
|
| 79 |
# Model output path (relative to project root)
|
| 80 |
model_path: "models/model.pkl"
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
# Notes:
|
| 83 |
# - All paths are relative to project root
|
| 84 |
# - Modify these parameters to experiment with different model configurations
|
|
|
|
| 29 |
# Minimum occurrences for a category to be kept
|
| 30 |
min_frequency: 50
|
| 31 |
|
| 32 |
+
# Default name for the catch-all "other" category
|
| 33 |
+
# Variants like "Other (please specify):" and "Other:" are normalized to this
|
| 34 |
+
other_category: "Other"
|
| 35 |
+
|
| 36 |
+
# Features where rows with the "Other" category should be dropped
|
| 37 |
+
# These catch-all categories hurt model quality (low R2, high prediction error)
|
| 38 |
+
drop_other_from:
|
| 39 |
+
- Country
|
| 40 |
+
- DevType
|
| 41 |
+
- Industry
|
| 42 |
+
- Age
|
| 43 |
+
- ICorPM
|
| 44 |
+
|
| 45 |
# One-hot encoding settings
|
| 46 |
encoding:
|
| 47 |
# Drop first category to avoid multicollinearity
|
|
|
|
| 92 |
# Model output path (relative to project root)
|
| 93 |
model_path: "models/model.pkl"
|
| 94 |
|
| 95 |
+
# Guardrail Evaluation Thresholds
|
| 96 |
+
guardrails:
|
| 97 |
+
# Minimum R2 score per category (below this triggers a warning)
|
| 98 |
+
min_r2_per_category: 0.20
|
| 99 |
+
|
| 100 |
+
# Maximum absolute percentage difference between mean actual and predicted salary
|
| 101 |
+
max_abs_pct_diff: 20
|
| 102 |
+
|
| 103 |
# Notes:
|
| 104 |
# - All paths are relative to project root
|
| 105 |
# - Modify these parameters to experiment with different model configurations
|
config/valid_categories.yaml
CHANGED
|
@@ -11,7 +11,6 @@ Country:
|
|
| 11 |
- India
|
| 12 |
- Italy
|
| 13 |
- Netherlands
|
| 14 |
-
- Other
|
| 15 |
- Poland
|
| 16 |
- Portugal
|
| 17 |
- Spain
|
|
@@ -25,7 +24,6 @@ EdLevel:
|
|
| 25 |
- Bachelor's degree (B.A., B.S., B.Eng., etc.)
|
| 26 |
- Master's degree (M.A., M.S., M.Eng., MBA, etc.)
|
| 27 |
- Other
|
| 28 |
-
- 'Other (please specify):'
|
| 29 |
- Primary/elementary school
|
| 30 |
- Professional degree (JD, MD, Ph.D, Ed.D, etc.)
|
| 31 |
- Secondary school (e.g. American high school, German Realschule or Gymnasium, etc.)
|
|
@@ -47,8 +45,6 @@ DevType:
|
|
| 47 |
- Developer, game or graphics
|
| 48 |
- Developer, mobile
|
| 49 |
- Engineering manager
|
| 50 |
-
- Other
|
| 51 |
-
- 'Other (please specify):'
|
| 52 |
- Senior executive (C-suite, VP, etc.)
|
| 53 |
- Student
|
| 54 |
- System administrator
|
|
@@ -64,8 +60,6 @@ Industry:
|
|
| 64 |
- Internet, Telecomm or Information Services
|
| 65 |
- Manufacturing
|
| 66 |
- Media & Advertising Services
|
| 67 |
-
- Other
|
| 68 |
-
- 'Other:'
|
| 69 |
- Retail and Consumer Services
|
| 70 |
- Software Development
|
| 71 |
- Transportation, or Supply Chain
|
|
@@ -76,4 +70,6 @@ Age:
|
|
| 76 |
- 45-54 years old
|
| 77 |
- 55-64 years old
|
| 78 |
- 65 years or older
|
| 79 |
-
|
|
|
|
|
|
|
|
|
| 11 |
- India
|
| 12 |
- Italy
|
| 13 |
- Netherlands
|
|
|
|
| 14 |
- Poland
|
| 15 |
- Portugal
|
| 16 |
- Spain
|
|
|
|
| 24 |
- Bachelor's degree (B.A., B.S., B.Eng., etc.)
|
| 25 |
- Master's degree (M.A., M.S., M.Eng., MBA, etc.)
|
| 26 |
- Other
|
|
|
|
| 27 |
- Primary/elementary school
|
| 28 |
- Professional degree (JD, MD, Ph.D, Ed.D, etc.)
|
| 29 |
- Secondary school (e.g. American high school, German Realschule or Gymnasium, etc.)
|
|
|
|
| 45 |
- Developer, game or graphics
|
| 46 |
- Developer, mobile
|
| 47 |
- Engineering manager
|
|
|
|
|
|
|
| 48 |
- Senior executive (C-suite, VP, etc.)
|
| 49 |
- Student
|
| 50 |
- System administrator
|
|
|
|
| 60 |
- Internet, Telecomm or Information Services
|
| 61 |
- Manufacturing
|
| 62 |
- Media & Advertising Services
|
|
|
|
|
|
|
| 63 |
- Retail and Consumer Services
|
| 64 |
- Software Development
|
| 65 |
- Transportation, or Supply Chain
|
|
|
|
| 70 |
- 45-54 years old
|
| 71 |
- 55-64 years old
|
| 72 |
- 65 years or older
|
| 73 |
+
ICorPM:
|
| 74 |
+
- Individual contributor
|
| 75 |
+
- People manager
|
debug_prepare_features.py
CHANGED
|
@@ -11,12 +11,14 @@ with open(config_path, "r") as f:
|
|
| 11 |
config = yaml.safe_load(f)
|
| 12 |
|
| 13 |
# Create test input
|
| 14 |
-
df = pd.DataFrame(
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
| 20 |
|
| 21 |
print("=" * 70)
|
| 22 |
print("STEP-BY-STEP DEBUGGING OF prepare_features()")
|
|
@@ -32,7 +34,7 @@ df_processed = df.copy()
|
|
| 32 |
# Step 3: Unicode normalization
|
| 33 |
for col in ["Country", "EdLevel", "DevType"]:
|
| 34 |
if col in df_processed.columns:
|
| 35 |
-
df_processed[col] = df_processed[col].str.replace(
|
| 36 |
|
| 37 |
print("\n2. After unicode normalization:")
|
| 38 |
print(f" Columns: {list(df_processed.columns)}")
|
|
@@ -72,7 +74,7 @@ print(f" Columns: {list(df_features.columns)}")
|
|
| 72 |
print(f" Values: {df_features.iloc[0].to_dict()}")
|
| 73 |
|
| 74 |
# Step 7: One-hot encode
|
| 75 |
-
drop_first = config[
|
| 76 |
print(f"\n6. One-hot encoding with drop_first={drop_first}:")
|
| 77 |
df_encoded = pd.get_dummies(df_features, drop_first=drop_first)
|
| 78 |
|
|
|
|
| 11 |
config = yaml.safe_load(f)
|
| 12 |
|
| 13 |
# Create test input
|
| 14 |
+
df = pd.DataFrame(
|
| 15 |
+
{
|
| 16 |
+
"Country": ["United States of America"],
|
| 17 |
+
"YearsCode": [5.0],
|
| 18 |
+
"EdLevel": ["Bachelor's degree (B.A., B.S., B.Eng., etc.)"],
|
| 19 |
+
"DevType": ["Developer, full-stack"],
|
| 20 |
+
}
|
| 21 |
+
)
|
| 22 |
|
| 23 |
print("=" * 70)
|
| 24 |
print("STEP-BY-STEP DEBUGGING OF prepare_features()")
|
|
|
|
| 34 |
# Step 3: Unicode normalization
|
| 35 |
for col in ["Country", "EdLevel", "DevType"]:
|
| 36 |
if col in df_processed.columns:
|
| 37 |
+
df_processed[col] = df_processed[col].str.replace("\u2019", "'", regex=False)
|
| 38 |
|
| 39 |
print("\n2. After unicode normalization:")
|
| 40 |
print(f" Columns: {list(df_processed.columns)}")
|
|
|
|
| 74 |
print(f" Values: {df_features.iloc[0].to_dict()}")
|
| 75 |
|
| 76 |
# Step 7: One-hot encode
|
| 77 |
+
drop_first = config["features"]["encoding"]["drop_first"]
|
| 78 |
print(f"\n6. One-hot encoding with drop_first={drop_first}:")
|
| 79 |
df_encoded = pd.get_dummies(df_features, drop_first=drop_first)
|
| 80 |
|
diagnose_encoding.py
CHANGED
|
@@ -4,19 +4,23 @@ from src.preprocessing import prepare_features
|
|
| 4 |
import pandas as pd
|
| 5 |
|
| 6 |
# Create two inputs that differ ONLY in Country
|
| 7 |
-
input1 = pd.DataFrame(
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
input2 = pd.DataFrame(
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
| 20 |
|
| 21 |
print("=" * 70)
|
| 22 |
print("ENCODING DIAGNOSIS")
|
|
@@ -26,13 +30,13 @@ print("=" * 70)
|
|
| 26 |
features1 = prepare_features(input1)
|
| 27 |
features2 = prepare_features(input2)
|
| 28 |
|
| 29 |
-
print(
|
| 30 |
print(f" Shape: {features1.shape}")
|
| 31 |
print(f" Columns: {list(features1.columns)}")
|
| 32 |
non_zero1 = [col for col in features1.columns if features1[col].iloc[0] != 0]
|
| 33 |
print(f" Non-zero features ({len(non_zero1)}): {non_zero1}")
|
| 34 |
|
| 35 |
-
print(
|
| 36 |
print(f" Shape: {features2.shape}")
|
| 37 |
non_zero2 = [col for col in features2.columns if features2[col].iloc[0] != 0]
|
| 38 |
print(f" Non-zero features ({len(non_zero2)}): {non_zero2}")
|
|
@@ -51,15 +55,17 @@ print("COUNTRY ENCODING CHECK")
|
|
| 51 |
print("=" * 70)
|
| 52 |
|
| 53 |
# Test just Country encoding
|
| 54 |
-
test_countries = [
|
| 55 |
for country in test_countries:
|
| 56 |
-
test_df = pd.DataFrame(
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
| 62 |
encoded = prepare_features(test_df)
|
| 63 |
-
country_cols = [col for col in encoded.columns if col.startswith(
|
| 64 |
non_zero_countries = [col for col in country_cols if encoded[col].iloc[0] != 0]
|
| 65 |
print(f"{country:40s} -> {non_zero_countries}")
|
|
|
|
| 4 |
import pandas as pd
|
| 5 |
|
| 6 |
# Create two inputs that differ ONLY in Country
|
| 7 |
+
input1 = pd.DataFrame(
|
| 8 |
+
{
|
| 9 |
+
"Country": ["United States of America"],
|
| 10 |
+
"YearsCode": [5.0],
|
| 11 |
+
"EdLevel": ["Bachelor's degree (B.A., B.S., B.Eng., etc.)"],
|
| 12 |
+
"DevType": ["Developer, full-stack"],
|
| 13 |
+
}
|
| 14 |
+
)
|
| 15 |
|
| 16 |
+
input2 = pd.DataFrame(
|
| 17 |
+
{
|
| 18 |
+
"Country": ["Germany"], # Different!
|
| 19 |
+
"YearsCode": [5.0],
|
| 20 |
+
"EdLevel": ["Bachelor's degree (B.A., B.S., B.Eng., etc.)"],
|
| 21 |
+
"DevType": ["Developer, full-stack"],
|
| 22 |
+
}
|
| 23 |
+
)
|
| 24 |
|
| 25 |
print("=" * 70)
|
| 26 |
print("ENCODING DIAGNOSIS")
|
|
|
|
| 30 |
features1 = prepare_features(input1)
|
| 31 |
features2 = prepare_features(input2)
|
| 32 |
|
| 33 |
+
print("\nInput 1 (USA):")
|
| 34 |
print(f" Shape: {features1.shape}")
|
| 35 |
print(f" Columns: {list(features1.columns)}")
|
| 36 |
non_zero1 = [col for col in features1.columns if features1[col].iloc[0] != 0]
|
| 37 |
print(f" Non-zero features ({len(non_zero1)}): {non_zero1}")
|
| 38 |
|
| 39 |
+
print("\nInput 2 (Germany):")
|
| 40 |
print(f" Shape: {features2.shape}")
|
| 41 |
non_zero2 = [col for col in features2.columns if features2[col].iloc[0] != 0]
|
| 42 |
print(f" Non-zero features ({len(non_zero2)}): {non_zero2}")
|
|
|
|
| 55 |
print("=" * 70)
|
| 56 |
|
| 57 |
# Test just Country encoding
|
| 58 |
+
test_countries = ["United States of America", "Germany", "India"]
|
| 59 |
for country in test_countries:
|
| 60 |
+
test_df = pd.DataFrame(
|
| 61 |
+
{
|
| 62 |
+
"Country": [country],
|
| 63 |
+
"YearsCode": [5.0],
|
| 64 |
+
"EdLevel": ["Bachelor's degree (B.A., B.S., B.Eng., etc.)"],
|
| 65 |
+
"DevType": ["Developer, full-stack"],
|
| 66 |
+
}
|
| 67 |
+
)
|
| 68 |
encoded = prepare_features(test_df)
|
| 69 |
+
country_cols = [col for col in encoded.columns if col.startswith("Country_")]
|
| 70 |
non_zero_countries = [col for col in country_cols if encoded[col].iloc[0] != 0]
|
| 71 |
print(f"{country:40s} -> {non_zero_countries}")
|
example_inference.py
CHANGED
|
@@ -23,6 +23,7 @@ def main():
|
|
| 23 |
dev_type="Developer, full-stack",
|
| 24 |
industry="Software Development",
|
| 25 |
age="25-34 years old",
|
|
|
|
| 26 |
)
|
| 27 |
|
| 28 |
print(f"Country: {input_data_1.country}")
|
|
@@ -32,6 +33,7 @@ def main():
|
|
| 32 |
print(f"Developer Type: {input_data_1.dev_type}")
|
| 33 |
print(f"Industry: {input_data_1.industry}")
|
| 34 |
print(f"Age: {input_data_1.age}")
|
|
|
|
| 35 |
|
| 36 |
salary_1 = predict_salary(input_data_1)
|
| 37 |
print(f"💰 Predicted Salary: ${salary_1:,.2f} USD/year")
|
|
@@ -48,6 +50,7 @@ def main():
|
|
| 48 |
dev_type="Developer, front-end",
|
| 49 |
industry="Fintech",
|
| 50 |
age="18-24 years old",
|
|
|
|
| 51 |
)
|
| 52 |
|
| 53 |
print(f"Country: {input_data_2.country}")
|
|
@@ -57,6 +60,7 @@ def main():
|
|
| 57 |
print(f"Developer Type: {input_data_2.dev_type}")
|
| 58 |
print(f"Industry: {input_data_2.industry}")
|
| 59 |
print(f"Age: {input_data_2.age}")
|
|
|
|
| 60 |
|
| 61 |
salary_2 = predict_salary(input_data_2)
|
| 62 |
print(f"💰 Predicted Salary: ${salary_2:,.2f} USD/year")
|
|
@@ -73,6 +77,7 @@ def main():
|
|
| 73 |
dev_type="Engineering manager",
|
| 74 |
industry="Banking/Financial Services",
|
| 75 |
age="35-44 years old",
|
|
|
|
| 76 |
)
|
| 77 |
|
| 78 |
print(f"Country: {input_data_3.country}")
|
|
@@ -82,6 +87,7 @@ def main():
|
|
| 82 |
print(f"Developer Type: {input_data_3.dev_type}")
|
| 83 |
print(f"Industry: {input_data_3.industry}")
|
| 84 |
print(f"Age: {input_data_3.age}")
|
|
|
|
| 85 |
|
| 86 |
salary_3 = predict_salary(input_data_3)
|
| 87 |
print(f"💰 Predicted Salary: ${salary_3:,.2f} USD/year")
|
|
@@ -98,6 +104,7 @@ def main():
|
|
| 98 |
dev_type="Developer, back-end",
|
| 99 |
industry="Manufacturing",
|
| 100 |
age="25-34 years old",
|
|
|
|
| 101 |
)
|
| 102 |
|
| 103 |
print(f"Country: {input_data_4.country}")
|
|
@@ -107,6 +114,7 @@ def main():
|
|
| 107 |
print(f"Developer Type: {input_data_4.dev_type}")
|
| 108 |
print(f"Industry: {input_data_4.industry}")
|
| 109 |
print(f"Age: {input_data_4.age}")
|
|
|
|
| 110 |
|
| 111 |
salary_4 = predict_salary(input_data_4)
|
| 112 |
print(f"💰 Predicted Salary: ${salary_4:,.2f} USD/year")
|
|
|
|
| 23 |
dev_type="Developer, full-stack",
|
| 24 |
industry="Software Development",
|
| 25 |
age="25-34 years old",
|
| 26 |
+
ic_or_pm="Individual contributor",
|
| 27 |
)
|
| 28 |
|
| 29 |
print(f"Country: {input_data_1.country}")
|
|
|
|
| 33 |
print(f"Developer Type: {input_data_1.dev_type}")
|
| 34 |
print(f"Industry: {input_data_1.industry}")
|
| 35 |
print(f"Age: {input_data_1.age}")
|
| 36 |
+
print(f"IC or PM: {input_data_1.ic_or_pm}")
|
| 37 |
|
| 38 |
salary_1 = predict_salary(input_data_1)
|
| 39 |
print(f"💰 Predicted Salary: ${salary_1:,.2f} USD/year")
|
|
|
|
| 50 |
dev_type="Developer, front-end",
|
| 51 |
industry="Fintech",
|
| 52 |
age="18-24 years old",
|
| 53 |
+
ic_or_pm="Individual contributor",
|
| 54 |
)
|
| 55 |
|
| 56 |
print(f"Country: {input_data_2.country}")
|
|
|
|
| 60 |
print(f"Developer Type: {input_data_2.dev_type}")
|
| 61 |
print(f"Industry: {input_data_2.industry}")
|
| 62 |
print(f"Age: {input_data_2.age}")
|
| 63 |
+
print(f"IC or PM: {input_data_2.ic_or_pm}")
|
| 64 |
|
| 65 |
salary_2 = predict_salary(input_data_2)
|
| 66 |
print(f"💰 Predicted Salary: ${salary_2:,.2f} USD/year")
|
|
|
|
| 77 |
dev_type="Engineering manager",
|
| 78 |
industry="Banking/Financial Services",
|
| 79 |
age="35-44 years old",
|
| 80 |
+
ic_or_pm="People manager",
|
| 81 |
)
|
| 82 |
|
| 83 |
print(f"Country: {input_data_3.country}")
|
|
|
|
| 87 |
print(f"Developer Type: {input_data_3.dev_type}")
|
| 88 |
print(f"Industry: {input_data_3.industry}")
|
| 89 |
print(f"Age: {input_data_3.age}")
|
| 90 |
+
print(f"IC or PM: {input_data_3.ic_or_pm}")
|
| 91 |
|
| 92 |
salary_3 = predict_salary(input_data_3)
|
| 93 |
print(f"💰 Predicted Salary: ${salary_3:,.2f} USD/year")
|
|
|
|
| 104 |
dev_type="Developer, back-end",
|
| 105 |
industry="Manufacturing",
|
| 106 |
age="25-34 years old",
|
| 107 |
+
ic_or_pm="Individual contributor",
|
| 108 |
)
|
| 109 |
|
| 110 |
print(f"Country: {input_data_4.country}")
|
|
|
|
| 114 |
print(f"Developer Type: {input_data_4.dev_type}")
|
| 115 |
print(f"Industry: {input_data_4.industry}")
|
| 116 |
print(f"Age: {input_data_4.age}")
|
| 117 |
+
print(f"IC or PM: {input_data_4.ic_or_pm}")
|
| 118 |
|
| 119 |
salary_4 = predict_salary(input_data_4)
|
| 120 |
print(f"💰 Predicted Salary: ${salary_4:,.2f} USD/year")
|
guardrail_evaluation.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-category guardrail evaluation for the salary prediction model.
|
| 2 |
+
|
| 3 |
+
Runs cross-validation and computes R2 scores and predicted vs actual salary
|
| 4 |
+
comparisons broken down by each categorical feature value. Flags categories
|
| 5 |
+
that fall below configurable thresholds.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import pandas as pd
|
| 13 |
+
import yaml
|
| 14 |
+
from sklearn.metrics import r2_score
|
| 15 |
+
from sklearn.model_selection import KFold
|
| 16 |
+
from xgboost import XGBRegressor
|
| 17 |
+
|
| 18 |
+
from src.preprocessing import prepare_features, reduce_cardinality
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
CATEGORICAL_FEATURES = ["Country", "EdLevel", "DevType", "Industry", "Age", "ICorPM"]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def load_and_preprocess(config: dict) -> tuple[pd.DataFrame, pd.DataFrame, pd.Series]:
|
| 25 |
+
"""Load data and apply same preprocessing as train.py.
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
(df, X, y) where df has original categorical columns (after cardinality
|
| 29 |
+
reduction), X is one-hot encoded features, y is the target.
|
| 30 |
+
"""
|
| 31 |
+
data_path = Path("data/survey_results_public.csv")
|
| 32 |
+
if not data_path.exists():
|
| 33 |
+
print(f"Error: Data file not found at {data_path}")
|
| 34 |
+
sys.exit(1)
|
| 35 |
+
|
| 36 |
+
df = pd.read_csv(
|
| 37 |
+
data_path,
|
| 38 |
+
usecols=[
|
| 39 |
+
"Country",
|
| 40 |
+
"YearsCode",
|
| 41 |
+
"WorkExp",
|
| 42 |
+
"EdLevel",
|
| 43 |
+
"DevType",
|
| 44 |
+
"Industry",
|
| 45 |
+
"Age",
|
| 46 |
+
"ICorPM",
|
| 47 |
+
"ConvertedCompYearly",
|
| 48 |
+
],
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
main_label = "ConvertedCompYearly"
|
| 52 |
+
min_salary = config["data"]["min_salary"]
|
| 53 |
+
df = df[df[main_label] > min_salary]
|
| 54 |
+
|
| 55 |
+
# Per-country percentile outlier removal
|
| 56 |
+
lower_pct = config["data"]["lower_percentile"] / 100
|
| 57 |
+
upper_pct = config["data"]["upper_percentile"] / 100
|
| 58 |
+
lower_bound = df.groupby("Country")[main_label].transform("quantile", lower_pct)
|
| 59 |
+
upper_bound = df.groupby("Country")[main_label].transform("quantile", upper_pct)
|
| 60 |
+
df = df[(df[main_label] > lower_bound) & (df[main_label] < upper_bound)]
|
| 61 |
+
|
| 62 |
+
df = df.dropna(subset=[main_label])
|
| 63 |
+
|
| 64 |
+
# Cardinality reduction (same as train.py)
|
| 65 |
+
for col in CATEGORICAL_FEATURES:
|
| 66 |
+
df[col] = reduce_cardinality(df[col])
|
| 67 |
+
|
| 68 |
+
# Drop rows with "Other" in specified features (same as train.py)
|
| 69 |
+
other_name = config["features"]["cardinality"].get("other_category", "Other")
|
| 70 |
+
drop_other_from = config["features"]["cardinality"].get("drop_other_from", [])
|
| 71 |
+
if drop_other_from:
|
| 72 |
+
before_drop = len(df)
|
| 73 |
+
for col in drop_other_from:
|
| 74 |
+
df = df[df[col] != other_name]
|
| 75 |
+
print(
|
| 76 |
+
f"Dropped {before_drop - len(df):,} rows with '{other_name}' in {drop_other_from}"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
X = prepare_features(df)
|
| 80 |
+
y = df[main_label]
|
| 81 |
+
|
| 82 |
+
return df, X, y
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def run_cv_predictions(
|
| 86 |
+
X: pd.DataFrame,
|
| 87 |
+
y: pd.Series,
|
| 88 |
+
config: dict,
|
| 89 |
+
) -> np.ndarray:
|
| 90 |
+
"""Run KFold CV and return out-of-fold predictions for every row.
|
| 91 |
+
|
| 92 |
+
Each row gets exactly one prediction (from the fold where it was in the
|
| 93 |
+
test set).
|
| 94 |
+
"""
|
| 95 |
+
n_splits = config["data"].get("cv_splits", 5)
|
| 96 |
+
random_state = config["data"]["random_state"]
|
| 97 |
+
model_config = config["model"]
|
| 98 |
+
|
| 99 |
+
kf = KFold(n_splits=n_splits, shuffle=True, random_state=random_state)
|
| 100 |
+
oof_predictions = np.empty(len(y))
|
| 101 |
+
oof_predictions[:] = np.nan
|
| 102 |
+
|
| 103 |
+
print(f"Running {n_splits}-fold cross-validation...")
|
| 104 |
+
for fold, (train_idx, test_idx) in enumerate(kf.split(X), 1):
|
| 105 |
+
X_train, X_test = X.iloc[train_idx], X.iloc[test_idx]
|
| 106 |
+
y_train, y_test = y.iloc[train_idx], y.iloc[test_idx]
|
| 107 |
+
|
| 108 |
+
model = XGBRegressor(
|
| 109 |
+
n_estimators=model_config["n_estimators"],
|
| 110 |
+
learning_rate=model_config["learning_rate"],
|
| 111 |
+
max_depth=model_config["max_depth"],
|
| 112 |
+
min_child_weight=model_config["min_child_weight"],
|
| 113 |
+
random_state=model_config["random_state"],
|
| 114 |
+
n_jobs=model_config["n_jobs"],
|
| 115 |
+
early_stopping_rounds=model_config["early_stopping_rounds"],
|
| 116 |
+
)
|
| 117 |
+
model.fit(
|
| 118 |
+
X_train,
|
| 119 |
+
y_train,
|
| 120 |
+
eval_set=[(X_test, y_test)],
|
| 121 |
+
verbose=False,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
test_r2 = model.score(X_test, y_test)
|
| 125 |
+
print(
|
| 126 |
+
f" Fold {fold}: Test R2 = {test_r2:.4f} (best iter: {model.best_iteration + 1})"
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
oof_predictions[test_idx] = model.predict(X_test)
|
| 130 |
+
|
| 131 |
+
overall_r2 = r2_score(y, oof_predictions)
|
| 132 |
+
print(f"\nOverall OOF R2: {overall_r2:.4f}")
|
| 133 |
+
|
| 134 |
+
return oof_predictions
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def compute_category_metrics(
|
| 138 |
+
df: pd.DataFrame,
|
| 139 |
+
y: pd.Series,
|
| 140 |
+
predictions: np.ndarray,
|
| 141 |
+
feature: str,
|
| 142 |
+
) -> pd.DataFrame:
|
| 143 |
+
"""Compute per-category R2, mean actual/predicted, and abs % diff."""
|
| 144 |
+
results = []
|
| 145 |
+
categories = df[feature].values
|
| 146 |
+
actuals = y.values
|
| 147 |
+
|
| 148 |
+
for cat in sorted(df[feature].unique()):
|
| 149 |
+
mask = categories == cat
|
| 150 |
+
cat_actual = actuals[mask]
|
| 151 |
+
cat_pred = predictions[mask]
|
| 152 |
+
count = int(mask.sum())
|
| 153 |
+
|
| 154 |
+
if count < 2:
|
| 155 |
+
cat_r2 = float("nan")
|
| 156 |
+
else:
|
| 157 |
+
cat_r2 = r2_score(cat_actual, cat_pred)
|
| 158 |
+
|
| 159 |
+
mean_actual = cat_actual.mean()
|
| 160 |
+
mean_pred = cat_pred.mean()
|
| 161 |
+
abs_pct_diff = abs(mean_pred - mean_actual) / mean_actual * 100
|
| 162 |
+
|
| 163 |
+
results.append(
|
| 164 |
+
{
|
| 165 |
+
"Category": cat,
|
| 166 |
+
"Count": count,
|
| 167 |
+
"R2": cat_r2,
|
| 168 |
+
"Mean Actual ($)": mean_actual,
|
| 169 |
+
"Mean Predicted ($)": mean_pred,
|
| 170 |
+
"Abs % Diff": abs_pct_diff,
|
| 171 |
+
}
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
return pd.DataFrame(results)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def format_table(metrics_df: pd.DataFrame) -> str:
|
| 178 |
+
"""Format metrics DataFrame as a markdown table."""
|
| 179 |
+
lines = []
|
| 180 |
+
header = (
|
| 181 |
+
"| Category | Count | R2 | Mean Actual ($) | Mean Predicted ($) | Abs % Diff |"
|
| 182 |
+
)
|
| 183 |
+
sep = (
|
| 184 |
+
"|----------|------:|----:|----------------:|-------------------:|-----------:|"
|
| 185 |
+
)
|
| 186 |
+
lines.append(header)
|
| 187 |
+
lines.append(sep)
|
| 188 |
+
|
| 189 |
+
for _, row in metrics_df.iterrows():
|
| 190 |
+
r2_str = f"{row['R2']:.2f}" if not np.isnan(row["R2"]) else "N/A"
|
| 191 |
+
lines.append(
|
| 192 |
+
f"| {row['Category'][:45]:45s} | {row['Count']:5,d} | {r2_str:>4s} "
|
| 193 |
+
f"| {row['Mean Actual ($)']:>15,.0f} | {row['Mean Predicted ($)']:>18,.0f} "
|
| 194 |
+
f"| {row['Abs % Diff']:>9.1f}% |"
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
return "\n".join(lines)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def main():
|
| 201 |
+
"""Run per-category guardrail evaluation."""
|
| 202 |
+
config_path = Path("config/model_parameters.yaml")
|
| 203 |
+
with open(config_path, "r") as f:
|
| 204 |
+
config = yaml.safe_load(f)
|
| 205 |
+
|
| 206 |
+
guardrails = config.get("guardrails", {})
|
| 207 |
+
min_r2 = guardrails.get("min_r2_per_category", 0.30)
|
| 208 |
+
max_pct_diff = guardrails.get("max_abs_pct_diff", 10)
|
| 209 |
+
|
| 210 |
+
print("=" * 80)
|
| 211 |
+
print("GUARDRAIL EVALUATION - Per-Category Model Quality")
|
| 212 |
+
print(f"Thresholds: min R2 = {min_r2}, max abs % diff = {max_pct_diff}%")
|
| 213 |
+
print("=" * 80)
|
| 214 |
+
|
| 215 |
+
df, X, y = load_and_preprocess(config)
|
| 216 |
+
print(f"Dataset: {len(df):,} rows, {X.shape[1]} features\n")
|
| 217 |
+
|
| 218 |
+
predictions = run_cv_predictions(X, y, config)
|
| 219 |
+
|
| 220 |
+
# Reset index alignment: df and y may have non-contiguous indices
|
| 221 |
+
# predictions array is positional, so align everything by position
|
| 222 |
+
df_eval = df.reset_index(drop=True)
|
| 223 |
+
y_eval = y.reset_index(drop=True)
|
| 224 |
+
|
| 225 |
+
warnings = []
|
| 226 |
+
|
| 227 |
+
for feature in CATEGORICAL_FEATURES:
|
| 228 |
+
print(f"\n## {feature}\n")
|
| 229 |
+
metrics = compute_category_metrics(df_eval, y_eval, predictions, feature)
|
| 230 |
+
print(format_table(metrics))
|
| 231 |
+
|
| 232 |
+
# Check guardrails
|
| 233 |
+
for _, row in metrics.iterrows():
|
| 234 |
+
cat = row["Category"]
|
| 235 |
+
if not np.isnan(row["R2"]) and row["R2"] < min_r2:
|
| 236 |
+
warnings.append(
|
| 237 |
+
f'{feature} "{cat}": R2 = {row["R2"]:.2f} (threshold: {min_r2})'
|
| 238 |
+
)
|
| 239 |
+
if row["Abs % Diff"] > max_pct_diff:
|
| 240 |
+
warnings.append(
|
| 241 |
+
f'{feature} "{cat}": Abs % Diff = {row["Abs % Diff"]:.1f}% '
|
| 242 |
+
f"(threshold: {max_pct_diff}%)"
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
# Summary
|
| 246 |
+
print("\n" + "=" * 80)
|
| 247 |
+
if warnings:
|
| 248 |
+
print("### Guardrail Warnings\n")
|
| 249 |
+
for w in warnings:
|
| 250 |
+
print(f" - {w}")
|
| 251 |
+
print(f"\n{len(warnings)} guardrail violation(s) found.")
|
| 252 |
+
else:
|
| 253 |
+
print("All categories pass guardrail thresholds.")
|
| 254 |
+
|
| 255 |
+
print("=" * 80)
|
| 256 |
+
|
| 257 |
+
sys.exit(1 if warnings else 0)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
if __name__ == "__main__":
|
| 261 |
+
main()
|
models/model.pkl
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4166bad87e5b67d7b498637d8ef286a3e3f74a82450db02d2aa7e4e09d750090
|
| 3 |
+
size 4146513
|
pyproject.toml
CHANGED
|
@@ -12,4 +12,20 @@ dependencies = [
|
|
| 12 |
"xgboost>=3.1.0",
|
| 13 |
"ruff>=0.15.0",
|
| 14 |
"pyyaml>=6.0.0",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"xgboost>=3.1.0",
|
| 13 |
"ruff>=0.15.0",
|
| 14 |
"pyyaml>=6.0.0",
|
| 15 |
+
"numpy>=2.4.2",
|
| 16 |
+
"radon>=6.0.1",
|
| 17 |
+
"pip-audit>=2.10.0",
|
| 18 |
+
"bandit>=1.9.3",
|
| 19 |
]
|
| 20 |
+
|
| 21 |
+
[project.optional-dependencies]
|
| 22 |
+
dev = [
|
| 23 |
+
"pytest>=8.0.0",
|
| 24 |
+
"pytest-cov>=6.0.0",
|
| 25 |
+
"radon>=6.0.0",
|
| 26 |
+
"pip-audit>=2.7.0",
|
| 27 |
+
"bandit>=1.8.0",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
[tool.pytest.ini_options]
|
| 31 |
+
testpaths = ["tests"]
|
src/infer.py
CHANGED
|
@@ -106,6 +106,13 @@ def predict_salary(data: SalaryInput) -> float:
|
|
| 106 |
f"Check config/valid_categories.yaml for all valid values."
|
| 107 |
)
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
# Create a DataFrame with the input data
|
| 110 |
input_df = pd.DataFrame(
|
| 111 |
{
|
|
@@ -116,6 +123,7 @@ def predict_salary(data: SalaryInput) -> float:
|
|
| 116 |
"DevType": [data.dev_type],
|
| 117 |
"Industry": [data.industry],
|
| 118 |
"Age": [data.age],
|
|
|
|
| 119 |
}
|
| 120 |
)
|
| 121 |
|
|
|
|
| 106 |
f"Check config/valid_categories.yaml for all valid values."
|
| 107 |
)
|
| 108 |
|
| 109 |
+
if data.ic_or_pm not in valid_categories["ICorPM"]:
|
| 110 |
+
raise ValueError(
|
| 111 |
+
f"Invalid IC or PM value: '{data.ic_or_pm}'. "
|
| 112 |
+
f"Must be one of {len(valid_categories['ICorPM'])} valid values. "
|
| 113 |
+
f"Check config/valid_categories.yaml for all valid values."
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
# Create a DataFrame with the input data
|
| 117 |
input_df = pd.DataFrame(
|
| 118 |
{
|
|
|
|
| 123 |
"DevType": [data.dev_type],
|
| 124 |
"Industry": [data.industry],
|
| 125 |
"Age": [data.age],
|
| 126 |
+
"ICorPM": [data.ic_or_pm],
|
| 127 |
}
|
| 128 |
)
|
| 129 |
|
src/preprocessing.py
CHANGED
|
@@ -10,10 +10,28 @@ with open(_config_path, "r") as f:
|
|
| 10 |
_config = yaml.safe_load(f)
|
| 11 |
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
def reduce_cardinality(
|
| 14 |
-
series: pd.Series,
|
| 15 |
-
max_categories: int = None,
|
| 16 |
-
min_frequency: int = None
|
| 17 |
) -> pd.Series:
|
| 18 |
"""
|
| 19 |
Reduce cardinality by grouping rare categories into 'Other'.
|
|
@@ -28,11 +46,16 @@ def reduce_cardinality(
|
|
| 28 |
Returns:
|
| 29 |
Series with rare categories replaced by 'Other'
|
| 30 |
"""
|
|
|
|
|
|
|
| 31 |
# Use config defaults if not provided
|
| 32 |
if max_categories is None:
|
| 33 |
-
max_categories = _config[
|
| 34 |
if min_frequency is None:
|
| 35 |
-
min_frequency = _config[
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
# Count value frequencies
|
| 38 |
value_counts = series.value_counts()
|
|
@@ -43,8 +66,8 @@ def reduce_cardinality(
|
|
| 43 |
top_categories = value_counts.head(max_categories)
|
| 44 |
kept_categories = top_categories[top_categories >= min_frequency].index.tolist()
|
| 45 |
|
| 46 |
-
# Replace rare categories with 'Other'
|
| 47 |
-
return series.apply(lambda x: x if x in kept_categories else
|
| 48 |
|
| 49 |
|
| 50 |
def prepare_features(df: pd.DataFrame) -> pd.DataFrame:
|
|
@@ -55,7 +78,7 @@ def prepare_features(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 55 |
during training and inference, preventing data leakage and inconsistencies.
|
| 56 |
|
| 57 |
Args:
|
| 58 |
-
df: DataFrame with columns: Country, YearsCode, WorkExp, EdLevel, DevType, Industry, Age
|
| 59 |
NOTE: During training, cardinality reduction should be applied to df
|
| 60 |
BEFORE calling this function. During inference, valid_categories.yaml
|
| 61 |
ensures only valid (already-reduced) categories are used.
|
|
@@ -67,7 +90,7 @@ def prepare_features(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 67 |
- Fills missing values with defaults (0 for numeric, "Unknown" for categorical)
|
| 68 |
- Normalizes Unicode apostrophes to regular apostrophes
|
| 69 |
- Applies one-hot encoding with drop_first=True to avoid multicollinearity
|
| 70 |
-
- Column names in output will be like: YearsCode, WorkExp, Country_X, EdLevel_Y, DevType_Z, Industry_W, Age_V
|
| 71 |
- Does NOT apply cardinality reduction (must be done before calling this)
|
| 72 |
"""
|
| 73 |
# Create a copy to avoid modifying the original
|
|
@@ -75,12 +98,22 @@ def prepare_features(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 75 |
|
| 76 |
# Normalize Unicode apostrophes to regular apostrophes for consistency
|
| 77 |
# This handles cases where data has \u2019 (') instead of '
|
| 78 |
-
for col in ["Country", "EdLevel", "DevType", "Industry", "Age"]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
if col in df_processed.columns:
|
| 80 |
-
df_processed[col] = df_processed[col]
|
| 81 |
|
| 82 |
# Handle legacy column name (YearsCodePro -> YearsCode)
|
| 83 |
-
if
|
|
|
|
|
|
|
|
|
|
| 84 |
df_processed.rename(columns={"YearsCodePro": "YearsCode"}, inplace=True)
|
| 85 |
|
| 86 |
# Fill missing values with defaults
|
|
@@ -91,13 +124,23 @@ def prepare_features(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 91 |
df_processed["DevType"] = df_processed["DevType"].fillna("Unknown")
|
| 92 |
df_processed["Industry"] = df_processed["Industry"].fillna("Unknown")
|
| 93 |
df_processed["Age"] = df_processed["Age"].fillna("Unknown")
|
|
|
|
| 94 |
|
| 95 |
# NOTE: Cardinality reduction is NOT applied here
|
| 96 |
# It should be applied during training BEFORE calling this function
|
| 97 |
# During inference, valid_categories.yaml ensures only valid values are used
|
| 98 |
|
| 99 |
# Select only the features we need
|
| 100 |
-
feature_cols = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
df_features = df_processed[feature_cols]
|
| 102 |
|
| 103 |
# Apply one-hot encoding for categorical variables
|
|
@@ -105,7 +148,9 @@ def prepare_features(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 105 |
# The reindex in infer.py will align with training columns
|
| 106 |
# For training (many rows), we use the config value
|
| 107 |
is_inference = len(df_features) == 1
|
| 108 |
-
drop_first =
|
|
|
|
|
|
|
| 109 |
df_encoded = pd.get_dummies(df_features, drop_first=drop_first)
|
| 110 |
|
| 111 |
return df_encoded
|
|
|
|
| 10 |
_config = yaml.safe_load(f)
|
| 11 |
|
| 12 |
|
| 13 |
+
def _get_other_category() -> str:
|
| 14 |
+
"""Get the standard 'Other' category name from config."""
|
| 15 |
+
return _config["features"]["cardinality"].get("other_category", "Other")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def normalize_other_categories(series: pd.Series) -> pd.Series:
|
| 19 |
+
"""
|
| 20 |
+
Normalize variants of 'Other' to the standard category name.
|
| 21 |
+
|
| 22 |
+
Replaces values like 'Other (please specify):', 'Other:', etc.
|
| 23 |
+
with the standard 'Other' category from config.
|
| 24 |
+
"""
|
| 25 |
+
other_name = _get_other_category()
|
| 26 |
+
return series.replace(
|
| 27 |
+
to_replace=r"^Other\b.*$",
|
| 28 |
+
value=other_name,
|
| 29 |
+
regex=True,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
def reduce_cardinality(
|
| 34 |
+
series: pd.Series, max_categories: int = None, min_frequency: int = None
|
|
|
|
|
|
|
| 35 |
) -> pd.Series:
|
| 36 |
"""
|
| 37 |
Reduce cardinality by grouping rare categories into 'Other'.
|
|
|
|
| 46 |
Returns:
|
| 47 |
Series with rare categories replaced by 'Other'
|
| 48 |
"""
|
| 49 |
+
other_name = _get_other_category()
|
| 50 |
+
|
| 51 |
# Use config defaults if not provided
|
| 52 |
if max_categories is None:
|
| 53 |
+
max_categories = _config["features"]["cardinality"]["max_categories"]
|
| 54 |
if min_frequency is None:
|
| 55 |
+
min_frequency = _config["features"]["cardinality"]["min_frequency"]
|
| 56 |
+
|
| 57 |
+
# Normalize "Other" variants before counting frequencies
|
| 58 |
+
series = normalize_other_categories(series)
|
| 59 |
|
| 60 |
# Count value frequencies
|
| 61 |
value_counts = series.value_counts()
|
|
|
|
| 66 |
top_categories = value_counts.head(max_categories)
|
| 67 |
kept_categories = top_categories[top_categories >= min_frequency].index.tolist()
|
| 68 |
|
| 69 |
+
# Replace rare categories with the standard 'Other' name
|
| 70 |
+
return series.apply(lambda x: x if x in kept_categories else other_name)
|
| 71 |
|
| 72 |
|
| 73 |
def prepare_features(df: pd.DataFrame) -> pd.DataFrame:
|
|
|
|
| 78 |
during training and inference, preventing data leakage and inconsistencies.
|
| 79 |
|
| 80 |
Args:
|
| 81 |
+
df: DataFrame with columns: Country, YearsCode, WorkExp, EdLevel, DevType, Industry, Age, ICorPM
|
| 82 |
NOTE: During training, cardinality reduction should be applied to df
|
| 83 |
BEFORE calling this function. During inference, valid_categories.yaml
|
| 84 |
ensures only valid (already-reduced) categories are used.
|
|
|
|
| 90 |
- Fills missing values with defaults (0 for numeric, "Unknown" for categorical)
|
| 91 |
- Normalizes Unicode apostrophes to regular apostrophes
|
| 92 |
- Applies one-hot encoding with drop_first=True to avoid multicollinearity
|
| 93 |
+
- Column names in output will be like: YearsCode, WorkExp, Country_X, EdLevel_Y, DevType_Z, Industry_W, Age_V, ICorPM_U
|
| 94 |
- Does NOT apply cardinality reduction (must be done before calling this)
|
| 95 |
"""
|
| 96 |
# Create a copy to avoid modifying the original
|
|
|
|
| 98 |
|
| 99 |
# Normalize Unicode apostrophes to regular apostrophes for consistency
|
| 100 |
# This handles cases where data has \u2019 (') instead of '
|
| 101 |
+
for col in ["Country", "EdLevel", "DevType", "Industry", "Age", "ICorPM"]:
|
| 102 |
+
if col in df_processed.columns:
|
| 103 |
+
df_processed[col] = df_processed[col].str.replace(
|
| 104 |
+
"\u2019", "'", regex=False
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# Normalize "Other" category variants (e.g. "Other (please specify):" -> "Other")
|
| 108 |
+
for col in ["Country", "EdLevel", "DevType", "Industry", "Age", "ICorPM"]:
|
| 109 |
if col in df_processed.columns:
|
| 110 |
+
df_processed[col] = normalize_other_categories(df_processed[col])
|
| 111 |
|
| 112 |
# Handle legacy column name (YearsCodePro -> YearsCode)
|
| 113 |
+
if (
|
| 114 |
+
"YearsCodePro" in df_processed.columns
|
| 115 |
+
and "YearsCode" not in df_processed.columns
|
| 116 |
+
):
|
| 117 |
df_processed.rename(columns={"YearsCodePro": "YearsCode"}, inplace=True)
|
| 118 |
|
| 119 |
# Fill missing values with defaults
|
|
|
|
| 124 |
df_processed["DevType"] = df_processed["DevType"].fillna("Unknown")
|
| 125 |
df_processed["Industry"] = df_processed["Industry"].fillna("Unknown")
|
| 126 |
df_processed["Age"] = df_processed["Age"].fillna("Unknown")
|
| 127 |
+
df_processed["ICorPM"] = df_processed["ICorPM"].fillna("Unknown")
|
| 128 |
|
| 129 |
# NOTE: Cardinality reduction is NOT applied here
|
| 130 |
# It should be applied during training BEFORE calling this function
|
| 131 |
# During inference, valid_categories.yaml ensures only valid values are used
|
| 132 |
|
| 133 |
# Select only the features we need
|
| 134 |
+
feature_cols = [
|
| 135 |
+
"Country",
|
| 136 |
+
"YearsCode",
|
| 137 |
+
"WorkExp",
|
| 138 |
+
"EdLevel",
|
| 139 |
+
"DevType",
|
| 140 |
+
"Industry",
|
| 141 |
+
"Age",
|
| 142 |
+
"ICorPM",
|
| 143 |
+
]
|
| 144 |
df_features = df_processed[feature_cols]
|
| 145 |
|
| 146 |
# Apply one-hot encoding for categorical variables
|
|
|
|
| 148 |
# The reindex in infer.py will align with training columns
|
| 149 |
# For training (many rows), we use the config value
|
| 150 |
is_inference = len(df_features) == 1
|
| 151 |
+
drop_first = (
|
| 152 |
+
False if is_inference else _config["features"]["encoding"]["drop_first"]
|
| 153 |
+
)
|
| 154 |
df_encoded = pd.get_dummies(df_features, drop_first=drop_first)
|
| 155 |
|
| 156 |
return df_encoded
|
src/schema.py
CHANGED
|
@@ -21,6 +21,7 @@ class SalaryInput(BaseModel):
|
|
| 21 |
dev_type: str = Field(..., description="Developer type")
|
| 22 |
industry: str = Field(..., description="Industry the developer works in")
|
| 23 |
age: str = Field(..., description="Developer's age range")
|
|
|
|
| 24 |
|
| 25 |
class Config:
|
| 26 |
"""Pydantic configuration."""
|
|
@@ -34,5 +35,6 @@ class SalaryInput(BaseModel):
|
|
| 34 |
"dev_type": "Developer, back-end",
|
| 35 |
"industry": "Software Development",
|
| 36 |
"age": "25-34 years old",
|
|
|
|
| 37 |
}
|
| 38 |
}
|
|
|
|
| 21 |
dev_type: str = Field(..., description="Developer type")
|
| 22 |
industry: str = Field(..., description="Industry the developer works in")
|
| 23 |
age: str = Field(..., description="Developer's age range")
|
| 24 |
+
ic_or_pm: str = Field(..., description="Individual contributor or people manager")
|
| 25 |
|
| 26 |
class Config:
|
| 27 |
"""Pydantic configuration."""
|
|
|
|
| 35 |
"dev_type": "Developer, back-end",
|
| 36 |
"industry": "Software Development",
|
| 37 |
"age": "25-34 years old",
|
| 38 |
+
"ic_or_pm": "Individual contributor",
|
| 39 |
}
|
| 40 |
}
|
src/train.py
CHANGED
|
@@ -25,15 +25,28 @@ def main():
|
|
| 25 |
|
| 26 |
if not data_path.exists():
|
| 27 |
print(f"Error: Data file not found at {data_path}")
|
| 28 |
-
print(
|
|
|
|
|
|
|
| 29 |
print("Download from: https://insights.stackoverflow.com/survey")
|
| 30 |
return
|
| 31 |
|
| 32 |
# Load only required columns to save memory
|
| 33 |
df = pd.read_csv(
|
| 34 |
data_path,
|
| 35 |
-
usecols=[
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
)
|
| 38 |
|
| 39 |
print(f"Loaded {len(df):,} rows")
|
|
@@ -42,13 +55,13 @@ def main():
|
|
| 42 |
# select main label
|
| 43 |
main_label = "ConvertedCompYearly"
|
| 44 |
# select records with main label more than min_salary threshold
|
| 45 |
-
min_salary = config[
|
| 46 |
df = df[df[main_label] > min_salary]
|
| 47 |
# Exclude outliers based on percentile bounds PER COUNTRY
|
| 48 |
# This preserves records from lower-paid and higher-paid countries
|
| 49 |
# that would otherwise be removed by global percentile filtering
|
| 50 |
-
lower_pct = config[
|
| 51 |
-
upper_pct = config[
|
| 52 |
lower_bound = df.groupby("Country")[main_label].transform("quantile", lower_pct)
|
| 53 |
upper_bound = df.groupby("Country")[main_label].transform("quantile", upper_pct)
|
| 54 |
df = df[(df[main_label] > lower_bound) & (df[main_label] < upper_bound)]
|
|
@@ -63,11 +76,12 @@ def main():
|
|
| 63 |
df_copy = df.copy()
|
| 64 |
|
| 65 |
# Normalize Unicode apostrophes to regular apostrophes for consistency
|
| 66 |
-
df_copy["Country"] = df_copy["Country"].str.replace(
|
| 67 |
-
df_copy["EdLevel"] = df_copy["EdLevel"].str.replace(
|
| 68 |
-
df_copy["DevType"] = df_copy["DevType"].str.replace(
|
| 69 |
-
df_copy["Industry"] = df_copy["Industry"].str.replace(
|
| 70 |
-
df_copy["Age"] = df_copy["Age"].str.replace(
|
|
|
|
| 71 |
|
| 72 |
# Apply cardinality reduction
|
| 73 |
df_copy["Country"] = reduce_cardinality(df_copy["Country"])
|
|
@@ -75,6 +89,7 @@ def main():
|
|
| 75 |
df_copy["DevType"] = reduce_cardinality(df_copy["DevType"])
|
| 76 |
df_copy["Industry"] = reduce_cardinality(df_copy["Industry"])
|
| 77 |
df_copy["Age"] = reduce_cardinality(df_copy["Age"])
|
|
|
|
| 78 |
|
| 79 |
# Apply cardinality reduction to the actual training data as well
|
| 80 |
# (prepare_features no longer does this internally)
|
|
@@ -83,6 +98,20 @@ def main():
|
|
| 83 |
df["DevType"] = reduce_cardinality(df["DevType"])
|
| 84 |
df["Industry"] = reduce_cardinality(df["Industry"])
|
| 85 |
df["Age"] = reduce_cardinality(df["Age"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
# Now apply full feature transformations for model training
|
| 88 |
X = prepare_features(df)
|
|
@@ -95,6 +124,7 @@ def main():
|
|
| 95 |
devtype_values = df_copy["DevType"].dropna().unique().tolist()
|
| 96 |
industry_values = df_copy["Industry"].dropna().unique().tolist()
|
| 97 |
age_values = df_copy["Age"].dropna().unique().tolist()
|
|
|
|
| 98 |
|
| 99 |
valid_categories = {
|
| 100 |
"Country": sorted(country_values),
|
|
@@ -102,13 +132,16 @@ def main():
|
|
| 102 |
"DevType": sorted(devtype_values),
|
| 103 |
"Industry": sorted(industry_values),
|
| 104 |
"Age": sorted(age_values),
|
|
|
|
| 105 |
}
|
| 106 |
|
| 107 |
valid_categories_path = Path("config/valid_categories.yaml")
|
| 108 |
with open(valid_categories_path, "w") as f:
|
| 109 |
yaml.dump(valid_categories, f, default_flow_style=False, sort_keys=False)
|
| 110 |
|
| 111 |
-
print(
|
|
|
|
|
|
|
| 112 |
|
| 113 |
# Compute currency conversion rates per country
|
| 114 |
# Use the original data with Currency and CompTotal columns
|
|
@@ -121,7 +154,9 @@ def main():
|
|
| 121 |
# Compute conversion rate: local currency / USD
|
| 122 |
currency_df["rate"] = currency_df["CompTotal"] / currency_df[main_label]
|
| 123 |
# Filter out unreasonable rates (negative, zero, or extreme)
|
| 124 |
-
currency_df = currency_df[
|
|
|
|
|
|
|
| 125 |
|
| 126 |
currency_rates = {}
|
| 127 |
for country in valid_categories["Country"]:
|
|
@@ -147,12 +182,21 @@ def main():
|
|
| 147 |
|
| 148 |
currency_rates_path = Path("config/currency_rates.yaml")
|
| 149 |
with open(currency_rates_path, "w") as f:
|
| 150 |
-
yaml.dump(
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
-
print(
|
|
|
|
|
|
|
| 154 |
for country, info in sorted(currency_rates.items()):
|
| 155 |
-
print(
|
|
|
|
|
|
|
| 156 |
|
| 157 |
print(f"\nFeature matrix shape: {X.shape}")
|
| 158 |
print(f"Total features: {X.shape[1]}")
|
|
@@ -166,31 +210,37 @@ def main():
|
|
| 166 |
print("\n📍 Top 10 Countries:")
|
| 167 |
top_countries = df["Country"].value_counts().head(10)
|
| 168 |
for country, count in top_countries.items():
|
| 169 |
-
print(f" - {country}: {count:,} ({count/len(df)*100:.1f}%)")
|
| 170 |
|
| 171 |
# Show top education levels
|
| 172 |
print("\n🎓 Top Education Levels:")
|
| 173 |
top_edu = df["EdLevel"].value_counts().head(10)
|
| 174 |
for edu, count in top_edu.items():
|
| 175 |
-
print(f" - {edu}: {count:,} ({count/len(df)*100:.1f}%)")
|
| 176 |
|
| 177 |
# Show top developer types
|
| 178 |
print("\n👨💻 Top Developer Types:")
|
| 179 |
top_devtype = df["DevType"].value_counts().head(10)
|
| 180 |
for devtype, count in top_devtype.items():
|
| 181 |
-
print(f" - {devtype}: {count:,} ({count/len(df)*100:.1f}%)")
|
| 182 |
|
| 183 |
# Show top industries
|
| 184 |
print("\n🏢 Top Industries:")
|
| 185 |
top_industry = df["Industry"].value_counts().head(10)
|
| 186 |
for industry, count in top_industry.items():
|
| 187 |
-
print(f" - {industry}: {count:,} ({count/len(df)*100:.1f}%)")
|
| 188 |
|
| 189 |
# Show age distribution
|
| 190 |
print("\n🎂 Age Distribution:")
|
| 191 |
top_age = df["Age"].value_counts().head(10)
|
| 192 |
for age, count in top_age.items():
|
| 193 |
-
print(f" - {age}: {count:,} ({count/len(df)*100:.1f}%)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
# Show YearsCode statistics
|
| 196 |
print("\n💼 Years of Coding Experience:")
|
|
@@ -217,47 +267,81 @@ def main():
|
|
| 217 |
feature_counts = X.sum().sort_values(ascending=False)
|
| 218 |
|
| 219 |
# Exclude numeric features (YearsCode)
|
| 220 |
-
categorical_features = feature_counts[
|
|
|
|
|
|
|
| 221 |
|
| 222 |
# Country features
|
| 223 |
print("\n🌍 Top 15 Country Features (most common):")
|
| 224 |
-
country_features = categorical_features[
|
|
|
|
|
|
|
| 225 |
for i, (feature, count) in enumerate(country_features.head(15).items(), 1):
|
| 226 |
percentage = (count / len(X)) * 100
|
| 227 |
-
country_name = feature.replace(
|
| 228 |
-
print(
|
|
|
|
|
|
|
| 229 |
|
| 230 |
# Education level features
|
| 231 |
print("\n🎓 Top 10 Education Level Features (most common):")
|
| 232 |
-
edlevel_features = categorical_features[
|
|
|
|
|
|
|
| 233 |
for i, (feature, count) in enumerate(edlevel_features.head(10).items(), 1):
|
| 234 |
percentage = (count / len(X)) * 100
|
| 235 |
-
edu_name = feature.replace(
|
| 236 |
-
print(
|
|
|
|
|
|
|
| 237 |
|
| 238 |
# Developer type features
|
| 239 |
print("\n👨💻 Top 10 Developer Type Features (most common):")
|
| 240 |
-
devtype_features = categorical_features[
|
|
|
|
|
|
|
| 241 |
for i, (feature, count) in enumerate(devtype_features.head(10).items(), 1):
|
| 242 |
percentage = (count / len(X)) * 100
|
| 243 |
-
devtype_name = feature.replace(
|
| 244 |
-
print(
|
|
|
|
|
|
|
| 245 |
|
| 246 |
# Industry features
|
| 247 |
print("\n🏢 Top 10 Industry Features (most common):")
|
| 248 |
-
industry_features = categorical_features[
|
|
|
|
|
|
|
| 249 |
for i, (feature, count) in enumerate(industry_features.head(10).items(), 1):
|
| 250 |
percentage = (count / len(X)) * 100
|
| 251 |
-
industry_name = feature.replace(
|
| 252 |
-
print(
|
|
|
|
|
|
|
| 253 |
|
| 254 |
# Age features
|
| 255 |
print("\n🎂 Top 10 Age Features (most common):")
|
| 256 |
-
age_features = categorical_features[
|
|
|
|
|
|
|
| 257 |
for i, (feature, count) in enumerate(age_features.head(10).items(), 1):
|
| 258 |
percentage = (count / len(X)) * 100
|
| 259 |
-
age_name = feature.replace(
|
| 260 |
-
print(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
print(f"\n📊 Total one-hot encoded features: {len(X.columns)}")
|
| 263 |
print(" - Numeric: 2 (YearsCode, WorkExp)")
|
|
@@ -266,13 +350,14 @@ def main():
|
|
| 266 |
print(f" - DevType: {len(devtype_features)}")
|
| 267 |
print(f" - Industry: {len(industry_features)}")
|
| 268 |
print(f" - Age: {len(age_features)}")
|
|
|
|
| 269 |
|
| 270 |
print("=" * 60 + "\n")
|
| 271 |
|
| 272 |
# Cross-validation for robust evaluation
|
| 273 |
-
n_splits = config[
|
| 274 |
-
random_state = config[
|
| 275 |
-
model_config = config[
|
| 276 |
|
| 277 |
print(f"Running {n_splits}-fold cross-validation...")
|
| 278 |
kf = KFold(n_splits=n_splits, shuffle=True, random_state=random_state)
|
|
@@ -286,16 +371,17 @@ def main():
|
|
| 286 |
y_train, y_test = y.iloc[train_idx], y.iloc[test_idx]
|
| 287 |
|
| 288 |
model = XGBRegressor(
|
| 289 |
-
n_estimators=model_config[
|
| 290 |
-
learning_rate=model_config[
|
| 291 |
-
max_depth=model_config[
|
| 292 |
-
min_child_weight=model_config[
|
| 293 |
-
random_state=model_config[
|
| 294 |
-
n_jobs=model_config[
|
| 295 |
-
early_stopping_rounds=model_config[
|
| 296 |
)
|
| 297 |
model.fit(
|
| 298 |
-
X_train,
|
|
|
|
| 299 |
eval_set=[(X_test, y_test)],
|
| 300 |
verbose=False,
|
| 301 |
)
|
|
@@ -305,7 +391,9 @@ def main():
|
|
| 305 |
train_scores.append(train_r2)
|
| 306 |
test_scores.append(test_r2)
|
| 307 |
best_iterations.append(model.best_iteration + 1)
|
| 308 |
-
print(
|
|
|
|
|
|
|
| 309 |
|
| 310 |
avg_train = np.mean(train_scores)
|
| 311 |
avg_test = np.mean(test_scores)
|
|
@@ -323,23 +411,24 @@ def main():
|
|
| 323 |
)
|
| 324 |
|
| 325 |
final_model = XGBRegressor(
|
| 326 |
-
n_estimators=model_config[
|
| 327 |
-
learning_rate=model_config[
|
| 328 |
-
max_depth=model_config[
|
| 329 |
-
min_child_weight=model_config[
|
| 330 |
-
random_state=model_config[
|
| 331 |
-
n_jobs=model_config[
|
| 332 |
-
early_stopping_rounds=model_config[
|
| 333 |
)
|
| 334 |
final_model.fit(
|
| 335 |
-
X_train_final,
|
|
|
|
| 336 |
eval_set=[(X_es, y_es)],
|
| 337 |
-
verbose=config[
|
| 338 |
)
|
| 339 |
print(f"Final model best iteration: {final_model.best_iteration + 1}")
|
| 340 |
|
| 341 |
# Save model and feature columns for inference
|
| 342 |
-
model_path = Path(config[
|
| 343 |
model_path.parent.mkdir(parents=True, exist_ok=True)
|
| 344 |
|
| 345 |
artifacts = {
|
|
|
|
| 25 |
|
| 26 |
if not data_path.exists():
|
| 27 |
print(f"Error: Data file not found at {data_path}")
|
| 28 |
+
print(
|
| 29 |
+
"Please download the Stack Overflow Developer Survey CSV and place it in the data/ directory."
|
| 30 |
+
)
|
| 31 |
print("Download from: https://insights.stackoverflow.com/survey")
|
| 32 |
return
|
| 33 |
|
| 34 |
# Load only required columns to save memory
|
| 35 |
df = pd.read_csv(
|
| 36 |
data_path,
|
| 37 |
+
usecols=[
|
| 38 |
+
"Country",
|
| 39 |
+
"YearsCode",
|
| 40 |
+
"WorkExp",
|
| 41 |
+
"EdLevel",
|
| 42 |
+
"DevType",
|
| 43 |
+
"Industry",
|
| 44 |
+
"Age",
|
| 45 |
+
"ICorPM",
|
| 46 |
+
"Currency",
|
| 47 |
+
"CompTotal",
|
| 48 |
+
"ConvertedCompYearly",
|
| 49 |
+
],
|
| 50 |
)
|
| 51 |
|
| 52 |
print(f"Loaded {len(df):,} rows")
|
|
|
|
| 55 |
# select main label
|
| 56 |
main_label = "ConvertedCompYearly"
|
| 57 |
# select records with main label more than min_salary threshold
|
| 58 |
+
min_salary = config["data"]["min_salary"]
|
| 59 |
df = df[df[main_label] > min_salary]
|
| 60 |
# Exclude outliers based on percentile bounds PER COUNTRY
|
| 61 |
# This preserves records from lower-paid and higher-paid countries
|
| 62 |
# that would otherwise be removed by global percentile filtering
|
| 63 |
+
lower_pct = config["data"]["lower_percentile"] / 100
|
| 64 |
+
upper_pct = config["data"]["upper_percentile"] / 100
|
| 65 |
lower_bound = df.groupby("Country")[main_label].transform("quantile", lower_pct)
|
| 66 |
upper_bound = df.groupby("Country")[main_label].transform("quantile", upper_pct)
|
| 67 |
df = df[(df[main_label] > lower_bound) & (df[main_label] < upper_bound)]
|
|
|
|
| 76 |
df_copy = df.copy()
|
| 77 |
|
| 78 |
# Normalize Unicode apostrophes to regular apostrophes for consistency
|
| 79 |
+
df_copy["Country"] = df_copy["Country"].str.replace("\u2019", "'", regex=False)
|
| 80 |
+
df_copy["EdLevel"] = df_copy["EdLevel"].str.replace("\u2019", "'", regex=False)
|
| 81 |
+
df_copy["DevType"] = df_copy["DevType"].str.replace("\u2019", "'", regex=False)
|
| 82 |
+
df_copy["Industry"] = df_copy["Industry"].str.replace("\u2019", "'", regex=False)
|
| 83 |
+
df_copy["Age"] = df_copy["Age"].str.replace("\u2019", "'", regex=False)
|
| 84 |
+
df_copy["ICorPM"] = df_copy["ICorPM"].str.replace("\u2019", "'", regex=False)
|
| 85 |
|
| 86 |
# Apply cardinality reduction
|
| 87 |
df_copy["Country"] = reduce_cardinality(df_copy["Country"])
|
|
|
|
| 89 |
df_copy["DevType"] = reduce_cardinality(df_copy["DevType"])
|
| 90 |
df_copy["Industry"] = reduce_cardinality(df_copy["Industry"])
|
| 91 |
df_copy["Age"] = reduce_cardinality(df_copy["Age"])
|
| 92 |
+
df_copy["ICorPM"] = reduce_cardinality(df_copy["ICorPM"])
|
| 93 |
|
| 94 |
# Apply cardinality reduction to the actual training data as well
|
| 95 |
# (prepare_features no longer does this internally)
|
|
|
|
| 98 |
df["DevType"] = reduce_cardinality(df["DevType"])
|
| 99 |
df["Industry"] = reduce_cardinality(df["Industry"])
|
| 100 |
df["Age"] = reduce_cardinality(df["Age"])
|
| 101 |
+
df["ICorPM"] = reduce_cardinality(df["ICorPM"])
|
| 102 |
+
|
| 103 |
+
# Drop rows with "Other" in specified features (low-quality catch-all categories)
|
| 104 |
+
other_name = config["features"]["cardinality"].get("other_category", "Other")
|
| 105 |
+
drop_other_from = config["features"]["cardinality"].get("drop_other_from", [])
|
| 106 |
+
if drop_other_from:
|
| 107 |
+
before_drop = len(df)
|
| 108 |
+
for col in drop_other_from:
|
| 109 |
+
df = df[df[col] != other_name]
|
| 110 |
+
df_copy = df_copy[df_copy[col] != other_name]
|
| 111 |
+
print(
|
| 112 |
+
f"Dropped {before_drop - len(df):,} rows with '{other_name}' in {drop_other_from}"
|
| 113 |
+
)
|
| 114 |
+
print(f"After dropping 'Other': {len(df):,} rows")
|
| 115 |
|
| 116 |
# Now apply full feature transformations for model training
|
| 117 |
X = prepare_features(df)
|
|
|
|
| 124 |
devtype_values = df_copy["DevType"].dropna().unique().tolist()
|
| 125 |
industry_values = df_copy["Industry"].dropna().unique().tolist()
|
| 126 |
age_values = df_copy["Age"].dropna().unique().tolist()
|
| 127 |
+
icorpm_values = df_copy["ICorPM"].dropna().unique().tolist()
|
| 128 |
|
| 129 |
valid_categories = {
|
| 130 |
"Country": sorted(country_values),
|
|
|
|
| 132 |
"DevType": sorted(devtype_values),
|
| 133 |
"Industry": sorted(industry_values),
|
| 134 |
"Age": sorted(age_values),
|
| 135 |
+
"ICorPM": sorted(icorpm_values),
|
| 136 |
}
|
| 137 |
|
| 138 |
valid_categories_path = Path("config/valid_categories.yaml")
|
| 139 |
with open(valid_categories_path, "w") as f:
|
| 140 |
yaml.dump(valid_categories, f, default_flow_style=False, sort_keys=False)
|
| 141 |
|
| 142 |
+
print(
|
| 143 |
+
f"\nSaved {len(valid_categories['Country'])} valid countries, {len(valid_categories['EdLevel'])} valid education levels, {len(valid_categories['DevType'])} valid developer types, {len(valid_categories['Industry'])} valid industries, {len(valid_categories['Age'])} valid age ranges, and {len(valid_categories['ICorPM'])} valid IC/PM values to {valid_categories_path}"
|
| 144 |
+
)
|
| 145 |
|
| 146 |
# Compute currency conversion rates per country
|
| 147 |
# Use the original data with Currency and CompTotal columns
|
|
|
|
| 154 |
# Compute conversion rate: local currency / USD
|
| 155 |
currency_df["rate"] = currency_df["CompTotal"] / currency_df[main_label]
|
| 156 |
# Filter out unreasonable rates (negative, zero, or extreme)
|
| 157 |
+
currency_df = currency_df[
|
| 158 |
+
(currency_df["rate"] > 0.001) & (currency_df["rate"] < 100000)
|
| 159 |
+
]
|
| 160 |
|
| 161 |
currency_rates = {}
|
| 162 |
for country in valid_categories["Country"]:
|
|
|
|
| 182 |
|
| 183 |
currency_rates_path = Path("config/currency_rates.yaml")
|
| 184 |
with open(currency_rates_path, "w") as f:
|
| 185 |
+
yaml.dump(
|
| 186 |
+
currency_rates,
|
| 187 |
+
f,
|
| 188 |
+
default_flow_style=False,
|
| 189 |
+
sort_keys=True,
|
| 190 |
+
allow_unicode=True,
|
| 191 |
+
)
|
| 192 |
|
| 193 |
+
print(
|
| 194 |
+
f"Saved currency rates for {len(currency_rates)} countries to {currency_rates_path}"
|
| 195 |
+
)
|
| 196 |
for country, info in sorted(currency_rates.items()):
|
| 197 |
+
print(
|
| 198 |
+
f" {country:45s} -> {info['code']} ({info['name']}, rate: {info['rate']})"
|
| 199 |
+
)
|
| 200 |
|
| 201 |
print(f"\nFeature matrix shape: {X.shape}")
|
| 202 |
print(f"Total features: {X.shape[1]}")
|
|
|
|
| 210 |
print("\n📍 Top 10 Countries:")
|
| 211 |
top_countries = df["Country"].value_counts().head(10)
|
| 212 |
for country, count in top_countries.items():
|
| 213 |
+
print(f" - {country}: {count:,} ({count / len(df) * 100:.1f}%)")
|
| 214 |
|
| 215 |
# Show top education levels
|
| 216 |
print("\n🎓 Top Education Levels:")
|
| 217 |
top_edu = df["EdLevel"].value_counts().head(10)
|
| 218 |
for edu, count in top_edu.items():
|
| 219 |
+
print(f" - {edu}: {count:,} ({count / len(df) * 100:.1f}%)")
|
| 220 |
|
| 221 |
# Show top developer types
|
| 222 |
print("\n👨💻 Top Developer Types:")
|
| 223 |
top_devtype = df["DevType"].value_counts().head(10)
|
| 224 |
for devtype, count in top_devtype.items():
|
| 225 |
+
print(f" - {devtype}: {count:,} ({count / len(df) * 100:.1f}%)")
|
| 226 |
|
| 227 |
# Show top industries
|
| 228 |
print("\n🏢 Top Industries:")
|
| 229 |
top_industry = df["Industry"].value_counts().head(10)
|
| 230 |
for industry, count in top_industry.items():
|
| 231 |
+
print(f" - {industry}: {count:,} ({count / len(df) * 100:.1f}%)")
|
| 232 |
|
| 233 |
# Show age distribution
|
| 234 |
print("\n🎂 Age Distribution:")
|
| 235 |
top_age = df["Age"].value_counts().head(10)
|
| 236 |
for age, count in top_age.items():
|
| 237 |
+
print(f" - {age}: {count:,} ({count / len(df) * 100:.1f}%)")
|
| 238 |
+
|
| 239 |
+
# Show IC or PM distribution
|
| 240 |
+
print("\n👥 IC or PM Distribution:")
|
| 241 |
+
top_icorpm = df["ICorPM"].value_counts().head(10)
|
| 242 |
+
for icorpm, count in top_icorpm.items():
|
| 243 |
+
print(f" - {icorpm}: {count:,} ({count / len(df) * 100:.1f}%)")
|
| 244 |
|
| 245 |
# Show YearsCode statistics
|
| 246 |
print("\n💼 Years of Coding Experience:")
|
|
|
|
| 267 |
feature_counts = X.sum().sort_values(ascending=False)
|
| 268 |
|
| 269 |
# Exclude numeric features (YearsCode)
|
| 270 |
+
categorical_features = feature_counts[
|
| 271 |
+
~feature_counts.index.str.startswith("YearsCode")
|
| 272 |
+
]
|
| 273 |
|
| 274 |
# Country features
|
| 275 |
print("\n🌍 Top 15 Country Features (most common):")
|
| 276 |
+
country_features = categorical_features[
|
| 277 |
+
categorical_features.index.str.startswith("Country_")
|
| 278 |
+
]
|
| 279 |
for i, (feature, count) in enumerate(country_features.head(15).items(), 1):
|
| 280 |
percentage = (count / len(X)) * 100
|
| 281 |
+
country_name = feature.replace("Country_", "")
|
| 282 |
+
print(
|
| 283 |
+
f" {i:2d}. {country_name:45s} - {count:6.0f} occurrences ({percentage:5.1f}%)"
|
| 284 |
+
)
|
| 285 |
|
| 286 |
# Education level features
|
| 287 |
print("\n🎓 Top 10 Education Level Features (most common):")
|
| 288 |
+
edlevel_features = categorical_features[
|
| 289 |
+
categorical_features.index.str.startswith("EdLevel_")
|
| 290 |
+
]
|
| 291 |
for i, (feature, count) in enumerate(edlevel_features.head(10).items(), 1):
|
| 292 |
percentage = (count / len(X)) * 100
|
| 293 |
+
edu_name = feature.replace("EdLevel_", "")
|
| 294 |
+
print(
|
| 295 |
+
f" {i:2d}. {edu_name:45s} - {count:6.0f} occurrences ({percentage:5.1f}%)"
|
| 296 |
+
)
|
| 297 |
|
| 298 |
# Developer type features
|
| 299 |
print("\n👨💻 Top 10 Developer Type Features (most common):")
|
| 300 |
+
devtype_features = categorical_features[
|
| 301 |
+
categorical_features.index.str.startswith("DevType_")
|
| 302 |
+
]
|
| 303 |
for i, (feature, count) in enumerate(devtype_features.head(10).items(), 1):
|
| 304 |
percentage = (count / len(X)) * 100
|
| 305 |
+
devtype_name = feature.replace("DevType_", "")
|
| 306 |
+
print(
|
| 307 |
+
f" {i:2d}. {devtype_name:45s} - {count:6.0f} occurrences ({percentage:5.1f}%)"
|
| 308 |
+
)
|
| 309 |
|
| 310 |
# Industry features
|
| 311 |
print("\n🏢 Top 10 Industry Features (most common):")
|
| 312 |
+
industry_features = categorical_features[
|
| 313 |
+
categorical_features.index.str.startswith("Industry_")
|
| 314 |
+
]
|
| 315 |
for i, (feature, count) in enumerate(industry_features.head(10).items(), 1):
|
| 316 |
percentage = (count / len(X)) * 100
|
| 317 |
+
industry_name = feature.replace("Industry_", "")
|
| 318 |
+
print(
|
| 319 |
+
f" {i:2d}. {industry_name:45s} - {count:6.0f} occurrences ({percentage:5.1f}%)"
|
| 320 |
+
)
|
| 321 |
|
| 322 |
# Age features
|
| 323 |
print("\n🎂 Top 10 Age Features (most common):")
|
| 324 |
+
age_features = categorical_features[
|
| 325 |
+
categorical_features.index.str.startswith("Age_")
|
| 326 |
+
]
|
| 327 |
for i, (feature, count) in enumerate(age_features.head(10).items(), 1):
|
| 328 |
percentage = (count / len(X)) * 100
|
| 329 |
+
age_name = feature.replace("Age_", "")
|
| 330 |
+
print(
|
| 331 |
+
f" {i:2d}. {age_name:45s} - {count:6.0f} occurrences ({percentage:5.1f}%)"
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
# ICorPM features
|
| 335 |
+
print("\n👥 Top 10 IC/PM Features (most common):")
|
| 336 |
+
icorpm_features = categorical_features[
|
| 337 |
+
categorical_features.index.str.startswith("ICorPM_")
|
| 338 |
+
]
|
| 339 |
+
for i, (feature, count) in enumerate(icorpm_features.head(10).items(), 1):
|
| 340 |
+
percentage = (count / len(X)) * 100
|
| 341 |
+
icorpm_name = feature.replace("ICorPM_", "")
|
| 342 |
+
print(
|
| 343 |
+
f" {i:2d}. {icorpm_name:45s} - {count:6.0f} occurrences ({percentage:5.1f}%)"
|
| 344 |
+
)
|
| 345 |
|
| 346 |
print(f"\n📊 Total one-hot encoded features: {len(X.columns)}")
|
| 347 |
print(" - Numeric: 2 (YearsCode, WorkExp)")
|
|
|
|
| 350 |
print(f" - DevType: {len(devtype_features)}")
|
| 351 |
print(f" - Industry: {len(industry_features)}")
|
| 352 |
print(f" - Age: {len(age_features)}")
|
| 353 |
+
print(f" - ICorPM: {len(icorpm_features)}")
|
| 354 |
|
| 355 |
print("=" * 60 + "\n")
|
| 356 |
|
| 357 |
# Cross-validation for robust evaluation
|
| 358 |
+
n_splits = config["data"].get("cv_splits", 5)
|
| 359 |
+
random_state = config["data"]["random_state"]
|
| 360 |
+
model_config = config["model"]
|
| 361 |
|
| 362 |
print(f"Running {n_splits}-fold cross-validation...")
|
| 363 |
kf = KFold(n_splits=n_splits, shuffle=True, random_state=random_state)
|
|
|
|
| 371 |
y_train, y_test = y.iloc[train_idx], y.iloc[test_idx]
|
| 372 |
|
| 373 |
model = XGBRegressor(
|
| 374 |
+
n_estimators=model_config["n_estimators"],
|
| 375 |
+
learning_rate=model_config["learning_rate"],
|
| 376 |
+
max_depth=model_config["max_depth"],
|
| 377 |
+
min_child_weight=model_config["min_child_weight"],
|
| 378 |
+
random_state=model_config["random_state"],
|
| 379 |
+
n_jobs=model_config["n_jobs"],
|
| 380 |
+
early_stopping_rounds=model_config["early_stopping_rounds"],
|
| 381 |
)
|
| 382 |
model.fit(
|
| 383 |
+
X_train,
|
| 384 |
+
y_train,
|
| 385 |
eval_set=[(X_test, y_test)],
|
| 386 |
verbose=False,
|
| 387 |
)
|
|
|
|
| 391 |
train_scores.append(train_r2)
|
| 392 |
test_scores.append(test_r2)
|
| 393 |
best_iterations.append(model.best_iteration + 1)
|
| 394 |
+
print(
|
| 395 |
+
f" Fold {fold}: Train R2 = {train_r2:.4f}, Test R2 = {test_r2:.4f} (best iter: {model.best_iteration + 1})"
|
| 396 |
+
)
|
| 397 |
|
| 398 |
avg_train = np.mean(train_scores)
|
| 399 |
avg_test = np.mean(test_scores)
|
|
|
|
| 411 |
)
|
| 412 |
|
| 413 |
final_model = XGBRegressor(
|
| 414 |
+
n_estimators=model_config["n_estimators"],
|
| 415 |
+
learning_rate=model_config["learning_rate"],
|
| 416 |
+
max_depth=model_config["max_depth"],
|
| 417 |
+
min_child_weight=model_config["min_child_weight"],
|
| 418 |
+
random_state=model_config["random_state"],
|
| 419 |
+
n_jobs=model_config["n_jobs"],
|
| 420 |
+
early_stopping_rounds=model_config["early_stopping_rounds"],
|
| 421 |
)
|
| 422 |
final_model.fit(
|
| 423 |
+
X_train_final,
|
| 424 |
+
y_train_final,
|
| 425 |
eval_set=[(X_es, y_es)],
|
| 426 |
+
verbose=config["training"]["verbose"],
|
| 427 |
)
|
| 428 |
print(f"Final model best iteration: {final_model.best_iteration + 1}")
|
| 429 |
|
| 430 |
# Save model and feature columns for inference
|
| 431 |
+
model_path = Path(config["training"]["model_path"])
|
| 432 |
model_path.parent.mkdir(parents=True, exist_ok=True)
|
| 433 |
|
| 434 |
artifacts = {
|
test_feature_impact.py
CHANGED
|
@@ -17,6 +17,7 @@ def test_years_experience_impact():
|
|
| 17 |
"dev_type": "Developer, full-stack",
|
| 18 |
"industry": "Software Development",
|
| 19 |
"age": "25-34 years old",
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
# Test with different years of experience
|
|
@@ -35,7 +36,9 @@ def test_years_experience_impact():
|
|
| 35 |
print(f"\n✅ PASS: All {len(predictions)} predictions are different")
|
| 36 |
return True
|
| 37 |
else:
|
| 38 |
-
print(
|
|
|
|
|
|
|
| 39 |
return False
|
| 40 |
|
| 41 |
|
|
@@ -52,6 +55,7 @@ def test_country_impact():
|
|
| 52 |
"dev_type": "Developer, full-stack",
|
| 53 |
"industry": "Software Development",
|
| 54 |
"age": "25-34 years old",
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
# Test with different countries (select diverse ones)
|
|
@@ -60,7 +64,7 @@ def test_country_impact():
|
|
| 60 |
"Germany",
|
| 61 |
"India",
|
| 62 |
"Brazil",
|
| 63 |
-
"Poland"
|
| 64 |
]
|
| 65 |
|
| 66 |
# Filter to only countries that exist in valid categories
|
|
@@ -83,8 +87,10 @@ def test_country_impact():
|
|
| 83 |
print(" This indicates the model is NOT using country as a feature!")
|
| 84 |
return False
|
| 85 |
else:
|
| 86 |
-
print(
|
| 87 |
-
|
|
|
|
|
|
|
| 88 |
return False
|
| 89 |
|
| 90 |
|
|
@@ -101,6 +107,7 @@ def test_education_impact():
|
|
| 101 |
"dev_type": "Developer, full-stack",
|
| 102 |
"industry": "Software Development",
|
| 103 |
"age": "25-34 years old",
|
|
|
|
| 104 |
}
|
| 105 |
|
| 106 |
# Test with different education levels
|
|
@@ -133,8 +140,10 @@ def test_education_impact():
|
|
| 133 |
print(" This indicates the model is NOT using education level as a feature!")
|
| 134 |
return False
|
| 135 |
else:
|
| 136 |
-
print(
|
| 137 |
-
|
|
|
|
|
|
|
| 138 |
return False
|
| 139 |
|
| 140 |
|
|
@@ -151,6 +160,7 @@ def test_devtype_impact():
|
|
| 151 |
"education_level": "Bachelor's degree (B.A., B.S., B.Eng., etc.)",
|
| 152 |
"industry": "Software Development",
|
| 153 |
"age": "25-34 years old",
|
|
|
|
| 154 |
}
|
| 155 |
|
| 156 |
# Test with different developer types (using actual values from trained model)
|
|
@@ -183,8 +193,10 @@ def test_devtype_impact():
|
|
| 183 |
print(" This indicates the model is NOT using developer type as a feature!")
|
| 184 |
return False
|
| 185 |
else:
|
| 186 |
-
print(
|
| 187 |
-
|
|
|
|
|
|
|
| 188 |
return False
|
| 189 |
|
| 190 |
|
|
@@ -201,6 +213,7 @@ def test_industry_impact():
|
|
| 201 |
"education_level": "Bachelor's degree (B.A., B.S., B.Eng., etc.)",
|
| 202 |
"dev_type": "Developer, full-stack",
|
| 203 |
"age": "25-34 years old",
|
|
|
|
| 204 |
}
|
| 205 |
|
| 206 |
# Test with different industries (using actual values from trained model)
|
|
@@ -233,8 +246,10 @@ def test_industry_impact():
|
|
| 233 |
print(" This indicates the model is NOT using industry as a feature!")
|
| 234 |
return False
|
| 235 |
else:
|
| 236 |
-
print(
|
| 237 |
-
|
|
|
|
|
|
|
| 238 |
return False
|
| 239 |
|
| 240 |
|
|
@@ -251,6 +266,7 @@ def test_age_impact():
|
|
| 251 |
"education_level": "Bachelor's degree (B.A., B.S., B.Eng., etc.)",
|
| 252 |
"dev_type": "Developer, full-stack",
|
| 253 |
"industry": "Software Development",
|
|
|
|
| 254 |
}
|
| 255 |
|
| 256 |
# Test with different age ranges (using actual values from trained model)
|
|
@@ -282,8 +298,10 @@ def test_age_impact():
|
|
| 282 |
print(" This indicates the model is NOT using age as a feature!")
|
| 283 |
return False
|
| 284 |
else:
|
| 285 |
-
print(
|
| 286 |
-
|
|
|
|
|
|
|
| 287 |
return False
|
| 288 |
|
| 289 |
|
|
@@ -300,6 +318,7 @@ def test_work_exp_impact():
|
|
| 300 |
"dev_type": "Developer, full-stack",
|
| 301 |
"industry": "Software Development",
|
| 302 |
"age": "25-34 years old",
|
|
|
|
| 303 |
}
|
| 304 |
|
| 305 |
# Test with different years of work experience
|
|
@@ -322,34 +341,142 @@ def test_work_exp_impact():
|
|
| 322 |
print(" This indicates the model is NOT using work experience as a feature!")
|
| 323 |
return False
|
| 324 |
else:
|
| 325 |
-
print(
|
| 326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
return False
|
| 328 |
|
| 329 |
|
| 330 |
def test_combined_features():
|
| 331 |
"""Test that combining different features produces expected variations."""
|
| 332 |
print("\n" + "=" * 70)
|
| 333 |
-
print("TEST
|
| 334 |
print("=" * 70)
|
| 335 |
|
| 336 |
# Create diverse combinations (using actual values from trained model)
|
| 337 |
test_cases = [
|
| 338 |
-
(
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
]
|
| 344 |
|
| 345 |
predictions = []
|
| 346 |
-
for
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
# Skip if not in valid categories
|
| 348 |
-
if (
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
| 353 |
continue
|
| 354 |
|
| 355 |
input_data = SalaryInput(
|
|
@@ -360,10 +487,13 @@ def test_combined_features():
|
|
| 360 |
dev_type=devtype,
|
| 361 |
industry=industry,
|
| 362 |
age=age,
|
|
|
|
| 363 |
)
|
| 364 |
salary = predict_salary(input_data)
|
| 365 |
predictions.append(salary)
|
| 366 |
-
print(
|
|
|
|
|
|
|
| 367 |
|
| 368 |
# Check if predictions are different
|
| 369 |
unique_predictions = len(set(predictions))
|
|
@@ -372,7 +502,7 @@ def test_combined_features():
|
|
| 372 |
return True
|
| 373 |
else:
|
| 374 |
print(f"\n⚠️ Only {unique_predictions}/{len(predictions)} unique predictions")
|
| 375 |
-
print(
|
| 376 |
return False
|
| 377 |
|
| 378 |
|
|
@@ -387,12 +517,19 @@ def print_feature_analysis():
|
|
| 387 |
print(f"\nTotal features in model: {len(feature_columns)}")
|
| 388 |
|
| 389 |
# Count by type
|
| 390 |
-
country_features = [f for f in feature_columns if f.startswith(
|
| 391 |
-
edlevel_features = [f for f in feature_columns if f.startswith(
|
| 392 |
-
devtype_features = [f for f in feature_columns if f.startswith(
|
| 393 |
-
industry_features = [f for f in feature_columns if f.startswith(
|
| 394 |
-
age_features = [f for f in feature_columns if f.startswith(
|
| 395 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
|
| 397 |
print(f" - Numeric features: {len(numeric_features)} -> {numeric_features}")
|
| 398 |
print(f" - Country features: {len(country_features)}")
|
|
@@ -400,32 +537,38 @@ def print_feature_analysis():
|
|
| 400 |
print(f" - DevType features: {len(devtype_features)}")
|
| 401 |
print(f" - Industry features: {len(industry_features)}")
|
| 402 |
print(f" - Age features: {len(age_features)}")
|
|
|
|
| 403 |
|
| 404 |
if len(country_features) > 0:
|
| 405 |
-
print(
|
| 406 |
for feat in country_features[:5]:
|
| 407 |
print(f" - {feat}")
|
| 408 |
|
| 409 |
if len(edlevel_features) > 0:
|
| 410 |
-
print(
|
| 411 |
for feat in edlevel_features[:5]:
|
| 412 |
print(f" - {feat}")
|
| 413 |
|
| 414 |
if len(devtype_features) > 0:
|
| 415 |
-
print(
|
| 416 |
for feat in devtype_features[:5]:
|
| 417 |
print(f" - {feat}")
|
| 418 |
|
| 419 |
if len(industry_features) > 0:
|
| 420 |
-
print(
|
| 421 |
for feat in industry_features[:5]:
|
| 422 |
print(f" - {feat}")
|
| 423 |
|
| 424 |
if len(age_features) > 0:
|
| 425 |
-
print(
|
| 426 |
for feat in age_features[:5]:
|
| 427 |
print(f" - {feat}")
|
| 428 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
# Check if there are any features at all
|
| 430 |
if len(country_features) == 0:
|
| 431 |
print("\n⚠️ WARNING: No country features found!")
|
|
@@ -437,6 +580,8 @@ def print_feature_analysis():
|
|
| 437 |
print("\n⚠️ WARNING: No industry features found!")
|
| 438 |
if len(age_features) == 0:
|
| 439 |
print("\n⚠️ WARNING: No age features found!")
|
|
|
|
|
|
|
| 440 |
|
| 441 |
|
| 442 |
def main():
|
|
@@ -458,6 +603,7 @@ def main():
|
|
| 458 |
"Industry": test_industry_impact(),
|
| 459 |
"Age": test_age_impact(),
|
| 460 |
"Work Experience": test_work_exp_impact(),
|
|
|
|
| 461 |
"Combined Features": test_combined_features(),
|
| 462 |
}
|
| 463 |
|
|
@@ -478,8 +624,12 @@ def main():
|
|
| 478 |
if passed_count == total_count:
|
| 479 |
print("\n🎉 All tests passed! The model is using all features correctly.")
|
| 480 |
else:
|
| 481 |
-
print(
|
| 482 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
|
| 484 |
|
| 485 |
if __name__ == "__main__":
|
|
|
|
| 17 |
"dev_type": "Developer, full-stack",
|
| 18 |
"industry": "Software Development",
|
| 19 |
"age": "25-34 years old",
|
| 20 |
+
"ic_or_pm": "Individual contributor",
|
| 21 |
}
|
| 22 |
|
| 23 |
# Test with different years of experience
|
|
|
|
| 36 |
print(f"\n✅ PASS: All {len(predictions)} predictions are different")
|
| 37 |
return True
|
| 38 |
else:
|
| 39 |
+
print(
|
| 40 |
+
f"\n❌ FAIL: Only {unique_predictions}/{len(predictions)} unique predictions"
|
| 41 |
+
)
|
| 42 |
return False
|
| 43 |
|
| 44 |
|
|
|
|
| 55 |
"dev_type": "Developer, full-stack",
|
| 56 |
"industry": "Software Development",
|
| 57 |
"age": "25-34 years old",
|
| 58 |
+
"ic_or_pm": "Individual contributor",
|
| 59 |
}
|
| 60 |
|
| 61 |
# Test with different countries (select diverse ones)
|
|
|
|
| 64 |
"Germany",
|
| 65 |
"India",
|
| 66 |
"Brazil",
|
| 67 |
+
"Poland",
|
| 68 |
]
|
| 69 |
|
| 70 |
# Filter to only countries that exist in valid categories
|
|
|
|
| 87 |
print(" This indicates the model is NOT using country as a feature!")
|
| 88 |
return False
|
| 89 |
else:
|
| 90 |
+
print(
|
| 91 |
+
f"\n⚠️ PARTIAL: Only {unique_predictions}/{len(predictions)} unique predictions"
|
| 92 |
+
)
|
| 93 |
+
print(" Duplicate salaries found - possible feature issue")
|
| 94 |
return False
|
| 95 |
|
| 96 |
|
|
|
|
| 107 |
"dev_type": "Developer, full-stack",
|
| 108 |
"industry": "Software Development",
|
| 109 |
"age": "25-34 years old",
|
| 110 |
+
"ic_or_pm": "Individual contributor",
|
| 111 |
}
|
| 112 |
|
| 113 |
# Test with different education levels
|
|
|
|
| 140 |
print(" This indicates the model is NOT using education level as a feature!")
|
| 141 |
return False
|
| 142 |
else:
|
| 143 |
+
print(
|
| 144 |
+
f"\n⚠️ PARTIAL: Only {unique_predictions}/{len(predictions)} unique predictions"
|
| 145 |
+
)
|
| 146 |
+
print(" Duplicate salaries found - possible feature issue")
|
| 147 |
return False
|
| 148 |
|
| 149 |
|
|
|
|
| 160 |
"education_level": "Bachelor's degree (B.A., B.S., B.Eng., etc.)",
|
| 161 |
"industry": "Software Development",
|
| 162 |
"age": "25-34 years old",
|
| 163 |
+
"ic_or_pm": "Individual contributor",
|
| 164 |
}
|
| 165 |
|
| 166 |
# Test with different developer types (using actual values from trained model)
|
|
|
|
| 193 |
print(" This indicates the model is NOT using developer type as a feature!")
|
| 194 |
return False
|
| 195 |
else:
|
| 196 |
+
print(
|
| 197 |
+
f"\n⚠️ PARTIAL: Only {unique_predictions}/{len(predictions)} unique predictions"
|
| 198 |
+
)
|
| 199 |
+
print(" Duplicate salaries found - possible feature issue")
|
| 200 |
return False
|
| 201 |
|
| 202 |
|
|
|
|
| 213 |
"education_level": "Bachelor's degree (B.A., B.S., B.Eng., etc.)",
|
| 214 |
"dev_type": "Developer, full-stack",
|
| 215 |
"age": "25-34 years old",
|
| 216 |
+
"ic_or_pm": "Individual contributor",
|
| 217 |
}
|
| 218 |
|
| 219 |
# Test with different industries (using actual values from trained model)
|
|
|
|
| 246 |
print(" This indicates the model is NOT using industry as a feature!")
|
| 247 |
return False
|
| 248 |
else:
|
| 249 |
+
print(
|
| 250 |
+
f"\n⚠️ PARTIAL: Only {unique_predictions}/{len(predictions)} unique predictions"
|
| 251 |
+
)
|
| 252 |
+
print(" Duplicate salaries found - possible feature issue")
|
| 253 |
return False
|
| 254 |
|
| 255 |
|
|
|
|
| 266 |
"education_level": "Bachelor's degree (B.A., B.S., B.Eng., etc.)",
|
| 267 |
"dev_type": "Developer, full-stack",
|
| 268 |
"industry": "Software Development",
|
| 269 |
+
"ic_or_pm": "Individual contributor",
|
| 270 |
}
|
| 271 |
|
| 272 |
# Test with different age ranges (using actual values from trained model)
|
|
|
|
| 298 |
print(" This indicates the model is NOT using age as a feature!")
|
| 299 |
return False
|
| 300 |
else:
|
| 301 |
+
print(
|
| 302 |
+
f"\n⚠️ PARTIAL: Only {unique_predictions}/{len(predictions)} unique predictions"
|
| 303 |
+
)
|
| 304 |
+
print(" Duplicate salaries found - possible feature issue")
|
| 305 |
return False
|
| 306 |
|
| 307 |
|
|
|
|
| 318 |
"dev_type": "Developer, full-stack",
|
| 319 |
"industry": "Software Development",
|
| 320 |
"age": "25-34 years old",
|
| 321 |
+
"ic_or_pm": "Individual contributor",
|
| 322 |
}
|
| 323 |
|
| 324 |
# Test with different years of work experience
|
|
|
|
| 341 |
print(" This indicates the model is NOT using work experience as a feature!")
|
| 342 |
return False
|
| 343 |
else:
|
| 344 |
+
print(
|
| 345 |
+
f"\n⚠️ PARTIAL: Only {unique_predictions}/{len(predictions)} unique predictions"
|
| 346 |
+
)
|
| 347 |
+
print(" Duplicate salaries found - possible feature issue")
|
| 348 |
+
return False
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def test_icorpm_impact():
|
| 352 |
+
"""Test that changing IC or PM changes prediction."""
|
| 353 |
+
print("\n" + "=" * 70)
|
| 354 |
+
print("TEST 8: IC or PM Impact")
|
| 355 |
+
print("=" * 70)
|
| 356 |
+
|
| 357 |
+
base_input = {
|
| 358 |
+
"country": "United States of America",
|
| 359 |
+
"years_code": 5.0,
|
| 360 |
+
"work_exp": 3.0,
|
| 361 |
+
"education_level": "Bachelor's degree (B.A., B.S., B.Eng., etc.)",
|
| 362 |
+
"dev_type": "Developer, full-stack",
|
| 363 |
+
"industry": "Software Development",
|
| 364 |
+
"age": "25-34 years old",
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
# Test with different IC/PM values (using actual values from trained model)
|
| 368 |
+
test_icorpm = [
|
| 369 |
+
"Individual contributor",
|
| 370 |
+
"People manager",
|
| 371 |
+
]
|
| 372 |
+
|
| 373 |
+
# Filter to only values that exist in valid categories
|
| 374 |
+
test_icorpm = [v for v in test_icorpm if v in valid_categories["ICorPM"]]
|
| 375 |
+
|
| 376 |
+
predictions = []
|
| 377 |
+
for icorpm in test_icorpm:
|
| 378 |
+
input_data = SalaryInput(**base_input, ic_or_pm=icorpm)
|
| 379 |
+
salary = predict_salary(input_data)
|
| 380 |
+
predictions.append(salary)
|
| 381 |
+
print(f" IC/PM: {icorpm[:50]:50s} -> Salary: ${salary:,.2f}")
|
| 382 |
+
|
| 383 |
+
# Check if predictions are different
|
| 384 |
+
unique_predictions = len(set(predictions))
|
| 385 |
+
if unique_predictions == len(predictions):
|
| 386 |
+
print(f"\n✅ PASS: All {len(predictions)} predictions are different")
|
| 387 |
+
return True
|
| 388 |
+
elif unique_predictions == 1:
|
| 389 |
+
print(f"\n❌ FAIL: All predictions are IDENTICAL (${predictions[0]:,.2f})")
|
| 390 |
+
print(" This indicates the model is NOT using IC/PM as a feature!")
|
| 391 |
+
return False
|
| 392 |
+
else:
|
| 393 |
+
print(
|
| 394 |
+
f"\n⚠️ PARTIAL: Only {unique_predictions}/{len(predictions)} unique predictions"
|
| 395 |
+
)
|
| 396 |
+
print(" Duplicate salaries found - possible feature issue")
|
| 397 |
return False
|
| 398 |
|
| 399 |
|
| 400 |
def test_combined_features():
|
| 401 |
"""Test that combining different features produces expected variations."""
|
| 402 |
print("\n" + "=" * 70)
|
| 403 |
+
print("TEST 9: Combined Feature Variations")
|
| 404 |
print("=" * 70)
|
| 405 |
|
| 406 |
# Create diverse combinations (using actual values from trained model)
|
| 407 |
test_cases = [
|
| 408 |
+
(
|
| 409 |
+
"India",
|
| 410 |
+
2,
|
| 411 |
+
1,
|
| 412 |
+
"Bachelor's degree (B.A., B.S., B.Eng., etc.)",
|
| 413 |
+
"Developer, back-end",
|
| 414 |
+
"Software Development",
|
| 415 |
+
"18-24 years old",
|
| 416 |
+
"Individual contributor",
|
| 417 |
+
),
|
| 418 |
+
(
|
| 419 |
+
"Germany",
|
| 420 |
+
5,
|
| 421 |
+
3,
|
| 422 |
+
"Master's degree (M.A., M.S., M.Eng., MBA, etc.)",
|
| 423 |
+
"Developer, full-stack",
|
| 424 |
+
"Manufacturing",
|
| 425 |
+
"25-34 years old",
|
| 426 |
+
"Individual contributor",
|
| 427 |
+
),
|
| 428 |
+
(
|
| 429 |
+
"United States of America",
|
| 430 |
+
10,
|
| 431 |
+
8,
|
| 432 |
+
"Master's degree (M.A., M.S., M.Eng., MBA, etc.)",
|
| 433 |
+
"Engineering manager",
|
| 434 |
+
"Fintech",
|
| 435 |
+
"35-44 years old",
|
| 436 |
+
"People manager",
|
| 437 |
+
),
|
| 438 |
+
(
|
| 439 |
+
"Poland",
|
| 440 |
+
15,
|
| 441 |
+
12,
|
| 442 |
+
"Bachelor's degree (B.A., B.S., B.Eng., etc.)",
|
| 443 |
+
"Developer, front-end",
|
| 444 |
+
"Healthcare",
|
| 445 |
+
"45-54 years old",
|
| 446 |
+
"Individual contributor",
|
| 447 |
+
),
|
| 448 |
+
(
|
| 449 |
+
"Brazil",
|
| 450 |
+
5,
|
| 451 |
+
3,
|
| 452 |
+
"Some college/university study without earning a degree",
|
| 453 |
+
"DevOps engineer or professional",
|
| 454 |
+
"Government",
|
| 455 |
+
"25-34 years old",
|
| 456 |
+
"Individual contributor",
|
| 457 |
+
),
|
| 458 |
]
|
| 459 |
|
| 460 |
predictions = []
|
| 461 |
+
for (
|
| 462 |
+
country,
|
| 463 |
+
years,
|
| 464 |
+
work_exp,
|
| 465 |
+
education,
|
| 466 |
+
devtype,
|
| 467 |
+
industry,
|
| 468 |
+
age,
|
| 469 |
+
icorpm,
|
| 470 |
+
) in test_cases:
|
| 471 |
# Skip if not in valid categories
|
| 472 |
+
if (
|
| 473 |
+
country not in valid_categories["Country"]
|
| 474 |
+
or education not in valid_categories["EdLevel"]
|
| 475 |
+
or devtype not in valid_categories["DevType"]
|
| 476 |
+
or industry not in valid_categories["Industry"]
|
| 477 |
+
or age not in valid_categories["Age"]
|
| 478 |
+
or icorpm not in valid_categories["ICorPM"]
|
| 479 |
+
):
|
| 480 |
continue
|
| 481 |
|
| 482 |
input_data = SalaryInput(
|
|
|
|
| 487 |
dev_type=devtype,
|
| 488 |
industry=industry,
|
| 489 |
age=age,
|
| 490 |
+
ic_or_pm=icorpm,
|
| 491 |
)
|
| 492 |
salary = predict_salary(input_data)
|
| 493 |
predictions.append(salary)
|
| 494 |
+
print(
|
| 495 |
+
f" {country[:15]:15s} | {years:2d}y | {work_exp:2d}w | {education[:25]:25s} | {devtype[:25]:25s} | {industry[:20]:20s} | {age[:15]:15s} | {icorpm[:5]:5s} -> ${salary:,.2f}"
|
| 496 |
+
)
|
| 497 |
|
| 498 |
# Check if predictions are different
|
| 499 |
unique_predictions = len(set(predictions))
|
|
|
|
| 502 |
return True
|
| 503 |
else:
|
| 504 |
print(f"\n⚠️ Only {unique_predictions}/{len(predictions)} unique predictions")
|
| 505 |
+
print(" Some combinations produce identical salaries")
|
| 506 |
return False
|
| 507 |
|
| 508 |
|
|
|
|
| 517 |
print(f"\nTotal features in model: {len(feature_columns)}")
|
| 518 |
|
| 519 |
# Count by type
|
| 520 |
+
country_features = [f for f in feature_columns if f.startswith("Country_")]
|
| 521 |
+
edlevel_features = [f for f in feature_columns if f.startswith("EdLevel_")]
|
| 522 |
+
devtype_features = [f for f in feature_columns if f.startswith("DevType_")]
|
| 523 |
+
industry_features = [f for f in feature_columns if f.startswith("Industry_")]
|
| 524 |
+
age_features = [f for f in feature_columns if f.startswith("Age_")]
|
| 525 |
+
icorpm_features = [f for f in feature_columns if f.startswith("ICorPM_")]
|
| 526 |
+
numeric_features = [
|
| 527 |
+
f
|
| 528 |
+
for f in feature_columns
|
| 529 |
+
if not f.startswith(
|
| 530 |
+
("Country_", "EdLevel_", "DevType_", "Industry_", "Age_", "ICorPM_")
|
| 531 |
+
)
|
| 532 |
+
]
|
| 533 |
|
| 534 |
print(f" - Numeric features: {len(numeric_features)} -> {numeric_features}")
|
| 535 |
print(f" - Country features: {len(country_features)}")
|
|
|
|
| 537 |
print(f" - DevType features: {len(devtype_features)}")
|
| 538 |
print(f" - Industry features: {len(industry_features)}")
|
| 539 |
print(f" - Age features: {len(age_features)}")
|
| 540 |
+
print(f" - ICorPM features: {len(icorpm_features)}")
|
| 541 |
|
| 542 |
if len(country_features) > 0:
|
| 543 |
+
print("\nSample country features:")
|
| 544 |
for feat in country_features[:5]:
|
| 545 |
print(f" - {feat}")
|
| 546 |
|
| 547 |
if len(edlevel_features) > 0:
|
| 548 |
+
print("\nSample education features:")
|
| 549 |
for feat in edlevel_features[:5]:
|
| 550 |
print(f" - {feat}")
|
| 551 |
|
| 552 |
if len(devtype_features) > 0:
|
| 553 |
+
print("\nSample developer type features:")
|
| 554 |
for feat in devtype_features[:5]:
|
| 555 |
print(f" - {feat}")
|
| 556 |
|
| 557 |
if len(industry_features) > 0:
|
| 558 |
+
print("\nSample industry features:")
|
| 559 |
for feat in industry_features[:5]:
|
| 560 |
print(f" - {feat}")
|
| 561 |
|
| 562 |
if len(age_features) > 0:
|
| 563 |
+
print("\nSample age features:")
|
| 564 |
for feat in age_features[:5]:
|
| 565 |
print(f" - {feat}")
|
| 566 |
|
| 567 |
+
if len(icorpm_features) > 0:
|
| 568 |
+
print("\nSample IC/PM features:")
|
| 569 |
+
for feat in icorpm_features[:5]:
|
| 570 |
+
print(f" - {feat}")
|
| 571 |
+
|
| 572 |
# Check if there are any features at all
|
| 573 |
if len(country_features) == 0:
|
| 574 |
print("\n⚠️ WARNING: No country features found!")
|
|
|
|
| 580 |
print("\n⚠️ WARNING: No industry features found!")
|
| 581 |
if len(age_features) == 0:
|
| 582 |
print("\n⚠️ WARNING: No age features found!")
|
| 583 |
+
if len(icorpm_features) == 0:
|
| 584 |
+
print("\n⚠️ WARNING: No IC/PM features found!")
|
| 585 |
|
| 586 |
|
| 587 |
def main():
|
|
|
|
| 603 |
"Industry": test_industry_impact(),
|
| 604 |
"Age": test_age_impact(),
|
| 605 |
"Work Experience": test_work_exp_impact(),
|
| 606 |
+
"IC or PM": test_icorpm_impact(),
|
| 607 |
"Combined Features": test_combined_features(),
|
| 608 |
}
|
| 609 |
|
|
|
|
| 624 |
if passed_count == total_count:
|
| 625 |
print("\n🎉 All tests passed! The model is using all features correctly.")
|
| 626 |
else:
|
| 627 |
+
print(
|
| 628 |
+
"\n⚠️ Some tests failed. The model may not be using all features properly."
|
| 629 |
+
)
|
| 630 |
+
print(
|
| 631 |
+
" This indicates potential training-testing skew or feature engineering issues."
|
| 632 |
+
)
|
| 633 |
|
| 634 |
|
| 635 |
if __name__ == "__main__":
|
test_fix.py
CHANGED
|
@@ -2,28 +2,33 @@
|
|
| 2 |
|
| 3 |
# Force reload of modules
|
| 4 |
import sys
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
| 9 |
|
| 10 |
from src.preprocessing import prepare_features
|
| 11 |
import pandas as pd
|
| 12 |
|
| 13 |
# Create test inputs with different countries (values from valid_categories)
|
| 14 |
-
input1 = pd.DataFrame(
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
print("Testing prepare_features with different countries...")
|
| 29 |
features1 = prepare_features(input1)
|
|
|
|
| 2 |
|
| 3 |
# Force reload of modules
|
| 4 |
import sys
|
| 5 |
+
|
| 6 |
+
if "src.preprocessing" in sys.modules:
|
| 7 |
+
del sys.modules["src.preprocessing"]
|
| 8 |
+
if "src.infer" in sys.modules:
|
| 9 |
+
del sys.modules["src.infer"]
|
| 10 |
|
| 11 |
from src.preprocessing import prepare_features
|
| 12 |
import pandas as pd
|
| 13 |
|
| 14 |
# Create test inputs with different countries (values from valid_categories)
|
| 15 |
+
input1 = pd.DataFrame(
|
| 16 |
+
{
|
| 17 |
+
"Country": ["United States of America"],
|
| 18 |
+
"YearsCode": [5.0],
|
| 19 |
+
"EdLevel": ["Bachelor's degree (B.A., B.S., B.Eng., etc.)"],
|
| 20 |
+
"DevType": ["Developer, full-stack"],
|
| 21 |
+
}
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
input2 = pd.DataFrame(
|
| 25 |
+
{
|
| 26 |
+
"Country": ["Germany"],
|
| 27 |
+
"YearsCode": [5.0],
|
| 28 |
+
"EdLevel": ["Bachelor's degree (B.A., B.S., B.Eng., etc.)"],
|
| 29 |
+
"DevType": ["Developer, full-stack"],
|
| 30 |
+
}
|
| 31 |
+
)
|
| 32 |
|
| 33 |
print("Testing prepare_features with different countries...")
|
| 34 |
features1 = prepare_features(input1)
|
tests/__init__.py
ADDED
|
File without changes
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared fixtures for pytest tests."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
import yaml
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@pytest.fixture
|
| 10 |
+
def sample_salary_input():
|
| 11 |
+
"""Return a dict with valid SalaryInput fields."""
|
| 12 |
+
return {
|
| 13 |
+
"country": "United States of America",
|
| 14 |
+
"years_code": 5.0,
|
| 15 |
+
"work_exp": 3.0,
|
| 16 |
+
"education_level": "Bachelor's degree (B.A., B.S., B.Eng., etc.)",
|
| 17 |
+
"dev_type": "Developer, full-stack",
|
| 18 |
+
"industry": "Software Development",
|
| 19 |
+
"age": "25-34 years old",
|
| 20 |
+
"ic_or_pm": "Individual contributor",
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@pytest.fixture
|
| 25 |
+
def valid_categories_data():
|
| 26 |
+
"""Load and return valid categories from config."""
|
| 27 |
+
path = Path("config/valid_categories.yaml")
|
| 28 |
+
with open(path, "r") as f:
|
| 29 |
+
return yaml.safe_load(f)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@pytest.fixture
|
| 33 |
+
def model_config():
|
| 34 |
+
"""Load and return model parameters config."""
|
| 35 |
+
path = Path("config/model_parameters.yaml")
|
| 36 |
+
with open(path, "r") as f:
|
| 37 |
+
return yaml.safe_load(f)
|
tests/test_infer.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for src/infer.py - Inference and validation."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from src.infer import get_local_currency, predict_salary
|
| 6 |
+
from src.schema import SalaryInput
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_predict_salary_returns_positive_float(sample_salary_input):
|
| 10 |
+
"""predict_salary returns a positive float."""
|
| 11 |
+
result = predict_salary(SalaryInput(**sample_salary_input))
|
| 12 |
+
assert isinstance(result, float)
|
| 13 |
+
assert result > 0
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_invalid_country(sample_salary_input):
|
| 17 |
+
"""Invalid country raises ValueError."""
|
| 18 |
+
sample_salary_input["country"] = "Narnia"
|
| 19 |
+
with pytest.raises(ValueError, match="Invalid country"):
|
| 20 |
+
predict_salary(SalaryInput(**sample_salary_input))
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_invalid_education_level(sample_salary_input):
|
| 24 |
+
"""Invalid education level raises ValueError."""
|
| 25 |
+
sample_salary_input["education_level"] = "Fake Degree"
|
| 26 |
+
with pytest.raises(ValueError, match="Invalid education level"):
|
| 27 |
+
predict_salary(SalaryInput(**sample_salary_input))
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_invalid_dev_type(sample_salary_input):
|
| 31 |
+
"""Invalid developer type raises ValueError."""
|
| 32 |
+
sample_salary_input["dev_type"] = "Wizard"
|
| 33 |
+
with pytest.raises(ValueError, match="Invalid developer type"):
|
| 34 |
+
predict_salary(SalaryInput(**sample_salary_input))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_invalid_industry(sample_salary_input):
|
| 38 |
+
"""Invalid industry raises ValueError."""
|
| 39 |
+
sample_salary_input["industry"] = "Space Tourism"
|
| 40 |
+
with pytest.raises(ValueError, match="Invalid industry"):
|
| 41 |
+
predict_salary(SalaryInput(**sample_salary_input))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_invalid_age(sample_salary_input):
|
| 45 |
+
"""Invalid age raises ValueError."""
|
| 46 |
+
sample_salary_input["age"] = "100+ years old"
|
| 47 |
+
with pytest.raises(ValueError, match="Invalid age"):
|
| 48 |
+
predict_salary(SalaryInput(**sample_salary_input))
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_invalid_ic_or_pm(sample_salary_input):
|
| 52 |
+
"""Invalid IC/PM value raises ValueError."""
|
| 53 |
+
sample_salary_input["ic_or_pm"] = "CEO"
|
| 54 |
+
with pytest.raises(ValueError, match="Invalid IC or PM"):
|
| 55 |
+
predict_salary(SalaryInput(**sample_salary_input))
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_get_local_currency_unknown_country():
|
| 59 |
+
"""get_local_currency returns None for unknown country."""
|
| 60 |
+
result = get_local_currency("Narnia", 100000)
|
| 61 |
+
assert result is None
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_get_local_currency_known_country():
|
| 65 |
+
"""get_local_currency returns dict with expected keys for a known country."""
|
| 66 |
+
# Use a country that is likely in currency_rates
|
| 67 |
+
result = get_local_currency("United States of America", 100000)
|
| 68 |
+
if result is not None:
|
| 69 |
+
assert "code" in result
|
| 70 |
+
assert "name" in result
|
| 71 |
+
assert "rate" in result
|
| 72 |
+
assert "salary_local" in result
|
| 73 |
+
assert isinstance(result["salary_local"], float)
|
tests/test_preprocessing.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for src/preprocessing.py - Feature engineering utilities."""
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import pandas as pd
|
| 5 |
+
|
| 6 |
+
from src.preprocessing import (
|
| 7 |
+
normalize_other_categories,
|
| 8 |
+
prepare_features,
|
| 9 |
+
reduce_cardinality,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class TestNormalizeOtherCategories:
|
| 14 |
+
"""Tests for normalize_other_categories()."""
|
| 15 |
+
|
| 16 |
+
def test_replaces_other_please_specify(self):
|
| 17 |
+
"""'Other (please specify):' is replaced with 'Other'."""
|
| 18 |
+
series = pd.Series(["Other (please specify):", "Developer, back-end"])
|
| 19 |
+
result = normalize_other_categories(series)
|
| 20 |
+
assert result.iloc[0] == "Other"
|
| 21 |
+
assert result.iloc[1] == "Developer, back-end"
|
| 22 |
+
|
| 23 |
+
def test_replaces_other_colon(self):
|
| 24 |
+
"""'Other:' is replaced with 'Other'."""
|
| 25 |
+
series = pd.Series(["Other:", "Software Development"])
|
| 26 |
+
result = normalize_other_categories(series)
|
| 27 |
+
assert result.iloc[0] == "Other"
|
| 28 |
+
|
| 29 |
+
def test_leaves_non_other_unchanged(self):
|
| 30 |
+
"""Non-Other values are not modified."""
|
| 31 |
+
values = ["Developer, back-end", "Software Development", "India"]
|
| 32 |
+
series = pd.Series(values)
|
| 33 |
+
result = normalize_other_categories(series)
|
| 34 |
+
assert list(result) == values
|
| 35 |
+
|
| 36 |
+
def test_preserves_exact_other(self):
|
| 37 |
+
"""Exact 'Other' is kept as-is."""
|
| 38 |
+
series = pd.Series(["Other"])
|
| 39 |
+
result = normalize_other_categories(series)
|
| 40 |
+
assert result.iloc[0] == "Other"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class TestReduceCardinality:
|
| 44 |
+
"""Tests for reduce_cardinality()."""
|
| 45 |
+
|
| 46 |
+
def test_groups_rare_categories(self):
|
| 47 |
+
"""Rare categories are grouped into 'Other'."""
|
| 48 |
+
# Create series with one dominant and many rare categories
|
| 49 |
+
values = ["Common"] * 100 + ["Rare1", "Rare2", "Rare3"]
|
| 50 |
+
series = pd.Series(values)
|
| 51 |
+
result = reduce_cardinality(series, max_categories=5, min_frequency=10)
|
| 52 |
+
assert "Common" in result.values
|
| 53 |
+
assert "Rare1" not in result.values
|
| 54 |
+
assert (result == "Other").sum() == 3
|
| 55 |
+
|
| 56 |
+
def test_keeps_frequent_categories(self):
|
| 57 |
+
"""Frequent categories are kept intact."""
|
| 58 |
+
values = ["A"] * 100 + ["B"] * 80 + ["C"] * 60
|
| 59 |
+
series = pd.Series(values)
|
| 60 |
+
result = reduce_cardinality(series, max_categories=5, min_frequency=50)
|
| 61 |
+
assert set(result.unique()) == {"A", "B", "C"}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class TestPrepareFeatures:
|
| 65 |
+
"""Tests for prepare_features()."""
|
| 66 |
+
|
| 67 |
+
def test_returns_dataframe_with_numeric_columns(self):
|
| 68 |
+
"""Output contains YearsCode and WorkExp as numeric columns."""
|
| 69 |
+
df = pd.DataFrame(
|
| 70 |
+
{
|
| 71 |
+
"Country": ["India"],
|
| 72 |
+
"YearsCode": [5.0],
|
| 73 |
+
"WorkExp": [3.0],
|
| 74 |
+
"EdLevel": ["Other"],
|
| 75 |
+
"DevType": ["Developer, back-end"],
|
| 76 |
+
"Industry": ["Software Development"],
|
| 77 |
+
"Age": ["25-34 years old"],
|
| 78 |
+
"ICorPM": ["Individual contributor"],
|
| 79 |
+
}
|
| 80 |
+
)
|
| 81 |
+
result = prepare_features(df)
|
| 82 |
+
assert "YearsCode" in result.columns
|
| 83 |
+
assert "WorkExp" in result.columns
|
| 84 |
+
|
| 85 |
+
def test_fills_missing_numeric_with_zero(self):
|
| 86 |
+
"""Missing numeric values are filled with 0."""
|
| 87 |
+
df = pd.DataFrame(
|
| 88 |
+
{
|
| 89 |
+
"Country": ["India"],
|
| 90 |
+
"YearsCode": [np.nan],
|
| 91 |
+
"WorkExp": [np.nan],
|
| 92 |
+
"EdLevel": ["Other"],
|
| 93 |
+
"DevType": ["Developer, back-end"],
|
| 94 |
+
"Industry": ["Software Development"],
|
| 95 |
+
"Age": ["25-34 years old"],
|
| 96 |
+
"ICorPM": ["Individual contributor"],
|
| 97 |
+
}
|
| 98 |
+
)
|
| 99 |
+
result = prepare_features(df)
|
| 100 |
+
assert result["YearsCode"].iloc[0] == 0.0
|
| 101 |
+
assert result["WorkExp"].iloc[0] == 0.0
|
| 102 |
+
|
| 103 |
+
def test_one_hot_encodes_categorical_columns(self):
|
| 104 |
+
"""Categorical columns are one-hot encoded."""
|
| 105 |
+
df = pd.DataFrame(
|
| 106 |
+
{
|
| 107 |
+
"Country": ["India", "Germany"],
|
| 108 |
+
"YearsCode": [5.0, 10.0],
|
| 109 |
+
"WorkExp": [3.0, 8.0],
|
| 110 |
+
"EdLevel": ["Other", "Other"],
|
| 111 |
+
"DevType": ["Developer, back-end", "Developer, front-end"],
|
| 112 |
+
"Industry": ["Software Development", "Healthcare"],
|
| 113 |
+
"Age": ["25-34 years old", "35-44 years old"],
|
| 114 |
+
"ICorPM": ["Individual contributor", "People manager"],
|
| 115 |
+
}
|
| 116 |
+
)
|
| 117 |
+
result = prepare_features(df)
|
| 118 |
+
# Should have one-hot columns for categorical features
|
| 119 |
+
categorical_cols = [
|
| 120 |
+
c for c in result.columns if "_" in c and c not in ("YearsCode", "WorkExp")
|
| 121 |
+
]
|
| 122 |
+
assert len(categorical_cols) > 0
|
| 123 |
+
|
| 124 |
+
def test_does_not_modify_original(self):
|
| 125 |
+
"""prepare_features does not modify the input DataFrame."""
|
| 126 |
+
df = pd.DataFrame(
|
| 127 |
+
{
|
| 128 |
+
"Country": ["India"],
|
| 129 |
+
"YearsCode": [5.0],
|
| 130 |
+
"WorkExp": [3.0],
|
| 131 |
+
"EdLevel": ["Other"],
|
| 132 |
+
"DevType": ["Developer, back-end"],
|
| 133 |
+
"Industry": ["Software Development"],
|
| 134 |
+
"Age": ["25-34 years old"],
|
| 135 |
+
"ICorPM": ["Individual contributor"],
|
| 136 |
+
}
|
| 137 |
+
)
|
| 138 |
+
original_country = df["Country"].iloc[0]
|
| 139 |
+
prepare_features(df)
|
| 140 |
+
assert df["Country"].iloc[0] == original_country
|
tests/test_schema.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for src/schema.py - Pydantic input validation."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from pydantic import ValidationError
|
| 5 |
+
|
| 6 |
+
from src.schema import SalaryInput
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_valid_input(sample_salary_input):
|
| 10 |
+
"""Valid input creates SalaryInput successfully."""
|
| 11 |
+
result = SalaryInput(**sample_salary_input)
|
| 12 |
+
assert result.country == sample_salary_input["country"]
|
| 13 |
+
assert result.years_code == sample_salary_input["years_code"]
|
| 14 |
+
assert result.work_exp == sample_salary_input["work_exp"]
|
| 15 |
+
assert result.education_level == sample_salary_input["education_level"]
|
| 16 |
+
assert result.dev_type == sample_salary_input["dev_type"]
|
| 17 |
+
assert result.industry == sample_salary_input["industry"]
|
| 18 |
+
assert result.age == sample_salary_input["age"]
|
| 19 |
+
assert result.ic_or_pm == sample_salary_input["ic_or_pm"]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_negative_years_code(sample_salary_input):
|
| 23 |
+
"""Negative years_code raises ValidationError."""
|
| 24 |
+
sample_salary_input["years_code"] = -1.0
|
| 25 |
+
with pytest.raises(ValidationError):
|
| 26 |
+
SalaryInput(**sample_salary_input)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_negative_work_exp(sample_salary_input):
|
| 30 |
+
"""Negative work_exp raises ValidationError."""
|
| 31 |
+
sample_salary_input["work_exp"] = -5.0
|
| 32 |
+
with pytest.raises(ValidationError):
|
| 33 |
+
SalaryInput(**sample_salary_input)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_missing_country():
|
| 37 |
+
"""Missing required field raises ValidationError."""
|
| 38 |
+
with pytest.raises(ValidationError):
|
| 39 |
+
SalaryInput(
|
| 40 |
+
years_code=5.0,
|
| 41 |
+
work_exp=3.0,
|
| 42 |
+
education_level="Bachelor's degree (B.A., B.S., B.Eng., etc.)",
|
| 43 |
+
dev_type="Developer, full-stack",
|
| 44 |
+
industry="Software Development",
|
| 45 |
+
age="25-34 years old",
|
| 46 |
+
ic_or_pm="Individual contributor",
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_missing_education_level():
|
| 51 |
+
"""Missing education_level raises ValidationError."""
|
| 52 |
+
with pytest.raises(ValidationError):
|
| 53 |
+
SalaryInput(
|
| 54 |
+
country="United States of America",
|
| 55 |
+
years_code=5.0,
|
| 56 |
+
work_exp=3.0,
|
| 57 |
+
dev_type="Developer, full-stack",
|
| 58 |
+
industry="Software Development",
|
| 59 |
+
age="25-34 years old",
|
| 60 |
+
ic_or_pm="Individual contributor",
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_zero_years_code(sample_salary_input):
|
| 65 |
+
"""Zero years_code is valid (ge=0)."""
|
| 66 |
+
sample_salary_input["years_code"] = 0.0
|
| 67 |
+
result = SalaryInput(**sample_salary_input)
|
| 68 |
+
assert result.years_code == 0.0
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_zero_work_exp(sample_salary_input):
|
| 72 |
+
"""Zero work_exp is valid (ge=0)."""
|
| 73 |
+
sample_salary_input["work_exp"] = 0.0
|
| 74 |
+
result = SalaryInput(**sample_salary_input)
|
| 75 |
+
assert result.work_exp == 0.0
|
uv.lock
CHANGED
|
@@ -44,6 +44,21 @@ wheels = [
|
|
| 44 |
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
| 45 |
]
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
[[package]]
|
| 48 |
name = "blinker"
|
| 49 |
version = "1.9.0"
|
|
@@ -53,6 +68,33 @@ wheels = [
|
|
| 53 |
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
| 54 |
]
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
[[package]]
|
| 57 |
name = "cachetools"
|
| 58 |
version = "6.2.6"
|
|
@@ -149,30 +191,171 @@ wheels = [
|
|
| 149 |
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
| 150 |
]
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
[[package]]
|
| 153 |
name = "developer-salary-prediction"
|
| 154 |
version = "0.1.0"
|
| 155 |
source = { virtual = "." }
|
| 156 |
dependencies = [
|
|
|
|
|
|
|
| 157 |
{ name = "pandas" },
|
|
|
|
| 158 |
{ name = "pydantic" },
|
| 159 |
{ name = "pyyaml" },
|
|
|
|
| 160 |
{ name = "ruff" },
|
| 161 |
{ name = "scikit-learn" },
|
| 162 |
{ name = "streamlit" },
|
| 163 |
{ name = "xgboost" },
|
| 164 |
]
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
[package.metadata]
|
| 167 |
requires-dist = [
|
|
|
|
|
|
|
|
|
|
| 168 |
{ name = "pandas", specifier = ">=2.0.0" },
|
|
|
|
|
|
|
| 169 |
{ name = "pydantic", specifier = ">=2.0.0" },
|
|
|
|
|
|
|
| 170 |
{ name = "pyyaml", specifier = ">=6.0.0" },
|
|
|
|
|
|
|
| 171 |
{ name = "ruff", specifier = ">=0.15.0" },
|
| 172 |
{ name = "scikit-learn", specifier = ">=1.3.0" },
|
| 173 |
{ name = "streamlit", specifier = ">=1.28.0" },
|
| 174 |
{ name = "xgboost", specifier = ">=3.1.0" },
|
| 175 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
[[package]]
|
| 178 |
name = "gitdb"
|
|
@@ -207,6 +390,15 @@ wheels = [
|
|
| 207 |
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
| 208 |
]
|
| 209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
[[package]]
|
| 211 |
name = "jinja2"
|
| 212 |
version = "3.1.6"
|
|
@@ -255,6 +447,42 @@ wheels = [
|
|
| 255 |
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
| 256 |
]
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
[[package]]
|
| 259 |
name = "markupsafe"
|
| 260 |
version = "3.0.3"
|
|
@@ -318,6 +546,59 @@ wheels = [
|
|
| 318 |
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
| 319 |
]
|
| 320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
[[package]]
|
| 322 |
name = "narwhals"
|
| 323 |
version = "2.16.0"
|
|
@@ -397,6 +678,15 @@ wheels = [
|
|
| 397 |
{ url = "https://files.pythonhosted.org/packages/31/5a/cac7d231f322b66caa16fd4b136ebc8e4b18b2805811c2d58dc47210cdea/nvidia_nccl_cu12-2.29.3-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:35ad42e7d5d722a83c36a3a478e281c20a5646383deaf1b9ed1a9ab7d61bed53", size = 289760316, upload-time = "2026-02-03T21:11:37.899Z" },
|
| 398 |
]
|
| 399 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
[[package]]
|
| 401 |
name = "packaging"
|
| 402 |
version = "26.0"
|
|
@@ -522,6 +812,79 @@ wheels = [
|
|
| 522 |
{ url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
|
| 523 |
]
|
| 524 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
[[package]]
|
| 526 |
name = "protobuf"
|
| 527 |
version = "6.33.5"
|
|
@@ -537,6 +900,18 @@ wheels = [
|
|
| 537 |
{ url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
|
| 538 |
]
|
| 539 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
[[package]]
|
| 541 |
name = "pyarrow"
|
| 542 |
version = "23.0.0"
|
|
@@ -679,6 +1054,54 @@ wheels = [
|
|
| 679 |
{ url = "https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038", size = 6900403, upload-time = "2024-05-10T15:36:17.36Z" },
|
| 680 |
]
|
| 681 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
[[package]]
|
| 683 |
name = "python-dateutil"
|
| 684 |
version = "2.9.0.post0"
|
|
@@ -746,6 +1169,19 @@ wheels = [
|
|
| 746 |
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
| 747 |
]
|
| 748 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
[[package]]
|
| 750 |
name = "referencing"
|
| 751 |
version = "0.37.0"
|
|
@@ -775,6 +1211,19 @@ wheels = [
|
|
| 775 |
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
| 776 |
]
|
| 777 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 778 |
[[package]]
|
| 779 |
name = "rpds-py"
|
| 780 |
version = "0.30.0"
|
|
@@ -1004,6 +1453,24 @@ wheels = [
|
|
| 1004 |
{ url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" },
|
| 1005 |
]
|
| 1006 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1007 |
[[package]]
|
| 1008 |
name = "streamlit"
|
| 1009 |
version = "1.54.0"
|
|
@@ -1060,6 +1527,60 @@ wheels = [
|
|
| 1060 |
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
|
| 1061 |
]
|
| 1062 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1063 |
[[package]]
|
| 1064 |
name = "tornado"
|
| 1065 |
version = "6.5.4"
|
|
|
|
| 44 |
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
| 45 |
]
|
| 46 |
|
| 47 |
+
[[package]]
|
| 48 |
+
name = "bandit"
|
| 49 |
+
version = "1.9.3"
|
| 50 |
+
source = { registry = "https://pypi.org/simple" }
|
| 51 |
+
dependencies = [
|
| 52 |
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
| 53 |
+
{ name = "pyyaml" },
|
| 54 |
+
{ name = "rich" },
|
| 55 |
+
{ name = "stevedore" },
|
| 56 |
+
]
|
| 57 |
+
sdist = { url = "https://files.pythonhosted.org/packages/89/76/a7f3e639b78601118aaa4a394db2c66ae2597fbd8c39644c32874ed11e0c/bandit-1.9.3.tar.gz", hash = "sha256:ade4b9b7786f89ef6fc7344a52b34558caec5da74cb90373aed01de88472f774", size = 4242154, upload-time = "2026-01-19T04:05:22.802Z" }
|
| 58 |
+
wheels = [
|
| 59 |
+
{ url = "https://files.pythonhosted.org/packages/e0/0b/8bdc52111c83e2dc2f97403dc87c0830b8989d9ae45732b34b686326fb2c/bandit-1.9.3-py3-none-any.whl", hash = "sha256:4745917c88d2246def79748bde5e08b9d5e9b92f877863d43fab70cd8814ce6a", size = 134451, upload-time = "2026-01-19T04:05:20.938Z" },
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
[[package]]
|
| 63 |
name = "blinker"
|
| 64 |
version = "1.9.0"
|
|
|
|
| 68 |
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
| 69 |
]
|
| 70 |
|
| 71 |
+
[[package]]
|
| 72 |
+
name = "boolean-py"
|
| 73 |
+
version = "5.0"
|
| 74 |
+
source = { registry = "https://pypi.org/simple" }
|
| 75 |
+
sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" }
|
| 76 |
+
wheels = [
|
| 77 |
+
{ url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" },
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
[[package]]
|
| 81 |
+
name = "cachecontrol"
|
| 82 |
+
version = "0.14.4"
|
| 83 |
+
source = { registry = "https://pypi.org/simple" }
|
| 84 |
+
dependencies = [
|
| 85 |
+
{ name = "msgpack" },
|
| 86 |
+
{ name = "requests" },
|
| 87 |
+
]
|
| 88 |
+
sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" }
|
| 89 |
+
wheels = [
|
| 90 |
+
{ url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" },
|
| 91 |
+
]
|
| 92 |
+
|
| 93 |
+
[package.optional-dependencies]
|
| 94 |
+
filecache = [
|
| 95 |
+
{ name = "filelock" },
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
[[package]]
|
| 99 |
name = "cachetools"
|
| 100 |
version = "6.2.6"
|
|
|
|
| 191 |
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
| 192 |
]
|
| 193 |
|
| 194 |
+
[[package]]
|
| 195 |
+
name = "coverage"
|
| 196 |
+
version = "7.13.4"
|
| 197 |
+
source = { registry = "https://pypi.org/simple" }
|
| 198 |
+
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
|
| 199 |
+
wheels = [
|
| 200 |
+
{ url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
|
| 201 |
+
{ url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
|
| 202 |
+
{ url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
|
| 203 |
+
{ url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
|
| 204 |
+
{ url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
|
| 205 |
+
{ url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
|
| 206 |
+
{ url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
|
| 207 |
+
{ url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
|
| 208 |
+
{ url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
|
| 209 |
+
{ url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
|
| 210 |
+
{ url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
|
| 211 |
+
{ url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
|
| 212 |
+
{ url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
|
| 213 |
+
{ url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
|
| 214 |
+
{ url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
|
| 215 |
+
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
|
| 216 |
+
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
|
| 217 |
+
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
|
| 218 |
+
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
|
| 219 |
+
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
|
| 220 |
+
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
|
| 221 |
+
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
|
| 222 |
+
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
|
| 223 |
+
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
|
| 224 |
+
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
|
| 225 |
+
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
|
| 226 |
+
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
|
| 227 |
+
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
|
| 228 |
+
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
|
| 229 |
+
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
|
| 230 |
+
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
|
| 231 |
+
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
|
| 232 |
+
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
|
| 233 |
+
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
|
| 234 |
+
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
|
| 235 |
+
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
|
| 236 |
+
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
|
| 237 |
+
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
|
| 238 |
+
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
|
| 239 |
+
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
|
| 240 |
+
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
|
| 241 |
+
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
|
| 242 |
+
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
|
| 243 |
+
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
|
| 244 |
+
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
|
| 245 |
+
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
|
| 246 |
+
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
|
| 247 |
+
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
|
| 248 |
+
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
|
| 249 |
+
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
|
| 250 |
+
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
|
| 251 |
+
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
|
| 252 |
+
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
|
| 253 |
+
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
|
| 254 |
+
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
|
| 255 |
+
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
|
| 256 |
+
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
|
| 257 |
+
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
|
| 258 |
+
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
|
| 259 |
+
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
|
| 260 |
+
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
|
| 261 |
+
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
|
| 262 |
+
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
|
| 263 |
+
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
|
| 264 |
+
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
|
| 265 |
+
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
|
| 266 |
+
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
|
| 267 |
+
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
|
| 268 |
+
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
|
| 269 |
+
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
|
| 270 |
+
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
|
| 271 |
+
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
|
| 272 |
+
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
|
| 273 |
+
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
|
| 274 |
+
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
|
| 275 |
+
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
| 276 |
+
]
|
| 277 |
+
|
| 278 |
+
[[package]]
|
| 279 |
+
name = "cyclonedx-python-lib"
|
| 280 |
+
version = "11.6.0"
|
| 281 |
+
source = { registry = "https://pypi.org/simple" }
|
| 282 |
+
dependencies = [
|
| 283 |
+
{ name = "license-expression" },
|
| 284 |
+
{ name = "packageurl-python" },
|
| 285 |
+
{ name = "py-serializable" },
|
| 286 |
+
{ name = "sortedcontainers" },
|
| 287 |
+
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
| 288 |
+
]
|
| 289 |
+
sdist = { url = "https://files.pythonhosted.org/packages/89/ed/54ecfa25fc145c58bf4f98090f7b6ffe5188d0759248c57dde44427ea239/cyclonedx_python_lib-11.6.0.tar.gz", hash = "sha256:7fb85a4371fa3a203e5be577ac22b7e9a7157f8b0058b7448731474d6dea7bf0", size = 1408147, upload-time = "2025-12-02T12:28:46.446Z" }
|
| 290 |
+
wheels = [
|
| 291 |
+
{ url = "https://files.pythonhosted.org/packages/c7/1b/534ad8a5e0f9470522811a8e5a9bc5d328fb7738ba29faf357467a4ef6d0/cyclonedx_python_lib-11.6.0-py3-none-any.whl", hash = "sha256:94f4aae97db42a452134dafdddcfab9745324198201c4777ed131e64c8380759", size = 511157, upload-time = "2025-12-02T12:28:44.158Z" },
|
| 292 |
+
]
|
| 293 |
+
|
| 294 |
+
[[package]]
|
| 295 |
+
name = "defusedxml"
|
| 296 |
+
version = "0.7.1"
|
| 297 |
+
source = { registry = "https://pypi.org/simple" }
|
| 298 |
+
sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
|
| 299 |
+
wheels = [
|
| 300 |
+
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
|
| 301 |
+
]
|
| 302 |
+
|
| 303 |
[[package]]
|
| 304 |
name = "developer-salary-prediction"
|
| 305 |
version = "0.1.0"
|
| 306 |
source = { virtual = "." }
|
| 307 |
dependencies = [
|
| 308 |
+
{ name = "bandit" },
|
| 309 |
+
{ name = "numpy" },
|
| 310 |
{ name = "pandas" },
|
| 311 |
+
{ name = "pip-audit" },
|
| 312 |
{ name = "pydantic" },
|
| 313 |
{ name = "pyyaml" },
|
| 314 |
+
{ name = "radon" },
|
| 315 |
{ name = "ruff" },
|
| 316 |
{ name = "scikit-learn" },
|
| 317 |
{ name = "streamlit" },
|
| 318 |
{ name = "xgboost" },
|
| 319 |
]
|
| 320 |
|
| 321 |
+
[package.optional-dependencies]
|
| 322 |
+
dev = [
|
| 323 |
+
{ name = "bandit" },
|
| 324 |
+
{ name = "pip-audit" },
|
| 325 |
+
{ name = "pytest" },
|
| 326 |
+
{ name = "pytest-cov" },
|
| 327 |
+
{ name = "radon" },
|
| 328 |
+
]
|
| 329 |
+
|
| 330 |
[package.metadata]
|
| 331 |
requires-dist = [
|
| 332 |
+
{ name = "bandit", specifier = ">=1.9.3" },
|
| 333 |
+
{ name = "bandit", marker = "extra == 'dev'", specifier = ">=1.8.0" },
|
| 334 |
+
{ name = "numpy", specifier = ">=2.4.2" },
|
| 335 |
{ name = "pandas", specifier = ">=2.0.0" },
|
| 336 |
+
{ name = "pip-audit", specifier = ">=2.10.0" },
|
| 337 |
+
{ name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7.0" },
|
| 338 |
{ name = "pydantic", specifier = ">=2.0.0" },
|
| 339 |
+
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
| 340 |
+
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0.0" },
|
| 341 |
{ name = "pyyaml", specifier = ">=6.0.0" },
|
| 342 |
+
{ name = "radon", specifier = ">=6.0.1" },
|
| 343 |
+
{ name = "radon", marker = "extra == 'dev'", specifier = ">=6.0.0" },
|
| 344 |
{ name = "ruff", specifier = ">=0.15.0" },
|
| 345 |
{ name = "scikit-learn", specifier = ">=1.3.0" },
|
| 346 |
{ name = "streamlit", specifier = ">=1.28.0" },
|
| 347 |
{ name = "xgboost", specifier = ">=3.1.0" },
|
| 348 |
]
|
| 349 |
+
provides-extras = ["dev"]
|
| 350 |
+
|
| 351 |
+
[[package]]
|
| 352 |
+
name = "filelock"
|
| 353 |
+
version = "3.24.0"
|
| 354 |
+
source = { registry = "https://pypi.org/simple" }
|
| 355 |
+
sdist = { url = "https://files.pythonhosted.org/packages/00/cd/fa3ab025a8f9772e8a9146d8fd8eef6d62649274d231ca84249f54a0de4a/filelock-3.24.0.tar.gz", hash = "sha256:aeeab479339ddf463a1cdd1f15a6e6894db976071e5883efc94d22ed5139044b", size = 37166, upload-time = "2026-02-14T16:05:28.723Z" }
|
| 356 |
+
wheels = [
|
| 357 |
+
{ url = "https://files.pythonhosted.org/packages/d9/dd/d7e7f4f49180e8591c9e1281d15ecf8e7f25eb2c829771d9682f1f9fe0c8/filelock-3.24.0-py3-none-any.whl", hash = "sha256:eebebb403d78363ef7be8e236b63cc6760b0004c7464dceaba3fd0afbd637ced", size = 23977, upload-time = "2026-02-14T16:05:27.578Z" },
|
| 358 |
+
]
|
| 359 |
|
| 360 |
[[package]]
|
| 361 |
name = "gitdb"
|
|
|
|
| 390 |
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
| 391 |
]
|
| 392 |
|
| 393 |
+
[[package]]
|
| 394 |
+
name = "iniconfig"
|
| 395 |
+
version = "2.3.0"
|
| 396 |
+
source = { registry = "https://pypi.org/simple" }
|
| 397 |
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
| 398 |
+
wheels = [
|
| 399 |
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
| 400 |
+
]
|
| 401 |
+
|
| 402 |
[[package]]
|
| 403 |
name = "jinja2"
|
| 404 |
version = "3.1.6"
|
|
|
|
| 447 |
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
| 448 |
]
|
| 449 |
|
| 450 |
+
[[package]]
|
| 451 |
+
name = "license-expression"
|
| 452 |
+
version = "30.4.4"
|
| 453 |
+
source = { registry = "https://pypi.org/simple" }
|
| 454 |
+
dependencies = [
|
| 455 |
+
{ name = "boolean-py" },
|
| 456 |
+
]
|
| 457 |
+
sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" }
|
| 458 |
+
wheels = [
|
| 459 |
+
{ url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" },
|
| 460 |
+
]
|
| 461 |
+
|
| 462 |
+
[[package]]
|
| 463 |
+
name = "mando"
|
| 464 |
+
version = "0.7.1"
|
| 465 |
+
source = { registry = "https://pypi.org/simple" }
|
| 466 |
+
dependencies = [
|
| 467 |
+
{ name = "six" },
|
| 468 |
+
]
|
| 469 |
+
sdist = { url = "https://files.pythonhosted.org/packages/35/24/cd70d5ae6d35962be752feccb7dca80b5e0c2d450e995b16abd6275f3296/mando-0.7.1.tar.gz", hash = "sha256:18baa999b4b613faefb00eac4efadcf14f510b59b924b66e08289aa1de8c3500", size = 37868, upload-time = "2022-02-24T08:12:27.316Z" }
|
| 470 |
+
wheels = [
|
| 471 |
+
{ url = "https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl", hash = "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a", size = 28149, upload-time = "2022-02-24T08:12:25.24Z" },
|
| 472 |
+
]
|
| 473 |
+
|
| 474 |
+
[[package]]
|
| 475 |
+
name = "markdown-it-py"
|
| 476 |
+
version = "4.0.0"
|
| 477 |
+
source = { registry = "https://pypi.org/simple" }
|
| 478 |
+
dependencies = [
|
| 479 |
+
{ name = "mdurl" },
|
| 480 |
+
]
|
| 481 |
+
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
| 482 |
+
wheels = [
|
| 483 |
+
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
| 484 |
+
]
|
| 485 |
+
|
| 486 |
[[package]]
|
| 487 |
name = "markupsafe"
|
| 488 |
version = "3.0.3"
|
|
|
|
| 546 |
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
| 547 |
]
|
| 548 |
|
| 549 |
+
[[package]]
|
| 550 |
+
name = "mdurl"
|
| 551 |
+
version = "0.1.2"
|
| 552 |
+
source = { registry = "https://pypi.org/simple" }
|
| 553 |
+
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
| 554 |
+
wheels = [
|
| 555 |
+
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
| 556 |
+
]
|
| 557 |
+
|
| 558 |
+
[[package]]
|
| 559 |
+
name = "msgpack"
|
| 560 |
+
version = "1.1.2"
|
| 561 |
+
source = { registry = "https://pypi.org/simple" }
|
| 562 |
+
sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
|
| 563 |
+
wheels = [
|
| 564 |
+
{ url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" },
|
| 565 |
+
{ url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" },
|
| 566 |
+
{ url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" },
|
| 567 |
+
{ url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" },
|
| 568 |
+
{ url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" },
|
| 569 |
+
{ url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" },
|
| 570 |
+
{ url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" },
|
| 571 |
+
{ url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" },
|
| 572 |
+
{ url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" },
|
| 573 |
+
{ url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" },
|
| 574 |
+
{ url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" },
|
| 575 |
+
{ url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" },
|
| 576 |
+
{ url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" },
|
| 577 |
+
{ url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" },
|
| 578 |
+
{ url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" },
|
| 579 |
+
{ url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" },
|
| 580 |
+
{ url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" },
|
| 581 |
+
{ url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" },
|
| 582 |
+
{ url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
|
| 583 |
+
{ url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
|
| 584 |
+
{ url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
|
| 585 |
+
{ url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
|
| 586 |
+
{ url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
|
| 587 |
+
{ url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
|
| 588 |
+
{ url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
|
| 589 |
+
{ url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
|
| 590 |
+
{ url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
|
| 591 |
+
{ url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
|
| 592 |
+
{ url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
|
| 593 |
+
{ url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
|
| 594 |
+
{ url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
|
| 595 |
+
{ url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
|
| 596 |
+
{ url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
|
| 597 |
+
{ url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
|
| 598 |
+
{ url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
|
| 599 |
+
{ url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
|
| 600 |
+
]
|
| 601 |
+
|
| 602 |
[[package]]
|
| 603 |
name = "narwhals"
|
| 604 |
version = "2.16.0"
|
|
|
|
| 678 |
{ url = "https://files.pythonhosted.org/packages/31/5a/cac7d231f322b66caa16fd4b136ebc8e4b18b2805811c2d58dc47210cdea/nvidia_nccl_cu12-2.29.3-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:35ad42e7d5d722a83c36a3a478e281c20a5646383deaf1b9ed1a9ab7d61bed53", size = 289760316, upload-time = "2026-02-03T21:11:37.899Z" },
|
| 679 |
]
|
| 680 |
|
| 681 |
+
[[package]]
|
| 682 |
+
name = "packageurl-python"
|
| 683 |
+
version = "0.17.6"
|
| 684 |
+
source = { registry = "https://pypi.org/simple" }
|
| 685 |
+
sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" }
|
| 686 |
+
wheels = [
|
| 687 |
+
{ url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" },
|
| 688 |
+
]
|
| 689 |
+
|
| 690 |
[[package]]
|
| 691 |
name = "packaging"
|
| 692 |
version = "26.0"
|
|
|
|
| 812 |
{ url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
|
| 813 |
]
|
| 814 |
|
| 815 |
+
[[package]]
|
| 816 |
+
name = "pip"
|
| 817 |
+
version = "26.0.1"
|
| 818 |
+
source = { registry = "https://pypi.org/simple" }
|
| 819 |
+
sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" }
|
| 820 |
+
wheels = [
|
| 821 |
+
{ url = "https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" },
|
| 822 |
+
]
|
| 823 |
+
|
| 824 |
+
[[package]]
|
| 825 |
+
name = "pip-api"
|
| 826 |
+
version = "0.0.34"
|
| 827 |
+
source = { registry = "https://pypi.org/simple" }
|
| 828 |
+
dependencies = [
|
| 829 |
+
{ name = "pip" },
|
| 830 |
+
]
|
| 831 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" }
|
| 832 |
+
wheels = [
|
| 833 |
+
{ url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" },
|
| 834 |
+
]
|
| 835 |
+
|
| 836 |
+
[[package]]
|
| 837 |
+
name = "pip-audit"
|
| 838 |
+
version = "2.10.0"
|
| 839 |
+
source = { registry = "https://pypi.org/simple" }
|
| 840 |
+
dependencies = [
|
| 841 |
+
{ name = "cachecontrol", extra = ["filecache"] },
|
| 842 |
+
{ name = "cyclonedx-python-lib" },
|
| 843 |
+
{ name = "packaging" },
|
| 844 |
+
{ name = "pip-api" },
|
| 845 |
+
{ name = "pip-requirements-parser" },
|
| 846 |
+
{ name = "platformdirs" },
|
| 847 |
+
{ name = "requests" },
|
| 848 |
+
{ name = "rich" },
|
| 849 |
+
{ name = "tomli" },
|
| 850 |
+
{ name = "tomli-w" },
|
| 851 |
+
]
|
| 852 |
+
sdist = { url = "https://files.pythonhosted.org/packages/bd/89/0e999b413facab81c33d118f3ac3739fd02c0622ccf7c4e82e37cebd8447/pip_audit-2.10.0.tar.gz", hash = "sha256:427ea5bf61d1d06b98b1ae29b7feacc00288a2eced52c9c58ceed5253ef6c2a4", size = 53776, upload-time = "2025-12-01T23:42:40.612Z" }
|
| 853 |
+
wheels = [
|
| 854 |
+
{ url = "https://files.pythonhosted.org/packages/be/f3/4888f895c02afa085630a3a3329d1b18b998874642ad4c530e9a4d7851fe/pip_audit-2.10.0-py3-none-any.whl", hash = "sha256:16e02093872fac97580303f0848fa3ad64f7ecf600736ea7835a2b24de49613f", size = 61518, upload-time = "2025-12-01T23:42:39.193Z" },
|
| 855 |
+
]
|
| 856 |
+
|
| 857 |
+
[[package]]
|
| 858 |
+
name = "pip-requirements-parser"
|
| 859 |
+
version = "32.0.1"
|
| 860 |
+
source = { registry = "https://pypi.org/simple" }
|
| 861 |
+
dependencies = [
|
| 862 |
+
{ name = "packaging" },
|
| 863 |
+
{ name = "pyparsing" },
|
| 864 |
+
]
|
| 865 |
+
sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" }
|
| 866 |
+
wheels = [
|
| 867 |
+
{ url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" },
|
| 868 |
+
]
|
| 869 |
+
|
| 870 |
+
[[package]]
|
| 871 |
+
name = "platformdirs"
|
| 872 |
+
version = "4.9.1"
|
| 873 |
+
source = { registry = "https://pypi.org/simple" }
|
| 874 |
+
sdist = { url = "https://files.pythonhosted.org/packages/6c/d5/763666321efaded11112de8b7a7f2273dd8d1e205168e73c334e54b0ab9a/platformdirs-4.9.1.tar.gz", hash = "sha256:f310f16e89c4e29117805d8328f7c10876eeff36c94eac879532812110f7d39f", size = 28392, upload-time = "2026-02-14T21:02:44.973Z" }
|
| 875 |
+
wheels = [
|
| 876 |
+
{ url = "https://files.pythonhosted.org/packages/70/77/e8c95e95f1d4cdd88c90a96e31980df7e709e51059fac150046ad67fac63/platformdirs-4.9.1-py3-none-any.whl", hash = "sha256:61d8b967d34791c162d30d60737369cbbd77debad5b981c4bfda1842e71e0d66", size = 21307, upload-time = "2026-02-14T21:02:43.492Z" },
|
| 877 |
+
]
|
| 878 |
+
|
| 879 |
+
[[package]]
|
| 880 |
+
name = "pluggy"
|
| 881 |
+
version = "1.6.0"
|
| 882 |
+
source = { registry = "https://pypi.org/simple" }
|
| 883 |
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
| 884 |
+
wheels = [
|
| 885 |
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
| 886 |
+
]
|
| 887 |
+
|
| 888 |
[[package]]
|
| 889 |
name = "protobuf"
|
| 890 |
version = "6.33.5"
|
|
|
|
| 900 |
{ url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
|
| 901 |
]
|
| 902 |
|
| 903 |
+
[[package]]
|
| 904 |
+
name = "py-serializable"
|
| 905 |
+
version = "2.1.0"
|
| 906 |
+
source = { registry = "https://pypi.org/simple" }
|
| 907 |
+
dependencies = [
|
| 908 |
+
{ name = "defusedxml" },
|
| 909 |
+
]
|
| 910 |
+
sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" }
|
| 911 |
+
wheels = [
|
| 912 |
+
{ url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" },
|
| 913 |
+
]
|
| 914 |
+
|
| 915 |
[[package]]
|
| 916 |
name = "pyarrow"
|
| 917 |
version = "23.0.0"
|
|
|
|
| 1054 |
{ url = "https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038", size = 6900403, upload-time = "2024-05-10T15:36:17.36Z" },
|
| 1055 |
]
|
| 1056 |
|
| 1057 |
+
[[package]]
|
| 1058 |
+
name = "pygments"
|
| 1059 |
+
version = "2.19.2"
|
| 1060 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1061 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
| 1062 |
+
wheels = [
|
| 1063 |
+
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
| 1064 |
+
]
|
| 1065 |
+
|
| 1066 |
+
[[package]]
|
| 1067 |
+
name = "pyparsing"
|
| 1068 |
+
version = "3.3.2"
|
| 1069 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1070 |
+
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
|
| 1071 |
+
wheels = [
|
| 1072 |
+
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
|
| 1073 |
+
]
|
| 1074 |
+
|
| 1075 |
+
[[package]]
|
| 1076 |
+
name = "pytest"
|
| 1077 |
+
version = "9.0.2"
|
| 1078 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1079 |
+
dependencies = [
|
| 1080 |
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
| 1081 |
+
{ name = "iniconfig" },
|
| 1082 |
+
{ name = "packaging" },
|
| 1083 |
+
{ name = "pluggy" },
|
| 1084 |
+
{ name = "pygments" },
|
| 1085 |
+
]
|
| 1086 |
+
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
| 1087 |
+
wheels = [
|
| 1088 |
+
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
| 1089 |
+
]
|
| 1090 |
+
|
| 1091 |
+
[[package]]
|
| 1092 |
+
name = "pytest-cov"
|
| 1093 |
+
version = "7.0.0"
|
| 1094 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1095 |
+
dependencies = [
|
| 1096 |
+
{ name = "coverage" },
|
| 1097 |
+
{ name = "pluggy" },
|
| 1098 |
+
{ name = "pytest" },
|
| 1099 |
+
]
|
| 1100 |
+
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
| 1101 |
+
wheels = [
|
| 1102 |
+
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
| 1103 |
+
]
|
| 1104 |
+
|
| 1105 |
[[package]]
|
| 1106 |
name = "python-dateutil"
|
| 1107 |
version = "2.9.0.post0"
|
|
|
|
| 1169 |
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
| 1170 |
]
|
| 1171 |
|
| 1172 |
+
[[package]]
|
| 1173 |
+
name = "radon"
|
| 1174 |
+
version = "6.0.1"
|
| 1175 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1176 |
+
dependencies = [
|
| 1177 |
+
{ name = "colorama" },
|
| 1178 |
+
{ name = "mando" },
|
| 1179 |
+
]
|
| 1180 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b1/6d/98e61600febf6bd929cf04154537c39dc577ce414bafbfc24a286c4fa76d/radon-6.0.1.tar.gz", hash = "sha256:d1ac0053943a893878940fedc8b19ace70386fc9c9bf0a09229a44125ebf45b5", size = 1874992, upload-time = "2023-03-26T06:24:38.868Z" }
|
| 1181 |
+
wheels = [
|
| 1182 |
+
{ url = "https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl", hash = "sha256:632cc032364a6f8bb1010a2f6a12d0f14bc7e5ede76585ef29dc0cecf4cd8859", size = 52784, upload-time = "2023-03-26T06:24:33.949Z" },
|
| 1183 |
+
]
|
| 1184 |
+
|
| 1185 |
[[package]]
|
| 1186 |
name = "referencing"
|
| 1187 |
version = "0.37.0"
|
|
|
|
| 1211 |
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
| 1212 |
]
|
| 1213 |
|
| 1214 |
+
[[package]]
|
| 1215 |
+
name = "rich"
|
| 1216 |
+
version = "14.3.2"
|
| 1217 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1218 |
+
dependencies = [
|
| 1219 |
+
{ name = "markdown-it-py" },
|
| 1220 |
+
{ name = "pygments" },
|
| 1221 |
+
]
|
| 1222 |
+
sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" }
|
| 1223 |
+
wheels = [
|
| 1224 |
+
{ url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" },
|
| 1225 |
+
]
|
| 1226 |
+
|
| 1227 |
[[package]]
|
| 1228 |
name = "rpds-py"
|
| 1229 |
version = "0.30.0"
|
|
|
|
| 1453 |
{ url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" },
|
| 1454 |
]
|
| 1455 |
|
| 1456 |
+
[[package]]
|
| 1457 |
+
name = "sortedcontainers"
|
| 1458 |
+
version = "2.4.0"
|
| 1459 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1460 |
+
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
|
| 1461 |
+
wheels = [
|
| 1462 |
+
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
|
| 1463 |
+
]
|
| 1464 |
+
|
| 1465 |
+
[[package]]
|
| 1466 |
+
name = "stevedore"
|
| 1467 |
+
version = "5.6.0"
|
| 1468 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1469 |
+
sdist = { url = "https://files.pythonhosted.org/packages/96/5b/496f8abebd10c3301129abba7ddafd46c71d799a70c44ab080323987c4c9/stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945", size = 516074, upload-time = "2025-11-20T10:06:07.264Z" }
|
| 1470 |
+
wheels = [
|
| 1471 |
+
{ url = "https://files.pythonhosted.org/packages/f4/40/8561ce06dc46fd17242c7724ab25b257a2ac1b35f4ebf551b40ce6105cfa/stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820", size = 54428, upload-time = "2025-11-20T10:06:05.946Z" },
|
| 1472 |
+
]
|
| 1473 |
+
|
| 1474 |
[[package]]
|
| 1475 |
name = "streamlit"
|
| 1476 |
version = "1.54.0"
|
|
|
|
| 1527 |
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
|
| 1528 |
]
|
| 1529 |
|
| 1530 |
+
[[package]]
|
| 1531 |
+
name = "tomli"
|
| 1532 |
+
version = "2.4.0"
|
| 1533 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1534 |
+
sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
|
| 1535 |
+
wheels = [
|
| 1536 |
+
{ url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
|
| 1537 |
+
{ url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
|
| 1538 |
+
{ url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
|
| 1539 |
+
{ url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
|
| 1540 |
+
{ url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
|
| 1541 |
+
{ url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
|
| 1542 |
+
{ url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
|
| 1543 |
+
{ url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
|
| 1544 |
+
{ url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
|
| 1545 |
+
{ url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
|
| 1546 |
+
{ url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
|
| 1547 |
+
{ url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
|
| 1548 |
+
{ url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
|
| 1549 |
+
{ url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
|
| 1550 |
+
{ url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
|
| 1551 |
+
{ url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
|
| 1552 |
+
{ url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
|
| 1553 |
+
{ url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
|
| 1554 |
+
{ url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
|
| 1555 |
+
{ url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
|
| 1556 |
+
{ url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
|
| 1557 |
+
{ url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
|
| 1558 |
+
{ url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
|
| 1559 |
+
{ url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
|
| 1560 |
+
{ url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
|
| 1561 |
+
{ url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
|
| 1562 |
+
{ url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
|
| 1563 |
+
{ url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
|
| 1564 |
+
{ url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
|
| 1565 |
+
{ url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
|
| 1566 |
+
{ url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
|
| 1567 |
+
{ url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
|
| 1568 |
+
{ url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
|
| 1569 |
+
{ url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
|
| 1570 |
+
{ url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
|
| 1571 |
+
{ url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
|
| 1572 |
+
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
|
| 1573 |
+
]
|
| 1574 |
+
|
| 1575 |
+
[[package]]
|
| 1576 |
+
name = "tomli-w"
|
| 1577 |
+
version = "1.2.0"
|
| 1578 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1579 |
+
sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" }
|
| 1580 |
+
wheels = [
|
| 1581 |
+
{ url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
|
| 1582 |
+
]
|
| 1583 |
+
|
| 1584 |
[[package]]
|
| 1585 |
name = "tornado"
|
| 1586 |
version = "6.5.4"
|