Predicting Wine Quality Using Physicochemical Properties¶


embed-resources: true echo: false warning: false message: false¶

Introduction¶

Wine quality is traditionally evaluated by trained sommeliers through sensory analysis of aroma, taste, and texture. While expert evaluation can provide valuable insights, it is subjective and requires extensive training and experience. For a startup seeking to develop an "AI Sommelier," an automated method for estimating wine quality could reduce costs and improve consistency. This report investigates whether machine learning models can predict wine quality using objective physiochemical measurements of wine. These measurements include properties such as acidity, sugar content, sulfur dioxide levels, density, and alcohol concentration. Using historical wine data labeled with quality scores assigned by human evaluators, regression models are trained to predict wine quality based solely on these measurable features. The goal is to determine whether such models are accurate enough to assist in automated wine quality assessment.

Methods¶

In [23]:
# imports
import pandas as pd
import numpy as np
from joblib import dump
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import accuracy_score
from sklearn.metrics import classification_report
from sklearn.metrics import mean_absolute_error
from sklearn.metrics import confusion_matrix

Data¶

In [24]:
# load data
wine_train = pd.read_csv(
    "wine-train.csv",
)
wine_test = pd.read_csv(
    "wine-test.csv",
)

The dataset contains physicochemical measurements for individual Portuguese “Vinho Verde” wines along with quality ratings assigned by human sensory evaluators. Each observation represents a single wine sample and includes chemical properties such as acidity levels, sulfur dioxide concentrations, density, pH, sulphates, alcohol content, and wine color. The training dataset is used to develop the predictive model, while the test dataset is reserved for evaluating model performance on unseen data. This separation helps ensure that the model’s performance reflects its ability to generalize to new wines rather than simply memorizing patterns in the training data.

Variable Type Description
quality int64 The quality of the wine based on evaluation by a minimum of three sensory assessors using blind tasting. Scores range from 0 (very bad) to 10 (excellent).
color object The human-perceivable color of the wine: red or white.
fixed acidity float64 Grams of tartaric acid per cubic decimeter.
volatile acidity float64 Grams of acetic acid per cubic decimeter.
citric acid float64 Grams of citric acid per cubic decimeter.
residual sugar float64 Grams of residual sugar per cubic decimeter.
chlorides float64 Grams of sodium chloride per cubic decimeter.
free sulfur dioxide float64 Milligrams of free sulfur dioxide per cubic decimeter.
total sulfur dioxide float64 Milligrams of total sulfur dioxide per cubic decimeter.
density float64 Total density of the wine in grams per cubic centimeter.
pH float64 Acidity of the wine measured using the pH scale.
sulphates float64 Grams of potassium sulphate per cubic decimeter.
alcohol float64 Percent alcohol by volume.
In [25]:
# summary statistics
wine_train.shape
Out[25]:
(4157, 13)

The mean for "quality":

In [26]:
wine_train["quality"].mean()
Out[26]:
np.float64(5.814289150829925)

The standard deviation for "quality":

In [27]:
wine_train["quality"].std()
Out[27]:
np.float64(0.868748287046691)

My summary statsitics shows the dataset contains 4157 samples and 12 features. I calculated the mean and standard deviation of quality to summarize its basic characteristics. Below is a table of basic characteristics for every variable in the dataset:

In [28]:
wine_train.describe()
Out[28]:
fixed acidity volatile acidity citric acid residual sugar chlorides free sulfur dioxide total sulfur dioxide density pH sulphates alcohol quality
count 3987.000000 4157.000000 3958.000000 3917.000000 4157.000000 4133.000000 4157.000000 4157.000000 3921.000000 3934.000000 3961.000000 4157.000000
mean 7.220579 0.342403 0.316478 5.430406 0.056358 30.600048 115.221915 0.994710 3.218454 0.531784 10.502847 5.814289
std 1.295282 0.166465 0.145276 4.750618 0.035460 17.906396 56.581139 0.003004 0.159184 0.150914 1.189918 0.868748
min 3.800000 0.080000 0.000000 0.600000 0.012000 1.000000 6.000000 0.987110 2.740000 0.230000 8.000000 3.000000
25% 6.400000 0.230000 0.240000 1.800000 0.038000 17.000000 77.000000 0.992380 3.110000 0.430000 9.500000 5.000000
50% 7.000000 0.300000 0.310000 3.000000 0.048000 29.000000 118.000000 0.994900 3.210000 0.505000 10.300000 6.000000
75% 7.700000 0.410000 0.390000 8.100000 0.066000 41.000000 155.000000 0.996990 3.320000 0.600000 11.300000 6.000000
max 15.900000 1.330000 1.230000 65.800000 0.611000 289.000000 440.000000 1.038980 4.010000 2.000000 14.900000 9.000000
In [29]:
#| fig-cap: "Figure 1. Relationship Between Alcohol Content and Wine Quality by Wine Color"
# exploratory visualization
for color in ["red", "white"]:
    subset = wine_train[wine_train["color"] == color]

    plt.scatter(
        subset["alcohol"],
        subset["quality"],
        alpha=0.5,
        label=color.title(),
    )

plt.xlabel("Alcohol (%)")
plt.ylabel("Wine Quality")
plt.title("Relationship Between Alcohol Content and Wine Quality")
plt.legend(title="Wine Color")

plt.show()
No description has been provided for this image

Models¶

In [30]:
# process data for ML
X_train = wine_train.drop("quality", axis=1)
y_train = wine_train["quality"]
X_test = wine_test.drop("quality", axis=1)
y_test = wine_test["quality"]
In [31]:
# train models
cat_cols = ["color"]
num_cols = [c for c in X_train.columns if c != "color"]

preprocess = ColumnTransformer(
    transformers=[
        ("cat", Pipeline([
            ("imputer", SimpleImputer(strategy="most_frequent")),
            ("onehot", OneHotEncoder(handle_unknown="ignore")),
        ]), cat_cols),

        ("num", Pipeline([
            ("imputer", SimpleImputer(strategy="median")),
            ("scaler", StandardScaler()),
        ]), num_cols),
    ]
)

mod = Pipeline([
    ("preprocess", preprocess),
    ("clf", KNeighborsClassifier(n_neighbors=25, weights="distance")),
])
mod.fit(X_train, y_train)
Out[31]:
Pipeline(steps=[('preprocess',
                 ColumnTransformer(transformers=[('cat',
                                                  Pipeline(steps=[('imputer',
                                                                   SimpleImputer(strategy='most_frequent')),
                                                                  ('onehot',
                                                                   OneHotEncoder(handle_unknown='ignore'))]),
                                                  ['color']),
                                                 ('num',
                                                  Pipeline(steps=[('imputer',
                                                                   SimpleImputer(strategy='median')),
                                                                  ('scaler',
                                                                   StandardScaler())]),
                                                  ['fixed acidity',
                                                   'volatile acidity',
                                                   'citric acid',
                                                   'residual sugar',
                                                   'chlorides',
                                                   'free sulfur dioxide',
                                                   'total sulfur dioxide',
                                                   'density', 'pH', 'sulphates',
                                                   'alcohol'])])),
                ('clf',
                 KNeighborsClassifier(n_neighbors=25, weights='distance'))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
steps steps: list of tuples

List of (name of step, estimator) tuples that are to be chained in
sequential order. To be compatible with the scikit-learn API, all steps
must define `fit`. All non-last steps must also define `transform`. See
:ref:`Combining Estimators <combining_estimators>` for more details.
[('preprocess', ...), ('clf', ...)]
transform_input transform_input: list of str, default=None

The names of the :term:`metadata` parameters that should be transformed by the
pipeline before passing it to the step consuming it.

This enables transforming some input arguments to ``fit`` (other than ``X``)
to be transformed by the steps of the pipeline up to the step which requires
them. Requirement is defined via :ref:`metadata routing <metadata_routing>`.
For instance, this can be used to pass a validation set through the pipeline.

You can only set this if metadata routing is enabled, which you
can enable using ``sklearn.set_config(enable_metadata_routing=True)``.

.. versionadded:: 1.6
None
memory memory: str or object with the joblib.Memory interface, default=None

Used to cache the fitted transformers of the pipeline. The last step
will never be cached, even if it is a transformer. By default, no
caching is performed. If a string is given, it is the path to the
caching directory. Enabling caching triggers a clone of the transformers
before fitting. Therefore, the transformer instance given to the
pipeline cannot be inspected directly. Use the attribute ``named_steps``
or ``steps`` to inspect estimators within the pipeline. Caching the
transformers is advantageous when fitting is time consuming. See
:ref:`sphx_glr_auto_examples_neighbors_plot_caching_nearest_neighbors.py`
for an example on how to enable caching.
None
verbose verbose: bool, default=False

If True, the time elapsed while fitting each step will be printed as it
is completed.
False
Fitted attributes
Name Type Value
classes_ classes_: ndarray of shape (n_classes,)

The classes labels. Only exist if the last step of the pipeline is a
classifier.
ndarray[int64](7,) [3,4,5,...,7,8,9]
feature_names_in_ feature_names_in_: ndarray of shape (`n_features_in_`,)

Names of features seen during :term:`fit`. Only defined if the
underlying estimator exposes such an attribute when fit.

.. versionadded:: 1.0
ndarray[object](12,) ['fixed acidity','volatile acidity','citric acid',...,'sulphates', 'alcohol','color']
n_features_in_ n_features_in_: int

Number of features seen during :term:`fit`. Only defined if the
underlying first estimator in `steps` exposes such an attribute
when fit.

.. versionadded:: 0.24
int 12
Parameters
transformers transformers: list of tuples

List of (name, transformer, columns) tuples specifying the
transformer objects to be applied to subsets of the data.

name : str
Like in Pipeline and FeatureUnion, this allows the transformer and
its parameters to be set using ``set_params`` and searched in grid
search.
transformer : {'drop', 'passthrough'} or estimator
Estimator must support :term:`fit` and :term:`transform`.
Special-cased strings 'drop' and 'passthrough' are accepted as
well, to indicate to drop the columns or to pass them through
untransformed, respectively.
columns : str, array-like of str, int, array-like of int, array-like of bool, slice or callable
Indexes the data on its second axis. Integers are interpreted as
positional columns, while strings can reference DataFrame columns
by name. A scalar string or int should be used where
``transformer`` expects X to be a 1d array-like (vector),
otherwise a 2d array will be passed to the transformer.
A callable is passed the input data `X` and can return any of the
above. To select multiple columns by name or dtype, you can use
:obj:`make_column_selector`.
[('cat', ...), ('num', ...)]
remainder remainder: {'drop', 'passthrough'} or estimator, default='drop'

By default, only the specified columns in `transformers` are
transformed and combined in the output, and the non-specified
columns are dropped. (default of ``'drop'``).
By specifying ``remainder='passthrough'``, all remaining columns that
were not specified in `transformers`, but present in the data passed
to `fit` will be automatically passed through. This subset of columns
is concatenated with the output of the transformers. For dataframes,
extra columns not seen during `fit` will be excluded from the output
of `transform`.
By setting ``remainder`` to be an estimator, the remaining
non-specified columns will use the ``remainder`` estimator. The
estimator must support :term:`fit` and :term:`transform`.
Note that using this feature requires that the DataFrame columns
input at :term:`fit` and :term:`transform` have identical order.
'drop'
sparse_threshold sparse_threshold: float, default=0.3

If the output of the different transformers contains sparse matrices,
these will be stacked as a sparse matrix if the overall density is
lower than this value. Use ``sparse_threshold=0`` to always return
dense. When the transformed output consists of all dense data, the
stacked result will be dense, and this keyword will be ignored.
0.3
n_jobs n_jobs: int, default=None

Number of jobs to run in parallel.
``None`` means 1 unless in a :obj:`joblib.parallel_backend` context.
``-1`` means using all processors. See :term:`Glossary <n_jobs>`
for more details.
None
transformer_weights transformer_weights: dict, default=None

Multiplicative weights for features per transformer. The output of the
transformer is multiplied by these weights. Keys are transformer names,
values the weights.
None
verbose verbose: bool, default=False

If True, the time elapsed while fitting each transformer will be
printed as it is completed.
False
verbose_feature_names_out verbose_feature_names_out: bool, str or Callable[[str, str], str], default=True

- If True, :meth:`ColumnTransformer.get_feature_names_out` will prefix
all feature names with the name of the transformer that generated that
feature. It is equivalent to setting
`verbose_feature_names_out="{transformer_name}__{feature_name}"`.
- If False, :meth:`ColumnTransformer.get_feature_names_out` will not
prefix any feature names and will error if feature names are not
unique.
- If ``Callable[[str, str], str]``,
:meth:`ColumnTransformer.get_feature_names_out` will rename all the features
using the name of the transformer. The first argument of the callable is the
transformer name and the second argument is the feature name. The returned
string will be the new feature name.
- If ``str``, it must be a string ready for formatting. The given string will
be formatted using two field names: ``transformer_name`` and ``feature_name``.
e.g. ``"{feature_name}__{transformer_name}"``. See :meth:`str.format` method
from the standard library for more info.

.. versionadded:: 1.0

.. versionchanged:: 1.6
`verbose_feature_names_out` can be a callable or a string to be formatted.
True
Fitted attributes
Name Type Value
feature_names_in_ feature_names_in_: ndarray of shape (`n_features_in_`,)

Names of features seen during :term:`fit`. Defined only when `X`
has feature names that are all strings.

.. versionadded:: 1.0
ndarray[object](12,) ['fixed acidity','volatile acidity','citric acid',...,'sulphates', 'alcohol','color']
n_features_in_ n_features_in_: int

Number of features seen during :term:`fit`. Only defined if the
underlying transformers expose such an attribute when fit.

.. versionadded:: 0.24
int 12
named_transformers_ named_transformers_: :class:`~sklearn.utils.Bunch`

Read-only attribute to access any transformer by given name.
Keys are transformer names and values are the fitted transformer
objects.
Bunch {'cat': Pipel...rdScaler())])}
output_indices_ output_indices_: dict

A dictionary from each transformer name to a slice, where the slice
corresponds to indices in the transformed output. This is useful to
inspect which transformer is responsible for which transformed
feature(s).

.. versionadded:: 1.0
dict {'cat': slice(0, 2, None), 'num': slice(2, 13, None), 're...er': slice(0, 0, None)}
sparse_output_ sparse_output_: bool

Boolean flag indicating whether the output of ``transform`` is a
sparse matrix or a dense numpy array, which depends on the output
of the individual transformers and the `sparse_threshold` keyword.
bool False
transformers_ transformers_: list

The collection of fitted transformers as tuples of (name,
fitted_transformer, column). `fitted_transformer` can be an estimator,
or `'drop'`; `'passthrough'` is replaced with an equivalent
:class:`~sklearn.preprocessing.FunctionTransformer`. In case there were
no columns selected, this will be the unfitted transformer. If there
are remaining columns, the final element is a tuple of the form:
('remainder', transformer, remaining_columns) corresponding to the
``remainder`` parameter. If there are remaining columns, then
``len(transformers_)==len(transformers)+1``, otherwise
``len(transformers_)==len(transformers)``.

.. versionadded:: 1.7
The format of the remaining columns now attempts to match that of the other
transformers: if all columns were provided as column names (`str`), the
remaining columns are stored as column names; if all columns were provided
as mask arrays (`bool`), so are the remaining columns; in all other cases
the remaining columns are stored as indices (`int`).
list [('cat', Pipeline(step...n='ignore'))]), ['color']), ('num', Pipeline(step...ardScaler())]), ['fi...ty', 'vo...ty', 'ci...id', 're...ar', ...])]
['color']
Parameters
strategy strategy: str or Callable, default='mean'

The imputation strategy.

- If "mean", then replace missing values using the mean along
each column. Can only be used with numeric data.
- If "median", then replace missing values using the median along
each column. Can only be used with numeric data.
- If "most_frequent", then replace missing using the most frequent
value along each column. Can be used with strings or numeric data.
If there is more than one such value, only the smallest is returned.
- If "constant", then replace missing values with fill_value. Can be
used with strings or numeric data.
- If an instance of Callable, then replace missing values using the
scalar statistic returned by running the callable over a dense 1d
array containing non-missing values of each column.

.. versionadded:: 0.20
strategy="constant" for fixed value imputation.

.. versionadded:: 1.5
strategy=callable for custom value imputation.
'most_frequent'
missing_values missing_values: int, float, str, np.nan, None or pandas.NA, default=np.nan

The placeholder for the missing values. All occurrences of
`missing_values` will be imputed. For pandas' dataframes with
nullable integer dtypes with missing values, `missing_values`
can be set to either `np.nan` or `pd.NA`.
nan
fill_value fill_value: str or numerical value, default=None

When strategy == "constant", `fill_value` is used to replace all
occurrences of missing_values. For string or object data types,
`fill_value` must be a string.
If `None`, `fill_value` will be 0 when imputing numerical
data and "missing_value" for strings or object data types.
None
copy copy: bool, default=True

If True, a copy of X will be created. If False, imputation will
be done in-place whenever possible. Note that, in the following cases,
a new copy will always be made, even if `copy=False`:

- If `X` is not an array of floating values;
- If `X` is encoded as a CSR matrix;
- If `add_indicator=True`.
True
add_indicator add_indicator: bool, default=False

If True, a :class:`MissingIndicator` transform will stack onto output
of the imputer's transform. This allows a predictive estimator
to account for missingness despite imputation. If a feature has no
missing values at fit/train time, the feature won't appear on
the missing indicator even if there are missing values at
transform/test time.
False
keep_empty_features keep_empty_features: bool, default=False

If True, features that consist exclusively of missing values when
`fit` is called are returned in results when `transform` is called.
The imputed value is always `0` except when `strategy="constant"`
in which case `fill_value` will be used instead.

.. versionadded:: 1.2
False
Fitted attributes
Name Type Value
feature_names_in_ feature_names_in_: ndarray of shape (`n_features_in_`,)

Names of features seen during :term:`fit`. Defined only when `X`
has feature names that are all strings.

.. versionadded:: 1.0
ndarray[object](1,) ['color']
indicator_ indicator_: :class:`~sklearn.impute.MissingIndicator`

Indicator used to add binary indicators for missing values.
`None` if `add_indicator=False`.
NoneType None
n_features_in_ n_features_in_: int

Number of features seen during :term:`fit`.

.. versionadded:: 0.24
int 1
statistics_ statistics_: array of shape (n_features,)

The imputation fill value for each feature.
Computing statistics can result in `np.nan` values.
During :meth:`transform`, features corresponding to `np.nan`
statistics will be discarded.
ndarray[object](1,) ['white']
1 feature
color
Parameters
handle_unknown handle_unknown: {'error', 'ignore', 'infrequent_if_exist', 'warn'}, default='error'

Specifies the way unknown categories are handled during :meth:`transform`.

- 'error' : Raise an error if an unknown category is present during transform.
- 'ignore' : When an unknown category is encountered during
transform, the resulting one-hot encoded columns for this feature
will be all zeros. In the inverse transform, an unknown category
will be denoted as None.
- 'infrequent_if_exist' : When an unknown category is encountered
during transform, the resulting one-hot encoded columns for this
feature will map to the infrequent category if it exists. The
infrequent category will be mapped to the last position in the
encoding. During inverse transform, an unknown category will be
mapped to the category denoted `'infrequent'` if it exists. If the
`'infrequent'` category does not exist, then :meth:`transform` and
:meth:`inverse_transform` will handle an unknown category as with
`handle_unknown='ignore'`. Infrequent categories exist based on
`min_frequency` and `max_categories`. Read more in the
:ref:`User Guide <encoder_infrequent_categories>`.
- 'warn' : When an unknown category is encountered during transform
a warning is issued, and the encoding then proceeds as described for
`handle_unknown="infrequent_if_exist"`.

.. versionchanged:: 1.1
`'infrequent_if_exist'` was added to automatically handle unknown
categories and infrequent categories.

.. versionadded:: 1.6
The option `"warn"` was added in 1.6.
'ignore'
categories categories: 'auto' or a list of array-like, default='auto'

Categories (unique values) per feature:

- 'auto' : Determine categories automatically from the training data.
- list : ``categories[i]`` holds the categories expected in the ith
column. The passed categories should not mix strings and numeric
values within a single feature, and should be sorted in case of
numeric values.

The used categories can be found in the ``categories_`` attribute.

.. versionadded:: 0.20
'auto'
drop drop: {'first', 'if_binary'} or an array-like of shape (n_features,), default=None

Specifies a methodology to use to drop one of the categories per
feature. This is useful in situations where perfectly collinear
features cause problems, such as when feeding the resulting data
into an unregularized linear regression model.

However, dropping one category breaks the symmetry of the original
representation and can therefore induce a bias in downstream models,
for instance for penalized linear classification or regression models.

- None : retain all features (the default).
- 'first' : drop the first category in each feature. If only one
category is present, the feature will be dropped entirely.
- 'if_binary' : drop the first category in each feature with two
categories. Features with 1 or more than 2 categories are
left intact.
- array : ``drop[i]`` is the category in feature ``X[:, i]`` that
should be dropped.

When `max_categories` or `min_frequency` is configured to group
infrequent categories, the dropping behavior is handled after the
grouping.

.. versionadded:: 0.21
The parameter `drop` was added in 0.21.

.. versionchanged:: 0.23
The option `drop='if_binary'` was added in 0.23.

.. versionchanged:: 1.1
Support for dropping infrequent categories.
None
sparse_output sparse_output: bool, default=True

When ``True``, it returns a SciPy sparse matrix/array
in "Compressed Sparse Row" (CSR) format.

.. versionadded:: 1.2
`sparse` was renamed to `sparse_output`
True
dtype dtype: number type, default=np.float64

Desired dtype of output.
<class 'numpy.float64'>
min_frequency min_frequency: int or float, default=None

Specifies the minimum frequency below which a category will be
considered infrequent.

- If `int`, categories with a smaller cardinality will be considered
infrequent.

- If `float`, categories with a smaller cardinality than
`min_frequency * n_samples` will be considered infrequent.

.. versionadded:: 1.1
Read more in the :ref:`User Guide <encoder_infrequent_categories>`.
None
max_categories max_categories: int, default=None

Specifies an upper limit to the number of output features for each input
feature when considering infrequent categories. If there are infrequent
categories, `max_categories` includes the category representing the
infrequent categories along with the frequent categories. If `None`,
there is no limit to the number of output features.

.. versionadded:: 1.1
Read more in the :ref:`User Guide <encoder_infrequent_categories>`.
None
feature_name_combiner feature_name_combiner: "concat" or callable, default="concat"

Callable with signature `def callable(input_feature, category)` that returns a
string. This is used to create feature names to be returned by
:meth:`get_feature_names_out`.

`"concat"` concatenates encoded feature name and category with
`feature + "_" + str(category)`.E.g. feature X with values 1, 6, 7 create
feature names `X_1, X_6, X_7`.

.. versionadded:: 1.3
'concat'
Fitted attributes
Name Type Value
categories_ categories_: list of arrays

The categories of each feature determined during fitting
(in order of the features in X and corresponding with the output
of ``transform``). This includes the category specified in ``drop``
(if any).
list [array(['red',... dtype=object)]
drop_idx_ drop_idx_: array of shape (n_features,)

- ``drop_idx_[i]`` is the index in ``categories_[i]`` of the category
to be dropped for each feature.
- ``drop_idx_[i] = None`` if no category is to be dropped from the
feature with index ``i``, e.g. when `drop='if_binary'` and the
feature isn't binary.
- ``drop_idx_ = None`` if all the transformed features will be
retained.

If infrequent categories are enabled by setting `min_frequency` or
`max_categories` to a non-default value and `drop_idx[i]` corresponds
to an infrequent category, then the entire infrequent category is
dropped.

.. versionchanged:: 0.23
Added the possibility to contain `None` values.
NoneType None
n_features_in_ n_features_in_: int

Number of features seen during :term:`fit`.

.. versionadded:: 1.0
int 1
2 features
x0_red
x0_white
['fixed acidity', 'volatile acidity', 'citric acid', 'residual sugar', 'chlorides', 'free sulfur dioxide', 'total sulfur dioxide', 'density', 'pH', 'sulphates', 'alcohol']
Parameters
strategy strategy: str or Callable, default='mean'

The imputation strategy.

- If "mean", then replace missing values using the mean along
each column. Can only be used with numeric data.
- If "median", then replace missing values using the median along
each column. Can only be used with numeric data.
- If "most_frequent", then replace missing using the most frequent
value along each column. Can be used with strings or numeric data.
If there is more than one such value, only the smallest is returned.
- If "constant", then replace missing values with fill_value. Can be
used with strings or numeric data.
- If an instance of Callable, then replace missing values using the
scalar statistic returned by running the callable over a dense 1d
array containing non-missing values of each column.

.. versionadded:: 0.20
strategy="constant" for fixed value imputation.

.. versionadded:: 1.5
strategy=callable for custom value imputation.
'median'
missing_values missing_values: int, float, str, np.nan, None or pandas.NA, default=np.nan

The placeholder for the missing values. All occurrences of
`missing_values` will be imputed. For pandas' dataframes with
nullable integer dtypes with missing values, `missing_values`
can be set to either `np.nan` or `pd.NA`.
nan
fill_value fill_value: str or numerical value, default=None

When strategy == "constant", `fill_value` is used to replace all
occurrences of missing_values. For string or object data types,
`fill_value` must be a string.
If `None`, `fill_value` will be 0 when imputing numerical
data and "missing_value" for strings or object data types.
None
copy copy: bool, default=True

If True, a copy of X will be created. If False, imputation will
be done in-place whenever possible. Note that, in the following cases,
a new copy will always be made, even if `copy=False`:

- If `X` is not an array of floating values;
- If `X` is encoded as a CSR matrix;
- If `add_indicator=True`.
True
add_indicator add_indicator: bool, default=False

If True, a :class:`MissingIndicator` transform will stack onto output
of the imputer's transform. This allows a predictive estimator
to account for missingness despite imputation. If a feature has no
missing values at fit/train time, the feature won't appear on
the missing indicator even if there are missing values at
transform/test time.
False
keep_empty_features keep_empty_features: bool, default=False

If True, features that consist exclusively of missing values when
`fit` is called are returned in results when `transform` is called.
The imputed value is always `0` except when `strategy="constant"`
in which case `fill_value` will be used instead.

.. versionadded:: 1.2
False
Fitted attributes
Name Type Value
feature_names_in_ feature_names_in_: ndarray of shape (`n_features_in_`,)

Names of features seen during :term:`fit`. Defined only when `X`
has feature names that are all strings.

.. versionadded:: 1.0
ndarray[object](11,) ['fixed acidity','volatile acidity','citric acid',...,'pH','sulphates', 'alcohol']
indicator_ indicator_: :class:`~sklearn.impute.MissingIndicator`

Indicator used to add binary indicators for missing values.
`None` if `add_indicator=False`.
NoneType None
n_features_in_ n_features_in_: int

Number of features seen during :term:`fit`.

.. versionadded:: 0.24
int 11
statistics_ statistics_: array of shape (n_features,)

The imputation fill value for each feature.
Computing statistics can result in `np.nan` values.
During :meth:`transform`, features corresponding to `np.nan`
statistics will be discarded.
ndarray[float64](11,) [ 7. , 0.3 , 0.31,..., 3.21, 0.51,10.3 ]
11 features
fixed acidity
volatile acidity
citric acid
residual sugar
chlorides
free sulfur dioxide
total sulfur dioxide
density
pH
sulphates
alcohol
Parameters
copy copy: bool, default=True

If False, try to avoid a copy and do inplace scaling instead.
This is not guaranteed to always work inplace; e.g. if the data is
not a NumPy array or scipy.sparse CSR matrix, a copy may still be
returned.
True
with_mean with_mean: bool, default=True

If True, center the data before scaling.
This does not work (and will raise an exception) when attempted on
sparse matrices, because centering them entails building a dense
matrix which in common use cases is likely to be too large to fit in
memory.
True
with_std with_std: bool, default=True

If True, scale the data to unit variance (or equivalently,
unit standard deviation).
True
Fitted attributes
Name Type Value
mean_ mean_: ndarray of shape (n_features,) or None

The mean value for each feature in the training set.
Equal to ``None`` when ``with_mean=False`` and ``with_std=False``.
ndarray[float64](11,) [ 7.21, 0.34, 0.32,..., 3.22, 0.53,10.49]
n_features_in_ n_features_in_: int

Number of features seen during :term:`fit`.

.. versionadded:: 0.24
int 11
n_samples_seen_ n_samples_seen_: int or ndarray of shape (n_features,)

The number of samples processed by the estimator for each feature.
If there are no missing samples, the ``n_samples_seen`` will be an
integer, otherwise it will be an array of dtype int. If
`sample_weights` are used it will be a float (if no missing data)
or an array of dtype float that sums the weights seen so far.
Will be reset on new calls to fit, but increments across
``partial_fit`` calls.
float64 4157
scale_ scale_: ndarray of shape (n_features,) or None

Per feature relative scaling of the data to achieve zero mean and unit
variance. Generally this is calculated using `np.sqrt(var_)`. If a
variance is zero, we can't achieve unit variance, and the data is left
as-is, giving a scaling factor of 1. `scale_` is equal to `None`
when `with_std=False`.

.. versionadded:: 0.17
*scale_*
ndarray[float64](11,) [1.27,0.17,0.14,...,0.15,0.15,1.16]
var_ var_: ndarray of shape (n_features,) or None

The variance for each feature in the training set. Used to compute
`scale_`. Equal to ``None`` when ``with_mean=False`` and
``with_std=False``.
ndarray[float64](11,) [1.61,0.03,0.02,...,0.02,0.02,1.35]
11 features
x0
x1
x2
x3
x4
x5
x6
x7
x8
x9
x10
13 features
cat__color_red
cat__color_white
num__fixed acidity
num__volatile acidity
num__citric acid
num__residual sugar
num__chlorides
num__free sulfur dioxide
num__total sulfur dioxide
num__density
num__pH
num__sulphates
num__alcohol
Parameters
n_neighbors n_neighbors: int, default=5

Number of neighbors to use by default for :meth:`kneighbors` queries.
25
weights weights: {'uniform', 'distance'}, callable or None, default='uniform'

Weight function used in prediction. Possible values:

- 'uniform' : uniform weights. All points in each neighborhood
are weighted equally.
- 'distance' : weight points by the inverse of their distance.
in this case, closer neighbors of a query point will have a
greater influence than neighbors which are further away.
- [callable] : a user-defined function which accepts an
array of distances, and returns an array of the same shape
containing the weights.

Refer to the example entitled
:ref:`sphx_glr_auto_examples_neighbors_plot_classification.py`
showing the impact of the `weights` parameter on the decision
boundary.
'distance'
algorithm algorithm: {'auto', 'ball_tree', 'kd_tree', 'brute'}, default='auto'

Algorithm used to compute the nearest neighbors:

- 'ball_tree' will use :class:`BallTree`
- 'kd_tree' will use :class:`KDTree`
- 'brute' will use a brute-force search.
- 'auto' will attempt to decide the most appropriate algorithm
based on the values passed to :meth:`fit` method.

Note: fitting on sparse input will override the setting of
this parameter, using brute force.
'auto'
leaf_size leaf_size: int, default=30

Leaf size passed to BallTree or KDTree. This can affect the
speed of the construction and query, as well as the memory
required to store the tree. The optimal value depends on the
nature of the problem.
30
p p: float, default=2

Power parameter for the Minkowski metric. When p = 1, this is equivalent
to using manhattan_distance (l1), and euclidean_distance (l2) for p = 2.
For arbitrary p, minkowski_distance (l_p) is used. This parameter is expected
to be positive.
2
metric metric: str or callable, default='minkowski'

Metric to use for distance computation. Default is "minkowski", which
results in the standard Euclidean distance when p = 2. See the
documentation of `scipy.spatial.distance
<https://docs.scipy.org/doc/scipy/reference/spatial.distance.html>`_ and
the metrics listed in
:class:`~sklearn.metrics.pairwise.distance_metrics` for valid metric
values.

If metric is "precomputed", X is assumed to be a distance matrix and
must be square during fit. X may be a :term:`sparse graph`, in which
case only "nonzero" elements may be considered neighbors.

If metric is a callable function, it takes two arrays representing 1D
vectors as inputs and must return one value indicating the distance
between those vectors. This works for Scipy's metrics, but is less
efficient than passing the metric name as a string.
'minkowski'
metric_params metric_params: dict, default=None

Additional keyword arguments for the metric function.
None
n_jobs n_jobs: int, default=None

The number of parallel jobs to run for neighbors search.
``None`` means 1 unless in a :obj:`joblib.parallel_backend` context.
``-1`` means using all processors. See :term:`Glossary <n_jobs>`
for more details.
Doesn't affect :meth:`fit` method.
None
Fitted attributes
Name Type Value
classes_ classes_: array of shape (n_classes,)

Class labels known to the classifier
ndarray[int64](7,) [3,4,5,...,7,8,9]
effective_metric_ effective_metric_: str or callble

The distance metric used. It will be same as the `metric` parameter
or a synonym of it, e.g. 'euclidean' if the `metric` parameter set to
'minkowski' and `p` parameter set to 2.
str 'eu...an'
effective_metric_params_ effective_metric_params_: dict

Additional keyword arguments for the metric function. For most metrics
will be same with `metric_params` parameter, but may also contain the
`p` parameter value if the `effective_metric_` attribute is set to
'minkowski'.
dict {}
n_features_in_ n_features_in_: int

Number of features seen during :term:`fit`.

.. versionadded:: 0.24
int 13
n_samples_fit_ n_samples_fit_: int

Number of samples in the fitted data.
int 4157
outputs_2d_ outputs_2d_: bool

False when `y`'s shape is (n_samples, ) or (n_samples, 1) during fit
otherwise True.
bool False

The wine quality prediction task was treated as a multi-class classification problem using physicochemical measurements of the wines as predictors. These predictors included acidity levels, sugar content, sulfur dioxide measurements, density, pH, sulphates, alcohol content, and wine color. Numeric features were imputed using the median and standardized, while the categorical feature (color) was imputed using the most frequent value and encoded using one-hot encoding. These preprocessing steps were combined using a column transformer and incorporated into a single scikit-learn pipeline to ensure consistent data processing during both training and prediction. A K-Nearest Neighbors (KNN) classifier with 25 neighbors and distance-based weighting was then trained on the training dataset and evaluated on the test dataset to assess how well the model predicts wine quality for new wines.

Results¶

In [32]:
# report model metrics
y_pred = mod.predict(X_test)
test_mae = mean_absolute_error(y_test, y_pred)
print(f"Test MAE: {test_mae:.4f}")
test_acc = accuracy_score(y_test, y_pred)
print(f"Test Accuracy: {test_acc:.4f}\n")
print("Classification Report:")
print(classification_report(y_test, y_pred, zero_division=0))
Test MAE: 0.4327
Test Accuracy: 0.6154

Classification Report:
              precision    recall  f1-score   support

           3       0.00      0.00      0.00         7
           4       1.00      0.06      0.11        35
           5       0.65      0.70      0.67       318
           6       0.59      0.69      0.64       449
           7       0.58      0.49      0.53       190
           8       1.00      0.28      0.44        39
           9       0.00      0.00      0.00         2

    accuracy                           0.62      1040
   macro avg       0.55      0.32      0.34      1040
weighted avg       0.63      0.62      0.60      1040

In [33]:
#| fig-cap: "Figure 2. Normalized Confusion Matrix for Wine Quality Predictions on the Test Dataset"
# summary figure
y_pred = mod.predict(X_test)

labels = sorted(y_test.unique())
cm = confusion_matrix(y_test, y_pred, labels=labels, normalize="true")

plt.imshow(cm)
plt.colorbar()

plt.xticks(range(len(labels)), labels)
plt.yticks(range(len(labels)), labels)

plt.xlabel("Predicted Wine Quality")
plt.ylabel("Actual Wine Quality")
plt.title("Normalized Confusion Matrix of Wine Quality Predictions")

plt.show()
No description has been provided for this image

The selected K-Nearest Neighbors model was evaluated on the test dataset using the physicochemical properties of the wines as predictors. The model achieved a test mean absolute error (MAE) of 0.4327, indicating that predicted wine quality scores were typically less than half a point away from the true ratings on average.

The visualization in this section compares the predicted wine quality scores to the actual scores in the test data. Most predictions fall near the diagonal pattern, indicating that the model frequently predicts the correct quality level. When errors occur, they generally involve neighboring quality values, such as predicting a wine with quality 5 as 6 or vice versa. This pattern suggests that the model captures meaningful relationships between the measured chemical properties and perceived wine quality, while occasionally struggling to distinguish between wines with very similar characteristics.

Overall, the results indicate that the model provides reasonably accurate predictions of wine quality using only physicochemical measurements.

Discussion¶

Based on the results presented above, I would recommend using this model in practice as a decision-support tool for the startup’s AI Sommelier system. The model demonstrates strong predictive performance, with an average prediction error of less than half a quality point. This suggests that the physicochemical measurements of wines contain useful information for estimating the quality ratings assigned by human evaluators.

One important benefit of this model is that it allows wine quality to be estimated quickly and consistently using objective chemical measurements. This could help reduce the time and cost associated with evaluating large numbers of wines while providing a standardized and repeatable method for preliminary quality assessment. In a real-world setting, the model could be used to automatically screen wines and identify those that are likely to have higher quality.

However, several limitations should also be considered. Wine quality is ultimately determined by sensory characteristics such as aroma, taste, and texture, which are not fully captured by the chemical measurements available in the dataset. Because of this, the model may sometimes assign similar quality scores to wines that differ in ways that human tasters can perceive.

The visualization shows that most prediction errors occur between neighboring quality levels. While these errors are relatively small, they could still have practical consequences. For example, predicting a lower-quality wine as higher quality could lead producers or distributors to overestimate its value, while predicting a higher-quality wine as lower quality could cause a strong product to be overlooked.

For these reasons, the model should not replace human sommeliers entirely. Instead, it would be most effective when used alongside human expertise. The system could provide an initial estimate of wine quality using measurable chemical features, and human evaluators could perform additional sensory assessments when necessary.

Overall, the model demonstrates that machine learning can successfully use physicochemical measurements to estimate wine quality. While it cannot fully replace expert tasting, it could serve as a valuable component of an automated wine evaluation system that improves efficiency and consistency in the wine industry.