gsbDBI / torch-choice

Choice modeling with PyTorch: logit model and nested logit model
MIT License
39 stars 8 forks source link

How to model outside option? #41

Closed kdzhang closed 4 months ago

kdzhang commented 9 months ago

First, thank you for this great package!

I wonder if there is a way to model outside option in the package? For example, it is possible that the user buys nothing in a visit. One hacky solution I have is to force all item_obs and user_item_obs to be zero for the outside option. But this approach cannot handle session_obs unless we make every session_obs to be session_item_obs.

According to the following comment in this issue, it seems that there is a way to formally model outside option. I am good with normalizing it to 0 in each category. I very much appreciate it if you can give me some guidance on how to do this. Thank you!

there needs to be a discussion on how the outside option is modeled. How does the model choose that the user buys nothing from a given category? Can we change the value of the outside option in each category or is normalized to 0 for each category?

kdzhang commented 9 months ago

I managed to modify the conditional_logit_model.py a little bit to allow for outside option. I share it here in case others have a similar need.

First, make the item_index for outside option to be -1. Recall that the item_index for actual choices start from 0.

Then I modify the __init__ to add an option called outside and register it as a field.

Modify forward function by adding this at the end:

        if self.outside:
            util_zero = torch.zeros(total_utility.size(0), 1, device=batch.device)
            total_utility = torch.cat((util_zero, total_utility), dim=1)

And modify the negative_log_likelihood function by adding this before the total_utility calculation:

        # if outside option exists, need to adjust y to match the dimension of total_utility
        y_new = y.clone()
        if self.outside:
            # move all actual choices starting from 1
            # empty index (outside option) is 0
            y_new[y_new<0] = -1
            y_new+=1

Then you can get point estimates by estimating as usual. Remember to set outside=True in model initiation.

To get proper std, we need to change the run function in run_helper_lightning.py

    if isinstance(model, ConditionalLogitModel):
        def nll_loss(model):
            y_pred = model(dataset_for_std)

            # modify item_index to accomodate the outside option
            if model.outside:
                item_index_new = dataset_for_std.item_index + 1
            else:
                item_index_new = dataset_for_std.item_index
            return F.cross_entropy(y_pred, item_index_new, reduction='sum')

Then you should be able to get the std as well.

This is a hacky solution of course. I would very much appreciate it if the author has a better solution.

TianyuDu commented 9 months ago

Hey Kaida, thank you for your question and awesome solution to it! Your solution looks pretty elegant to me; for other users to utilize what you implemented, shall we make a pull request to integrate your implementation into the main branch? I could make the PR and acknowledge you in the PR, or you could do it if you prefer to have your contribution shown on GitHub.

Thank you for your contribution!

kdzhang commented 9 months ago

Hi Tianyu, thank you for your kinds words! Feel free to make the PR, I think you are the best person to do it as you know the edges of this package better. I am more than happy to contribute wherever I can. Regarding the feature, I think there is a bit more work left:

  1. Expand this implementation to nested logit;
  2. Probably need some consistency checking. E.g., if outside option is on, then item_index must start from -1.
  3. Add some examples on outside option in the documentation. I can certainly help with this one.

Again, thank you for writing this great package!

TianyuDu commented 8 months ago

I have attached a new branch to this thread to implement the outside option for conditional logit models. Kaida's preliminary solution was awesome, and I made a couple of modifications:

  1. In the solution Kaida proposed, the outside option zero utility was prepended to the total_utility so that total_utility[:, 0] = 0. Since the outside option is indicated by item_index = -1, I have appended the outside option zero utility so that total_utility[:, -1] = 0 in the current implementation. This would make the indexing easier. For example, suppose total_utility has the shape N*(num_items+1), in which the last column corresponds to the zero utility of the outside option. Suppose item_index has shape N with some -1 value in it to indicate outside option was chosen; then total_utility[range(item_index), item_index] could retrieve the utility for both real items and the outside option without modifying the item_index.

I am currently testing the implementation, and @kanodiaayush you can find the latest progress in the pull request here: https://github.com/gsbDBI/torch-choice/pull/42

@kanodiaayush Does this make sense to you?

TianyuDu commented 8 months ago

Here is a demonstration of using the outside option for condition logit models: https://github.com/gsbDBI/torch-choice/blob/41-how-to-model-outside-option/tutorials/outside_option.ipynb

I am working on testing if this works for nested regressions.

TianyuDu commented 8 months ago

I have implemented the outside option for nested logit model as well (see the updated pull request), @kanodiaayush could you please review it?