Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support new bucketing logic #186

Merged
merged 18 commits into from Jul 16, 2018
Merged

support new bucketing logic #186

merged 18 commits into from Jul 16, 2018

Conversation

Kaitaan
Copy link
Contributor

@Kaitaan Kaitaan commented Jun 22, 2018

add support for:

  • single variant experiments (one control, one variant)
  • multi variant experiments (multiple controls/variants)
  • feature rollout (single variant)

add support for:
- single variant experiments (one control, one variant)
- multi variant experiments (multiple controls/variants)
- feature rollout (single variant)
@Kaitaan Kaitaan requested review from mattknox and spladug June 22, 2018 21:00
@mattknox
Copy link

probably should include @pacejackson, given his previous work and r2 knowledge.

@Kaitaan Kaitaan requested a review from pacejackson June 22, 2018 21:56
Copy link

@mattknox mattknox left a comment

Choose a reason for hiding this comment

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

Bunch of questions, possible documentation needed in some cases.


enabled = config.get("enabled", True)
if not enabled:
if now < start_ts or now > stop_ts:

Choose a reason for hiding this comment

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

either or both of start_ts and stop_ts could be None, I think, so this might raise.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

if start_ts or stop_ts is None, there's an error that would be thrown a couple lines above (line 83).

Though, now that you mention it, if they're explicitly passed as None, that'll cause an issue. Fix inbound.


enabled = config.get("enabled", True)
if not enabled:
if now < start_ts or now > stop_ts:

Choose a reason for hiding this comment

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

I'd check for enabled first-a disabled experiment might lack one or both of start/stop.

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 think start_ts and stop_ts should be required fields. Even if an experiment is disabled, it should still have configured start and end times.

Thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

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

I believe that the initial intention was that it was always required.

@@ -119,6 +127,35 @@ def parse_experiment(config):
version=version,
config=experiment_config,
)
elif experiment_type == "multi_variant":
return MultiVariantExperiment.from_dict(

Choose a reason for hiding this comment

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

I'd probably assign the class to something like experiment_type and then return experiment_type.from_dict(...) to DRY this up

@@ -7,7 +7,9 @@


class FeatureFlag(R2Experiment):
"""An experiment with a single variant "active".
"""An experiment with a single variant "active". This type of experiment

Choose a reason for hiding this comment

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

might be useful to link to the design doc rationale for why. (For example, I don't know)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This repo is OS, so can't really link internal docs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Reason for deprecation? Just because it's redundant. I'd like to stop supporting the legacy bucketing altogether for simplicity if possible.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not opposed to deprecating this to try to simplify the config format/keep things more consistent. The "legacy" bucketing doesn't really affect anything here since there's only one variant, it can be any size.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My thinking is that it's basically a feature rollout, with 100%, but with specific targeting. That's going to be replaced with the feature_rollout option once targeting is in place (work in progress)

"""Basic experiment, handling more than two variants (typically two
controls, and multiple treatments).
This type of experiment does not guarantee that changing the variant size
will not rebucketing existing users.

Choose a reason for hiding this comment

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

will not rebucket.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed

def make_seed(self, seed_data):
id = seed_data.get('id')
name = seed_data.get('name')
version = seed_data.get('version')

Choose a reason for hiding this comment

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

version should NOT be used in the randomization. Shuffle_version should be, although it's fine to exclude it for now if you want.

Choose a reason for hiding this comment

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

oh, hmm. Since this is legacy, maybe that's OK? Still think it should not be, for consistency.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

note that the "version" used in this case is whatever version is desired to be used for seed generation. I actually pass the "shuffle_version" value in here, since that's the specific type of version we want to use for making the seed. So it's actually doing the right thing, but I agree that it's actually a bit convoluted. Will clean this up a bit.


super(SingleVariantExperiment, self)._validate_variants(variants)

if len(variants) != 2:

Choose a reason for hiding this comment

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

probably want to verify that the sizes work out too, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

what do you mean by "sizes work out"?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

See comment below about super call, if you're referring to sum(sizes) < 1.0


super(MultiVariantExperiment, self)._validate_variants(variants)

if len(variants) < 3:

Choose a reason for hiding this comment

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

sum(sizes) < 1.0 . ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done by the super call

return True

def _is_enabled(self, **kwargs):
return self.start_ts

Choose a reason for hiding this comment

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

also the enabled field ,right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

couple things missing here. Fixed.

any of the variants
"""

if bucket < (self.variants[0]["size"] * self.num_buckets):

Choose a reason for hiding this comment

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

again, why * num_buckets?

@pacejackson
Copy link
Contributor

It looks like your .vagrant stuff snuck into this PR, maybe we should add that to .gitignore?

@Kaitaan
Copy link
Contributor Author

Kaitaan commented Jun 25, 2018

ah damn. Will remove, and add to my .gitignore.

version=version,
start_ts=start_ts,
stop_ts=stop_ts,
enabled=enabled,
Copy link
Contributor

Choose a reason for hiding this comment

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

Why pass this stuff to the experiment? This stuff should be handled by ForcedVariantExperiment-s rather than the other providers so you don't have to worry about it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Setting aside how it was done in the old types, I disagree for a few reasons:

  1. an experiment being disabled (or not started, or ended) doesn't make it a fundamentally different experiment. It just makes it disabled so that it doesn't bucket users.
  2. if we don't pass this information in, then we can't use it in the experiment. I can see cases where we want to enforce different restrictions based on the type of experiment. For instance, multivariant may be required to run for 2 weeks, whereas a multi-armed bandit may be required to run longer.
  3. If we don't pass this in, it makes it more complex to allow us to start an experiment in the future (or end one). Assume the following flow:
    a) I create an experiment, set to start in an hour
    b) a bucketing call is made, but the experiment hasn't started yet, so it gets created as a ForcedVariantExperiment. This experiment is then added to the experiment_cache
    c) No further config changes happen for a day

Now it's going to be treated as a forced variant until the config has been re-parsed for some reason.

Copy link
Contributor

Choose a reason for hiding this comment

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

I still think that the current way this is handled is the way to go, not implementing it in the experiment provider:

  1. While true, now you have to implement this logic in all of your experiment providers, the reason for putting this in the parsing logic and using ForcedVariantExperiments is so you don't have to re-implement the same "disable" logic every time.
  2. But we don't need to use this right now, if we have some new type of experiment where we do need to use it in the future, we can pass it in/implement it there.
  3. I'm not sure where an experiment would be cached for an hour, the only cache is local to the request, it's not shared between requests.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  1. but that's fundamentally a part of the experiment. I'd be willing to agree that it could be in the base experiment class (I put it in the base class for a simple experiment for that reason), but the status, start, and end times of an experiment are features of an experiment, not features of an experiment parser.
  2. You're right, in that the hypothetical situations are just that: hypothetical. I still think that the properties of an experiment (whether it's enabled, when it's meant to start and end) should be available to the experiment, not used to infer the type of experiment (that's what the experiment_type is for)
  3. I may be showing my ignorance on some things here, but is there anything inherent in the baseplate.Experiments class that restricts its scope to request level? When you request bucketing on an experiment that hasn't been seen yet, its config is parsed, and the experiment object is added to this dict: https://github.com/reddit/baseplate/blob/master/baseplate/experiments/__init__.py#L67
    That's the cache I'm referring to.

Generally speaking though, I think using an entirely new type of object for the sake of abstracting away the enabled/disabled/started/ended has more downsides than upsides.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure which chain to respond to here but I wanted to hop in. I'm seeing a lot of discussion of "override in subclasses" and "implement in base classes". In general, I'd really strongly like to avoid 1) implementation inheritance, and 2) deep hierarchies of objects. This ends up being quite byzantine to follow (jumping back and forth between class definitions to follow a code path) and very brittle. Rather, it'd be good to encapsulate specific pieces of logic in functions or discrete objects we bring together with composition. Duplication by putting into multiple classes is definitely a bummer, but so is putting it into a base class instead. Would it make sense to put this kind of shared logic into an object that only handles expiration logic and then it can be included in any experiment type that cares?

Copy link
Contributor

Choose a reason for hiding this comment

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

ah! I see @pacejackson had a much better suggested structure for this here: https://github.com/reddit/baseplate/pull/186/files#r198285273 :)

Copy link
Contributor

Choose a reason for hiding this comment

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

As far as (3) goes, the Experiments objects are created by the ExperimentsContextFactory when you get a new request/context so you get a new cache on each request.

self._validate_variants(self.variants)

def _get_seed(self):
return self.seed
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems like a weird pattern, the thing you are returning is "public" but this is "private", I think the way to do this would be something like:

@property
def seed(self):
    return self._seed

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed

self.variants = config.get("variants", [])

seed_data = {"id": id, "name": name, "shuffle_version": self.shuffle_version}
self.seed = config.get("bucket_seed", self.make_seed(seed_data))
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the general preference is to do this parsing in the factory methods (i.e. from_dict) and pass everything into the constructor explicitly rather than having it done in the constructor.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is that a python thing, a Reddit thing, or a "this is how it was done in here previously, so please stay consistent" thing? I realize that currently my from_dict method is pretty useless, but I'm just trying to determine the reasoning for needing that method at all is.

Copy link
Contributor

Choose a reason for hiding this comment

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

It's more of 3 thing, although that's not always consistent. You can see other examples of this in other parts of baseplate such as https://github.com/reddit/baseplate/blob/69eba73ad7493e8c4822677b391346b5a8ee7c38/baseplate/queue_consumer.py#L113

Copy link
Contributor

Choose a reason for hiding this comment

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

IMO, my reason for preferring this is I like the constructor to make it explicit what the dependencies of that object are. When you can just pass in a couple of things and build off of that, you have to dive into the constructor to try to figure that out.

"""Check if any of the kwargs override the variant. Functionality
to be built in the future. For now, overrides are not supported.
"""
return None
Copy link
Contributor

Choose a reason for hiding this comment

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

IMO, we should leave code like this out if it's not being implemented, we can implement it when we need it rather than having code that doesn't actually do anything lying around until we do.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Normally, I'd agree. In this case, however, I think there's value in including it for 2 reasons:

  1. to indicate that this code is coming shortly (it's currently in-progress)
  2. by including the non-functionality here, anyone using this can easily override targeting and overrides in subclasses.

Copy link
Contributor

Choose a reason for hiding this comment

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

"coming shortly" is weird in fixed-release libraries though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

2 to 1. Removed.

Advanced targeting functionality to be supported in the future.
For now, return True.
"""
return True
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment as about _check_overrides.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

See reply above

def _is_enabled(self, **kwargs):
current_ts = time.time()

return (self.enabled and current_ts >= self.start_ts
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think the experiments should be checking this, this would be handled by the parser who would return a ForcedVariantExperiment if any of this is true.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

See comment above

stop_ts=stop_ts,
enabled=enabled,
config=config,
)
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems like it's the same across the subclasses of SimpleExperiment, maybe we should just implement this there?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, could do. Fixed

with the same bucket and varaints will result in the same answer

:param bucket -- an integer bucket representation
:param variants -- a dictionary of
Copy link
Contributor

Choose a reason for hiding this comment

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

This isn't a parameter.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed

return self.variants[0]["name"]
elif bucket >= int(self.num_buckets
- (self.variants[1]["size"] * self.num_buckets)):
return self.variants[1]["name"]
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we really need to implement this in every one of these subclasses, it seems like it's all the same logic, the only difference is how many variants are allowed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The logic is actually a bit different, at least for the single_variant. Single variant buckets one variant at the high end of the bucketing range, and one at the low end. This is to allow those variants to change sizes without rebucketing.

Copy link
Contributor

Choose a reason for hiding this comment

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

Something to consider, most of what these different experiment types are doing is the same, the main differences are 1) the number of allowed variants and 2) exactly how variants are assigned to buckets. It might be cleaner to separate out that concern into it's own object VariantCollection or something like that. You can have one that allows multiple variants and assigns buckets the way that MultiVariantExperiment does and another that assigns buckets the way that SingleVariantExperiment does. This lets you decouple that from the rest of the Experiment and lets you build things more by composition rather than by subclassing.

'feature_flag': FeatureFlag,
'single_variant': SingleVariantExperiment,
'multi_variant': MultiVariantExperiment,
'feature_rollout': FeatureRollout,
Copy link
Contributor

Choose a reason for hiding this comment

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

maybe this type should be feature_flag_v2? Establish a versioning pattern rather than finding new names for the same thing?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The implication with a feature flag is "on/off (for a targeted group)" which isn't the case here. Even if targeted, there's no guarantee that a user will be bucketed in one or the other (unless the rollout percentage is 0 or 100)

Copy link
Contributor

Choose a reason for hiding this comment

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

That's fair, although that's not how the current FeatureFlags work in practice. I'm not opposed to changing the name if it fits better though.

@Kaitaan
Copy link
Contributor Author

Kaitaan commented Jun 27, 2018

💇

"""

if variants is None:
raise ValueError('Sum of all variants is greater than 100%')
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This error message is wrong. Will fix.

Copy link
Contributor

@spladug spladug left a comment

Choose a reason for hiding this comment

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

thanks for taking this on and sorry for delay in reviewing! i think the biggest thing for me here is the heavy use of implementation inheritance. many of the other discussion points in here might sort themselves out if that's different.

)
start_ts = config.get("start_ts")
stop_ts = config.get("stop_ts")
if (start_ts is None or stop_ts is None):
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: no parens around the if condition in python please!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed. This used to be multi-line.

Copy link
Contributor

Choose a reason for hiding this comment

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

this doesn't appear fixed in the current diff?

experiment_type = config.get("type")
if experiment_type:
experiment_type = experiment_type.lower()
experiment_id = config.get("id")
Copy link
Contributor

Choose a reason for hiding this comment

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

why change from ["id"] to .get("id")? with the assert on the next line all we've done is change the error from a fairly understandable KeyError: id to AssertionError in the case where the ID wasn't defined

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fair. Altered it to still raise a KeyError (let me know if you think there's a better exception type), but made the error message a bit more explicit.

return R2Experiment.from_dict(
experiment_class = type_class_map.get(experiment_type, ForcedVariantExperiment)

if experiment_type in ['r2', 'feature_flag']:
Copy link
Contributor

Choose a reason for hiding this comment

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

it feels really weird to have the type map and then still branch on types. it'd be cleaner to move this branching into factory functions so this part of the code doesn't continue to need to be updated any time new variants are added.

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've changed this a bit, so let me know if you still have objections here. I'm not sure I'm entirely following what you're suggesting though; this, for all intents and purposes is the factory function.

Keep in mind that the legacy types (r2, feature_flag, forced_variant) are slated for deprecation soon.

@@ -7,7 +7,9 @@


class FeatureFlag(R2Experiment):
"""An experiment with a single variant "active".
"""An experiment with a single variant "active". This type of experiment
Copy link
Contributor

Choose a reason for hiding this comment

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

rather than overembiggening the summary line of the docstring, you can add a sphinx deprecation notice to the docs instead.

http://www.sphinx-doc.org/en/stable/markup/para.html#directive-deprecated

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed

version=version,
start_ts=start_ts,
stop_ts=stop_ts,
enabled=enabled,
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure which chain to respond to here but I wanted to hop in. I'm seeing a lot of discussion of "override in subclasses" and "implement in base classes". In general, I'd really strongly like to avoid 1) implementation inheritance, and 2) deep hierarchies of objects. This ends up being quite byzantine to follow (jumping back and forth between class definitions to follow a code path) and very brittle. Rather, it'd be good to encapsulate specific pieces of logic in functions or discrete objects we bring together with composition. Duplication by putting into multiple classes is definitely a bummer, but so is putting it into a base class instead. Would it make sense to put this kind of shared logic into an object that only handles expiration logic and then it can be included in any experiment type that cares?

@@ -14,7 +14,9 @@


class R2Experiment(Experiment):
"""A "legacy", r2-style experiment.
"""A "legacy", r2-style experiment. This experiment type is deprecated,
Copy link
Contributor

Choose a reason for hiding this comment

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

same note about sphinx deprecation thing

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed



class SimpleExperiment(Experiment):
"""Base class for simple experiments. Note that this class shares a lot of
Copy link
Contributor

Choose a reason for hiding this comment

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

the stuff about sharing code would be better for a comment since it's irrelvant to someone using baseplate and just reading the docs but is important to someone working inside baseplate.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

removed

self.bucket_val,
self.name,
)
return None
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: can you put a blank between these ifs please? when jammed together it reads from bird's eye view as an if/elif

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed

"""Check if any of the kwargs override the variant. Functionality
to be built in the future. For now, overrides are not supported.
"""
return None
Copy link
Contributor

Choose a reason for hiding this comment

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

"coming shortly" is weird in fixed-release libraries though.

version=version,
start_ts=start_ts,
stop_ts=stop_ts,
enabled=enabled,
Copy link
Contributor

Choose a reason for hiding this comment

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

ah! I see @pacejackson had a much better suggested structure for this here: https://github.com/reddit/baseplate/pull/186/files#r198285273 :)

@Kaitaan
Copy link
Contributor Author

Kaitaan commented Jun 29, 2018

CI probably failed on the tests that don't run locally. Will fix them up.

@Kaitaan
Copy link
Contributor Author

Kaitaan commented Jun 29, 2018

that should do it.

💇‍♂️

@Kaitaan
Copy link
Contributor Author

Kaitaan commented Jul 2, 2018

💇‍♂️
ping

@@ -0,0 +1,16 @@


class VariantSet:
Copy link
Contributor

Choose a reason for hiding this comment

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

You'll need to add (object) to this:

class VariantSet(object)

You can leave it off in Python3 but in 2, not including it means you are an "old-style" class.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed

from .base import Experiment
from ..._compat import long, iteritems

from .variant_sets.single_variant_set import SingleVariantSet
Copy link
Contributor

Choose a reason for hiding this comment

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

🔕 I don't feel strongly about this, so I would like to see what others think as well. I feel like we can put variant_sets under experiments/ rather than experiments/providers/ and just leave experiments/providers/ for the provider classes.

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 also don't have any strong opinions here. I just put it here because a variantset is a core piece of a provider.

Copy link
Contributor

Choose a reason for hiding this comment

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

seems reasonable to pop it up a level if it itself is not a provider

@@ -103,21 +123,25 @@ def parse_experiment(config):
override = config.get("global_override")
return ForcedVariantExperiment(override)

if experiment_type == "r2":
return R2Experiment.from_dict(
if experiment_type in legacy_type_class_map.keys():
Copy link
Contributor

Choose a reason for hiding this comment

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

You don't need the .keys() for this, in on a dict checks if the value is a key.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

TIL

if experiment_type == "r2":
return R2Experiment.from_dict(
if experiment_type in legacy_type_class_map.keys():
experiment_class = legacy_type_class_map.get(experiment_type)
Copy link
Contributor

Choose a reason for hiding this comment

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

I would just use the [] syntax to access this rather than get since you already know that it's in the dict and it's a little bit more concise.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

changed


def should_log_bucketing(self):
""" Default to true. Override if logging of eligibility events not required. """
return True
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this change depending on variant_type?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

good catch. Given that it's not inherent to a variant though, I'm going to punt the responsibility to the config to determine whether it wants to log them or not, defaulting to true. Added a param on instantiation

raise NotImplementedError

def validate_variants(self):
raise NotImplementedError
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure this needs to be part of the interface, it doesn't look like you're technically implementing it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

removed

self.variants = variants
self.num_buckets = num_buckets

def __contains__(self, item):
Copy link
Contributor

Choose a reason for hiding this comment

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

🔕 If you check this often, it might be a good idea to just store a set of all of the variant names and return item in self._variant_names.

raise ValueError('No variants provided')

if len(variants) < 3:
raise ValueError("MultiVariant experiments expect two controls "
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this also check that at least 2 of the variants are named control_X?

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 don't think the system should enforce variant naming.

Copy link
Contributor

Choose a reason for hiding this comment

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

Makes sense, should the error message not reference "control" then? I feel like that implies that it is looking for variants called "control".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense. Changed.

from .base import VariantSet


class MultiVariantSet(VariantSet):

This comment was marked as resolved.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

def test_contains(self):
variant_set = create_multi_variant_set()

self.assertTrue("variant_2" in variant_set)
Copy link
Contributor

Choose a reason for hiding this comment

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

🔕 self.assertIn/self.assertNotIn work as well for this.

@Kaitaan
Copy link
Contributor Author

Kaitaan commented Jul 5, 2018

💇‍♂️

}


simple_type_class_list = [
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you make this a frozenset? I don't think we care about the ordering and it should make checking the type quicker.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done.


class SimpleExperiment(Experiment):

def make_seed(self, seed_data):
Copy link
Contributor

Choose a reason for hiding this comment

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

This should probably be a @classmethod or just a function in this module.

Copy link
Contributor

Choose a reason for hiding this comment

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

also, maybe we should just pass in the different values as arguments to the function rather than as a dict?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed method altogether.

bucket_seed, bucket_val, enabled=True, log_bucketing=True,
num_buckets=1000, **kwargs):
"""
:param int id -- the experiment id. This should be unique.
Copy link
Contributor

Choose a reason for hiding this comment

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

I think these should be formatted as :param type name: rather than :param type name --

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, we want to capitalize the first letter in each description.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed.

as a floating point value between 0 and 1.
:param bucket_seed -- if provided, this provides the seed for determining
which bucket a variant request lands in. Providing a consistent
bucket_seed will ensure a user is bucketed consistently.
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you flesh this out a little more? I feel like the way this is currently written, it implies that the user will not be bucketed consistently which is not the case, it's still consistent, it's just based on a generated seed rather than a static one that you provide in the config.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed.

:param int num_buckets -- how many available buckets there are for
bucketing requests. This should match the num_buckets in the
provided VariantSet. The default value is 1000, which provides
a potential variant granularity of 0.1%
Copy link
Contributor

Choose a reason for hiding this comment

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

Some of these don't have a period at the end.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed.

logger = logging.getLogger(__name__)


NUM_BUCKETS_DEF = 1000
Copy link
Contributor

Choose a reason for hiding this comment

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

If DEF supposed to be DEFAULT? Can you call it that so it's more clear?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed


def create_multi_variant_set():
cfg = generate_variant_config()
return MultiVariantSet(cfg)
Copy link
Contributor

Choose a reason for hiding this comment

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

IIRC you can pass in the number of buckets, you should probably pass it in here, maybe making it an argument to create_multi_variant_set with a default value of NUM_BUCKETS_DEF so you don't have to worry about keeping those in sync with the default values.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed

self.assertEqual(len(variant_counts), 4)

for variant_count in variant_counts.values():
self.assertEqual(variant_count, 250)
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you also add a test for the other extreme end of this, where you only have a single bucket i.e.:

[
    {'name': 'active', 'size': 0.001},
    {'name': 'control_1', 'size': 0},
    {'name': 'control_2', 'size': 0},
]

Copy link
Contributor Author

Choose a reason for hiding this comment

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

added

logger = logging.getLogger(__name__)


NUM_BUCKETS_DEF = 1000
Copy link
Contributor

Choose a reason for hiding this comment

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

The comments in multi_variant_set_tests.py also apply here.

logger = logging.getLogger(__name__)


NUM_BUCKETS_DEF = 1000
Copy link
Contributor

Choose a reason for hiding this comment

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

The comments in multi_variant_set_tests.py also apply here.

@Kaitaan
Copy link
Contributor Author

Kaitaan commented Jul 6, 2018

💇‍♂️

Copy link
Contributor

@pacejackson pacejackson left a comment

Choose a reason for hiding this comment

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

Mostly just small nits over formatting, looks good overall.

if now < start_ts or now > stop_ts:
enabled = False

if not enabled and experiment_type in ["r2", "feature_flag"]:
Copy link
Contributor

Choose a reason for hiding this comment

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

You can write this as if not enabled and experiment_type in legacy_type_class_map:.

@@ -7,7 +7,10 @@


class FeatureFlag(R2Experiment):
"""An experiment with a single variant "active".
""".. deprecated:: 0.27
Use FeatureRollout instead.
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe these comments should say Use SimpleExperiment with X VariantStore?

def _calculate_bucket(self, bucket_val):
"""Sort something into one of self.num_buckets buckets.

:param bucket_val -- a string used for shifting the deterministic bucketing
Copy link
Contributor

Choose a reason for hiding this comment

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

This is missing the type and still has the -- rather than :.

:return int -- a bucket, 0 <= bucket < self.num_buckets
"""
# Mix the experiment seed with the bucket_val so the same users don't
# get bucketed into the same bucket for each experiment.
Copy link
Contributor

Choose a reason for hiding this comment

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

placed into the same bucket?


A VariantSet contains a set of experimental variants, as well as
their distributions. It is used by experiments to track which
variant a check is meant to return.
Copy link
Contributor

Choose a reason for hiding this comment

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

to track which bucket a variant is assigned to?

"""

def __init__(self, variants, num_buckets=1000):
""" :param variants -- array of dicts, each containing the keys 'name'
Copy link
Contributor

Choose a reason for hiding this comment

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

Formatting and param type.

"""Deterministically choose a percentage-based variant. Every call
with the same bucket and varaints will result in the same answer.

:param bucket -- an integer bucket representation
Copy link
Contributor

Choose a reason for hiding this comment

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

Formatting and param type.

"""

def __init__(self, variants, num_buckets=1000):
""" :param variants -- array of dicts, each containing the keys 'name'
Copy link
Contributor

Choose a reason for hiding this comment

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

Formatting and param type.

"""Deterministically choose a variant. Every call with the same bucket
on one instance will result in the same answer

:param bucket -- an integer bucket representation
Copy link
Contributor

Choose a reason for hiding this comment

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

Formatting and param type.

bucketing_changed = True
break

self.assertTrue(bucketing_changed)

This comment was marked as resolved.

Copy link
Contributor

@pacejackson pacejackson left a comment

Choose a reason for hiding this comment

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

Just a few small comments/spelling fixes. LGTM.

experiment_type = experiment_type.lower()
experiment_id = config.get("id")
if not isinstance(experiment_id, int):
raise KeyError("Integer id must be provided for experiment.")
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems weird to raise a KeyError if experiment_id happens to be a string or something like that, KeyError only really makes sense if "id" isn't in the config so experiment_id would be None.

Copy link
Contributor

Choose a reason for hiding this comment

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

TypeError would be the more appropriate built-in exception

raise ValueError("MultiVariant experiments expect three or "
"more variants.")

total_size = 0.0
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you want to make this an total_size = 0 so it doesn't get turned into a float.


def choose_variant(self, bucket):
"""Deterministically choose a percentage-based variant. Every call
with the same bucket and varaints will result in the same answer.
Copy link
Contributor

Choose a reason for hiding this comment

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

varaints

Copy link
Contributor

@spladug spladug left a comment

Choose a reason for hiding this comment

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

awesome, this looks great. thanks for splitting up the variant stuff from the providers, that's super clean now.

just a few remaining nits and andrew's comments.

)
start_ts = config.get("start_ts")
stop_ts = config.get("stop_ts")
if (start_ts is None or stop_ts is None):
Copy link
Contributor

Choose a reason for hiding this comment

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

this doesn't appear fixed in the current diff?

if (start_ts is None or stop_ts is None):
if "expires" in config:
warn_deprecated(
"The 'expires' field is in experiment %s deprecated, you should "
Copy link
Contributor

Choose a reason for hiding this comment

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

I think that "is" needs to move after the %s

@@ -7,7 +7,10 @@


class FeatureFlag(R2Experiment):
"""An experiment with a single variant "active".
""".. deprecated:: 0.27
Copy link
Contributor

Choose a reason for hiding this comment

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

the deprecated thing should go near the bottom of the docstring, not replace the summary line. you can run make docs in the vm and navigate to http://baseplate.local/html to see what the output of sphinx looks like to make sure it all makes sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Would it make sense to have it at the top of the docstring, but not replace the summary line? Seems to me the first thing you'd want to know when reading a block of docs is that the class is deprecated?

from .base import Experiment
from ..._compat import long, iteritems

from .variant_sets.single_variant_set import SingleVariantSet
Copy link
Contributor

Choose a reason for hiding this comment

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

seems reasonable to pop it up a level if it itself is not a provider

@Kaitaan
Copy link
Contributor Author

Kaitaan commented Jul 10, 2018

💇‍♂️

@Kaitaan Kaitaan merged commit ebdef7d into master Jul 16, 2018
@Kaitaan Kaitaan deleted the new_experiment_types branch July 18, 2018 21:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants