Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ good-names= on, x, df, NonparametricElectionModel, GaussianElectionModel,
BaseElectionModel, qr, X, y, f, LiveData, n, Featurizer, Estimandizer, fe, PreprocessedData, CombinedData,
ModelResults, GaussianModel, MODEL_THRESHOLD, LOG, w, df_X, df_y, v, n, g, a, b
disable=missing-function-docstring, missing-module-docstring, missing-class-docstring, #missing
too-many-arguments, too-many-locals, too-many-branches, too-many-instance-attributes, too-many-statements, #structure: too-many
too-many-arguments, too-many-locals, too-many-branches, too-many-instance-attributes, too-many-statements, too-many-positional-arguments, #structure: too-many
too-few-public-methods, #structure: too-few
cell-var-from-loop, function-redefined, attribute-defined-outside-init, arguments-differ, unnecessary-dict-index-lookup, unnecessary-lambda, #structure: other
invalid-name, #naming
redefined-outer-name, pointless-statement, no-member, dangerous-default-value, broad-exception-raised, inconsistent-return-statements, #testing specific
R0801, #similar lines in two files
pointless-string-statement, unused-argument, wrong-import-position, #readability
protected-access, useless-parent-delegation #other

protected-access, useless-parent-delegation, #other
logging-fstring-interpolation
3 changes: 1 addition & 2 deletions src/elexmodel/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,8 +393,7 @@ def get_estimates(
minimum_reporting_units_max = 0
for alpha in prediction_intervals:
minimum_reporting_units = self.model.get_minimum_reporting_units(alpha)
if minimum_reporting_units > minimum_reporting_units_max:
minimum_reporting_units_max = minimum_reporting_units
minimum_reporting_units_max = max(minimum_reporting_units, minimum_reporting_units_max)

if APP_ENV != "local" and self.save_results:
data.write_data(self.election_id, self.office)
Expand Down
32 changes: 20 additions & 12 deletions src/elexmodel/handlers/data/VersionedData.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import warnings
from datetime import datetime

import numpy as np
Expand Down Expand Up @@ -64,10 +65,12 @@ def get_versioned_results(self, filepath=None):

if self.election_id.startswith("2020-11-03_USA_G"):
path = "elex-models-prod/2020-general/results/pres/current.csv"
elif self.election_id.startswith("2024-11-05_USA_G"):
path = f"{S3_FILE_PATH}/{self.election_id}/results/{self.office_id}/{self.geographic_unit_type}/current_counties.csv"
else:
path = f"{S3_FILE_PATH}/{self.election_id}/results/{self.office_id}/{self.geographic_unit_type}/current.csv"
base_dir = f"{S3_FILE_PATH}/{self.election_id}/results/{self.office_id}/{self.geographic_unit_type}"
if self.election_id.startswith("2024-11-05_USA_G"):
path = base_dir + "/current_counties.csv"
else:
path = base_dir + "/current.csv"

data = self.s3_client.get(path, self.sample)
LOG.info("Loaded versioned results from S3")
Expand Down Expand Up @@ -124,7 +127,8 @@ def compute_estimated_margin(df):
casting="unsafe",
)

# check if perc_expected_vote_corr is monotone increasing (if not, give up and don't try to estimate a margin)
# check if perc_expected_vote_corr is monotone increasing
# (if not, give up and don't try to estimate a margin)
if not np.all(np.diff(perc_expected_vote_corr) >= 0):
return pd.DataFrame(
{
Expand All @@ -143,15 +147,18 @@ def compute_estimated_margin(df):
# Compute batch_margin using NumPy
# this is the difference in dem_votes - the difference in gop_votes divided by the difference in total votes
# that is, this is the normalized margin in the batch of votes recorded between versions
batch_margin = (
np.diff(results_dem, append=results_dem[-1]) - np.diff(results_gop, append=results_gop[-1])
) / np.diff(results_weights, append=results_weights[-1])
with warnings.catch_warnings():
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I vaguely remember us doing something previously to avoid this already. At least stopping it turning up in the logs. Am I misremembering?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting 🤔 Maybe it was another spot where we can occasionally get divisions by zero? Without this, right now I get warnings here when running the unit tests 🤔 Curious 🤔

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you re-run the 2024 in the testbed (without writing anything to s3), do you see this warning?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright so I ran this:

python run.py 2024-11-05_USA_G redo --office_id S --estimands="['margin']" --features "['baseline_normalized_margin']" --end_timestamp 2024-11-06T12:00:00-05:00 --model_parameters='{"extrapolation" : True, "versioned_start_date" : "2024-11-05T21:00:00-05:00", "versioned_end_date" : "2024-11-05T21:30:00-05:00"}

And the answer right now is no, BUT, I noticed that even though we're capturing warnings, they're not currently coming through in the logs. Having changed that, this actually generates a lot of warnings 😬

PR incoming 👀

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @jjcherian ! Hope you're doing well 😄 👋🏻

We noticed that these few lines can generate quite a few invalid value encountered in divide and divide by zero encountered in divide warnings. If you clone the develop branch and run, for example,

elexmodel 2024-11-05_USA_G --estimands=margin --features=baseline_normalized_margin --office_id=S --geographic_unit_type=county --pi_method bootstrap --national_summary --model_parameters '{"extrapolation" : True, "versioned_start_date" : "2024-11-05T21:00:00-05:00", "versioned_end_date" : "2024-11-05T21:30:00-05:00"}'

you should see them. We were wondering what the best way to handle these warning is. Right now, in this PR, I've added code to ignore (and therefore silence) them, but do you think there's a better way to resolve these? 🤔

Thanks in advance 😄

warnings.simplefilter("ignore", RuntimeWarning)
batch_margin = (
np.diff(results_dem, append=results_dem[-1]) - np.diff(results_gop, append=results_gop[-1])
) / np.diff(results_weights, append=results_weights[-1])

# nan values in batch_margin are due to div-by-zero since there's no change in votes
batch_margin[np.isnan(batch_margin)] = 0 # Set NaN margins to 0
df["batch_margin"] = batch_margin

# batch_margins should be between -1 and 1 (otherwise, there was a data entry issue and we will not use this unit)
# batch_margins should be between -1 and 1
# (otherwise, there was a data entry issue and we will not use this unit)
if np.abs(batch_margin).max() > 1:
return pd.DataFrame(
{
Expand Down Expand Up @@ -208,7 +215,9 @@ def compute_estimated_margin(df):
}
)

results = results.groupby("geographic_unit_fips").apply(compute_estimated_margin).reset_index()
results = (
results.groupby("geographic_unit_fips").apply(compute_estimated_margin, include_groups=False).reset_index()
)

for error_type in sorted(set(results["error_type"])):
if error_type == "none":
Expand All @@ -222,9 +231,8 @@ def get_versioned_predictions(self, filepath=None):
return pd.read_csv(filepath)

if self.election_id.startswith("2020-11-03_USA_G"):
path = "elex-models-prod/2020-general/prediction/pres/current.csv"
raise ValueError("No versioned predictions available for this election.")
else:
path = f"{S3_FILE_PATH}/{self.election_id}/predictions/{self.office_id}/{self.geographic_unit_type}/current.csv"

path = f"{S3_FILE_PATH}/{self.election_id}/predictions/{self.office_id}/{self.geographic_unit_type}/current.csv"

return self.s3_client.get(path, self.sample)
8 changes: 6 additions & 2 deletions src/elexmodel/handlers/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,13 @@ def put(self, filename, data, **kwargs):

def get_file_path(self, file_type, path_info):
if file_type == "preprocessed":
file_path = f'{S3_FILE_PATH}/{path_info["election_id"]}/data/{path_info["office"]}/data_{path_info["geographic_unit_type"]}.csv'
csv_file = f'data_{path_info["geographic_unit_type"]}.csv'
file_path = f'{S3_FILE_PATH}/{path_info["election_id"]}/data/{path_info["office"]}/{csv_file}'
elif file_type == "config":
file_path = f'{S3_FILE_PATH}/{path_info["election_id"]}/config/{path_info["election_id"]}'
else:
LOG.warning("Unknown file type %s", file_type)
file_path = None
return file_path


Expand Down Expand Up @@ -128,7 +132,7 @@ def wait_for_versions(self, q):
try:
future.result()
yield version, data
except Exception as e:
except Exception as e: # pylint: disable=broad-exception-caught
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is just to remove the tox error? We haven't really cared about those in the past, since we use our pre-commit hooks to make sure we are pep compliant.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case maybe we should disable pylint altogether. If we're just going to ignore its complaints, we might as well speed up our tox runs 😄

Another option here would be to add more things to our list of pylint complaints we ignore in setup.cfg, although I'm hesitant to do that with too many things because a lot of these are useful, but there are cases where we want to ignore them. This one in particular is here because boto3 and its related libraries can raise several different types of Exceptions and we want to handle them all the same way. Ideally, we'd break that out or specify the types but honestly, I find it hard to know up front which exception types boto3 can throw 😬

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any downside to disabling pylint? Do we use it in other live team repos? If we do, I think we just ignore the complaints.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll ask, but if they're using pylint, they're probably not ignoring the complaints. In which case, I'm not sure why we would either. Sure, some of these are annoying, but we can put in rules to have pylint not issue annoying complaints about benign things (e.g. "too many arguments") and keep the ones that are useful and point towards potential problems 👀

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the answer is we should stop using tox and use pytest directly in the unit tests. Since there is no need to use two different linters

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmmmmmm...I'd argue in favor of using tox but removing pylint from it. tox is great because we can specify multiple Python versions and it'll run whatever suite of tests etc. for each version in isolated environments. We can also have it run pre-commit for us, e.g. https://github.com/jugmac00/flask-reuploaded/blob/master/tox.ini 🤔 But yeah it looks pretty easy to remove pylint from tox, which is good 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lennybronner alright so I did some reading here and it seems like pylint is designed with the intention of being overly-cautious. The best way I saw it described is that pylint is a superset of flake8. We probably don't need the extra complaints pylint generates on top of flake8 🤔

So yeah, let me know if you want me to remove pylint in this PR or open another one 😄

LOG.error(f"Error downloading {version['VersionId']}: {e}")

q.task_done()
Expand Down
38 changes: 30 additions & 8 deletions src/elexmodel/models/BootstrapElectionModel.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,10 @@ def __init__(self, model_settings={}, versioned_data_handler=None, pres_predicti
self.rng = np.random.default_rng(seed=self.seed) # used for sampling
self.ran_bootstrap = False

# these are the max/min values for called races. Ie. if a contest is called for LHS party then the prediction/intervals should be at least lhs_called_threshold
# if a contest is called for RHS party then the prediction/interval should be at most rhs_called_threshold (at most because the values are negative)
# these are the max/min values for called races. Ie.
# if a contest is called for LHS party then the prediction/intervals should be at least lhs_called_threshold
# if a contest is called for RHS party then the prediction/interval should be at most rhs_called_threshold
# (at most because the values are negative)
self.lhs_called_threshold = 0.005
self.rhs_called_threshold = -0.005

Expand Down Expand Up @@ -359,6 +361,9 @@ def _generate_nonreporting_bounds(
# if 0 percent of the vote is in, the upper bound would be zero if we used the above
# code. So instead we set it to the naive bound
upper_bound[np.isclose(upper_bound, 0)] = unobserved_upper_bound
else:
LOG.warning("Unknown bootstrap estimand %s", bootstrap_estimand)
return None, None

# if percent reporting is 0 or 1, don't try to compute anything and revert to naive bounds
lower_bound[
Expand Down Expand Up @@ -639,7 +644,8 @@ def _get_epsilon_hat_std(residuals, epsilon):
1 - aggregate_indicator_train.sum(axis=0) / aggregate_indicator.sum(axis=0)
) / aggregate_indicator_train.sum(axis=0)

# where we have < 2 units in a contest, we set the variance to the variance of the observed epsilon_hat values
# where we have < 2 units in a contest,
# we set the variance to the variance of the observed epsilon_hat values
var[np.isnan(var) | np.isinf(var)] = np.var(epsilon[np.nonzero(epsilon)[0]].T, ddof=1)
return np.sqrt(var)

Expand Down Expand Up @@ -789,7 +795,8 @@ def _extrapolate_unit_margin(self, reporting_units: pd.DataFrame, nonreporting_u
percent_expected_vote is too far away.

4) The correction estimates (obtained using VersionedResultsHandler) are also np.nan when there are
irregularities in the reporting (e.g., there's a correction to the dem/gop vote totals that revises them downwards).
irregularities in the reporting
(e.g., there's a correction to the dem/gop vote totals that revises them downwards).

5) We only run this method in states with at least self.min_extrapolating_units counties available.
"""
Expand Down Expand Up @@ -954,6 +961,9 @@ def compute_correction_statistics(df):
prediction_std = (
nonreporting_units.est_correction_max.values - nonreporting_units.est_correction_min.values
).reshape(-1, 1)
else:
LOG.warning("Unknown extrapolate standard deviation method %s", self.extrapolate_std_method)
prediction_std = 0

return prediction, prediction_std

Expand Down Expand Up @@ -1444,19 +1454,28 @@ def _format_called_contests(
lhs_rhs_intersection = set(lhs_called_contests) & set(rhs_called_contests)
if len(lhs_rhs_intersection) > 0:
raise BootstrapElectionModelException(
f"You can only call a contest for one party, not for both. Currently these contests are called for both parties: {lhs_rhs_intersection}"
(
"You can only call a contest for one party, not for both. "
+ f"Currently these contests are called for both parties: {lhs_rhs_intersection}"
)
)

lhs_difference_with_contests = set(lhs_called_contests) - set(contests)
if len(lhs_difference_with_contests) > 0:
raise BootstrapElectionModelException(
f"You can only call contests that are being run by the model. These LHS called contests do not exist: {lhs_difference_with_contests}"
(
"You can only call contests that are being run by the model. "
+ f"These LHS called contests do not exist: {lhs_difference_with_contests}"
)
)

rhs_difference_with_contests = set(rhs_called_contests) - set(contests)
if len(rhs_difference_with_contests) > 0:
raise BootstrapElectionModelException(
f"You can only call contests that are being run by the model. These RHS called contests do not exist: {rhs_difference_with_contests}"
(
"You can only call contests that are being run by the model. "
+ f"These RHS called contests do not exist: {rhs_difference_with_contests}"
)
)

# the order in called_coteests need
Expand Down Expand Up @@ -1797,7 +1816,10 @@ def get_national_summary_estimates(self, nat_sum_data_dict: dict, base_to_add: i
# (ie. the number of contests) then raise an exception
if len(nat_sum_data_dict) != self.divided_error_B_1.shape[0]:
raise BootstrapElectionModelException(
f"nat_sum_data_dict is of length {len(nat_sum_data_dict)} but there are {self.divided_error_B_1.shape[0]} contests"
(
f"nat_sum_data_dict is of length {len(nat_sum_data_dict)} "
+ f"but there are {self.divided_error_B_1.shape[0]} contests"
)
)

# NOTE: This assumes that pd.get_dummies does alphabetical ordering
Expand Down
2 changes: 1 addition & 1 deletion src/elexmodel/models/ConformalElectionModel.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

class ConformalElectionModel(BaseElectionModel.BaseElectionModel, ABC):
def __init__(self, model_settings: dict):
super(ConformalElectionModel, self).__init__(model_settings)
super(ConformalElectionModel, self).__init__(model_settings) # pylint: disable=super-with-arguments
self.lambda_ = model_settings.get("lambda_", 0)

@classmethod
Expand Down
2 changes: 1 addition & 1 deletion tests/distributions/test_gaussian_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ def test_winsorized_intervals():
lower_n = lower - lower_correction_n[0]
upper_n = upper + upper_correction_n[0]

for i in range(len(lower_w)):
for i in range(len(lower_w)): # pylint: disable=consider-using-enumerate
assert lower_w[i] >= lower_n[i]
assert upper_w[i] <= upper_n[i]

Expand Down
6 changes: 3 additions & 3 deletions tests/models/test_bootstrap_election_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -1063,7 +1063,7 @@ def test_get_national_summary_estimates(bootstrap_election_model, rng):
bootstrap_election_model.get_aggregate_predictions(
reporting_units, nonreporting_units, unexpected_units, ["postal_code"], "margin"
) # race calling for aggregate prediction interval assumes that the point prediction has been set accordingly
lower, upper = bootstrap_election_model.get_aggregate_prediction_intervals(
_, _ = bootstrap_election_model.get_aggregate_prediction_intervals(
reporting_units, nonreporting_units, unexpected_units, ["postal_code"], 0.95, None, None
)

Expand All @@ -1084,7 +1084,7 @@ def test_get_national_summary_estimates(bootstrap_election_model, rng):
"margin",
lhs_called_contests=lhs_called_contests,
) # race calling for aggregate prediction interval assumes that the point prediction has been set accordingly
lower, upper = bootstrap_election_model.get_aggregate_prediction_intervals(
_, _ = bootstrap_election_model.get_aggregate_prediction_intervals(
reporting_units,
nonreporting_units,
unexpected_units,
Expand Down Expand Up @@ -1115,7 +1115,7 @@ def test_get_national_summary_estimates(bootstrap_election_model, rng):
lhs_called_contests=lhs_called_contests,
rhs_called_contests=rhs_called_contests,
) # race calling for aggregate prediction interval assumes that the point prediction has been set accordingly
lower, upper = bootstrap_election_model.get_aggregate_prediction_intervals(
_, _ = bootstrap_election_model.get_aggregate_prediction_intervals(
reporting_units,
nonreporting_units,
unexpected_units,
Expand Down
Loading