dima806 commited on
Commit
a32e584
·
verified ·
1 Parent(s): 07d23c4

Upload 32 files

Browse files
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 industry values
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 = "United States of America" if "United States of America" in valid_countries else valid_countries[0]
62
- default_education = "Bachelor's degree (B.A., B.S., B.Eng., etc.)" if "Bachelor's degree (B.A., B.S., B.Eng., etc.)" in valid_education_levels else valid_education_levels[0]
63
- default_dev_type = "Developer, back-end" if "Developer, back-end" in valid_dev_types else valid_dev_types[0]
64
- default_industry = "Software Development" if "Software Development" in valid_industries else valid_industries[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- - Other
 
 
 
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
- 'Country': ['United States of America'],
16
- 'YearsCode': [5.0],
17
- 'EdLevel': ["Bachelor's degree (B.A., B.S., B.Eng., etc.)"],
18
- 'DevType': ['Developer, full-stack']
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('\u2019', "'", regex=False)
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['features']['encoding']['drop_first']
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
- 'Country': ['United States of America'],
9
- 'YearsCode': [5.0],
10
- 'EdLevel': ["Bachelor's degree (B.A., B.S., B.Eng., etc.)"],
11
- 'DevType': ['Developer, full-stack']
12
- })
 
 
13
 
14
- input2 = pd.DataFrame({
15
- 'Country': ['Germany'], # Different!
16
- 'YearsCode': [5.0],
17
- 'EdLevel': ["Bachelor's degree (B.A., B.S., B.Eng., etc.)"],
18
- 'DevType': ['Developer, full-stack']
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(f"\nInput 1 (USA):")
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(f"\nInput 2 (Germany):")
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 = ['United States of America', 'Germany', 'India']
55
  for country in test_countries:
56
- test_df = pd.DataFrame({
57
- 'Country': [country],
58
- 'YearsCode': [5.0],
59
- 'EdLevel': ["Bachelor's degree (B.A., B.S., B.Eng., etc.)"],
60
- 'DevType': ['Developer, full-stack']
61
- })
 
 
62
  encoded = prepare_features(test_df)
63
- country_cols = [col for col in encoded.columns if col.startswith('Country_')]
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:efa053c467c2c1ea33a65f04aedc32fb7a6c47658238f26d88cd0a10986c0c98
3
- size 3985657
 
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['features']['cardinality']['max_categories']
34
  if min_frequency is None:
35
- min_frequency = _config['features']['cardinality']['min_frequency']
 
 
 
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 'Other')
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].str.replace('\u2019', "'", regex=False)
81
 
82
  # Handle legacy column name (YearsCodePro -> YearsCode)
83
- if "YearsCodePro" in df_processed.columns and "YearsCode" not in df_processed.columns:
 
 
 
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 = ["Country", "YearsCode", "WorkExp", "EdLevel", "DevType", "Industry", "Age"]
 
 
 
 
 
 
 
 
 
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 = False if is_inference else _config['features']['encoding']['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("Please download the Stack Overflow Developer Survey CSV and place it in the data/ directory.")
 
 
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=["Country", "YearsCode", "WorkExp", "EdLevel", "DevType", "Industry", "Age",
36
- "Currency", "CompTotal", "ConvertedCompYearly"],
 
 
 
 
 
 
 
 
 
 
 
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['data']['min_salary']
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['data']['lower_percentile'] / 100
51
- upper_pct = config['data']['upper_percentile'] / 100
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('\u2019', "'", regex=False)
67
- df_copy["EdLevel"] = df_copy["EdLevel"].str.replace('\u2019', "'", regex=False)
68
- df_copy["DevType"] = df_copy["DevType"].str.replace('\u2019', "'", regex=False)
69
- df_copy["Industry"] = df_copy["Industry"].str.replace('\u2019', "'", regex=False)
70
- df_copy["Age"] = df_copy["Age"].str.replace('\u2019', "'", regex=False)
 
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(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, and {len(valid_categories['Age'])} valid age ranges to {valid_categories_path}")
 
 
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[(currency_df["rate"] > 0.001) & (currency_df["rate"] < 100000)]
 
 
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(currency_rates, f, default_flow_style=False, sort_keys=True,
151
- allow_unicode=True)
 
 
 
 
 
152
 
153
- print(f"Saved currency rates for {len(currency_rates)} countries to {currency_rates_path}")
 
 
154
  for country, info in sorted(currency_rates.items()):
155
- print(f" {country:45s} -> {info['code']} ({info['name']}, rate: {info['rate']})")
 
 
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[~feature_counts.index.str.startswith('YearsCode')]
 
 
221
 
222
  # Country features
223
  print("\n🌍 Top 15 Country Features (most common):")
224
- country_features = categorical_features[categorical_features.index.str.startswith('Country_')]
 
 
225
  for i, (feature, count) in enumerate(country_features.head(15).items(), 1):
226
  percentage = (count / len(X)) * 100
227
- country_name = feature.replace('Country_', '')
228
- print(f" {i:2d}. {country_name:45s} - {count:6.0f} occurrences ({percentage:5.1f}%)")
 
 
229
 
230
  # Education level features
231
  print("\n🎓 Top 10 Education Level Features (most common):")
232
- edlevel_features = categorical_features[categorical_features.index.str.startswith('EdLevel_')]
 
 
233
  for i, (feature, count) in enumerate(edlevel_features.head(10).items(), 1):
234
  percentage = (count / len(X)) * 100
235
- edu_name = feature.replace('EdLevel_', '')
236
- print(f" {i:2d}. {edu_name:45s} - {count:6.0f} occurrences ({percentage:5.1f}%)")
 
 
237
 
238
  # Developer type features
239
  print("\n👨‍💻 Top 10 Developer Type Features (most common):")
240
- devtype_features = categorical_features[categorical_features.index.str.startswith('DevType_')]
 
 
241
  for i, (feature, count) in enumerate(devtype_features.head(10).items(), 1):
242
  percentage = (count / len(X)) * 100
243
- devtype_name = feature.replace('DevType_', '')
244
- print(f" {i:2d}. {devtype_name:45s} - {count:6.0f} occurrences ({percentage:5.1f}%)")
 
 
245
 
246
  # Industry features
247
  print("\n🏢 Top 10 Industry Features (most common):")
248
- industry_features = categorical_features[categorical_features.index.str.startswith('Industry_')]
 
 
249
  for i, (feature, count) in enumerate(industry_features.head(10).items(), 1):
250
  percentage = (count / len(X)) * 100
251
- industry_name = feature.replace('Industry_', '')
252
- print(f" {i:2d}. {industry_name:45s} - {count:6.0f} occurrences ({percentage:5.1f}%)")
 
 
253
 
254
  # Age features
255
  print("\n🎂 Top 10 Age Features (most common):")
256
- age_features = categorical_features[categorical_features.index.str.startswith('Age_')]
 
 
257
  for i, (feature, count) in enumerate(age_features.head(10).items(), 1):
258
  percentage = (count / len(X)) * 100
259
- age_name = feature.replace('Age_', '')
260
- print(f" {i:2d}. {age_name:45s} - {count:6.0f} occurrences ({percentage:5.1f}%)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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['data'].get('cv_splits', 5)
274
- random_state = config['data']['random_state']
275
- model_config = config['model']
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['n_estimators'],
290
- learning_rate=model_config['learning_rate'],
291
- max_depth=model_config['max_depth'],
292
- min_child_weight=model_config['min_child_weight'],
293
- random_state=model_config['random_state'],
294
- n_jobs=model_config['n_jobs'],
295
- early_stopping_rounds=model_config['early_stopping_rounds'],
296
  )
297
  model.fit(
298
- X_train, y_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(f" Fold {fold}: Train R2 = {train_r2:.4f}, Test R2 = {test_r2:.4f} (best iter: {model.best_iteration + 1})")
 
 
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['n_estimators'],
327
- learning_rate=model_config['learning_rate'],
328
- max_depth=model_config['max_depth'],
329
- min_child_weight=model_config['min_child_weight'],
330
- random_state=model_config['random_state'],
331
- n_jobs=model_config['n_jobs'],
332
- early_stopping_rounds=model_config['early_stopping_rounds'],
333
  )
334
  final_model.fit(
335
- X_train_final, y_train_final,
 
336
  eval_set=[(X_es, y_es)],
337
- verbose=config['training']['verbose'],
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['training']['model_path'])
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(f"\n❌ FAIL: Only {unique_predictions}/{len(predictions)} unique predictions")
 
 
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(f"\n⚠️ PARTIAL: Only {unique_predictions}/{len(predictions)} unique predictions")
87
- print(f" Duplicate salaries found - possible feature issue")
 
 
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(f"\n⚠️ PARTIAL: Only {unique_predictions}/{len(predictions)} unique predictions")
137
- print(f" Duplicate salaries found - possible feature issue")
 
 
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(f"\n⚠️ PARTIAL: Only {unique_predictions}/{len(predictions)} unique predictions")
187
- print(f" Duplicate salaries found - possible feature issue")
 
 
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(f"\n⚠️ PARTIAL: Only {unique_predictions}/{len(predictions)} unique predictions")
237
- print(f" Duplicate salaries found - possible feature issue")
 
 
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(f"\n⚠️ PARTIAL: Only {unique_predictions}/{len(predictions)} unique predictions")
286
- print(f" Duplicate salaries found - possible feature issue")
 
 
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(f"\n⚠️ PARTIAL: Only {unique_predictions}/{len(predictions)} unique predictions")
326
- print(f" Duplicate salaries found - possible feature issue")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 8: Combined Feature Variations")
334
  print("=" * 70)
335
 
336
  # Create diverse combinations (using actual values from trained model)
337
  test_cases = [
338
- ("India", 2, 1, "Bachelor's degree (B.A., B.S., B.Eng., etc.)", "Developer, back-end", "Software Development", "18-24 years old"),
339
- ("Germany", 5, 3, "Master's degree (M.A., M.S., M.Eng., MBA, etc.)", "Developer, full-stack", "Manufacturing", "25-34 years old"),
340
- ("United States of America", 10, 8, "Master's degree (M.A., M.S., M.Eng., MBA, etc.)", "Engineering manager", "Fintech", "35-44 years old"),
341
- ("Poland", 15, 12, "Bachelor's degree (B.A., B.S., B.Eng., etc.)", "Developer, front-end", "Healthcare", "45-54 years old"),
342
- ("Brazil", 5, 3, "Some college/university study without earning a degree", "DevOps engineer or professional", "Government", "25-34 years old"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  ]
344
 
345
  predictions = []
346
- for country, years, work_exp, education, devtype, industry, age in test_cases:
 
 
 
 
 
 
 
 
 
347
  # Skip if not in valid categories
348
- if (country not in valid_categories["Country"]
349
- or education not in valid_categories["EdLevel"]
350
- or devtype not in valid_categories["DevType"]
351
- or industry not in valid_categories["Industry"]
352
- or age not in valid_categories["Age"]):
 
 
 
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(f" {country[:15]:15s} | {years:2d}y | {work_exp:2d}w | {education[:25]:25s} | {devtype[:25]:25s} | {industry[:20]:20s} | {age[:15]:15s} -> ${salary:,.2f}")
 
 
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(f" Some combinations produce identical salaries")
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('Country_')]
391
- edlevel_features = [f for f in feature_columns if f.startswith('EdLevel_')]
392
- devtype_features = [f for f in feature_columns if f.startswith('DevType_')]
393
- industry_features = [f for f in feature_columns if f.startswith('Industry_')]
394
- age_features = [f for f in feature_columns if f.startswith('Age_')]
395
- numeric_features = [f for f in feature_columns if not f.startswith(('Country_', 'EdLevel_', 'DevType_', 'Industry_', 'Age_'))]
 
 
 
 
 
 
 
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(f"\nSample country features:")
406
  for feat in country_features[:5]:
407
  print(f" - {feat}")
408
 
409
  if len(edlevel_features) > 0:
410
- print(f"\nSample education features:")
411
  for feat in edlevel_features[:5]:
412
  print(f" - {feat}")
413
 
414
  if len(devtype_features) > 0:
415
- print(f"\nSample developer type features:")
416
  for feat in devtype_features[:5]:
417
  print(f" - {feat}")
418
 
419
  if len(industry_features) > 0:
420
- print(f"\nSample industry features:")
421
  for feat in industry_features[:5]:
422
  print(f" - {feat}")
423
 
424
  if len(age_features) > 0:
425
- print(f"\nSample age features:")
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("\n⚠️ Some tests failed. The model may not be using all features properly.")
482
- print(" This indicates potential training-testing skew or feature engineering issues.")
 
 
 
 
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
- if 'src.preprocessing' in sys.modules:
6
- del sys.modules['src.preprocessing']
7
- if 'src.infer' in sys.modules:
8
- del sys.modules['src.infer']
 
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
- 'Country': ['United States of America'],
16
- 'YearsCode': [5.0],
17
- 'EdLevel': ["Bachelor's degree (B.A., B.S., B.Eng., etc.)"],
18
- 'DevType': ['Developer, full-stack']
19
- })
20
-
21
- input2 = pd.DataFrame({
22
- 'Country': ['Germany'],
23
- 'YearsCode': [5.0],
24
- 'EdLevel': ["Bachelor's degree (B.A., B.S., B.Eng., etc.)"],
25
- 'DevType': ['Developer, full-stack']
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"