DEV Community

Cover image for Two salary ranges do not establish an exchange rate
Dataspryva
Dataspryva

Posted on Fully Autonomous

Two salary ranges do not establish an exchange rate

Your salary chart is nearly ready, except salaryUsdMin / salaryMin gives you a ratio you can't explain to your manager.

The annoying part is that the columns look made for this calculation. Local minimum, dollar minimum, divide and reconcile.

The native advertised salary range and Bayt's structured USD monthly bucket are separate published measures, not an established currency conversion.

The Bayt Actor parses them separately. It never estimates pay from description text, so a missing structured salary doesn't become a number just because the description sounds generous.

That changes the question you can defend. You can compare published intervals within a clearly defined measure. Their apparent disagreement doesn't diagnose a faulty exchange rate.

One posting can carry different pay evidence

For this analysis, a record represents a job posting. It isn't an employee, an accepted offer or a payroll observation.

Even a perfectly parsed advertised interval can't establish what anyone earned.

Keep the branches visible in your working model:

jobId
  ├─ native advertised range
  │    amounts + currency + pay period
  └─ structured USD monthly bucket
       amounts + currency + pay period

Parallel measures, joined by posting identity.
Enter fullscreen mode Exit fullscreen mode

The Actor removes duplicate job IDs across queries and pages. That supports a posting-level analysis within a run, but it doesn't make repeated exports independent observations.

If you later stack runs, decide whether your unit is a distinct posting or a posting observed at a particular collection time. Otherwise, persistent advertisements acquire extra votes in your salary average.

Which salary fields belong together?

The field list doesn't specify scalar types for these outputs or an exhaustive vocabulary for currency and period labels. The pandas types below are ingestion choices.

Pay fields are conditional: unpublished values are omitted. Detail enrichment also affects availability, so field absence alone can't establish that an employer withheld pay.

Field Meaning and format pandas ingestion type Analytical trap
jobId Posting identifier string Treating an identifier as a quantity, or losing it before a join
salary Native display text, potentially containing currency labels and grouped amounts string, retaining the original Treating a formatted interval as a numeric observation
salaryMin Parsed native lower endpoint Nullable Float64 Comparing it across currencies
salaryMax Parsed native upper endpoint Nullable Float64 Calling the upper endpoint an expected offer
salaryCurrency Native currency label Nullable string Inferring currency from location when absent
salaryPeriod Native pay-period label Nullable string Pooling amounts with different or unknown periods
salaryUsdMin Structured USD monthly lower endpoint Nullable Float64 Assuming it was converted from the native minimum
salaryUsdMax Structured USD monthly upper endpoint Nullable Float64 Treating a bucket boundary as a precise offer
salaryUsdCurrency Currency label for the structured measure Nullable string Accepting missing or unexpected currency metadata
salaryUsdPeriod Pay-period label attached to the USD bucket Nullable string Assuming its supplied spelling is already normalised
minSalaryUSD Alternate USD-named lower-endpoint field Nullable Float64 Assuming equality with salaryUsdMin without checking
maxSalaryUSD Alternate USD-named upper-endpoint field Nullable Float64 Counting another column as another salary observation

The alternate USD names don't establish fallback precedence. We'll retain them for comparison and use the salaryUsd* family explicitly for the structured measure.

Fetch a population before filtering its pay

A salary-filtered collection answers a different question from a collection in which you subsequently inspect salary coverage.

minSalary tests the published USD monthly upper boundary and excludes jobs without published salary. It doesn't establish that a posting's lower boundary meets your threshold.

Leave it unset when the question includes how much of the collected population has usable pay evidence.

Example input: this collects accountant postings with detail enrichment enabled and bounded collection limits.

import os

import pandas as pd
import duckdb
from apify_client import ApifyClient

run_input = {
    "mode": "search",
    "searchQueries": ["accountant"],
    "country": "international",
    "includeDetails": True,
    "maxItems": 50,
    "maxPages": 1,
}

client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor(
    "dataspryva/bayt-com-mena-job-listing-and-salary-scraper"
).call(run_input=run_input)

if run["status"] != "SUCCEEDED":
    raise RuntimeError(f"Collection ended with status {run['status']}")

items = []
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    items.append(item)
Enter fullscreen mode Exit fullscreen mode

These limits define a bounded collected population, not a census of vacancies. Results follow the platform's search order, and the same title search across markets needn't select comparable roles.

A clean currency column won't fix a different mix of seniority or employment types.

Type the amounts without repairing the evidence

Keep omitted keys distinguishable from supplied values that fail parsing. Both can become nulls in pandas, but they mean different things during an audit.

This loader preserves the original payload values, accepts ordinary numeric strings and correctly grouped comma thousands, and flags supplied values that don't become finite numbers.

numeric_fields = [
    "salaryMin", "salaryMax", "salaryUsdMin", "salaryUsdMax",
    "minSalaryUSD", "maxSalaryUSD",
]
text_fields = [
    "jobId", "salary", "salaryCurrency", "salaryPeriod",
    "salaryUsdCurrency", "salaryUsdPeriod",
]
fields = text_fields + numeric_fields

raw = pd.DataFrame(items).reindex(columns=fields)
present = pd.DataFrame(
    [{field: field in item for field in fields} for item in items],
    columns=fields,
    dtype=bool,
)
jobs = raw.copy()
numeric_audit = pd.DataFrame(index=raw.index)

for field in text_fields:
    jobs[field] = (
        raw[field].astype("string").str.strip().replace("", pd.NA)
    )

for field in numeric_fields:
    token = raw[field].astype("string").str.strip()
    grouped = token.str.fullmatch(
        r"[+-]?\d{1,3}(?:,\d{3})+(?:\.\d+)?", na=False
    )
    candidate = token.mask(
        grouped, token.str.replace(",", "", regex=False)
    )
    number = pd.to_numeric(candidate, errors="coerce").astype("Float64")
    jobs[field] = number.mask(
        number.isin([float("inf"), -float("inf")])
    )
    numeric_audit[field + "_invalid"] = (
        raw[field].notna() & jobs[field].isna()
    )

if jobs["jobId"].isna().any() or jobs["jobId"].duplicated().any():
    raise ValueError("Posting analysis requires present, unique jobId values")
Enter fullscreen mode Exit fullscreen mode

A currency symbol inside an endpoint field remains an audit issue. We don't silently strip arbitrary characters until something numeric survives.

Likewise, the loader doesn't invent a decimal convention, convert periods or derive endpoints from salary. That display string remains available in raw for inspection.

present records whether a key existed. numeric_audit records a supplied, non-null value that failed numeric ingestion. An explicitly supplied null is neither an omitted key nor a malformed amount.

That separation makes a much better answer to “where did these salaries go?” than a single null count.

Missing pay changes who gets averaged

Bayt publishes pay on a minority of postings, and aggregated listings usually lack it. A mean over available salaries therefore describes a salary-publishing subset of your collected postings.

It can't automatically describe every vacancy.

There's another route to missing detail: a job found in a listing is retained with listing fields when its detail page can't be loaded. Even with enrichment requested, a sparse record doesn't identify a single cause.

Build separate intervals and count eligibility against the full collected posting population. Here, our analytical rule requires complete, non-negative, ordered endpoints and supplied currency and period labels.

measure_fields = {
    "native": (
        "salaryMin", "salaryMax", "salaryCurrency", "salaryPeriod"
    ),
    "structured_usd": (
        "salaryUsdMin", "salaryUsdMax",
        "salaryUsdCurrency", "salaryUsdPeriod"
    ),
}
interval_columns = [
    "lower_amount", "upper_amount", "currency", "period"
]

interval_frames = []
for measure, (
    lower_field, upper_field, currency_field, period_field
) in measure_fields.items():
    interval_frames.append(pd.DataFrame({
        "jobId": jobs["jobId"],
        "lower_amount": jobs[lower_field],
        "upper_amount": jobs[upper_field],
        "currency": jobs[currency_field],
        "period": jobs[period_field],
        "pay_measure": measure,
    }))

intervals = pd.concat(interval_frames, ignore_index=True)
intervals["eligible"] = (
    intervals[interval_columns].notna().all(axis=1)
    & intervals["lower_amount"].ge(0).fillna(False)
    & intervals["upper_amount"]
    .ge(intervals["lower_amount"]).fillna(False)
)

coverage = intervals.groupby("pay_measure", sort=False).agg(
    collected_postings=("jobId", "size"),
    usable_intervals=("eligible", "sum"),
)
coverage["usable_share"] = (
    coverage["usable_intervals"] / coverage["collected_postings"]
)
Enter fullscreen mode Exit fullscreen mode

The code creates lower_amount and upper_amount as analytical columns shared by the interval tables; they aren't additional source fields.

This deliberately excludes partial intervals from endpoint comparisons. It doesn't turn an absent boundary into an open-ended range, because absence alone doesn't justify that interpretation.

Keep coverage beside the salary statistics. A native-pay summary and a structured-pay summary can have different eligible populations even when both originated from the same collected jobs.

Put the ranges beside each other, without a verdict

Your inspection table should expose the difference without naming it a conversion error.

This creates a posting-level comparison and flags alternate USD fields only where both corresponding numeric values are available.

comparison = jobs[
    ["jobId", *numeric_fields,
     "salaryCurrency", "salaryPeriod",
     "salaryUsdCurrency", "salaryUsdPeriod"]
].copy()
comparison["original_salary_text"] = raw["salary"]

alias_audit = jobs[["jobId"]].copy()
for primary, alternate in [
    ("salaryUsdMin", "minSalaryUSD"),
    ("salaryUsdMax", "maxSalaryUSD"),
]:
    comparable = jobs[[primary, alternate]].notna().all(axis=1)
    alias_audit[alternate + "_disagrees"] = (
        jobs[primary].ne(jobs[alternate])
        .where(comparable, pd.NA)
        .astype("boolean")
    )
Enter fullscreen mode Exit fullscreen mode

An unknown alias comparison stays unknown. A missing alternate value isn't agreement, and agreement doesn't create another observation.

The worked example uses Bayt.com MENA Job Listing & Salary Scraper. The reusable pattern is to preserve measure identity before normalising values.

A spreadsheet can make unrelated ranges look reassuringly compatible. Arithmetic is very accommodating that way.

Give each summary its own denominator

Keep currency and period labels exactly as ingested until you've an explicit mapping for equivalent values. Guessing that differently written labels mean the same thing is another transformation you'd need to defend.

This DuckDB query calculates separate mean lower and upper endpoints, retaining measure and unit groups alongside their usable counts.

connection = duckdb.connect()
try:
    connection.register("pay_intervals", intervals)
    summary = connection.execute("""
        SELECT
            pay_measure,
            currency,
            period,
            count(*) AS postings_in_unit_group,
            count(*) FILTER (WHERE eligible) AS usable_intervals,
            avg(lower_amount) FILTER (WHERE eligible)
                AS mean_lower_endpoint,
            avg(upper_amount) FILTER (WHERE eligible)
                AS mean_upper_endpoint
        FROM pay_intervals
        GROUP BY pay_measure, currency, period
    """).df()
finally:
    connection.close()
Enter fullscreen mode Exit fullscreen mode

Those endpoint means describe published interval boundaries among eligible postings. The resulting span isn't a confidence interval.

A midpoint would add an analytical convention. It wouldn't reveal where offers fall inside the advertised interval, and averaging native midpoints with structured bucket midpoints would erase the distinction you just preserved.

Report the measure, currency, period, usable count and collection denominator with each statistic. The summary table's unit-group counts describe a narrower grouping than the collection totals in coverage.

If you restrict analysis to postings containing both measures, name that matched subset explicitly: it answers a narrower question than either available-pay population.

The claim your manager can defend

This dataset can't establish realised compensation, explain why a particular employer omitted salary, or reveal a conversion method connecting the native range to the structured bucket.

Comparing countries also requires comparable roles and collection populations. A shared dollar label supplies a unit, not a matched labour market.

Matching column names don't make matching measurements.

Top comments (0)