dima806 commited on
Commit
f1718f3
·
verified ·
1 Parent(s): 0456403

Upload files from src

Browse files
Files changed (6) hide show
  1. src/__init__.py +0 -0
  2. src/infer.py +92 -0
  3. src/preprocessing.py +108 -0
  4. src/schema.py +26 -0
  5. src/streamlit_app.py +3 -1
  6. src/train.py +225 -0
src/__init__.py ADDED
File without changes
src/infer.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inference utilities for salary prediction."""
2
+
3
+ import pickle
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+ import yaml
8
+
9
+ from src.schema import SalaryInput
10
+ from src.preprocessing import prepare_features
11
+
12
+ # Load model and artifacts at module level
13
+ model_path = Path("models/model.pkl")
14
+
15
+ if not model_path.exists():
16
+ raise FileNotFoundError(
17
+ f"Model file not found at {model_path}. Please run 'python -m src.train' first."
18
+ )
19
+
20
+ with open(model_path, "rb") as f:
21
+ artifacts = pickle.load(f)
22
+ model = artifacts["model"]
23
+ feature_columns = artifacts["feature_columns"]
24
+
25
+ # Load valid categories for input validation
26
+ valid_categories_path = Path("config/valid_categories.yaml")
27
+
28
+ if not valid_categories_path.exists():
29
+ raise FileNotFoundError(
30
+ f"Valid categories file not found at {valid_categories_path}. Please run 'python -m src.train' first."
31
+ )
32
+
33
+ with open(valid_categories_path, "r") as f:
34
+ valid_categories = yaml.safe_load(f)
35
+
36
+
37
+ def predict_salary(data: SalaryInput) -> float:
38
+ """Predict salary based on input features.
39
+
40
+ Args:
41
+ data: SalaryInput model with developer information
42
+
43
+ Returns:
44
+ Predicted annual salary in USD
45
+
46
+ Raises:
47
+ ValueError: If country or education_level is not in valid categories
48
+ """
49
+ # Validate input against valid categories from training
50
+ if data.country not in valid_categories["Country"]:
51
+ raise ValueError(
52
+ f"Invalid country: '{data.country}'. "
53
+ f"Must be one of {len(valid_categories['Country'])} valid countries. "
54
+ f"Check config/valid_categories.yaml for all valid values."
55
+ )
56
+
57
+ if data.education_level not in valid_categories["EdLevel"]:
58
+ raise ValueError(
59
+ f"Invalid education level: '{data.education_level}'. "
60
+ f"Must be one of {len(valid_categories['EdLevel'])} valid education levels. "
61
+ f"Check config/valid_categories.yaml for all valid values."
62
+ )
63
+
64
+ if data.dev_type not in valid_categories["DevType"]:
65
+ raise ValueError(
66
+ f"Invalid developer type: '{data.dev_type}'. "
67
+ f"Must be one of {len(valid_categories['DevType'])} valid developer types. "
68
+ f"Check config/valid_categories.yaml for all valid values."
69
+ )
70
+
71
+ # Create a DataFrame with the input data
72
+ input_df = pd.DataFrame(
73
+ {
74
+ "Country": [data.country],
75
+ "YearsCodePro": [data.years_code_pro],
76
+ "EdLevel": [data.education_level],
77
+ "DevType": [data.dev_type],
78
+ }
79
+ )
80
+
81
+ # Apply the same preprocessing as training
82
+ input_encoded = prepare_features(input_df)
83
+
84
+ # Ensure all feature columns from training are present and in correct order
85
+ # Use reindex to add missing columns with 0s and reorder in one operation
86
+ input_encoded = input_encoded.reindex(columns=feature_columns, fill_value=0)
87
+
88
+ # Make prediction
89
+ prediction = model.predict(input_encoded)[0]
90
+
91
+ # Ensure non-negative salary
92
+ return max(0.0, float(prediction))
src/preprocessing.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data preprocessing utilities for consistent feature engineering."""
2
+
3
+ from pathlib import Path
4
+ import pandas as pd
5
+ import yaml
6
+
7
+ # Load configuration once at module level
8
+ _config_path = Path("config/model_parameters.yaml")
9
+ 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'.
20
+
21
+ Args:
22
+ series: Pandas Series with categorical values
23
+ max_categories: Maximum number of categories to keep
24
+ (default: from config)
25
+ min_frequency: Minimum occurrences for a category to be kept
26
+ (default: from config)
27
+
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()
39
+
40
+ # Keep only categories that meet both criteria:
41
+ # 1. In top max_categories by frequency
42
+ # 2. Have at least min_frequency occurrences
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:
51
+ """
52
+ Apply consistent feature transformations for both training and inference.
53
+
54
+ This function ensures that the same preprocessing steps are applied
55
+ during training and inference, preventing data leakage and inconsistencies.
56
+
57
+ Args:
58
+ df: DataFrame with columns: Country, YearsCode (or YearsCodePro), EdLevel, DevType
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.
62
+
63
+ Returns:
64
+ DataFrame with one-hot encoded features ready for model input
65
+
66
+ Note:
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, Country_X, EdLevel_Y, DevType_Z
71
+ - Does NOT apply cardinality reduction (must be done before calling this)
72
+ """
73
+ # Create a copy to avoid modifying the original
74
+ df_processed = df.copy()
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"]:
79
+ if col in df_processed.columns:
80
+ df_processed[col] = df_processed[col].str.replace('\u2019', "'", regex=False)
81
+
82
+ # Handle column name variations (YearsCode vs YearsCodePro)
83
+ if "YearsCodePro" in df_processed.columns and "YearsCode" not in df_processed.columns:
84
+ df_processed["YearsCode"] = df_processed["YearsCodePro"]
85
+
86
+ # Fill missing values with defaults
87
+ df_processed["YearsCode"] = df_processed["YearsCode"].fillna(0)
88
+ df_processed["Country"] = df_processed["Country"].fillna("Unknown")
89
+ df_processed["EdLevel"] = df_processed["EdLevel"].fillna("Unknown")
90
+ df_processed["DevType"] = df_processed["DevType"].fillna("Unknown")
91
+
92
+ # NOTE: Cardinality reduction is NOT applied here
93
+ # It should be applied during training BEFORE calling this function
94
+ # During inference, valid_categories.yaml ensures only valid values are used
95
+
96
+ # Select only the features we need
97
+ feature_cols = ["Country", "YearsCode", "EdLevel", "DevType"]
98
+ df_features = df_processed[feature_cols]
99
+
100
+ # Apply one-hot encoding for categorical variables
101
+ # For inference (single rows), we need drop_first=False to create columns
102
+ # The reindex in infer.py will align with training columns
103
+ # For training (many rows), we use the config value
104
+ is_inference = len(df_features) == 1
105
+ drop_first = False if is_inference else _config['features']['encoding']['drop_first']
106
+ df_encoded = pd.get_dummies(df_features, drop_first=drop_first)
107
+
108
+ return df_encoded
src/schema.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic models for input validation."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class SalaryInput(BaseModel):
7
+ """Input model for salary prediction."""
8
+
9
+ country: str = Field(..., description="Developer's country")
10
+ years_code_pro: float = Field(
11
+ ..., ge=0, description="Years of professional coding experience"
12
+ )
13
+ education_level: str = Field(..., description="Education level")
14
+ dev_type: str = Field(..., description="Developer type")
15
+
16
+ class Config:
17
+ """Pydantic configuration."""
18
+
19
+ json_schema_extra = {
20
+ "example": {
21
+ "country": "United States",
22
+ "years_code_pro": 5.0,
23
+ "education_level": "Bachelor's degree",
24
+ "dev_type": "Developer, back-end",
25
+ }
26
+ }
src/streamlit_app.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import streamlit as st
2
 
3
  from src.infer import predict_salary, valid_categories
@@ -124,4 +126,4 @@ if st.button("🔮 Predict Salary", type="primary", use_container_width=True):
124
  st.divider()
125
  st.caption(
126
  "Built with Streamlit • Data from Stack Overflow Developer Survey • Model: XGBoost"
127
- )
 
1
+ """Streamlit web app for salary prediction."""
2
+
3
  import streamlit as st
4
 
5
  from src.infer import predict_salary, valid_categories
 
126
  st.divider()
127
  st.caption(
128
  "Built with Streamlit • Data from Stack Overflow Developer Survey • Model: XGBoost"
129
+ )
src/train.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training script for salary prediction model."""
2
+
3
+ import pickle
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+ import numpy as np
8
+ import yaml
9
+ from xgboost import XGBRegressor
10
+ from sklearn.model_selection import train_test_split
11
+
12
+ from src.preprocessing import prepare_features, reduce_cardinality
13
+
14
+
15
+ def main():
16
+ """Train and save the salary prediction model."""
17
+ # Load configuration
18
+ print("Loading configuration...")
19
+ config_path = Path("config/model_parameters.yaml")
20
+ with open(config_path, "r") as f:
21
+ config = yaml.safe_load(f)
22
+
23
+ print("Loading data...")
24
+ data_path = Path("data/survey_results_public.csv")
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", "EdLevel", "DevType", "ConvertedCompYearly"],
36
+ )
37
+
38
+ print(f"Loaded {len(df):,} rows")
39
+
40
+ print("Removing null, extremely small and large reported salaries")
41
+ # select main label
42
+ main_label = "ConvertedCompYearly"
43
+ # select records with main label more than min_salary threshold
44
+ min_salary = config['data']['min_salary']
45
+ df = df[df[main_label] > min_salary]
46
+ # further exclude outliers based on percentile bounds
47
+ lower_pct = config['data']['lower_percentile']
48
+ upper_pct = config['data']['upper_percentile']
49
+ P = np.percentile(df[main_label], [lower_pct, upper_pct])
50
+ df = df[(df[main_label] > P[0]) & (df[main_label] < P[1])]
51
+
52
+ print(df.shape)
53
+
54
+ # Drop rows with missing target
55
+ df = df.dropna(subset=[main_label])
56
+ print(f"After removing missing targets: {len(df):,} rows")
57
+
58
+ # Apply preprocessing first to get cardinality-reduced categories
59
+ df_copy = df.copy()
60
+
61
+ # Normalize Unicode apostrophes to regular apostrophes for consistency
62
+ df_copy["Country"] = df_copy["Country"].str.replace('\u2019', "'", regex=False)
63
+ df_copy["EdLevel"] = df_copy["EdLevel"].str.replace('\u2019', "'", regex=False)
64
+ df_copy["DevType"] = df_copy["DevType"].str.replace('\u2019', "'", regex=False)
65
+
66
+ # Apply cardinality reduction
67
+ df_copy["Country"] = reduce_cardinality(df_copy["Country"])
68
+ df_copy["EdLevel"] = reduce_cardinality(df_copy["EdLevel"])
69
+ df_copy["DevType"] = reduce_cardinality(df_copy["DevType"])
70
+
71
+ # Apply cardinality reduction to the actual training data as well
72
+ # (prepare_features no longer does this internally)
73
+ df["Country"] = reduce_cardinality(df["Country"])
74
+ df["EdLevel"] = reduce_cardinality(df["EdLevel"])
75
+ df["DevType"] = reduce_cardinality(df["DevType"])
76
+
77
+ # Now apply full feature transformations for model training
78
+ X = prepare_features(df)
79
+ y = df[main_label]
80
+
81
+ # Save valid categories after cardinality reduction for validation during inference
82
+ # Extract unique values from the reduced dataframe
83
+ country_values = df_copy["Country"].dropna().unique().tolist()
84
+ edlevel_values = df_copy["EdLevel"].dropna().unique().tolist()
85
+ devtype_values = df_copy["DevType"].dropna().unique().tolist()
86
+
87
+ valid_categories = {
88
+ "Country": sorted(country_values),
89
+ "EdLevel": sorted(edlevel_values),
90
+ "DevType": sorted(devtype_values),
91
+ }
92
+
93
+ valid_categories_path = Path("config/valid_categories.yaml")
94
+ with open(valid_categories_path, "w") as f:
95
+ yaml.dump(valid_categories, f, default_flow_style=False, sort_keys=False)
96
+
97
+ print(f"\nSaved {len(valid_categories['Country'])} valid countries, {len(valid_categories['EdLevel'])} valid education levels, and {len(valid_categories['DevType'])} valid developer types to {valid_categories_path}")
98
+
99
+ print(f"\nFeature matrix shape: {X.shape}")
100
+ print(f"Total features: {X.shape[1]}")
101
+
102
+ # Display feature information for debugging and inference comparison
103
+ print("\n" + "=" * 60)
104
+ print("FEATURE ANALYSIS (for comparing with inference)")
105
+ print("=" * 60)
106
+
107
+ # Show top countries in the dataset
108
+ print("\n📍 Top 10 Countries:")
109
+ top_countries = df["Country"].value_counts().head(10)
110
+ for country, count in top_countries.items():
111
+ print(f" - {country}: {count:,} ({count/len(df)*100:.1f}%)")
112
+
113
+ # Show top education levels
114
+ print("\n🎓 Top Education Levels:")
115
+ top_edu = df["EdLevel"].value_counts().head(10)
116
+ for edu, count in top_edu.items():
117
+ print(f" - {edu}: {count:,} ({count/len(df)*100:.1f}%)")
118
+
119
+ # Show top developer types
120
+ print("\n👨‍💻 Top Developer Types:")
121
+ top_devtype = df["DevType"].value_counts().head(10)
122
+ for devtype, count in top_devtype.items():
123
+ print(f" - {devtype}: {count:,} ({count/len(df)*100:.1f}%)")
124
+
125
+ # Show YearsCode statistics
126
+ print("\n💼 Years of Coding Experience:")
127
+ print(f" - Min: {df['YearsCode'].min():.1f}")
128
+ print(f" - Max: {df['YearsCode'].max():.1f}")
129
+ print(f" - Mean: {df['YearsCode'].mean():.1f}")
130
+ print(f" - Median: {df['YearsCode'].median():.1f}")
131
+ print(f" - 25th percentile: {df['YearsCode'].quantile(0.25):.1f}")
132
+ print(f" - 75th percentile: {df['YearsCode'].quantile(0.75):.1f}")
133
+
134
+ # Show most common one-hot encoded features (by frequency)
135
+ # Separate analysis for each categorical feature
136
+
137
+ # Calculate feature frequencies (sum of each column for one-hot encoded)
138
+ feature_counts = X.sum().sort_values(ascending=False)
139
+
140
+ # Exclude numeric features (YearsCode)
141
+ categorical_features = feature_counts[~feature_counts.index.str.startswith('YearsCode')]
142
+
143
+ # Country features
144
+ print("\n🌍 Top 15 Country Features (most common):")
145
+ country_features = categorical_features[categorical_features.index.str.startswith('Country_')]
146
+ for i, (feature, count) in enumerate(country_features.head(15).items(), 1):
147
+ percentage = (count / len(X)) * 100
148
+ country_name = feature.replace('Country_', '')
149
+ print(f" {i:2d}. {country_name:45s} - {count:6.0f} occurrences ({percentage:5.1f}%)")
150
+
151
+ # Education level features
152
+ print("\n🎓 Top 10 Education Level Features (most common):")
153
+ edlevel_features = categorical_features[categorical_features.index.str.startswith('EdLevel_')]
154
+ for i, (feature, count) in enumerate(edlevel_features.head(10).items(), 1):
155
+ percentage = (count / len(X)) * 100
156
+ edu_name = feature.replace('EdLevel_', '')
157
+ print(f" {i:2d}. {edu_name:45s} - {count:6.0f} occurrences ({percentage:5.1f}%)")
158
+
159
+ # Developer type features
160
+ print("\n👨‍💻 Top 10 Developer Type Features (most common):")
161
+ devtype_features = categorical_features[categorical_features.index.str.startswith('DevType_')]
162
+ for i, (feature, count) in enumerate(devtype_features.head(10).items(), 1):
163
+ percentage = (count / len(X)) * 100
164
+ devtype_name = feature.replace('DevType_', '')
165
+ print(f" {i:2d}. {devtype_name:45s} - {count:6.0f} occurrences ({percentage:5.1f}%)")
166
+
167
+ print(f"\n📊 Total one-hot encoded features: {len(X.columns)}")
168
+ print(" - Numeric: 1 (YearsCode)")
169
+ print(f" - Country: {len(country_features)}")
170
+ print(f" - Education: {len(edlevel_features)}")
171
+ print(f" - DevType: {len(devtype_features)}")
172
+
173
+ print("=" * 60 + "\n")
174
+
175
+ # Split data
176
+ X_train, X_test, y_train, y_test = train_test_split(
177
+ X, y,
178
+ test_size=config['data']['test_size'],
179
+ random_state=config['data']['random_state']
180
+ )
181
+
182
+ # Train model
183
+ print("Training XGBoost model...")
184
+ model_config = config['model']
185
+ model = XGBRegressor(
186
+ n_estimators=model_config['n_estimators'],
187
+ learning_rate=model_config['learning_rate'],
188
+ max_depth=model_config['max_depth'],
189
+ min_child_weight=model_config['min_child_weight'],
190
+ random_state=model_config['random_state'],
191
+ n_jobs=model_config['n_jobs'],
192
+ early_stopping_rounds=model_config['early_stopping_rounds'],
193
+ )
194
+ model.fit(
195
+ X_train,
196
+ y_train,
197
+ eval_set=[(X_test, y_test)],
198
+ verbose=config['training']['verbose'],
199
+ )
200
+
201
+ print(f"Best iteration: {model.best_iteration + 1} (early stopping at {model.n_estimators} max)")
202
+
203
+ # Evaluate
204
+ train_score = model.score(X_train, y_train)
205
+ test_score = model.score(X_test, y_test)
206
+ print(f"Training R2 score: {train_score:.4f}")
207
+ print(f"Test R2 score: {test_score:.4f}")
208
+
209
+ # Save model and feature columns for inference
210
+ model_path = Path(config['training']['model_path'])
211
+ model_path.parent.mkdir(parents=True, exist_ok=True) # Ensure directory exists
212
+
213
+ artifacts = {
214
+ "model": model,
215
+ "feature_columns": list(X.columns),
216
+ }
217
+
218
+ with open(model_path, "wb") as f:
219
+ pickle.dump(artifacts, f)
220
+
221
+ print(f"Model saved to {model_path}")
222
+
223
+
224
+ if __name__ == "__main__":
225
+ main()