{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "\n# Common pitfalls in the interpretation of coefficients of linear models\n\nIn linear models, the target value is modeled as a linear combination of the\nfeatures (see the `linear_model` User Guide section for a description of a\nset of linear models available in scikit-learn). Coefficients in multiple linear\nmodels represent the relationship between the given feature, $X_i$ and the\ntarget, $y$, assuming that all the other features remain constant\n([conditional dependence](https://en.wikipedia.org/wiki/Conditional_dependence)). This is different\nfrom plotting $X_i$ versus $y$ and fitting a linear relationship: in\nthat case all possible values of the other features are taken into account in\nthe estimation (marginal dependence).\n\nThis example will provide some hints in interpreting coefficient in linear\nmodels, pointing at problems that arise when either the linear model is not\nappropriate to describe the dataset, or when features are correlated.\n\n

Note

Keep in mind that the features $X$ and the outcome $y$ are in\n general the result of a data generating process that is unknown to us.\n Machine learning models are trained to approximate the unobserved\n mathematical function that links $X$ to $y$ from sample data. As\n a result, any interpretation made about a model may not necessarily\n generalize to the true data generating process. This is especially true when\n the model is of bad quality or when the sample data is not representative of\n the population.

\n\nWe will use data from the [\"Current Population Survey\"](https://www.openml.org/d/534) from 1985 to predict wage as a function of\nvarious features such as experience, age, or education.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "# Authors: The scikit-learn developers\n# SPDX-License-Identifier: BSD-3-Clause" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "import matplotlib.pyplot as plt\nimport numpy as np\nimport pandas as pd\nimport scipy as sp\nimport seaborn as sns" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## The dataset: wages\n\nWe fetch the data from [OpenML](http://openml.org/).\nNote that setting the parameter `as_frame` to True will retrieve the data\nas a pandas dataframe.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "from sklearn.datasets import fetch_openml\n\nsurvey = fetch_openml(data_id=534, as_frame=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Then, we identify features `X` and targets `y`: the column WAGE is our\ntarget variable (i.e., the variable which we want to predict).\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "X = survey.data[survey.feature_names]\nX.describe(include=\"all\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Note that the dataset contains categorical and numerical variables.\nWe will need to take this into account when preprocessing the dataset\nthereafter.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "X.head()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Our target for prediction: the wage.\nWages are described as floating-point number in dollars per hour.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "y = survey.target.values.ravel()\nsurvey.target.head()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We split the sample into a train and a test dataset.\nOnly the train dataset will be used in the following exploratory analysis.\nThis is a way to emulate a real situation where predictions are performed on\nan unknown target, and we don't want our analysis and decisions to be biased\nby our knowledge of the test data.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "from sklearn.model_selection import train_test_split\n\nX_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "First, let's get some insights by looking at the variable distributions and\nat the pairwise relationships between them. Only numerical\nvariables will be used. In the following plot, each dot represents a sample.\n\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "train_dataset = X_train.copy()\ntrain_dataset.insert(0, \"WAGE\", y_train)\n_ = sns.pairplot(train_dataset, kind=\"reg\", diag_kind=\"kde\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Looking closely at the WAGE distribution reveals that it has a\nlong tail. For this reason, we should take its logarithm\nto turn it approximately into a normal distribution (linear models such\nas ridge or lasso work best for a normal distribution of error).\n\nThe WAGE is increasing when EDUCATION is increasing.\nNote that the dependence between WAGE and EDUCATION\nrepresented here is a marginal dependence, i.e., it describes the behavior\nof a specific variable without keeping the others fixed.\n\nAlso, the EXPERIENCE and AGE are strongly linearly correlated.\n\n\n## The machine-learning pipeline\n\nTo design our machine-learning pipeline, we first manually\ncheck the type of data that we are dealing with:\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "survey.data.info()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "As seen previously, the dataset contains columns with different data types\nand we need to apply a specific preprocessing for each data types.\nIn particular categorical variables cannot be included in linear model if not\ncoded as integers first. In addition, to avoid categorical features to be\ntreated as ordered values, we need to one-hot-encode them.\nOur pre-processor will\n\n- one-hot encode (i.e., generate a column by category) the categorical\n columns, only for non-binary categorical variables;\n- as a first approach (we will see after how the normalisation of numerical\n values will affect our discussion), keep numerical values as they are.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "from sklearn.compose import make_column_transformer\nfrom sklearn.preprocessing import OneHotEncoder\n\ncategorical_columns = [\"RACE\", \"OCCUPATION\", \"SECTOR\", \"MARR\", \"UNION\", \"SEX\", \"SOUTH\"]\nnumerical_columns = [\"EDUCATION\", \"EXPERIENCE\", \"AGE\"]\n\npreprocessor = make_column_transformer(\n (OneHotEncoder(drop=\"if_binary\"), categorical_columns),\n remainder=\"passthrough\",\n verbose_feature_names_out=False, # avoid to prepend the preprocessor names\n)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To describe the dataset as a linear model we use a ridge regressor\nwith a very small regularization and to model the logarithm of the WAGE.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "from sklearn.compose import TransformedTargetRegressor\nfrom sklearn.linear_model import Ridge\nfrom sklearn.pipeline import make_pipeline\n\nmodel = make_pipeline(\n preprocessor,\n TransformedTargetRegressor(\n regressor=Ridge(alpha=1e-10), func=np.log10, inverse_func=sp.special.exp10\n ),\n)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Processing the dataset\n\nFirst, we fit the model.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "model.fit(X_train, y_train)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Then we check the performance of the computed model plotting its predictions\non the test set and computing,\nfor example, the median absolute error of the model.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "from sklearn.metrics import PredictionErrorDisplay, median_absolute_error\n\nmae_train = median_absolute_error(y_train, model.predict(X_train))\ny_pred = model.predict(X_test)\nmae_test = median_absolute_error(y_test, y_pred)\nscores = {\n \"MedAE on training set\": f\"{mae_train:.2f} $/hour\",\n \"MedAE on testing set\": f\"{mae_test:.2f} $/hour\",\n}" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "_, ax = plt.subplots(figsize=(5, 5))\ndisplay = PredictionErrorDisplay.from_predictions(\n y_test, y_pred, kind=\"actual_vs_predicted\", ax=ax, scatter_kwargs={\"alpha\": 0.5}\n)\nax.set_title(\"Ridge model, small regularization\")\nfor name, score in scores.items():\n ax.plot([], [], \" \", label=f\"{name}: {score}\")\nax.legend(loc=\"upper left\")\nplt.tight_layout()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The model learnt is far from being a good model making accurate predictions:\nthis is obvious when looking at the plot above, where good predictions\nshould lie on the black dashed line.\n\nIn the following section, we will interpret the coefficients of the model.\nWhile we do so, we should keep in mind that any conclusion we draw is\nabout the model that we build, rather than about the true (real-world)\ngenerative process of the data.\n\n## Interpreting coefficients: scale matters\n\nFirst of all, we can take a look to the values of the coefficients of the\nregressor we have fitted.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "feature_names = model[:-1].get_feature_names_out()\n\ncoefs = pd.DataFrame(\n model[-1].regressor_.coef_,\n columns=[\"Coefficients\"],\n index=feature_names,\n)\n\ncoefs" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The AGE coefficient is expressed in \"dollars/hour per living years\" while the\nEDUCATION one is expressed in \"dollars/hour per years of education\". This\nrepresentation of the coefficients has the benefit of making clear the\npractical predictions of the model: an increase of $1$ year in AGE\nmeans a decrease of $0.030867$ dollars/hour, while an increase of\n$1$ year in EDUCATION means an increase of $0.054699$\ndollars/hour. On the other hand, categorical variables (as UNION or SEX) are\nadimensional numbers taking either the value 0 or 1. Their coefficients\nare expressed in dollars/hour. Then, we cannot compare the magnitude of\ndifferent coefficients since the features have different natural scales, and\nhence value ranges, because of their different unit of measure. This is more\nvisible if we plot the coefficients.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "coefs.plot.barh(figsize=(9, 7))\nplt.title(\"Ridge model, small regularization\")\nplt.axvline(x=0, color=\".5\")\nplt.xlabel(\"Raw coefficient values\")\nplt.subplots_adjust(left=0.3)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Indeed, from the plot above the most important factor in determining WAGE\nappears to be the\nvariable UNION, even if our intuition might tell us that variables\nlike EXPERIENCE should have more impact.\n\nLooking at the coefficient plot to gauge feature importance can be\nmisleading as some of them vary on a small scale, while others, like AGE,\nvaries a lot more, several decades.\n\nThis is visible if we compare the standard deviations of different\nfeatures.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "X_train_preprocessed = pd.DataFrame(\n model[:-1].transform(X_train), columns=feature_names\n)\n\nX_train_preprocessed.std(axis=0).plot.barh(figsize=(9, 7))\nplt.title(\"Feature ranges\")\nplt.xlabel(\"Std. dev. of feature values\")\nplt.subplots_adjust(left=0.3)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Multiplying the coefficients by the standard deviation of the related\nfeature would reduce all the coefficients to the same unit of measure.\nAs we will see `after` this is equivalent to normalize\nnumerical variables to their standard deviation,\nas $y = \\sum{coef_i \\times X_i} =\n\\sum{(coef_i \\times std_i) \\times (X_i / std_i)}$.\n\nIn that way, we emphasize that the\ngreater the variance of a feature, the larger the weight of the corresponding\ncoefficient on the output, all else being equal.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "coefs = pd.DataFrame(\n model[-1].regressor_.coef_ * X_train_preprocessed.std(axis=0),\n columns=[\"Coefficient importance\"],\n index=feature_names,\n)\ncoefs.plot(kind=\"barh\", figsize=(9, 7))\nplt.xlabel(\"Coefficient values corrected by the feature's std. dev.\")\nplt.title(\"Ridge model, small regularization\")\nplt.axvline(x=0, color=\".5\")\nplt.subplots_adjust(left=0.3)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now that the coefficients have been scaled, we can safely compare them.\n\n

Warning

Why does the plot above suggest that an increase in age leads to a\n decrease in wage? Why the `initial pairplot\n ` is telling the opposite?

\n\nThe plot above tells us about dependencies between a specific feature and\nthe target when all other features remain constant, i.e., **conditional\ndependencies**. An increase of the AGE will induce a decrease\nof the WAGE when all other features remain constant. On the contrary, an\nincrease of the EXPERIENCE will induce an increase of the WAGE when all\nother features remain constant.\nAlso, AGE, EXPERIENCE and EDUCATION are the three variables that most\ninfluence the model.\n\n## Interpreting coefficients: being cautious about causality\n\nLinear models are a great tool for measuring statistical association, but we\nshould be cautious when making statements about causality, after all\ncorrelation doesn't always imply causation. This is particularly difficult in\nthe social sciences because the variables we observe only function as proxies\nfor the underlying causal process.\n\nIn our particular case we can think of the EDUCATION of an individual as a\nproxy for their professional aptitude, the real variable we're interested in\nbut can't observe. We'd certainly like to think that staying in school for\nlonger would increase technical competency, but it's also quite possible that\ncausality goes the other way too. That is, those who are technically\ncompetent tend to stay in school for longer.\n\nAn employer is unlikely to care which case it is (or if it's a mix of both),\nas long as they remain convinced that a person with more EDUCATION is better\nsuited for the job, they will be happy to pay out a higher WAGE.\n\nThis confounding of effects becomes problematic when thinking about some\nform of intervention e.g. government subsidies of university degrees or\npromotional material encouraging individuals to take up higher education.\nThe usefulness of these measures could end up being overstated, especially if\nthe degree of confounding is strong. Our model predicts a $0.054699$\nincrease in hourly wage for each year of education. The actual causal effect\nmight be lower because of this confounding.\n\n## Checking the variability of the coefficients\n\nWe can check the coefficient variability through cross-validation:\nit is a form of data perturbation (related to\n[resampling](https://en.wikipedia.org/wiki/Resampling_(statistics))).\n\nIf coefficients vary significantly when changing the input dataset\ntheir robustness is not guaranteed, and they should probably be interpreted\nwith caution.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "from sklearn.model_selection import RepeatedKFold, cross_validate\n\ncv = RepeatedKFold(n_splits=5, n_repeats=5, random_state=0)\ncv_model = cross_validate(\n model,\n X,\n y,\n cv=cv,\n return_estimator=True,\n n_jobs=2,\n)\n\ncoefs = pd.DataFrame(\n [\n est[-1].regressor_.coef_ * est[:-1].transform(X.iloc[train_idx]).std(axis=0)\n for est, (train_idx, _) in zip(cv_model[\"estimator\"], cv.split(X, y))\n ],\n columns=feature_names,\n)" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "plt.figure(figsize=(9, 7))\nsns.stripplot(data=coefs, orient=\"h\", palette=\"dark:k\", alpha=0.5)\nsns.boxplot(data=coefs, orient=\"h\", color=\"cyan\", saturation=0.5, whis=10)\nplt.axvline(x=0, color=\".5\")\nplt.xlabel(\"Coefficient importance\")\nplt.title(\"Coefficient importance and its variability\")\nplt.suptitle(\"Ridge model, small regularization\")\nplt.subplots_adjust(left=0.3)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## The problem of correlated variables\n\nThe AGE and EXPERIENCE coefficients are affected by strong variability which\nmight be due to the collinearity between the 2 features: as AGE and\nEXPERIENCE vary together in the data, their effect is difficult to tease\napart.\n\nTo verify this interpretation we plot the variability of the AGE and\nEXPERIENCE coefficient.\n\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "plt.ylabel(\"Age coefficient\")\nplt.xlabel(\"Experience coefficient\")\nplt.grid(True)\nplt.xlim(-0.4, 0.5)\nplt.ylim(-0.4, 0.5)\nplt.scatter(coefs[\"AGE\"], coefs[\"EXPERIENCE\"])\n_ = plt.title(\"Co-variations of coefficients for AGE and EXPERIENCE across folds\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Two regions are populated: when the EXPERIENCE coefficient is\npositive the AGE one is negative and vice-versa.\n\nTo go further we remove one of the 2 features and check what is the impact\non the model stability.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "column_to_drop = [\"AGE\"]\n\ncv_model = cross_validate(\n model,\n X.drop(columns=column_to_drop),\n y,\n cv=cv,\n return_estimator=True,\n n_jobs=2,\n)\n\ncoefs = pd.DataFrame(\n [\n est[-1].regressor_.coef_\n * est[:-1].transform(X.drop(columns=column_to_drop).iloc[train_idx]).std(axis=0)\n for est, (train_idx, _) in zip(cv_model[\"estimator\"], cv.split(X, y))\n ],\n columns=feature_names[:-1],\n)" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "plt.figure(figsize=(9, 7))\nsns.stripplot(data=coefs, orient=\"h\", palette=\"dark:k\", alpha=0.5)\nsns.boxplot(data=coefs, orient=\"h\", color=\"cyan\", saturation=0.5)\nplt.axvline(x=0, color=\".5\")\nplt.title(\"Coefficient importance and its variability\")\nplt.xlabel(\"Coefficient importance\")\nplt.suptitle(\"Ridge model, small regularization, AGE dropped\")\nplt.subplots_adjust(left=0.3)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The estimation of the EXPERIENCE coefficient now shows a much reduced\nvariability. EXPERIENCE remains important for all models trained during\ncross-validation.\n\n\n## Preprocessing numerical variables\n\nAs said above (see \"`the-pipeline`\"), we could also choose to scale\nnumerical values before training the model.\nThis can be useful when we apply a similar amount of regularization to all of them\nin the ridge.\nThe preprocessor is redefined in order to subtract the mean and scale\nvariables to unit variance.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "from sklearn.preprocessing import StandardScaler\n\npreprocessor = make_column_transformer(\n (OneHotEncoder(drop=\"if_binary\"), categorical_columns),\n (StandardScaler(), numerical_columns),\n)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The model will stay unchanged.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "model = make_pipeline(\n preprocessor,\n TransformedTargetRegressor(\n regressor=Ridge(alpha=1e-10), func=np.log10, inverse_func=sp.special.exp10\n ),\n)\nmodel.fit(X_train, y_train)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Again, we check the performance of the computed\nmodel using, for example, the median absolute error of the model and the R\nsquared coefficient.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "mae_train = median_absolute_error(y_train, model.predict(X_train))\ny_pred = model.predict(X_test)\nmae_test = median_absolute_error(y_test, y_pred)\nscores = {\n \"MedAE on training set\": f\"{mae_train:.2f} $/hour\",\n \"MedAE on testing set\": f\"{mae_test:.2f} $/hour\",\n}\n\n_, ax = plt.subplots(figsize=(5, 5))\ndisplay = PredictionErrorDisplay.from_predictions(\n y_test, y_pred, kind=\"actual_vs_predicted\", ax=ax, scatter_kwargs={\"alpha\": 0.5}\n)\nax.set_title(\"Ridge model, small regularization\")\nfor name, score in scores.items():\n ax.plot([], [], \" \", label=f\"{name}: {score}\")\nax.legend(loc=\"upper left\")\nplt.tight_layout()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "For the coefficient analysis, scaling is not needed this time because it\nwas performed during the preprocessing step.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "coefs = pd.DataFrame(\n model[-1].regressor_.coef_,\n columns=[\"Coefficients importance\"],\n index=feature_names,\n)\ncoefs.plot.barh(figsize=(9, 7))\nplt.title(\"Ridge model, small regularization, normalized variables\")\nplt.xlabel(\"Raw coefficient values\")\nplt.axvline(x=0, color=\".5\")\nplt.subplots_adjust(left=0.3)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We now inspect the coefficients across several cross-validation folds. As in\nthe above example, we do not need to scale the coefficients by the std. dev.\nof the feature values since this scaling was already\ndone in the preprocessing step of the pipeline.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "cv_model = cross_validate(\n model,\n X,\n y,\n cv=cv,\n return_estimator=True,\n n_jobs=2,\n)\ncoefs = pd.DataFrame(\n [est[-1].regressor_.coef_ for est in cv_model[\"estimator\"]], columns=feature_names\n)" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "plt.figure(figsize=(9, 7))\nsns.stripplot(data=coefs, orient=\"h\", palette=\"dark:k\", alpha=0.5)\nsns.boxplot(data=coefs, orient=\"h\", color=\"cyan\", saturation=0.5, whis=10)\nplt.axvline(x=0, color=\".5\")\nplt.title(\"Coefficient variability\")\nplt.subplots_adjust(left=0.3)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The result is quite similar to the non-normalized case.\n\n## Linear models with regularization\n\nIn machine-learning practice, ridge regression is more often used with\nnon-negligible regularization.\n\nAbove, we limited this regularization to a very little amount. Regularization\nimproves the conditioning of the problem and reduces the variance of the\nestimates. :class:`~sklearn.linear_model.RidgeCV` applies cross validation\nin order to determine which value of the regularization parameter (`alpha`)\nis best suited for prediction.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "from sklearn.linear_model import RidgeCV\n\nalphas = np.logspace(-10, 10, 21) # alpha values to be chosen from by cross-validation\nmodel = make_pipeline(\n preprocessor,\n TransformedTargetRegressor(\n regressor=RidgeCV(alphas=alphas),\n func=np.log10,\n inverse_func=sp.special.exp10,\n ),\n)\nmodel.fit(X_train, y_train)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "First we check which value of $\\alpha$ has been selected.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "model[-1].regressor_.alpha_" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Then we check the quality of the predictions.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "mae_train = median_absolute_error(y_train, model.predict(X_train))\ny_pred = model.predict(X_test)\nmae_test = median_absolute_error(y_test, y_pred)\nscores = {\n \"MedAE on training set\": f\"{mae_train:.2f} $/hour\",\n \"MedAE on testing set\": f\"{mae_test:.2f} $/hour\",\n}\n\n_, ax = plt.subplots(figsize=(5, 5))\ndisplay = PredictionErrorDisplay.from_predictions(\n y_test, y_pred, kind=\"actual_vs_predicted\", ax=ax, scatter_kwargs={\"alpha\": 0.5}\n)\nax.set_title(\"Ridge model, optimum regularization\")\nfor name, score in scores.items():\n ax.plot([], [], \" \", label=f\"{name}: {score}\")\nax.legend(loc=\"upper left\")\nplt.tight_layout()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The ability to reproduce the data of the regularized model is similar to\nthe one of the non-regularized model.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "coefs = pd.DataFrame(\n model[-1].regressor_.coef_,\n columns=[\"Coefficients importance\"],\n index=feature_names,\n)\ncoefs.plot.barh(figsize=(9, 7))\nplt.title(\"Ridge model, with regularization, normalized variables\")\nplt.xlabel(\"Raw coefficient values\")\nplt.axvline(x=0, color=\".5\")\nplt.subplots_adjust(left=0.3)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The coefficients are significantly different.\nAGE and EXPERIENCE coefficients are both positive but they now have less\ninfluence on the prediction.\n\nThe regularization reduces the influence of correlated\nvariables on the model because the weight is shared between the two\npredictive variables, so neither alone would have strong weights.\n\nOn the other hand, the weights obtained with regularization are more\nstable (see the `ridge_regression` User Guide section). This\nincreased stability is visible from the plot, obtained from data\nperturbations, in a cross-validation. This plot can be compared with\nthe `previous one`.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "cv_model = cross_validate(\n model,\n X,\n y,\n cv=cv,\n return_estimator=True,\n n_jobs=2,\n)\ncoefs = pd.DataFrame(\n [est[-1].regressor_.coef_ for est in cv_model[\"estimator\"]], columns=feature_names\n)" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "plt.ylabel(\"Age coefficient\")\nplt.xlabel(\"Experience coefficient\")\nplt.grid(True)\nplt.xlim(-0.4, 0.5)\nplt.ylim(-0.4, 0.5)\nplt.scatter(coefs[\"AGE\"], coefs[\"EXPERIENCE\"])\n_ = plt.title(\"Co-variations of coefficients for AGE and EXPERIENCE across folds\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Linear models with sparse coefficients\n\nAnother possibility to take into account correlated variables in the dataset,\nis to estimate sparse coefficients. In some way we already did it manually\nwhen we dropped the AGE column in a previous ridge estimation.\n\nLasso models (see the `lasso` User Guide section) estimates sparse\ncoefficients. :class:`~sklearn.linear_model.LassoCV` applies cross\nvalidation in order to determine which value of the regularization parameter\n(`alpha`) is best suited for the model estimation.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "from sklearn.linear_model import LassoCV\n\nalphas = np.logspace(-10, 10, 21) # alpha values to be chosen from by cross-validation\nmodel = make_pipeline(\n preprocessor,\n TransformedTargetRegressor(\n regressor=LassoCV(alphas=alphas, max_iter=100_000),\n func=np.log10,\n inverse_func=sp.special.exp10,\n ),\n)\n\n_ = model.fit(X_train, y_train)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "First we verify which value of $\\alpha$ has been selected.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "model[-1].regressor_.alpha_" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Then we check the quality of the predictions.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "mae_train = median_absolute_error(y_train, model.predict(X_train))\ny_pred = model.predict(X_test)\nmae_test = median_absolute_error(y_test, y_pred)\nscores = {\n \"MedAE on training set\": f\"{mae_train:.2f} $/hour\",\n \"MedAE on testing set\": f\"{mae_test:.2f} $/hour\",\n}\n\n_, ax = plt.subplots(figsize=(6, 6))\ndisplay = PredictionErrorDisplay.from_predictions(\n y_test, y_pred, kind=\"actual_vs_predicted\", ax=ax, scatter_kwargs={\"alpha\": 0.5}\n)\nax.set_title(\"Lasso model, optimum regularization\")\nfor name, score in scores.items():\n ax.plot([], [], \" \", label=f\"{name}: {score}\")\nax.legend(loc=\"upper left\")\nplt.tight_layout()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "For our dataset, again the model is not very predictive.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "coefs = pd.DataFrame(\n model[-1].regressor_.coef_,\n columns=[\"Coefficients importance\"],\n index=feature_names,\n)\ncoefs.plot(kind=\"barh\", figsize=(9, 7))\nplt.title(\"Lasso model, optimum regularization, normalized variables\")\nplt.axvline(x=0, color=\".5\")\nplt.subplots_adjust(left=0.3)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "A Lasso model identifies the correlation between\nAGE and EXPERIENCE and suppresses one of them for the sake of the prediction.\n\nIt is important to keep in mind that the coefficients that have been\ndropped may still be related to the outcome by themselves: the model\nchose to suppress them because they bring little or no additional\ninformation on top of the other features. Additionally, this selection\nis unstable for correlated features, and should be interpreted with\ncaution.\n\nIndeed, we can check the variability of the coefficients across folds.\n\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "cv_model = cross_validate(\n model,\n X,\n y,\n cv=cv,\n return_estimator=True,\n n_jobs=2,\n)\ncoefs = pd.DataFrame(\n [est[-1].regressor_.coef_ for est in cv_model[\"estimator\"]], columns=feature_names\n)" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "plt.figure(figsize=(9, 7))\nsns.stripplot(data=coefs, orient=\"h\", palette=\"dark:k\", alpha=0.5)\nsns.boxplot(data=coefs, orient=\"h\", color=\"cyan\", saturation=0.5, whis=100)\nplt.axvline(x=0, color=\".5\")\nplt.title(\"Coefficient variability\")\nplt.subplots_adjust(left=0.3)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We observe that the AGE and EXPERIENCE coefficients are varying a lot\ndepending of the fold.\n\n## Wrong causal interpretation\n\nPolicy makers might want to know the effect of education on wage to assess\nwhether or not a certain policy designed to entice people to pursue more\neducation would make economic sense. While Machine Learning models are great\nfor measuring statistical associations, they are generally unable to infer\ncausal effects.\n\nIt might be tempting to look at the coefficient of education on wage from our\nlast model (or any model for that matter) and conclude that it captures the\ntrue effect of a change in the standardized education variable on wages.\n\nUnfortunately there are likely unobserved confounding variables that either\ninflate or deflate that coefficient. A confounding variable is a variable that\ncauses both EDUCATION and WAGE. One example of such variable is ability.\nPresumably, more able people are more likely to pursue education while at the\nsame time being more likely to earn a higher hourly wage at any level of\neducation. In this case, ability induces a positive [Omitted Variable Bias](https://en.wikipedia.org/wiki/Omitted-variable_bias) (OVB) on the EDUCATION\ncoefficient, thereby exaggerating the effect of education on wages.\n\nSee the `sphx_glr_auto_examples_inspection_plot_causal_interpretation.py`\nfor a simulated case of ability OVB.\n\n## Lessons learned\n\n* Coefficients must be scaled to the same unit of measure to retrieve\n feature importance. Scaling them with the standard-deviation of the\n feature is a useful proxy.\n* Interpreting causality is difficult when there are confounding effects. If\n the relationship between two variables is also affected by something\n unobserved, we should be careful when making conclusions about causality.\n* Coefficients in multivariate linear models represent the dependency\n between a given feature and the target, **conditional** on the other\n features.\n* Correlated features induce instabilities in the coefficients of linear\n models and their effects cannot be well teased apart.\n* Different linear models respond differently to feature correlation and\n coefficients could significantly vary from one another.\n* Inspecting coefficients across the folds of a cross-validation loop\n gives an idea of their stability.\n* Coefficients are unlikely to have any causal meaning. They tend\n to be biased by unobserved confounders.\n* Inspection tools may not necessarily provide insights on the true\n data generating process.\n\n" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.9.21" } }, "nbformat": 4, "nbformat_minor": 0 }