dima806 commited on
Commit
be7b61c
·
verified ·
1 Parent(s): 509d1a8

Upload 7 files

Browse files
Files changed (5) hide show
  1. src/infer.py +54 -18
  2. src/preprocess.py +29 -46
  3. src/preprocessing.py +4 -1
  4. src/train.py +134 -58
  5. src/tune.py +5 -26
src/infer.py CHANGED
@@ -33,43 +33,78 @@ if not valid_categories_path.exists():
33
  with open(valid_categories_path, "r") as f:
34
  valid_categories = yaml.safe_load(f)
35
 
36
- # Load currency conversion rates
37
- currency_rates_path = Path("config/currency_rates.yaml")
38
- currency_rates = {}
39
- if currency_rates_path.exists():
40
- with open(currency_rates_path, "r") as f:
41
- currency_rates = yaml.safe_load(f) or {}
42
 
 
 
 
 
 
43
 
44
- def get_local_currency(country: str, salary_usd: float) -> dict | None:
45
- """Convert USD salary to local currency for a given country.
 
 
 
 
 
 
 
 
 
46
 
47
  Returns:
48
- Dict with code, name, rate, and salary_local, or None if unavailable.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  """
50
- if country not in currency_rates:
51
- return None
52
- info = currency_rates[country]
53
  return {
54
- "code": info["code"],
55
- "name": info["name"],
56
- "rate": info["rate"],
57
- "salary_local": round(salary_usd * info["rate"], 2),
58
  }
59
 
60
 
61
- def predict_salary(data: SalaryInput) -> float:
62
  """Predict salary based on input features.
63
 
64
  Args:
65
  data: SalaryInput model with developer information
 
66
 
67
  Returns:
68
  Predicted annual salary in USD
69
 
70
  Raises:
71
- ValueError: If country or education_level is not in valid categories
 
72
  """
 
 
 
 
 
 
73
  # Validate input against valid categories from training
74
  if data.country not in valid_categories["Country"]:
75
  raise ValueError(
@@ -140,6 +175,7 @@ def predict_salary(data: SalaryInput) -> float:
140
  "ICorPM": [data.ic_or_pm],
141
  "OrgSize": [data.org_size],
142
  "Employment": [data.employment],
 
143
  }
144
  )
145
 
 
33
  with open(valid_categories_path, "r") as f:
34
  valid_categories = yaml.safe_load(f)
35
 
36
+ # Load currency conversion rates, one file per survey year
37
+ with open(Path("config/model_parameters.yaml"), "r") as f:
38
+ _currency_rate_paths = yaml.safe_load(f)["data"]["currency_rates"]
 
 
 
39
 
40
+ currency_rates_by_year = {}
41
+ for _year, _path in _currency_rate_paths.items():
42
+ if Path(_path).exists():
43
+ with open(_path, "r") as f:
44
+ currency_rates_by_year[str(_year)] = yaml.safe_load(f) or {}
45
 
46
+ # 2025 rates, kept under the old name for backward compatibility
47
+ currency_rates = currency_rates_by_year.get("2025", {})
48
+
49
+
50
+ def get_local_currency(
51
+ country: str, salary_usd: float, survey_year: str = "2025"
52
+ ) -> dict | None:
53
+ """Convert USD salary to local currency for a given country and year.
54
+
55
+ Uses that survey year's rate; if the country has no rate for that year,
56
+ falls back to another year's rate.
57
 
58
  Returns:
59
+ Dict with code, name, rate, rate_year, and salary_local, or None if
60
+ no rate is available for the country in any year.
61
+ """
62
+ years = [survey_year] + [y for y in currency_rates_by_year if y != survey_year]
63
+ for year in years:
64
+ info = currency_rates_by_year.get(year, {}).get(country)
65
+ if info is not None:
66
+ return {
67
+ "code": info["code"],
68
+ "name": info["name"],
69
+ "rate": info["rate"],
70
+ "rate_year": year,
71
+ "salary_local": round(salary_usd * info["rate"], 2),
72
+ }
73
+ return None
74
+
75
+
76
+ def predict_salaries(data: SalaryInput) -> dict[str, float]:
77
+ """Predict salary for every survey year the model was trained on.
78
+
79
+ Returns:
80
+ Dict mapping survey year (e.g. "2025", "2026") to USD salary.
81
  """
 
 
 
82
  return {
83
+ year: predict_salary(data, survey_year=year)
84
+ for year in valid_categories["SurveyYear"]
 
 
85
  }
86
 
87
 
88
+ def predict_salary(data: SalaryInput, survey_year: str = "2025") -> float:
89
  """Predict salary based on input features.
90
 
91
  Args:
92
  data: SalaryInput model with developer information
93
+ survey_year: Survey year to predict for (e.g. "2025", "2026")
94
 
95
  Returns:
96
  Predicted annual salary in USD
97
 
98
  Raises:
99
+ ValueError: If any categorical input or survey_year is not in
100
+ valid categories
101
  """
102
+ if survey_year not in valid_categories["SurveyYear"]:
103
+ raise ValueError(
104
+ f"Invalid survey year: '{survey_year}'. "
105
+ f"Must be one of {valid_categories['SurveyYear']}."
106
+ )
107
+
108
  # Validate input against valid categories from training
109
  if data.country not in valid_categories["Country"]:
110
  raise ValueError(
 
175
  "ICorPM": [data.ic_or_pm],
176
  "OrgSize": [data.org_size],
177
  "Employment": [data.employment],
178
+ "SurveyYear": [survey_year],
179
  }
180
  )
181
 
src/preprocess.py CHANGED
@@ -4,7 +4,8 @@ Validates the raw CSV, applies the same data-cleaning steps used by
4
  src/train.py, then writes:
5
 
6
  - config/valid_categories.yaml — valid input values for runtime guardrails
7
- - config/currency_rates.yaml — per-country median currency conversion rates
 
8
 
9
  Run before ``make train`` to validate data and pre-generate configs, or
10
  standalone to inspect what categories the current dataset supports.
@@ -18,38 +19,29 @@ import yaml
18
 
19
  from src.train import (
20
  CATEGORICAL_FEATURES,
 
21
  apply_cardinality_reduction,
22
- compute_currency_rates,
23
  drop_other_rows,
24
  extract_valid_categories,
25
  filter_salaries,
 
 
26
  )
27
 
28
- REQUIRED_COLUMNS = [
29
- "Country",
30
- "YearsCode",
31
- "WorkExp",
32
- "EdLevel",
33
- "DevType",
34
- "Industry",
35
- "Age",
36
- "ICorPM",
37
- "OrgSize",
38
- "Employment",
39
- "Currency",
40
- "CompTotal",
41
- "ConvertedCompYearly",
42
- ]
43
-
44
-
45
- def validate_columns(data_path: Path) -> None:
46
- """Exit 1 if any required column is absent from the CSV header."""
47
- header = pd.read_csv(data_path, nrows=0)
48
  missing = [c for c in REQUIRED_COLUMNS if c not in header.columns]
49
  if missing:
50
- print(f"Error: missing required columns: {missing}")
51
  sys.exit(1)
52
- print(f"All {len(REQUIRED_COLUMNS)} required columns present.")
53
 
54
 
55
  def print_category_summary(df: pd.DataFrame) -> None:
@@ -65,27 +57,28 @@ def main() -> None:
65
  with open(config_path) as f:
66
  config = yaml.safe_load(f)
67
 
68
- data_path = Path("data/survey_results_public.csv")
69
 
70
- # Step 1 — Validate data file ------------------------------------------------
71
  print("=" * 60)
72
- print("STEP 1 — Validate data file")
73
  print("=" * 60)
74
 
75
- if not data_path.exists():
76
- print(f"Error: {data_path} not found.")
77
- print("Download from: https://insights.stackoverflow.com/survey")
78
- sys.exit(1)
79
-
80
- print(f"Checking columns in {data_path} ...")
81
- validate_columns(data_path)
 
82
 
83
  # Step 2 — Load and filter salaries ------------------------------------------
84
  print("\n" + "=" * 60)
85
  print("STEP 2 — Load and filter salaries")
86
  print("=" * 60)
87
 
88
- df = pd.read_csv(data_path, usecols=REQUIRED_COLUMNS)
89
  print(f"Loaded {len(df):,} rows")
90
 
91
  df = filter_salaries(df, config)
@@ -123,17 +116,7 @@ def main() -> None:
123
  n_total = sum(len(v) for v in valid_categories.values())
124
  print(f"Saved {vc_path} ({n_total} total valid values)")
125
 
126
- currency_rates = compute_currency_rates(df, valid_categories["Country"])
127
- cr_path = Path("config/currency_rates.yaml")
128
- with open(cr_path, "w") as f:
129
- yaml.dump(
130
- currency_rates,
131
- f,
132
- default_flow_style=False,
133
- sort_keys=True,
134
- allow_unicode=True,
135
- )
136
- print(f"Saved {cr_path} ({len(currency_rates)} countries)")
137
 
138
  print("\nPre-processing complete. Ready for `make train`.")
139
 
 
4
  src/train.py, then writes:
5
 
6
  - config/valid_categories.yaml — valid input values for runtime guardrails
7
+ - config/currency_rates.yaml — 2025 per-country median currency rates
8
+ - config/currency_rates_2026.yaml — 2026 per-country median currency rates
9
 
10
  Run before ``make train`` to validate data and pre-generate configs, or
11
  standalone to inspect what categories the current dataset supports.
 
19
 
20
  from src.train import (
21
  CATEGORICAL_FEATURES,
22
+ REQUIRED_COLUMNS,
23
  apply_cardinality_reduction,
 
24
  drop_other_rows,
25
  extract_valid_categories,
26
  filter_salaries,
27
+ load_survey_data,
28
+ save_currency_rates,
29
  )
30
 
31
+
32
+ def validate_columns(data_path: Path, renames: dict | None = None) -> None:
33
+ """Exit 1 if any required column is absent from the CSV header.
34
+
35
+ Args:
36
+ data_path: Path to a survey CSV.
37
+ renames: Optional {source_name: required_name} column renames.
38
+ """
39
+ header = pd.read_csv(data_path, nrows=0).rename(columns=renames or {})
 
 
 
 
 
 
 
 
 
 
 
40
  missing = [c for c in REQUIRED_COLUMNS if c not in header.columns]
41
  if missing:
42
+ print(f"Error: missing required columns in {data_path}: {missing}")
43
  sys.exit(1)
44
+ print(f"All {len(REQUIRED_COLUMNS)} required columns present in {data_path}.")
45
 
46
 
47
  def print_category_summary(df: pd.DataFrame) -> None:
 
57
  with open(config_path) as f:
58
  config = yaml.safe_load(f)
59
 
60
+ renames = config["data"].get("column_renames") or {}
61
 
62
+ # Step 1 — Validate data files -----------------------------------------------
63
  print("=" * 60)
64
+ print("STEP 1 — Validate data files")
65
  print("=" * 60)
66
 
67
+ for year, path in config["data"]["sources"].items():
68
+ data_path = Path(path)
69
+ if not data_path.exists():
70
+ print(f"Error: {data_path} not found.")
71
+ print("Download from: https://survey.stackoverflow.co/")
72
+ sys.exit(1)
73
+ print(f"Checking columns in {data_path} ...")
74
+ validate_columns(data_path, renames.get(str(year)))
75
 
76
  # Step 2 — Load and filter salaries ------------------------------------------
77
  print("\n" + "=" * 60)
78
  print("STEP 2 — Load and filter salaries")
79
  print("=" * 60)
80
 
81
+ df = load_survey_data(config)
82
  print(f"Loaded {len(df):,} rows")
83
 
84
  df = filter_salaries(df, config)
 
116
  n_total = sum(len(v) for v in valid_categories.values())
117
  print(f"Saved {vc_path} ({n_total} total valid values)")
118
 
119
+ save_currency_rates(df, valid_categories["Country"], config)
 
 
 
 
 
 
 
 
 
 
120
 
121
  print("\nPre-processing complete. Ready for `make train`.")
122
 
src/preprocessing.py CHANGED
@@ -79,7 +79,7 @@ def prepare_features(df: pd.DataFrame) -> pd.DataFrame:
79
 
80
  Args:
81
  df: DataFrame with columns: Country, YearsCode, WorkExp, EdLevel,
82
- DevType, Industry, Age, ICorPM, OrgSize, Employment.
83
  NOTE: During training, cardinality reduction should be applied to df
84
  BEFORE calling this function. During inference, valid_categories.yaml
85
  ensures only valid (already-reduced) categories are used.
@@ -108,6 +108,7 @@ def prepare_features(df: pd.DataFrame) -> pd.DataFrame:
108
  "ICorPM",
109
  "OrgSize",
110
  "Employment",
 
111
  ]
112
  for col in _categorical_cols:
113
  if col in df_processed.columns:
@@ -138,6 +139,7 @@ def prepare_features(df: pd.DataFrame) -> pd.DataFrame:
138
  df_processed["ICorPM"] = df_processed["ICorPM"].fillna("Unknown")
139
  df_processed["OrgSize"] = df_processed["OrgSize"].fillna("Unknown")
140
  df_processed["Employment"] = df_processed["Employment"].fillna("Unknown")
 
141
 
142
  # NOTE: Cardinality reduction is NOT applied here
143
  # It should be applied during training BEFORE calling this function
@@ -155,6 +157,7 @@ def prepare_features(df: pd.DataFrame) -> pd.DataFrame:
155
  "ICorPM",
156
  "OrgSize",
157
  "Employment",
 
158
  ]
159
  df_features = df_processed[feature_cols]
160
 
 
79
 
80
  Args:
81
  df: DataFrame with columns: Country, YearsCode, WorkExp, EdLevel,
82
+ DevType, Industry, Age, ICorPM, OrgSize, Employment, SurveyYear.
83
  NOTE: During training, cardinality reduction should be applied to df
84
  BEFORE calling this function. During inference, valid_categories.yaml
85
  ensures only valid (already-reduced) categories are used.
 
108
  "ICorPM",
109
  "OrgSize",
110
  "Employment",
111
+ "SurveyYear",
112
  ]
113
  for col in _categorical_cols:
114
  if col in df_processed.columns:
 
139
  df_processed["ICorPM"] = df_processed["ICorPM"].fillna("Unknown")
140
  df_processed["OrgSize"] = df_processed["OrgSize"].fillna("Unknown")
141
  df_processed["Employment"] = df_processed["Employment"].fillna("Unknown")
142
+ df_processed["SurveyYear"] = df_processed["SurveyYear"].fillna("Unknown")
143
 
144
  # NOTE: Cardinality reduction is NOT applied here
145
  # It should be applied during training BEFORE calling this function
 
157
  "ICorPM",
158
  "OrgSize",
159
  "Employment",
160
+ "SurveyYear",
161
  ]
162
  df_features = df_processed[feature_cols]
163
 
src/train.py CHANGED
@@ -20,14 +20,81 @@ CATEGORICAL_FEATURES = [
20
  "ICorPM",
21
  "OrgSize",
22
  "Employment",
 
23
  ]
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  def filter_salaries(df: pd.DataFrame, config: dict) -> pd.DataFrame:
27
- """Filter rows by minimum salary and per-country percentile outlier removal.
28
 
29
  Args:
30
- df: DataFrame with ConvertedCompYearly and Country columns.
31
  config: Config dict with data.min_salary, data.lower_percentile,
32
  data.upper_percentile.
33
 
@@ -40,8 +107,9 @@ def filter_salaries(df: pd.DataFrame, config: dict) -> pd.DataFrame:
40
 
41
  lower_pct = config["data"]["lower_percentile"] / 100
42
  upper_pct = config["data"]["upper_percentile"] / 100
43
- lower_bound = df.groupby("Country")[main_label].transform("quantile", lower_pct)
44
- upper_bound = df.groupby("Country")[main_label].transform("quantile", upper_pct)
 
45
  df = df[(df[main_label] > lower_bound) & (df[main_label] < upper_bound)]
46
 
47
  df = df.dropna(subset=[main_label])
@@ -96,12 +164,19 @@ def extract_valid_categories(df: pd.DataFrame) -> dict:
96
  }
97
 
98
 
99
- def compute_currency_rates(df: pd.DataFrame, valid_countries: list[str]) -> dict:
 
 
 
 
100
  """Compute median currency conversion rates per country.
101
 
102
  Args:
103
  df: DataFrame with Country, Currency, CompTotal, ConvertedCompYearly columns.
104
  valid_countries: List of valid country names to compute rates for.
 
 
 
105
 
106
  Returns:
107
  Dict mapping country to {code, name, rate}.
@@ -121,10 +196,11 @@ def compute_currency_rates(df: pd.DataFrame, valid_countries: list[str]) -> dict
121
  country_data = currency_df[currency_df["Country"] == country]
122
  if country_data.empty:
123
  continue
124
- most_common = country_data["CurrencyCode"].mode()
125
- if most_common.empty:
126
- continue
127
- code = most_common.iloc[0]
 
128
  name_row = country_data[country_data["CurrencyCode"] == code].iloc[0]
129
  full_name = name_row["CurrencyName"]
130
  rates = country_data[country_data["CurrencyCode"] == code]["rate"]
@@ -138,6 +214,43 @@ def compute_currency_rates(df: pd.DataFrame, valid_countries: list[str]) -> dict
138
  return currency_rates
139
 
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  def main():
142
  """Train and save the salary prediction model."""
143
  # Load configuration
@@ -147,36 +260,12 @@ def main():
147
  config = yaml.safe_load(f)
148
 
149
  print("Loading data...")
150
- data_path = Path("data/survey_results_public.csv")
151
-
152
- if not data_path.exists():
153
- print(f"Error: Data file not found at {data_path}")
154
- print(
155
- "Please download the Stack Overflow Developer Survey CSV and place it in the data/ directory."
156
- )
157
- print("Download from: https://insights.stackoverflow.com/survey")
158
  return
159
 
160
- # Load only required columns to save memory
161
- df = pd.read_csv(
162
- data_path,
163
- usecols=[
164
- "Country",
165
- "YearsCode",
166
- "WorkExp",
167
- "EdLevel",
168
- "DevType",
169
- "Industry",
170
- "Age",
171
- "ICorPM",
172
- "OrgSize",
173
- "Employment",
174
- "Currency",
175
- "CompTotal",
176
- "ConvertedCompYearly",
177
- ],
178
- )
179
-
180
  print(f"Loaded {len(df):,} rows")
181
 
182
  print("Removing null, extremely small and large reported salaries")
@@ -215,27 +304,9 @@ def main():
215
  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}"
216
  )
217
 
218
- # Compute currency conversion rates per country
219
- print("\nComputing currency conversion rates per country...")
220
- currency_rates = compute_currency_rates(df, valid_categories["Country"])
221
-
222
- currency_rates_path = Path("config/currency_rates.yaml")
223
- with open(currency_rates_path, "w") as f:
224
- yaml.dump(
225
- currency_rates,
226
- f,
227
- default_flow_style=False,
228
- sort_keys=True,
229
- allow_unicode=True,
230
- )
231
-
232
- print(
233
- f"Saved currency rates for {len(currency_rates)} countries to {currency_rates_path}"
234
- )
235
- for country, info in sorted(currency_rates.items()):
236
- print(
237
- f" {country:45s} -> {info['code']} ({info['name']}, rate: {info['rate']})"
238
- )
239
 
240
  print(f"\nFeature matrix shape: {X.shape}")
241
  print(f"Total features: {X.shape[1]}")
@@ -287,6 +358,11 @@ def main():
287
  for emp, count in top_employment.items():
288
  print(f" - {emp}: {count:,} ({count / len(df) * 100:.1f}%)")
289
 
 
 
 
 
 
290
  # Show YearsCode statistics
291
  print("\n💼 Years of Coding Experience:")
292
  print(f" - Min: {df['YearsCode'].min():.1f}")
 
20
  "ICorPM",
21
  "OrgSize",
22
  "Employment",
23
+ "SurveyYear",
24
  ]
25
 
26
+ # Columns read from each survey CSV (after per-year column renames)
27
+ REQUIRED_COLUMNS = [
28
+ "Country",
29
+ "YearsCode",
30
+ "WorkExp",
31
+ "EdLevel",
32
+ "DevType",
33
+ "Industry",
34
+ "Age",
35
+ "ICorPM",
36
+ "OrgSize",
37
+ "Employment",
38
+ "Currency",
39
+ "CompTotal",
40
+ "ConvertedCompYearly",
41
+ ]
42
+
43
+
44
+ def load_survey_data(config: dict) -> pd.DataFrame:
45
+ """Load every configured survey year and stack them with SurveyYear.
46
+
47
+ For each year in config data.sources, reads REQUIRED_COLUMNS (applying
48
+ data.column_renames), maps answer wording via data.category_mappings,
49
+ and adds SurveyYear as a string.
50
+
51
+ Args:
52
+ config: Config dict with data.sources and optional
53
+ data.column_renames / data.category_mappings.
54
+
55
+ Returns:
56
+ Concatenated DataFrame of all years.
57
+
58
+ Raises:
59
+ FileNotFoundError: If a configured survey CSV does not exist.
60
+ """
61
+ data_config = config["data"]
62
+ renames = data_config.get("column_renames") or {}
63
+ mappings = data_config.get("category_mappings") or {}
64
+
65
+ frames = []
66
+ for year, path in data_config["sources"].items():
67
+ year = str(year)
68
+ path = Path(path)
69
+ if not path.exists():
70
+ raise FileNotFoundError(
71
+ f"Data file for {year} not found at {path}. Download from "
72
+ "https://survey.stackoverflow.co/"
73
+ )
74
+
75
+ year_renames = renames.get(year) or {}
76
+ source_names = {new: old for old, new in year_renames.items()}
77
+ usecols = [source_names.get(col, col) for col in REQUIRED_COLUMNS]
78
+ df = pd.read_csv(path, usecols=usecols).rename(columns=year_renames)
79
+
80
+ if year in mappings:
81
+ with open(mappings[year], "r") as f:
82
+ category_mapping = yaml.safe_load(f) or {}
83
+ for col, mapping in category_mapping.items():
84
+ df[col] = df[col].replace(mapping)
85
+
86
+ df["SurveyYear"] = year
87
+ print(f"Loaded {len(df):,} rows for {year} from {path}")
88
+ frames.append(df)
89
+
90
+ return pd.concat(frames, ignore_index=True)
91
+
92
 
93
  def filter_salaries(df: pd.DataFrame, config: dict) -> pd.DataFrame:
94
+ """Filter rows by minimum salary and per-year, per-country outlier removal.
95
 
96
  Args:
97
+ df: DataFrame with ConvertedCompYearly, SurveyYear and Country columns.
98
  config: Config dict with data.min_salary, data.lower_percentile,
99
  data.upper_percentile.
100
 
 
107
 
108
  lower_pct = config["data"]["lower_percentile"] / 100
109
  upper_pct = config["data"]["upper_percentile"] / 100
110
+ groups = df.groupby(["SurveyYear", "Country"])[main_label]
111
+ lower_bound = groups.transform("quantile", lower_pct)
112
+ upper_bound = groups.transform("quantile", upper_pct)
113
  df = df[(df[main_label] > lower_bound) & (df[main_label] < upper_bound)]
114
 
115
  df = df.dropna(subset=[main_label])
 
164
  }
165
 
166
 
167
+ def compute_currency_rates(
168
+ df: pd.DataFrame,
169
+ valid_countries: list[str],
170
+ preferred_codes: dict[str, str] | None = None,
171
+ ) -> dict:
172
  """Compute median currency conversion rates per country.
173
 
174
  Args:
175
  df: DataFrame with Country, Currency, CompTotal, ConvertedCompYearly columns.
176
  valid_countries: List of valid country names to compute rates for.
177
+ preferred_codes: Optional {country: currency code} to keep (e.g. the
178
+ previous survey year's code) when it still appears in the data;
179
+ otherwise the most common code is used.
180
 
181
  Returns:
182
  Dict mapping country to {code, name, rate}.
 
196
  country_data = currency_df[currency_df["Country"] == country]
197
  if country_data.empty:
198
  continue
199
+ preferred = (preferred_codes or {}).get(country)
200
+ if preferred in set(country_data["CurrencyCode"]):
201
+ code = preferred
202
+ else:
203
+ code = country_data["CurrencyCode"].mode().iloc[0]
204
  name_row = country_data[country_data["CurrencyCode"] == code].iloc[0]
205
  full_name = name_row["CurrencyName"]
206
  rates = country_data[country_data["CurrencyCode"] == code]["rate"]
 
214
  return currency_rates
215
 
216
 
217
+ def save_currency_rates(
218
+ df: pd.DataFrame, valid_countries: list[str], config: dict
219
+ ) -> dict:
220
+ """Compute currency rates per survey year and write one YAML per year.
221
+
222
+ Each year's rates use only that year's rows; output paths come from
223
+ config data.currency_rates. A country keeps the previous year's currency
224
+ while that currency still appears in the data (e.g. Argentina stays on
225
+ ARS even if as many respondents report USD), so years stay comparable.
226
+
227
+ Returns:
228
+ Dict mapping survey year to its {country: {code, name, rate}} dict.
229
+ """
230
+ rates_by_year = {}
231
+ previous_codes = {}
232
+ for year, path in sorted(config["data"]["currency_rates"].items()):
233
+ year = str(year)
234
+ year_df = df[df["SurveyYear"] == year]
235
+ rates = compute_currency_rates(year_df, valid_countries, previous_codes)
236
+ previous_codes = {country: info["code"] for country, info in rates.items()}
237
+ with open(path, "w") as f:
238
+ yaml.dump(
239
+ rates,
240
+ f,
241
+ default_flow_style=False,
242
+ sort_keys=True,
243
+ allow_unicode=True,
244
+ )
245
+ print(f"Saved currency rates for {len(rates)} countries to {path}")
246
+ for country, info in sorted(rates.items()):
247
+ print(
248
+ f" {country:45s} -> {info['code']} ({info['name']}, rate: {info['rate']})"
249
+ )
250
+ rates_by_year[year] = rates
251
+ return rates_by_year
252
+
253
+
254
  def main():
255
  """Train and save the salary prediction model."""
256
  # Load configuration
 
260
  config = yaml.safe_load(f)
261
 
262
  print("Loading data...")
263
+ try:
264
+ df = load_survey_data(config)
265
+ except FileNotFoundError as e:
266
+ print(f"Error: {e}")
 
 
 
 
267
  return
268
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  print(f"Loaded {len(df):,} rows")
270
 
271
  print("Removing null, extremely small and large reported salaries")
 
304
  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}"
305
  )
306
 
307
+ # Compute currency conversion rates per country, separately per year
308
+ print("\nComputing currency conversion rates per country and year...")
309
+ save_currency_rates(df, valid_categories["Country"], config)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
 
311
  print(f"\nFeature matrix shape: {X.shape}")
312
  print(f"Total features: {X.shape[1]}")
 
358
  for emp, count in top_employment.items():
359
  print(f" - {emp}: {count:,} ({count / len(df) * 100:.1f}%)")
360
 
361
+ # Show survey year distribution
362
+ print("\n📅 Survey Year Distribution:")
363
+ for year, count in df["SurveyYear"].value_counts().sort_index().items():
364
+ print(f" - {year}: {count:,} ({count / len(df) * 100:.1f}%)")
365
+
366
  # Show YearsCode statistics
367
  print("\n💼 Years of Coding Experience:")
368
  print(f" - Min: {df['YearsCode'].min():.1f}")
src/tune.py CHANGED
@@ -15,6 +15,7 @@ from src.train import (
15
  apply_cardinality_reduction,
16
  drop_other_rows,
17
  filter_salaries,
 
18
  )
19
 
20
 
@@ -128,33 +129,11 @@ def main():
128
 
129
  # Load and preprocess data
130
  print("Loading data...")
131
- data_path = Path("data/survey_results_public.csv")
132
- if not data_path.exists():
133
- print(f"Error: Data file not found at {data_path}")
134
- print(
135
- "Please download the Stack Overflow Developer Survey CSV "
136
- "and place it in the data/ directory."
137
- )
138
  return
139
-
140
- df = pd.read_csv(
141
- data_path,
142
- usecols=[
143
- "Country",
144
- "YearsCode",
145
- "WorkExp",
146
- "EdLevel",
147
- "DevType",
148
- "Industry",
149
- "Age",
150
- "ICorPM",
151
- "OrgSize",
152
- "Employment",
153
- "Currency",
154
- "CompTotal",
155
- "ConvertedCompYearly",
156
- ],
157
- )
158
  print(f"Loaded {len(df):,} rows")
159
 
160
  df = filter_salaries(df, config)
 
15
  apply_cardinality_reduction,
16
  drop_other_rows,
17
  filter_salaries,
18
+ load_survey_data,
19
  )
20
 
21
 
 
129
 
130
  # Load and preprocess data
131
  print("Loading data...")
132
+ try:
133
+ df = load_survey_data(config)
134
+ except FileNotFoundError as e:
135
+ print(f"Error: {e}")
 
 
 
136
  return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  print(f"Loaded {len(df):,} rows")
138
 
139
  df = filter_salaries(df, config)